From 65db8fee4971865ec2d46d9facfe8a61dc3a79c1 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Wed, 6 May 2026 16:39:59 +0200 Subject: [PATCH] bolt12: add chains TLV subtype Introduce the ChainsRecord subtype used by the offer_chains and invoice_chains TLV fields. Decoding caps the count at maxOfferChains to bound allocation. --- bolt12/doc.go | 19 +++++ bolt12/subtypes.go | 87 +++++++++++++++++++++++ bolt12/subtypes_test.go | 154 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 260 insertions(+) create mode 100644 bolt12/doc.go create mode 100644 bolt12/subtypes.go create mode 100644 bolt12/subtypes_test.go diff --git a/bolt12/doc.go b/bolt12/doc.go new file mode 100644 index 000000000..c58a1ced1 --- /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 000000000..dcd5e41de --- /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 000000000..ceecd14a0 --- /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()) + }) + } +}