What changed, and why it matters
This commit adds a new data type for handling lists of blockchain identifiers in BOLT 12 offers and invoices. The main security-relevant detail is that the decoder now limits how many chain hashes can be requested at once, preventing a malformed message from tricking the software into allocating excessive memory. It is a defensive, forward-looking change rather than a fix for an active bug.
No immediate action required. Treat as routine defensive hardening. Continue reviewing the broader BOLT 12 codec series for consistent input validation and resource limits.
Security signals we found
New decoder enforces maxOfferChains cap to bound memory allocation
Length validation ensures chain hash list length is a multiple of 32 bytes
No use of untrusted length for allocation without an upper bound
Test coverage includes malformed length and cap-exceeded cases
Evidence from the diff
The change introduces ChainsRecord in a new bolt12 package, implementing tlv.RecordProducer for the BOLT 12 offer_chains/invoice_chains fields. Encoding concatenates 32-byte chain hashes with no length prefix; decoding uses the TLV length to compute the count and rejects inputs whose length is not a multiple of 32 bytes or that imply more than maxOfferChains (32) entries. Tests verify rejection of malformed lengths, the cap, and round-trip behavior against BOLT 12 test vectors.
Changed components
bolt12/subtypes.gobolt12/subtypes_test.gobolt12/doc.goInspect captured patch +260 / −0
diff --git a/bolt12/doc.go b/bolt12/doc.go
new file mode 100644
index 0000000..c58a1ce
--- /dev/null
+++ b/bolt12/doc.go
@@ -0,0 +1,19 @@
+// Package bolt12 implements encoding, decoding, and validation for BOLT 12
+// Offers, Invoice Requests, and Invoices. It provides a pure codec library
+// with no LND daemon dependencies.
+//
+// BOLT 12 messages use TLV streams encoded with a checksumless bech32 variant
+// and signed with BIP-340 Schnorr signatures over a Merkle tree of TLV fields.
+//
+// Human-readable prefixes:
+// - lno: Offer
+// - lnr: Invoice Request
+// - lni: Invoice
+//
+// # Codec Contract
+//
+// Encode validates before serialising and refuses to emit bytes that would fail
+// the writer requirements, invalid bytes are unrepresentable on the wire.
+// Low-level decoders stay permissive so diagnostic and fuzz harnesses can
+// inspect malformed input.
+package bolt12
diff --git a/bolt12/subtypes.go b/bolt12/subtypes.go
new file mode 100644
index 0000000..dcd5e41
--- /dev/null
+++ b/bolt12/subtypes.go
@@ -0,0 +1,87 @@
+package bolt12
+
+import (
+ "errors"
+ "fmt"
+ "io"
+
+ "github.com/lightningnetwork/lnd/tlv"
+)
+
+// ErrTooManyChains is returned when offer_chains declares more entries than
+// maxOfferChains.
+var ErrTooManyChains = errors.New("offer_chains exceeds maxOfferChains")
+
+const (
+ // chainHashLen is the length of a chain hash (32 bytes).
+ chainHashLen = 32
+
+ // maxOfferChains caps decoded offer_chains entries. This is a sanity
+ // check to prevent excessive memory allocation and is not a protocol
+ // limit but a local implementation choice.
+ maxOfferChains = 32
+)
+
+// ChainsRecord holds one or more chain hashes for the offer_chains field.
+type ChainsRecord struct {
+ Chains [][chainHashLen]byte
+}
+
+var _ tlv.RecordProducer = (*ChainsRecord)(nil)
+
+// Record returns a TLV record for ChainsRecord.
+func (c *ChainsRecord) Record() tlv.Record {
+ return tlv.MakeDynamicRecord(
+ 0, c,
+ func() uint64 {
+ return uint64(len(c.Chains)) * chainHashLen
+ },
+ encodeChainsRecord,
+ decodeChainsRecord,
+ )
+}
+
+// encodeChainsRecord writes the chain hashes in sequence, without a count
+// prefix.
+func encodeChainsRecord(w io.Writer, val any, _ *[8]byte) error {
+ c, ok := val.(*ChainsRecord)
+ if !ok {
+ return fmt.Errorf("expected *ChainsRecord, got %T", val)
+ }
+
+ for _, chain := range c.Chains {
+ if _, err := w.Write(chain[:]); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+// decodeChainsRecord caps the count at maxOfferChains to bound allocation.
+func decodeChainsRecord(r io.Reader, val any, _ *[8]byte, l uint64) error {
+ c, ok := val.(*ChainsRecord)
+ if !ok {
+ return fmt.Errorf("expected *ChainsRecord, got %T", val)
+ }
+
+ if l%chainHashLen != 0 {
+ return fmt.Errorf("chains length %d not a multiple of %d", l,
+ chainHashLen)
+ }
+
+ numChains := l / chainHashLen
+ if numChains > maxOfferChains {
+ return fmt.Errorf("%w: %d > %d", ErrTooManyChains, numChains,
+ maxOfferChains)
+ }
+
+ c.Chains = make([][chainHashLen]byte, numChains)
+ for i := range c.Chains {
+ if _, err := io.ReadFull(r, c.Chains[i][:]); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
diff --git a/bolt12/subtypes_test.go b/bolt12/subtypes_test.go
new file mode 100644
index 0000000..ceecd14
--- /dev/null
+++ b/bolt12/subtypes_test.go
@@ -0,0 +1,154 @@
+package bolt12
+
+import (
+ "bytes"
+ "encoding/hex"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// TestDecodeChainsRecord pins the chain-array decoder's structural rejections.
+func TestDecodeChainsRecord(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ data []byte
+ wantErr error
+ wantMsg string
+ }{
+ {
+ name: "length not multiple of 32",
+ data: append(
+ bytes.Repeat(
+ []byte{0xaa}, chainHashLen,
+ ),
+ 187,
+ ),
+ wantMsg: "not a multiple of",
+ },
+ {
+ name: "exceeds cap",
+ data: bytes.Repeat(
+ []byte{0x00}, (maxOfferChains+1)*chainHashLen,
+ ),
+ wantErr: ErrTooManyChains,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(
+ tc.name,
+ func(t *testing.T) {
+ t.Parallel()
+
+ var c ChainsRecord
+ err := decodeChainsRecord(
+ bytes.NewReader(tc.data), &c,
+ new([8]byte),
+ uint64(
+ len(tc.data),
+ ),
+ )
+ require.Error(t, err)
+
+ if tc.wantErr != nil {
+ require.ErrorIs(t, err, tc.wantErr)
+ }
+
+ if tc.wantMsg != "" {
+ require.Contains(
+ t, err.Error(), tc.wantMsg,
+ )
+ }
+ },
+ )
+ }
+}
+
+// TestChainsRecordRoundTrip pins decode→re-encode against the BOLT 12 offer
+// test vectors.
+func TestChainsRecordRoundTrip(t *testing.T) {
+ t.Parallel()
+
+ // bitcoinHash is the bitcoin mainnet genesis hash hex-decoded into a
+ // fixed array. Defined locally so the test does not depend on constants
+ // introduced by later commits.
+ bitcoinHashHex := "6fe28c0ab6f1b372c1a6a246ae63f74f931e8365" +
+ "e15a089c68d6190000000000"
+
+ var bitcoinHash [chainHashLen]byte
+ bitcoinHashBytes, err := hex.DecodeString(bitcoinHashHex)
+ require.NoError(t, err)
+ copy(bitcoinHash[:], bitcoinHashBytes)
+
+ tests := []struct {
+ name string
+ // hex is the on-wire bytes of the offer_chains TLV value
+ // (concatenated 32-byte chain hashes), copied from
+ // bolt12/offers-test.json.
+ hex string
+ wantLen int
+ wantHash [chainHashLen]byte
+ }{
+ {
+ name: "single testnet chain",
+ hex: "43497fd7f826957108f4a30fd9cec3ae" +
+ "ba79972084e90ead01ea330900000000",
+ wantLen: 1,
+ },
+ {
+ name: "single bitcoin chain",
+ hex: bitcoinHashHex,
+ wantLen: 1,
+ wantHash: bitcoinHash,
+ },
+ {
+ name: "two chains liquidv1 then bitcoin",
+ hex: "1466275836220db2944ca059a3a10ef6fd2ea684b" +
+ "0688d2c379296888a206003" + bitcoinHashHex,
+ wantLen: 2,
+ // Second chain in the list is bitcoin mainnet.
+ wantHash: bitcoinHash,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ data, err := hex.DecodeString(tc.hex)
+ require.NoError(t, err)
+
+ var c ChainsRecord
+ err = decodeChainsRecord(
+ bytes.NewReader(data), &c, new([8]byte),
+ uint64(
+ len(data),
+ ),
+ )
+ require.NoError(t, err)
+ require.Len(t, c.Chains, tc.wantLen)
+
+ // Cross-check the canonical bitcoin chain hash where
+ // the row knows which slot it lives in.
+ var zero [chainHashLen]byte
+ if tc.wantHash != zero {
+ idx := tc.wantLen - 1
+ require.Equal(
+ t, tc.wantHash, c.Chains[idx],
+ "bitcoin hash mismatch in slot %d",
+ idx,
+ )
+ }
+
+ var buf bytes.Buffer
+ require.NoError(
+ t, encodeChainsRecord(&buf, &c, new([8]byte)),
+ )
+
+ require.Equal(t, data, buf.Bytes())
+ })
+ }
+}
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.