diff --git a/cmd/commands/invoicesrpc_active.go b/cmd/commands/invoicesrpc_active.go index 0ee767c8b22..c58822170e3 100644 --- a/cmd/commands/invoicesrpc_active.go +++ b/cmd/commands/invoicesrpc_active.go @@ -145,12 +145,18 @@ var addHoldInvoiceCommand = cli.Command{ Category: "Invoices", Usage: "Add a new hold invoice.", Description: ` - Add a new invoice, expressing intent for a future payment. + Add a new hold invoice, expressing intent for a future payment. Invoices without an amount can be created by not supplying any parameters or providing an amount of 0. These invoices allow the payer - to specify the amount of satoshis they wish to send.`, - ArgsUsage: "hash [amt]", + to specify the amount of satoshis they wish to send. + + The hash can be provided as the first positional argument (legacy) or + via the --hash flag. If no hash is provided, the server will + auto-generate a random preimage and derive the hash, returning the + generated preimage in the response. The caller must save that + preimage to settle the invoice later.`, + ArgsUsage: "[hash] [amt]", Flags: []cli.Flag{ cli.StringFlag{ Name: "memo", @@ -198,6 +204,14 @@ var addHoldInvoiceCommand = cli.Command{ "private channels in order to assist the " + "payer in reaching you", }, + cli.StringFlag{ + Name: "hash", + Usage: "the hash of the preimage (32 bytes, hex " + + "encoded). If not set, the server will " + + "auto-generate a random preimage and " + + "hash, returning the preimage in the " + + "response.", + }, }, Action: actionDecorator(addHoldInvoice), } @@ -205,6 +219,9 @@ var addHoldInvoiceCommand = cli.Command{ func addHoldInvoice(ctx *cli.Context) error { var ( descHash []byte + hash []byte + amt int64 + amtMsat int64 err error ) @@ -213,26 +230,33 @@ func addHoldInvoice(ctx *cli.Context) error { defer cleanUp() args := ctx.Args() - if ctx.NArg() == 0 { - cli.ShowCommandHelp(ctx, "addholdinvoice") - return nil - } - hash, err := hex.DecodeString(args.First()) - if err != nil { - return fmt.Errorf("unable to parse hash: %w", err) - } + switch { + // --hash flag takes priority. + case ctx.IsSet("hash"): + hash, err = hex.DecodeString(ctx.String("hash")) + if err != nil { + return fmt.Errorf("unable to parse hash: %w", err) + } - args = args.Tail() + // If the first positional arg looks like a hex-encoded hash + // (64 hex chars = 32 bytes), use it for backward compatibility. + case args.Present() && len(args.First()) == 64: + hash, err = hex.DecodeString(args.First()) + if err != nil { + return fmt.Errorf("unable to parse hash: %w", err) + } + args = args.Tail() + } - amt := ctx.Int64("amt") - amtMsat := ctx.Int64("amt_msat") + amt = ctx.Int64("amt") + amtMsat = ctx.Int64("amt_msat") if !ctx.IsSet("amt") && !ctx.IsSet("amt_msat") && args.Present() { amt, err = strconv.ParseInt(args.First(), 10, 64) if err != nil { - return fmt.Errorf("unable to decode amt argument: %w", - err) + return fmt.Errorf("unable to decode amt argument: "+ + "%w", err) } } diff --git a/docs/release-notes/release-notes-0.22.0.md b/docs/release-notes/release-notes-0.22.0.md new file mode 100644 index 00000000000..3a16ed75709 --- /dev/null +++ b/docs/release-notes/release-notes-0.22.0.md @@ -0,0 +1,75 @@ +# Release Notes +- [Bug Fixes](#bug-fixes) +- [New Features](#new-features) + - [Functional Enhancements](#functional-enhancements) + - [RPC Additions](#rpc-additions) + - [lncli Additions](#lncli-additions) +- [Improvements](#improvements) + - [Functional Updates](#functional-updates) + - [RPC Updates](#rpc-updates) + - [lncli Updates](#lncli-updates) + - [Breaking Changes](#breaking-changes) + - [Performance Improvements](#performance-improvements) + - [Deprecations](#deprecations) +- [Technical and Architectural Updates](#technical-and-architectural-updates) + - [BOLT Spec Updates](#bolt-spec-updates) + - [Testing](#testing) + - [Database](#database) + - [Code Health](#code-health) + - [Tooling and Documentation](#tooling-and-documentation) + +# Bug Fixes + +# New Features + +## Functional Enhancements + +## RPC Additions + +## lncli Additions + +# Improvements + +## Functional Updates + +## RPC Updates + +* [`AddHoldInvoice` hash field is now + optional](https://github.com/lightningnetwork/lnd/pull/10685). When omitted, + the server generates a random preimage, derives the payment hash, and + returns the preimage in the response. The preimage is never persisted — the + caller must save the returned value to settle the invoice later, preserving + the hold-invoice invariant that lnd only learns the preimage at + `SettleInvoice` time. The response adds `payment_preimage` (populated only + for the auto-generated case) and `payment_hash` (always populated). + +## lncli Updates + +* The [`addholdinvoice` command now accepts `--hash` and `--preimage` + flags](https://github.com/lightningnetwork/lnd/pull/10685). When neither is + provided, the server generates both automatically. The legacy positional hash + argument is still supported for backward compatibility. + +## Code Health + +## Breaking Changes + +## Performance Improvements + +## Deprecations + +# Technical and Architectural Updates + +## BOLT Spec Updates + +## Testing + +## Database + +## Code Health + +## Tooling and Documentation + +# Contributors (Alphabetical Order) + +* Suheb diff --git a/itest/list_on_test.go b/itest/list_on_test.go index c8e4244e7e3..3d4c1ec5e35 100644 --- a/itest/list_on_test.go +++ b/itest/list_on_test.go @@ -250,6 +250,10 @@ var allTestCases = []*lntest.TestCase{ Name: "hold invoice sender persistence", TestFunc: testHoldInvoicePersistence, }, + { + Name: "hold invoice auto generate", + TestFunc: testHoldInvoiceAutoGenerate, + }, { Name: "maximum channel size", TestFunc: testMaxChannelSize, diff --git a/itest/lnd_hold_invoice_auto_generate_test.go b/itest/lnd_hold_invoice_auto_generate_test.go new file mode 100644 index 00000000000..cc903e3178c --- /dev/null +++ b/itest/lnd_hold_invoice_auto_generate_test.go @@ -0,0 +1,104 @@ +package itest + +import ( + "github.com/btcsuite/btcd/btcutil" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/invoicesrpc" + "github.com/lightningnetwork/lnd/lnrpc/routerrpc" + "github.com/lightningnetwork/lnd/lntest" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/stretchr/testify/require" +) + +// testHoldInvoiceAutoGenerate tests that hold invoices can be created without +// providing a hash. The server auto-generates a preimage, derives the hash, +// and returns the preimage in the response without persisting it. It also +// verifies the hash-only flow still works unchanged. +func testHoldInvoiceAutoGenerate(ht *lntest.HarnessTest) { + // Open a channel between Alice and Bob. + _, nodes := ht.CreateSimpleNetwork( + [][]string{nil, nil}, lntest.OpenChannelParams{ + Amt: btcutil.Amount(1_000_000), + }, + ) + alice, bob := nodes[0], nodes[1] + + // Test 1: Auto-generate preimage and hash (no hash provided). + autoReq := &invoicesrpc.AddHoldInvoiceRequest{ + Memo: "auto-generated", + Value: 10_000, + } + autoResp := bob.RPC.AddHoldInvoice(autoReq) + + // The response must contain both a preimage and hash. + require.Len(ht, autoResp.PaymentPreimage, 32, + "expected 32-byte preimage") + require.Len(ht, autoResp.PaymentHash, 32, + "expected 32-byte payment hash") + + // Verify the hash matches SHA256 of the preimage. + var autoPreimage lntypes.Preimage + copy(autoPreimage[:], autoResp.PaymentPreimage) + autoHash := autoPreimage.Hash() + require.Equal(ht, autoHash[:], autoResp.PaymentHash, + "hash should be SHA256 of preimage") + + // Verify the generated preimage is not persisted: a LookupInvoice + // before settlement must report an empty r_preimage. The server + // only learns the preimage again when the caller passes it to + // SettleInvoice. + lookup := bob.RPC.LookupInvoice(autoResp.PaymentHash) + require.Empty(ht, lookup.RPreimage, + "auto-generated preimage must not be stored in the DB") + + // Subscribe, pay, and settle. + autoStream := bob.RPC.SubscribeSingleInvoice(autoResp.PaymentHash) + + ht.SendPaymentAndAssertStatus(alice, &routerrpc.SendPaymentRequest{ + PaymentRequest: autoResp.PaymentRequest, + FeeLimitSat: 1_000_000, + }, lnrpc.Payment_IN_FLIGHT) + + ht.AssertInvoiceState(autoStream, lnrpc.Invoice_ACCEPTED) + + bob.RPC.SettleInvoice(autoResp.PaymentPreimage) + ht.AssertInvoiceState(autoStream, lnrpc.Invoice_SETTLED) + ht.AssertPaymentStatus( + alice, autoHash, lnrpc.Payment_SUCCEEDED, + ) + + // Test 2: Traditional hash-only flow still works. The response + // preimage must be empty because the server does not know it. + var hashPreimage lntypes.Preimage + copy(hashPreimage[:], ht.Random32Bytes()) + payHash := hashPreimage.Hash() + + hashResp := bob.RPC.AddHoldInvoice( + &invoicesrpc.AddHoldInvoiceRequest{ + Memo: "hash-only", + Value: 10_000, + Hash: payHash[:], + }, + ) + + require.Empty(ht, hashResp.PaymentPreimage, + "preimage should be empty for hash-only") + require.Equal(ht, payHash[:], hashResp.PaymentHash, + "returned hash should match provided hash") + + // Subscribe, pay, and settle. + hashStream := bob.RPC.SubscribeSingleInvoice(payHash[:]) + + ht.SendPaymentAndAssertStatus(alice, &routerrpc.SendPaymentRequest{ + PaymentRequest: hashResp.PaymentRequest, + FeeLimitSat: 1_000_000, + }, lnrpc.Payment_IN_FLIGHT) + + ht.AssertInvoiceState(hashStream, lnrpc.Invoice_ACCEPTED) + + bob.RPC.SettleInvoice(hashPreimage[:]) + ht.AssertInvoiceState(hashStream, lnrpc.Invoice_SETTLED) + ht.AssertPaymentStatus( + alice, payHash, lnrpc.Payment_SUCCEEDED, + ) +} diff --git a/lnrpc/invoicesrpc/invoices.pb.go b/lnrpc/invoicesrpc/invoices.pb.go index e1202699888..713f08d3a9b 100644 --- a/lnrpc/invoicesrpc/invoices.pb.go +++ b/lnrpc/invoicesrpc/invoices.pb.go @@ -167,7 +167,11 @@ type AddHoldInvoiceRequest struct { // field of the encoded payment request if the description_hash field is not // being used. Memo string `protobuf:"bytes,1,opt,name=memo,proto3" json:"memo,omitempty"` - // The hash of the preimage + // The hash of the preimage. Optional: when omitted, the server generates + // a random preimage locally, derives the hash, and returns the preimage + // in the response (see payment_preimage). The generated preimage is not + // persisted by the server, so the caller must save the returned value to + // settle the invoice later. Hash []byte `protobuf:"bytes,2,opt,name=hash,proto3" json:"hash,omitempty"` // The value of this invoice in satoshis // @@ -311,7 +315,16 @@ type AddHoldInvoiceResp struct { // the payment secret in specifications (e.g. BOLT 11). This value should // be used in all payments for this invoice as we require it for end to end // security. - PaymentAddr []byte `protobuf:"bytes,3,opt,name=payment_addr,json=paymentAddr,proto3" json:"payment_addr,omitempty"` + PaymentAddr []byte `protobuf:"bytes,3,opt,name=payment_addr,json=paymentAddr,proto3" json:"payment_addr,omitempty"` + // The preimage for this invoice. Populated only when the server + // auto-generates the preimage (i.e. the request was made without a + // hash). The server does not persist this preimage — callers must + // save it and supply it to SettleInvoice later. Empty when the + // caller provided a hash, since the server does not know the + // preimage in that case. + PaymentPreimage []byte `protobuf:"bytes,4,opt,name=payment_preimage,json=paymentPreimage,proto3" json:"payment_preimage,omitempty"` + // The payment hash for this invoice. Always populated. + PaymentHash []byte `protobuf:"bytes,5,opt,name=payment_hash,json=paymentHash,proto3" json:"payment_hash,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -367,6 +380,20 @@ func (x *AddHoldInvoiceResp) GetPaymentAddr() []byte { return nil } +func (x *AddHoldInvoiceResp) GetPaymentPreimage() []byte { + if x != nil { + return x.PaymentPreimage + } + return nil +} + +func (x *AddHoldInvoiceResp) GetPaymentHash() []byte { + if x != nil { + return x.PaymentHash + } + return nil +} + type SettleInvoiceMsg struct { state protoimpl.MessageState `protogen:"open.v1"` // Externally discovered pre-image that should be used to settle the hold @@ -840,11 +867,13 @@ const file_invoicesrpc_invoices_proto_rawDesc = "" + "cltvExpiry\x121\n" + "\vroute_hints\x18\b \x03(\v2\x10.lnrpc.RouteHintR\n" + "routeHints\x12\x18\n" + - "\aprivate\x18\t \x01(\bR\aprivate\"}\n" + + "\aprivate\x18\t \x01(\bR\aprivate\"\xcb\x01\n" + "\x12AddHoldInvoiceResp\x12'\n" + "\x0fpayment_request\x18\x01 \x01(\tR\x0epaymentRequest\x12\x1b\n" + "\tadd_index\x18\x02 \x01(\x04R\baddIndex\x12!\n" + - "\fpayment_addr\x18\x03 \x01(\fR\vpaymentAddr\".\n" + + "\fpayment_addr\x18\x03 \x01(\fR\vpaymentAddr\x12)\n" + + "\x10payment_preimage\x18\x04 \x01(\fR\x0fpaymentPreimage\x12!\n" + + "\fpayment_hash\x18\x05 \x01(\fR\vpaymentHash\".\n" + "\x10SettleInvoiceMsg\x12\x1a\n" + "\bpreimage\x18\x01 \x01(\fR\bpreimage\"\x13\n" + "\x11SettleInvoiceResp\"<\n" + diff --git a/lnrpc/invoicesrpc/invoices.proto b/lnrpc/invoicesrpc/invoices.proto index d9d3bc12365..8789ff1fb9a 100644 --- a/lnrpc/invoicesrpc/invoices.proto +++ b/lnrpc/invoicesrpc/invoices.proto @@ -87,7 +87,13 @@ message AddHoldInvoiceRequest { */ string memo = 1; - // The hash of the preimage + /* + The hash of the preimage. Optional: when omitted, the server generates + a random preimage locally, derives the hash, and returns the preimage + in the response (see payment_preimage). The generated preimage is not + persisted by the server, so the caller must save the returned value to + settle the invoice later. + */ bytes hash = 2; /* @@ -153,6 +159,21 @@ message AddHoldInvoiceResp { security. */ bytes payment_addr = 3; + + /* + The preimage for this invoice. Populated only when the server + auto-generates the preimage (i.e. the request was made without a + hash). The server does not persist this preimage — callers must + save it and supply it to SettleInvoice later. Empty when the + caller provided a hash, since the server does not know the + preimage in that case. + */ + bytes payment_preimage = 4; + + /* + The payment hash for this invoice. Always populated. + */ + bytes payment_hash = 5; } message SettleInvoiceMsg { diff --git a/lnrpc/invoicesrpc/invoices.swagger.json b/lnrpc/invoicesrpc/invoices.swagger.json index a2ca66ec510..7c628ce5900 100644 --- a/lnrpc/invoicesrpc/invoices.swagger.json +++ b/lnrpc/invoicesrpc/invoices.swagger.json @@ -282,7 +282,7 @@ "hash": { "type": "string", "format": "byte", - "title": "The hash of the preimage" + "description": "The hash of the preimage. Optional: when omitted, the server generates\na random preimage locally, derives the hash, and returns the preimage\nin the response (see payment_preimage). The generated preimage is not\npersisted by the server, so the caller must save the returned value to\nsettle the invoice later." }, "value": { "type": "string", @@ -345,6 +345,16 @@ "type": "string", "format": "byte", "description": "The payment address of the generated invoice. This is also called\nthe payment secret in specifications (e.g. BOLT 11). This value should\nbe used in all payments for this invoice as we require it for end to end\nsecurity." + }, + "payment_preimage": { + "type": "string", + "format": "byte", + "description": "The preimage for this invoice. Populated only when the server\nauto-generates the preimage (i.e. the request was made without a\nhash). The server does not persist this preimage — callers must\nsave it and supply it to SettleInvoice later. Empty when the\ncaller provided a hash, since the server does not know the\npreimage in that case." + }, + "payment_hash": { + "type": "string", + "format": "byte", + "description": "The payment hash for this invoice. Always populated." } } }, diff --git a/lnrpc/invoicesrpc/invoices_server.go b/lnrpc/invoicesrpc/invoices_server.go index 7b00b9b6438..32ed4eeea23 100644 --- a/lnrpc/invoicesrpc/invoices_server.go +++ b/lnrpc/invoicesrpc/invoices_server.go @@ -5,6 +5,7 @@ package invoicesrpc import ( "context" + "crypto/rand" "errors" "fmt" "os" @@ -352,9 +353,34 @@ func (s *Server) AddHoldInvoice(ctx context.Context, GetAlias: s.cfg.GetAlias, } - hash, err := lntypes.MakeHash(invoice.Hash) - if err != nil { - return nil, err + var ( + hashPtr *lntypes.Hash + autoPreimage *lntypes.Preimage + ) + + switch { + // Hash provided: existing behavior. + case len(invoice.Hash) > 0: + hash, err := lntypes.MakeHash(invoice.Hash) + if err != nil { + return nil, fmt.Errorf("invalid hash: %w", err) + } + hashPtr = &hash + + // Neither provided: auto-generate a preimage locally. The + // generated preimage is returned in the response but not passed + // to AddInvoice, so it is never persisted by the invoice DB. + // This preserves the hold-invoice invariant that the server only + // learns the preimage at SettleInvoice time. + default: + var preimage lntypes.Preimage + if _, err := rand.Read(preimage[:]); err != nil { + return nil, fmt.Errorf("unable to generate "+ + "preimage: %w", err) + } + hash := preimage.Hash() + hashPtr = &hash + autoPreimage = &preimage } value, err := lnrpc.UnmarshallAmt(invoice.Value, invoice.ValueMsat) @@ -367,9 +393,10 @@ func (s *Server) AddHoldInvoice(ctx context.Context, if err != nil { return nil, err } + addInvoiceData := &AddInvoiceData{ Memo: invoice.Memo, - Hash: &hash, + Hash: hashPtr, Value: value, DescriptionHash: invoice.DescriptionHash, Expiry: invoice.Expiry, @@ -377,20 +404,28 @@ func (s *Server) AddHoldInvoice(ctx context.Context, CltvExpiry: invoice.CltvExpiry, Private: invoice.Private, HodlInvoice: true, - Preimage: nil, RouteHints: routeHints, } - _, dbInvoice, err := AddInvoice(ctx, addInvoiceCfg, addInvoiceData) + payHash, dbInvoice, err := AddInvoice( + ctx, addInvoiceCfg, addInvoiceData, + ) if err != nil { return nil, err } - return &AddHoldInvoiceResp{ + resp := &AddHoldInvoiceResp{ AddIndex: dbInvoice.AddIndex, PaymentRequest: string(dbInvoice.PaymentRequest), PaymentAddr: dbInvoice.Terms.PaymentAddr[:], - }, nil + PaymentHash: payHash[:], + } + + if autoPreimage != nil { + resp.PaymentPreimage = autoPreimage[:] + } + + return resp, nil } // LookupInvoiceV2 attempts to look up at invoice. An invoice can be referenced