bolt12: add InvoiceError onion message replies
What changed, and why it matters
This commit adds support in LND for a new Lightning protocol message called InvoiceError. It is used to politely tell another node why their payment invoice or invoice request was rejected, sent privately through an onion-routed message. The change is mostly a feature addition with built-in validation to make sure the error message is well-formed before it is sent.
No immediate security action is required. Reviewers should verify that callers of ErrorMessage() sanitize the returned string before logging or UI display, as the code itself explicitly does not sanitize remote-provided bytes. Future work should address the documented FIXME regarding undefined BOLT 12 reader semantic requirements.
Security signals we found
New unsigned onion message type added with no cryptographic signature or bech32 form
Writer-side validation prevents empty or non-UTF-8 error strings and disallowed suggested_value without erroneous_field
Reader-side BOLT 1 must-understand rule enforced: unknown even TLVs rejected, unknown odd TLVs tolerated
Encode() intentionally drops unknown TLVs because the message is unsigned
ErrorMessage() returns unsanitized remote peer bytes and documents that callers must scrub before logging/display
Evidence from the diff
The commit introduces a new BOLT 12 message type, invoice_error (onion message payload namespace type 68), implemented in bolt12/invoice_error.go with validation in bolt12/validate.go. It supports three odd (informational) TLV fields: erroneous_field (1), suggested_value (3), and error (5). Encode() enforces writer-side validation: error must be present, non-empty, and valid UTF-8; suggested_value requires erroneous_field. DecodeInvoiceError() is permissive and preserves a TypeMap so ValidateInvoiceErrorRead() can enforce the BOLT 1 must-understand rule, rejecting unknown even TLVs while tolerating unknown odd ones. The code explicitly notes that unknown TLVs are dropped on encode because invoice_error is unsigned, unlike signed BOLT 12 messages. Tests cover round-trips, unknown TLV handling, and validation paths.
Changed components
bolt12/invoice_error.gobolt12/invoice_error_test.gobolt12/validate.gobolt12/validate_test.goInspect captured patch +567 / −0
diff --git a/bolt12/invoice_error.go b/bolt12/invoice_error.go
new file mode 100644
index 0000000..051b7d8
--- /dev/null
+++ b/bolt12/invoice_error.go
@@ -0,0 +1,113 @@
+package bolt12
+
+import (
+ "fmt"
+
+ "github.com/lightningnetwork/lnd/lnwire"
+ "github.com/lightningnetwork/lnd/tlv"
+)
+
+// InvoiceError represents a BOLT 12 invoice_error message, the negative reply a
+// node sends when it rejects an invoice_request or a returned invoice.
+type InvoiceError struct {
+ // ErroneousField names the TLV type in the rejected message that caused
+ // the failure, letting the recipient pinpoint what to change.
+ ErroneousField tlv.OptionalRecordT[tlv.TlvType1, TUint64]
+
+ // SuggestedValue provides a valid replacement for the erroneous field.
+ // MUST NOT be set if ErroneousField is absent.
+ SuggestedValue tlv.OptionalRecordT[tlv.TlvType3, tlv.Blob]
+
+ // Error is a UTF-8 string explaining the rejection. Required by the
+ // spec.
+ Error tlv.OptionalRecordT[tlv.TlvType5, tlv.Blob]
+
+ // decodedTLVs holds every wire TLV type, including unknown ones, so
+ // ValidateInvoiceErrorRead can apply the must-understand rule.
+ decodedTLVs tlv.TypeMap
+}
+
+// allRecordProducers returns record producers for every set optional field, in
+// declaration order.
+func (ie *InvoiceError) allRecordProducers() []tlv.RecordProducer {
+ var p []tlv.RecordProducer
+
+ lnwire.AddOpt(&p, ie.ErroneousField)
+ lnwire.AddOpt(&p, ie.SuggestedValue)
+ lnwire.AddOpt(&p, ie.Error)
+
+ return p
+}
+
+// Encode validates the invoice error per writer requirements and serialises it
+// into a TLV byte stream suitable for embedding in an onion message payload at
+// type 68. Note that Encode intentionally drops any unknown TLVs. Since
+// invoice_error does not carry a cryptographic signature, there is no
+// signature to invalidate by dropping unrecognized TLVs (unlike signed
+// messages such as invoices, where unknown TLVs must be preserved to keep
+// signatures valid).
+func (ie *InvoiceError) Encode() ([]byte, error) {
+ if err := ValidateInvoiceErrorWrite(ie); err != nil {
+ return nil, fmt.Errorf("validate invoice error: %w", err)
+ }
+
+ records := lnwire.ProduceRecordsSorted(ie.allRecordProducers()...)
+
+ return lnwire.EncodeRecords(records)
+}
+
+// DecodeInvoiceError deserializes an invoice error from a TLV byte stream (the
+// raw value of onion message payload type 68). Decoding is permissive. Run
+// ValidateInvoiceErrorRead for the BOLT 1 must-understand check.
+func DecodeInvoiceError(data []byte) (*InvoiceError, error) {
+ var ie InvoiceError
+
+ errField := tlv.ZeroRecordT[tlv.TlvType1, TUint64]()
+ sugVal := tlv.ZeroRecordT[tlv.TlvType3, tlv.Blob]()
+ errMsg := tlv.ZeroRecordT[tlv.TlvType5, tlv.Blob]()
+
+ tm, err := decodeStream(
+ data,
+ errField.Record(),
+ sugVal.Record(),
+ errMsg.Record(),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("decode invoice error: %w", err)
+ }
+
+ lnwire.SetOptFromMap(tm, &ie.ErroneousField, errField)
+ lnwire.SetOptFromMap(tm, &ie.SuggestedValue, sugVal)
+ lnwire.SetOptFromMap(tm, &ie.Error, errMsg)
+ ie.decodedTLVs = tm
+
+ return &ie, nil
+}
+
+// ErrorMessage returns the decoded error string, or empty if not set. The bytes
+// originate from a remote peer over an onion message and are not sanitised
+// here, so callers must scrub them before logging or display.
+func (ie *InvoiceError) ErrorMessage() string {
+ var msg []byte
+ ie.Error.WhenSome(func(r tlv.RecordT[tlv.TlvType5, tlv.Blob]) {
+ msg = r.Val
+ })
+
+ return string(msg)
+}
+
+// FieldNumber returns the erroneous field number, if set.
+func (ie *InvoiceError) FieldNumber() (uint64, bool) {
+ var (
+ val uint64
+ ok bool
+ )
+ ie.ErroneousField.WhenSome(
+ func(r tlv.RecordT[tlv.TlvType1, TUint64]) {
+ val = uint64(r.Val)
+ ok = true
+ },
+ )
+
+ return val, ok
+}
diff --git a/bolt12/invoice_error_test.go b/bolt12/invoice_error_test.go
new file mode 100644
index 0000000..e050126
--- /dev/null
+++ b/bolt12/invoice_error_test.go
@@ -0,0 +1,287 @@
+package bolt12
+
+import (
+ "testing"
+
+ "github.com/lightningnetwork/lnd/tlv"
+ "github.com/stretchr/testify/require"
+)
+
+// someErrField builds a set erroneous_field record for the given field
+// number, keeping the test tables compact.
+func someErrField(n uint64) tlv.OptionalRecordT[tlv.TlvType1, TUint64] {
+ return tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType1](TUint64(n)),
+ )
+}
+
+// someSuggested builds a set suggested_value record from raw bytes.
+func someSuggested(b tlv.Blob) tlv.OptionalRecordT[tlv.TlvType3, tlv.Blob] {
+ return tlv.SomeRecordT(tlv.NewPrimitiveRecord[tlv.TlvType3](b))
+}
+
+// someError builds a set error record from a string.
+func someError(s string) tlv.OptionalRecordT[tlv.TlvType5, tlv.Blob] {
+ return tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType5](tlv.Blob(s)),
+ )
+}
+
+// TestInvoiceErrorRoundTrip verifies that encoding an invoice_error and
+// decoding the result recovers every field, and that re-encoding the decoded
+// message reproduces the original bytes, both for a fully-populated message
+// and for the minimal error-only case. Note that this round-trip property
+// only guarantees exact byte reproducibility for messages containing only
+// known/declared fields; any unknown fields present in decoded messages are
+// intentionally dropped when re-encoded.
+func TestInvoiceErrorRoundTrip(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ ie *InvoiceError
+ wantMsg string
+ wantHasField bool
+ wantFieldNum uint64
+ wantSuggest []byte
+ }{
+ {
+ name: "all fields",
+ ie: &InvoiceError{
+ ErroneousField: someErrField(82),
+ SuggestedValue: someSuggested(
+ []byte{0x00, 0x01, 0x86, 0xa0},
+ ),
+ Error: someError("amount too low"),
+ },
+ wantMsg: "amount too low",
+ wantHasField: true,
+ wantFieldNum: 82,
+ wantSuggest: []byte{0x00, 0x01, 0x86, 0xa0},
+ },
+ {
+ name: "minimal error only",
+ ie: &InvoiceError{
+ Error: someError("rejected"),
+ },
+ wantMsg: "rejected",
+ wantHasField: false,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ encoded, err := tc.ie.Encode()
+ require.NoError(t, err)
+ require.NotEmpty(t, encoded)
+
+ decoded, err := DecodeInvoiceError(encoded)
+ require.NoError(t, err)
+
+ require.Equal(t, tc.wantMsg, decoded.ErrorMessage())
+
+ fieldNum, ok := decoded.FieldNumber()
+ require.Equal(t, tc.wantHasField, ok)
+ if tc.wantHasField {
+ require.Equal(t, tc.wantFieldNum, fieldNum)
+ }
+
+ var sugVal []byte
+ decoded.SuggestedValue.WhenSome(
+ func(r tlv.RecordT[tlv.TlvType3, tlv.Blob]) {
+ sugVal = r.Val
+ },
+ )
+ require.Equal(t, tc.wantSuggest, sugVal)
+
+ // Re-encoding the decoded message must reproduce the
+ // original bytes, pinning canonical record ordering.
+ reencoded, err := decoded.Encode()
+ require.NoError(t, err)
+ require.Equal(t, encoded, reencoded)
+ })
+ }
+}
+
+// TestInvoiceErrorRoundTripWithUnknown verifies that decoding an invoice_error
+// containing unknown odd fields works, but re-encoding the decoded structure
+// drops those unknown fields, yielding only the known fields in the encoded
+// byte stream.
+func TestInvoiceErrorRoundTripWithUnknown(t *testing.T) {
+ t.Parallel()
+
+ // Create a valid invoice_error with only known fields and encode it.
+ ie := &InvoiceError{
+ Error: someError("rejected with unknown field present"),
+ }
+ valid, err := ie.Encode()
+ require.NoError(t, err)
+
+ // Append an unknown odd TLV (type 7) to the valid TLV stream.
+ // 0x07 (type), 0x02 (length), 0xaa, 0xbb (value).
+ streamWithUnknown := append(
+ append([]byte{}, valid...), 0x07, 0x02, 0xaa, 0xbb,
+ )
+
+ // Decode the stream. It should succeed because unknown odd fields are
+ // ignored/tolerated.
+ decoded, err := DecodeInvoiceError(streamWithUnknown)
+ require.NoError(t, err)
+ require.Equal(
+ t, "rejected with unknown field present",
+ decoded.ErrorMessage(),
+ )
+
+ // Re-encode the decoded message.
+ reencoded, err := decoded.Encode()
+ require.NoError(t, err)
+
+ // The re-encoded stream must drop the unknown type 7 field, recovering
+ // exactly the 'valid' bytes, rather than 'streamWithUnknown'.
+ require.Equal(t, valid, reencoded)
+}
+
+// TestInvoiceErrorEncodeValidates verifies that Encode runs the writer
+// validation before serialising, so an invalid invoice_error never reaches the
+// wire.
+func TestInvoiceErrorEncodeValidates(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ ie *InvoiceError
+ wantErr error
+ }{
+ {
+ name: "missing error",
+ ie: &InvoiceError{},
+ wantErr: ErrMissingError,
+ },
+ {
+ name: "empty error",
+ ie: &InvoiceError{Error: someError("")},
+ wantErr: ErrEmptyError,
+ },
+ {
+ name: "non-utf8 error",
+ ie: &InvoiceError{
+ Error: someError(string([]byte{0xff, 0xfe})),
+ },
+ wantErr: ErrInvalidUTF8,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ _, err := tc.ie.Encode()
+ require.ErrorIs(t, err, tc.wantErr)
+ })
+ }
+}
+
+// TestDecodeInvoiceError verifies decode-level behavior: a truncated stream
+// errors, and an unknown odd TLV trailing a valid message is tolerated per the
+// BOLT rule that unknown odd types may be ignored.
+func TestDecodeInvoiceError(t *testing.T) {
+ t.Parallel()
+
+ valid, err := (&InvoiceError{Error: someError("rejected")}).Encode()
+ require.NoError(t, err)
+
+ // A valid message with an unknown odd TLV (type 7) appended after error
+ // (type 5), kept in ascending type order.
+ withOdd := append(append([]byte{}, valid...), 0x07, 0x02, 0xaa, 0xbb)
+
+ tests := []struct {
+ name string
+ data []byte
+ wantErr bool
+ wantMsg string
+ }{
+ {
+ // Type 5 (error) claims length 16 but supplies one
+ // byte.
+ name: "truncated",
+ data: []byte{0x05, 0x10, 0x01},
+ wantErr: true,
+ },
+ {
+ name: "unknown odd tolerated",
+ data: withOdd,
+ wantMsg: "rejected",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ decoded, err := DecodeInvoiceError(tc.data)
+ if tc.wantErr {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ require.Equal(t, tc.wantMsg, decoded.ErrorMessage())
+ })
+ }
+}
+
+// TestValidateInvoiceErrorRead verifies the BOLT 1 must-understand rule: an
+// unknown even TLV is rejected (including a zero-length one, which still
+// occupies a type slot), while an unknown odd TLV is tolerated.
+func TestValidateInvoiceErrorRead(t *testing.T) {
+ t.Parallel()
+
+ // A valid encoded invoice_error (error = "rejected", type 5). Trailers
+ // use types > 5 to keep the stream strictly increasing.
+ base, err := (&InvoiceError{Error: someError("rejected")}).Encode()
+ require.NoError(t, err)
+
+ tests := []struct {
+ name string
+ trailer []byte
+ wantErr error
+ }{
+ {
+ name: "known only",
+ },
+ {
+ name: "unknown odd tolerated",
+ trailer: []byte{0x07, 0x02, 0xaa, 0xbb},
+ },
+ {
+ name: "unknown even rejected",
+ trailer: []byte{0x06, 0x02, 0xaa, 0xbb},
+ wantErr: ErrUnknownEvenType,
+ },
+ {
+ name: "unknown even zero-length rejected",
+ trailer: []byte{0x06, 0x00},
+ wantErr: ErrUnknownEvenType,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ stream := append(
+ append([]byte{}, base...), tc.trailer...,
+ )
+ decoded, err := DecodeInvoiceError(stream)
+ require.NoError(t, err)
+
+ err = ValidateInvoiceErrorRead(decoded)
+ if tc.wantErr != nil {
+ require.ErrorIs(t, err, tc.wantErr)
+ } else {
+ require.NoError(t, err)
+ }
+ })
+ }
+}
diff --git a/bolt12/validate.go b/bolt12/validate.go
index 86c7c7d..c29922a 100644
--- a/bolt12/validate.go
+++ b/bolt12/validate.go
@@ -200,6 +200,22 @@ var (
// distinguish from a missing-field violation.
ErrZeroInvoiceAmount = errors.New("invoice_amount must be greater " +
"than zero")
+
+ // ErrMissingError is returned when an invoice_error omits the error
+ // field.
+ ErrMissingError = errors.New("invoice_error missing error field")
+
+ // ErrEmptyError is returned when an invoice_error carries a zero-length
+ // error field.
+ ErrEmptyError = errors.New(
+ "invoice_error error field is empty",
+ )
+
+ // ErrSuggestedWithoutField is returned when suggested_value is set
+ // without erroneous_field.
+ ErrSuggestedWithoutField = errors.New(
+ "suggested_value set without erroneous_field",
+ )
)
const (
@@ -238,8 +254,86 @@ const (
invoiceFallbacksType tlv.Type = 172
invoiceFeaturesType tlv.Type = 174
invoiceNodeIDType tlv.Type = 176
+
+ // InvoiceError TLV types.
+ invoiceErrorErroneousFieldType tlv.Type = 1
+ invoiceErrorSuggestedValueType tlv.Type = 3
+ invoiceErrorErrorType tlv.Type = 5
)
+// ValidateInvoiceErrorWrite validates an invoice_error per the BOLT 12 writer
+// requirements. The checks follow the spec's writer section in order. The
+// caller must check that the suggested value, if present, contains a valid
+// type.
+func ValidateInvoiceErrorWrite(ie *InvoiceError) error {
+ // - MUST set error to an explanatory string.
+ if !ie.Error.IsSome() {
+ return ErrMissingError
+ }
+
+ // The spec's "explanatory string" is a type-5 UTF-8 message, so an
+ // empty or non-UTF-8 blob carries no explanation and fails the
+ // requirement even though IsSome passes. checkUTF8 mirrors the
+ // treatment every other BOLT 12 UTF-8 field receives.
+ if len(ie.Error.ValOpt().UnwrapOr(nil)) == 0 {
+ return ErrEmptyError
+ }
+ if err := checkUTF8(ie.Error, "error"); err != nil {
+ return err
+ }
+
+ // - MAY set erroneous_field to a specific field number in the invoice
+ // or invoice_request which had a problem.
+ // No presence check: erroneous_field is optional for the writer.
+ hasErrField := ie.ErroneousField.IsSome()
+
+ // - if it sets erroneous_field:
+ // - MAY set suggested_value.
+ // - otherwise:
+ // - MUST NOT set suggested_value.
+ if ie.SuggestedValue.IsSome() && !hasErrField {
+ return ErrSuggestedWithoutField
+ }
+
+ // - if it sets suggested_value:
+ // - MUST set suggested_value to a valid field for that
+ // tlv_fieldnum.
+ // NOT CHECKED HERE: verifying the replacement is a valid encoding for
+ // the erroneous field needs the schema of the rejected invoice or
+ // invoice_request, which is caller context this validator does not
+ // have.
+
+ return nil
+}
+
+// isKnownInvoiceErrorTLVType reports whether typ is a defined invoice_error
+// TLV type (1, 3, 5).
+func isKnownInvoiceErrorTLVType(typ tlv.Type) bool {
+ switch typ {
+ case invoiceErrorErroneousFieldType,
+ invoiceErrorSuggestedValueType,
+ invoiceErrorErrorType:
+
+ return true
+ default:
+ return false
+ }
+}
+
+// ValidateInvoiceErrorRead applies the BOLT 1 must-understand rule to a decoded
+// invoice_error: reject unknown even TLV types, tolerate unknown odd ones. It
+// is the only read-side check, since BOLT 12 leaves the semantic reader
+// requirements undefined (FIXME).
+func ValidateInvoiceErrorRead(ie *InvoiceError) error {
+ for _, t := range sortedTypes(ie.decodedTLVs) {
+ if !isKnownInvoiceErrorTLVType(t) && t%2 == 0 {
+ return fmt.Errorf("%w: type %d", ErrUnknownEvenType, t)
+ }
+ }
+
+ return nil
+}
+
// isKnownInvreqTLVType determines if a TLV type is defined in the
// invoice_request specification.
func isKnownInvreqTLVType(typ tlv.Type) bool {
diff --git a/bolt12/validate_test.go b/bolt12/validate_test.go
index c4736fc..f0650e6 100644
--- a/bolt12/validate_test.go
+++ b/bolt12/validate_test.go
@@ -2701,3 +2701,76 @@ func TestValidateInvoiceWriteRejectsNilPubkeys(t *testing.T) {
require.ErrorIs(t, ValidateInvoiceWrite(inv), ErrNilPublicKey)
})
}
+
+// TestValidateInvoiceErrorWrite verifies the BOLT 12 invoice_error writer
+// requirements: error is mandatory and must be a non-empty UTF-8 string, and
+// suggested_value may only accompany a set erroneous_field.
+func TestValidateInvoiceErrorWrite(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ ie *InvoiceError
+ wantErr error
+ }{
+ {
+ name: "missing error",
+ ie: &InvoiceError{},
+ wantErr: ErrMissingError,
+ },
+ {
+ name: "empty error",
+ ie: &InvoiceError{Error: someError("")},
+ wantErr: ErrEmptyError,
+ },
+ {
+ name: "non-utf8 error",
+ ie: &InvoiceError{
+ Error: someError(string([]byte{0xff, 0xfe})),
+ },
+ wantErr: ErrInvalidUTF8,
+ },
+ {
+ name: "suggested without erroneous field",
+ ie: &InvoiceError{
+ SuggestedValue: someSuggested([]byte{0x01}),
+ Error: someError("bad"),
+ },
+ wantErr: ErrSuggestedWithoutField,
+ },
+ {
+ // The spec permits erroneous_field on its own;
+ // suggested_value is only "MAY set" once
+ // erroneous_field is present.
+ name: "erroneous field without suggested value",
+ ie: &InvoiceError{
+ ErroneousField: someErrField(82),
+ Error: someError("bad amount"),
+ },
+ wantErr: nil,
+ },
+ {
+ name: "valid with all fields",
+ ie: &InvoiceError{
+ ErroneousField: someErrField(82),
+ SuggestedValue: someSuggested([]byte{0x01}),
+ Error: someError("bad amount"),
+ },
+ wantErr: nil,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ err := ValidateInvoiceErrorWrite(tc.ie)
+ if tc.wantErr == nil {
+ require.NoError(t, err)
+
+ return
+ }
+ require.ErrorIs(t, err, tc.wantErr)
+ })
+ }
+}
Why this scored 21/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.