mirror of
https://github.com/lightningnetwork/lnd.git
synced 2026-08-13 12:32:48 +02:00
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:
parent
771c34f2f5
commit
65db8fee49
3 changed files with 260 additions and 0 deletions
19
bolt12/doc.go
Normal file
19
bolt12/doc.go
Normal 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
87
bolt12/subtypes.go
Normal 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
154
bolt12/subtypes_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue