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.
This commit is contained in:
bitromortac 2026-05-06 16:39:59 +02:00
parent 771c34f2f5
commit 65db8fee49
3 changed files with 260 additions and 0 deletions

19
bolt12/doc.go Normal file
View file

@ -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

87
bolt12/subtypes.go Normal file
View file

@ -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
}

154
bolt12/subtypes_test.go Normal file
View file

@ -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())
})
}
}