diff --git a/internal/handshake/block_test.go b/internal/handshake/block_test.go index 55c2d49..10a9b07 100644 --- a/internal/handshake/block_test.go +++ b/internal/handshake/block_test.go @@ -95,7 +95,7 @@ func TestDecodeHandshakeBlock(t *testing.T) { Version: 0, Hash: decodeHex("5ad99a3052017938562ede6e228b68ca50c14663"), }, - Covenant: handshake.Covenant{ + Covenant: handshake.GenericCovenant{ Type: 8, Items: [][]byte{ decodeHex("c89c49ce327748244702f481f35097199cca2f7c2549a33ecacbdf973690e534"), diff --git a/internal/handshake/covenant.go b/internal/handshake/covenant.go new file mode 100644 index 0000000..bbd4d59 --- /dev/null +++ b/internal/handshake/covenant.go @@ -0,0 +1,149 @@ +// Copyright 2025 Blink Labs Software +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +package handshake + +import ( + "encoding/binary" + "errors" + "io" +) + +// Covenant types +const ( + CovenantTypeNone = 0 + CovenantTypeClaim = 1 + CovenantTypeOpen = 2 + CovenantTypeBid = 3 + CovenantTypeReveal = 4 + CovenantTypeRedeem = 5 + CovenantTypeRegister = 6 + CovenantTypeUpdate = 7 + CovenantTypeRenew = 8 + CovenantTypeTransfer = 9 + CovenantTypeFinalize = 10 + CovenantTypeRevoke = 11 +) + +type Covenant interface { + isCovenant() +} + +type GenericCovenant struct { + Type uint8 + Items [][]byte +} + +func (*GenericCovenant) isCovenant() {} + +func (c *GenericCovenant) Decode(r io.Reader) error { + if err := binary.Read(r, binary.LittleEndian, &c.Type); err != nil { + return err + } + itemCount, err := binary.ReadUvarint(r.(io.ByteReader)) + if err != nil { + return err + } + for i := uint64(0); i < itemCount; i++ { + itemLength, err := binary.ReadUvarint(r.(io.ByteReader)) + if err != nil { + return err + } + item := make([]byte, itemLength) + if err := binary.Read(r, binary.LittleEndian, &item); err != nil { + return err + } + c.Items = append(c.Items, item) + } + return nil +} + +func (c *GenericCovenant) Covenant() Covenant { + switch c.Type { + case CovenantTypeRegister: + ret, err := NewRegisterCovenantFromGeneric(c) + if err != nil { + panic("can't convert generic covenant to Register") + } + return ret + case CovenantTypeUpdate: + ret, err := NewUpdateCovenantFromGeneric(c) + if err != nil { + panic("can't convert generic covenant to Update") + } + return ret + } + // Return generic covenant (ourselves) + return c +} + +type RegisterCovenant struct { + NameHash []byte + Height uint32 + ResourceData DomainResourceData + BlockHash []byte +} + +func (RegisterCovenant) isCovenant() {} + +func NewRegisterCovenantFromGeneric(gc *GenericCovenant) (*RegisterCovenant, error) { + if gc.Type != CovenantTypeRegister { + return nil, errors.New("wrong covenant type") + } + if len(gc.Items) != 4 { + return nil, errors.New("incorrect items length") + } + ret := &RegisterCovenant{ + NameHash: make([]byte, len(gc.Items[0])), + BlockHash: make([]byte, len(gc.Items[3])), + } + // Copy hashes + copy(ret.NameHash, gc.Items[0]) + copy(ret.BlockHash, gc.Items[3]) + // Decode height from bytes + ret.Height = binary.LittleEndian.Uint32(gc.Items[1]) + // Decode resource data + tmpData, err := NewDomainResourceDataFromBytes(gc.Items[2]) + if err != nil { + return nil, err + } + ret.ResourceData = *tmpData + return ret, nil +} + +type UpdateCovenant struct { + NameHash []byte + Height uint32 + ResourceData DomainResourceData + BlockHash []byte +} + +func (UpdateCovenant) isCovenant() {} + +func NewUpdateCovenantFromGeneric(gc *GenericCovenant) (*UpdateCovenant, error) { + if gc.Type != CovenantTypeUpdate { + return nil, errors.New("wrong covenant type") + } + if len(gc.Items) != 4 { + return nil, errors.New("incorrect items length") + } + ret := &UpdateCovenant{ + NameHash: make([]byte, len(gc.Items[0])), + BlockHash: make([]byte, len(gc.Items[3])), + } + // Copy hashes + copy(ret.NameHash, gc.Items[0]) + copy(ret.BlockHash, gc.Items[3]) + // Decode height from bytes + ret.Height = binary.LittleEndian.Uint32(gc.Items[1]) + // Decode resource data + tmpData, err := NewDomainResourceDataFromBytes(gc.Items[2]) + if err != nil { + return nil, err + } + ret.ResourceData = *tmpData + return ret, nil +} diff --git a/internal/handshake/covenant_test.go b/internal/handshake/covenant_test.go new file mode 100644 index 0000000..29d6f3d --- /dev/null +++ b/internal/handshake/covenant_test.go @@ -0,0 +1,147 @@ +// Copyright 2025 Blink Labs Software +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +package handshake_test + +import ( + "net" + "reflect" + "testing" + + "github.com/blinklabs-io/cdnsd/internal/handshake" +) + +func TestCovenantRegisterFromGeneric(t *testing.T) { + // This data comes from mainnet TX 63ba84b6362724aa8fd484d3616c8d1bdea68240c8e0cd6a104fcf85a35d52fb + testGenericCovenant := &handshake.GenericCovenant{ + Type: handshake.CovenantTypeRegister, + Items: [][]byte{ + decodeHex("62a90ce374b1499d0b67b1e4e6164b18acacbd16be905a6ffee593d48d4e0a82"), + decodeHex("8d1e0400"), + decodeHex("0002036e73310a69727677696c6c69616d002ce706b701c00202036e7332c00636d688f601c01a00d5580d0114402ed0125506f35ba249265f39b988d7028a28c300d5580d02200c6c45064c26b529b4ac074dff5de60a99d6025d5b0d7f32c2b8c7d40ec8b3de00d5580d043071cb0417852b08b965413f3b871b033996159d121a585e35111a335d4cfb79b67e49a99c3829f6a1f42e100f7f33d7d9"), + decodeHex("0000000000000000153c62dbcabb762c254fb4104ab7cdd779926b79b34601fc"), + }, + } + expectedCovenant := &handshake.RegisterCovenant{ + NameHash: decodeHex("62a90ce374b1499d0b67b1e4e6164b18acacbd16be905a6ffee593d48d4e0a82"), + Height: 269965, + ResourceData: handshake.DomainResourceData{ + Version: 0, + Records: []handshake.DomainRecord{ + &handshake.Glue4DomainRecord{ + Name: "ns1.irvwilliam.", + Address: net.ParseIP("44.231.6.183").To4(), + }, + &handshake.NsDomainRecord{ + Name: "ns1.irvwilliam.", + }, + &handshake.Glue4DomainRecord{ + Name: "ns2.irvwilliam.", + Address: net.ParseIP("54.214.136.246").To4(), + }, + &handshake.NsDomainRecord{ + Name: "ns2.irvwilliam.", + }, + &handshake.DsDomainRecord{ + KeyTag: 54616, + Algorithm: 13, + DigestType: 1, + Digest: decodeHex("402ed0125506f35ba249265f39b988d7028a28c3"), + }, + &handshake.DsDomainRecord{ + KeyTag: 54616, + Algorithm: 13, + DigestType: 2, + Digest: decodeHex("0c6c45064c26b529b4ac074dff5de60a99d6025d5b0d7f32c2b8c7d40ec8b3de"), + }, + &handshake.DsDomainRecord{ + KeyTag: 54616, + Algorithm: 13, + DigestType: 4, + Digest: decodeHex("71cb0417852b08b965413f3b871b033996159d121a585e35111a335d4cfb79b67e49a99c3829f6a1f42e100f7f33d7d9"), + }, + }, + }, + BlockHash: decodeHex("0000000000000000153c62dbcabb762c254fb4104ab7cdd779926b79b34601fc"), + } + tmpCovenant, err := handshake.NewRegisterCovenantFromGeneric(testGenericCovenant) + if err != nil { + t.Fatalf("unexpected error creating RegisterCovenant from GenericCovenant: %s", err) + } + if !reflect.DeepEqual(tmpCovenant, expectedCovenant) { + t.Fatalf( + "did not get expected covenant:\n got: %#v\n wanted: %#v", + tmpCovenant, + expectedCovenant, + ) + } +} + +func TestCovenantUpdateFromGeneric(t *testing.T) { + // This data comes from mainnet TX 63ba84b6362724aa8fd484d3616c8d1bdea68240c8e0cd6a104fcf85a35d52fb + testGenericCovenant := &handshake.GenericCovenant{ + Type: handshake.CovenantTypeUpdate, + Items: [][]byte{ + decodeHex("62a90ce374b1499d0b67b1e4e6164b18acacbd16be905a6ffee593d48d4e0a82"), + decodeHex("8d1e0400"), + decodeHex("0002036e73310a69727677696c6c69616d002ce706b701c00202036e7332c00636d688f601c01a00d5580d0114402ed0125506f35ba249265f39b988d7028a28c300d5580d02200c6c45064c26b529b4ac074dff5de60a99d6025d5b0d7f32c2b8c7d40ec8b3de00d5580d043071cb0417852b08b965413f3b871b033996159d121a585e35111a335d4cfb79b67e49a99c3829f6a1f42e100f7f33d7d9"), + decodeHex("0000000000000000153c62dbcabb762c254fb4104ab7cdd779926b79b34601fc"), + }, + } + expectedCovenant := &handshake.UpdateCovenant{ + NameHash: decodeHex("62a90ce374b1499d0b67b1e4e6164b18acacbd16be905a6ffee593d48d4e0a82"), + Height: 269965, + ResourceData: handshake.DomainResourceData{ + Version: 0, + Records: []handshake.DomainRecord{ + &handshake.Glue4DomainRecord{ + Name: "ns1.irvwilliam.", + Address: net.ParseIP("44.231.6.183").To4(), + }, + &handshake.NsDomainRecord{ + Name: "ns1.irvwilliam.", + }, + &handshake.Glue4DomainRecord{ + Name: "ns2.irvwilliam.", + Address: net.ParseIP("54.214.136.246").To4(), + }, + &handshake.NsDomainRecord{ + Name: "ns2.irvwilliam.", + }, + &handshake.DsDomainRecord{ + KeyTag: 54616, + Algorithm: 13, + DigestType: 1, + Digest: decodeHex("402ed0125506f35ba249265f39b988d7028a28c3"), + }, + &handshake.DsDomainRecord{ + KeyTag: 54616, + Algorithm: 13, + DigestType: 2, + Digest: decodeHex("0c6c45064c26b529b4ac074dff5de60a99d6025d5b0d7f32c2b8c7d40ec8b3de"), + }, + &handshake.DsDomainRecord{ + KeyTag: 54616, + Algorithm: 13, + DigestType: 4, + Digest: decodeHex("71cb0417852b08b965413f3b871b033996159d121a585e35111a335d4cfb79b67e49a99c3829f6a1f42e100f7f33d7d9"), + }, + }, + }, + BlockHash: decodeHex("0000000000000000153c62dbcabb762c254fb4104ab7cdd779926b79b34601fc"), + } + tmpCovenant, err := handshake.NewUpdateCovenantFromGeneric(testGenericCovenant) + if err != nil { + t.Fatalf("unexpected error creating UpdateCovenant from GenericCovenant: %s", err) + } + if !reflect.DeepEqual(tmpCovenant, expectedCovenant) { + t.Fatalf( + "did not get expected covenant:\n got: %#v\n wanted: %#v", + tmpCovenant, + expectedCovenant, + ) + } +} diff --git a/internal/handshake/domain.go b/internal/handshake/domain.go new file mode 100644 index 0000000..01f1637 --- /dev/null +++ b/internal/handshake/domain.go @@ -0,0 +1,342 @@ +// Copyright 2025 Blink Labs Software +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +package handshake + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "io" + "net" +) + +const ( + DnsMaxName = 255 + DnsMaxLabel = 63 +) + +// Record types +const ( + RecordTypeDS = 0 + RecordTypeNS = 1 + RecordTypeGLUE4 = 2 + RecordTypeGLUE6 = 3 + RecordTypeSYNTH4 = 4 + RecordTypeSYNTH6 = 5 + RecordTypeTEXT = 6 +) + +type BytesReader struct { + *bytes.Buffer + origBytes []byte +} + +func NewBytesReader(buf []byte) *BytesReader { + ret := &BytesReader{ + Buffer: bytes.NewBuffer(buf), + } + if buf != nil { + ret.origBytes = make([]byte, len(buf)) + copy(ret.origBytes, buf) + } + return ret +} + +func (b *BytesReader) OriginalBytes() []byte { + return b.origBytes +} + +type DomainResourceData struct { + Version uint8 + Records []DomainRecord +} + +func NewDomainResourceDataFromBytes(data []byte) (*DomainResourceData, error) { + ret := &DomainResourceData{} + if err := ret.decode(data); err != nil { + return nil, err + } + return ret, nil +} + +func (d *DomainResourceData) decode(data []byte) error { + r := NewBytesReader(data) + var err error + // Version + if err = binary.Read(r, binary.LittleEndian, &d.Version); err != nil { + return err + } + // Records + var recordType uint8 + var record DomainRecord + for { + // Read record type + if err = binary.Read(r, binary.LittleEndian, &recordType); err != nil { + if errors.Is(err, io.EOF) { + break + } + return err + } + switch recordType { + case RecordTypeDS: + record = &DsDomainRecord{} + case RecordTypeNS: + record = &NsDomainRecord{} + case RecordTypeGLUE4: + record = &Glue4DomainRecord{} + case RecordTypeGLUE6: + record = &Glue6DomainRecord{} + case RecordTypeSYNTH4: + record = &Synth4DomainRecord{} + case RecordTypeSYNTH6: + record = &Synth6DomainRecord{} + case RecordTypeTEXT: + record = &TextDomainRecord{} + default: + return fmt.Errorf("unsupported record type %d", recordType) + } + if record != nil { + err = record.decode(r) + if err != nil { + return err + } + } + d.Records = append(d.Records, record) + } + return nil +} + +type DomainRecord interface { + isDomainRecord() + decode(*BytesReader) error +} + +type Glue4DomainRecord struct { + Name string + Address net.IP +} + +func (*Glue4DomainRecord) isDomainRecord() {} + +func (g *Glue4DomainRecord) decode(r *BytesReader) error { + name, err := domainRecordNameDecode(r) + if err != nil { + return err + } + g.Name = name + addr, err := domainRecordIPv4Decode(r) + if err != nil { + return err + } + g.Address = addr + return nil +} + +type Glue6DomainRecord struct { + Name string + Address net.IP +} + +func (*Glue6DomainRecord) isDomainRecord() {} + +func (g *Glue6DomainRecord) decode(r *BytesReader) error { + name, err := domainRecordNameDecode(r) + if err != nil { + return err + } + g.Name = name + addr, err := domainRecordIPv6Decode(r) + if err != nil { + return err + } + g.Address = addr + return nil +} + +type NsDomainRecord struct { + Name string +} + +func (*NsDomainRecord) isDomainRecord() {} + +func (n *NsDomainRecord) decode(r *BytesReader) error { + name, err := domainRecordNameDecode(r) + if err != nil { + return err + } + n.Name = name + return nil +} + +type DsDomainRecord struct { + KeyTag uint16 + Algorithm uint8 + DigestType uint8 + Digest []byte +} + +func (*DsDomainRecord) isDomainRecord() {} + +func (d *DsDomainRecord) decode(r *BytesReader) error { + if err := binary.Read(r, binary.BigEndian, &d.KeyTag); err != nil { + return err + } + if err := binary.Read(r, binary.LittleEndian, &d.Algorithm); err != nil { + return err + } + if err := binary.Read(r, binary.LittleEndian, &d.DigestType); err != nil { + return err + } + var size uint8 + if err := binary.Read(r, binary.LittleEndian, &size); err != nil { + return err + } + d.Digest = make([]byte, size) + if err := binary.Read(r, binary.LittleEndian, &d.Digest); err != nil { + return err + } + return nil +} + +type Synth4DomainRecord struct { + Address net.IP +} + +func (*Synth4DomainRecord) isDomainRecord() {} + +func (s *Synth4DomainRecord) decode(r *BytesReader) error { + addr, err := domainRecordIPv4Decode(r) + if err != nil { + return err + } + s.Address = addr + return nil +} + +type Synth6DomainRecord struct { + Address net.IP +} + +func (*Synth6DomainRecord) isDomainRecord() {} + +func (s *Synth6DomainRecord) decode(r *BytesReader) error { + addr, err := domainRecordIPv6Decode(r) + if err != nil { + return err + } + s.Address = addr + return nil +} + +type TextDomainRecord struct { + Items [][]byte +} + +func (*TextDomainRecord) isDomainRecord() {} + +func (t *TextDomainRecord) decode(r *BytesReader) error { + // Read length of items list + var length uint8 + if err := binary.Read(r, binary.LittleEndian, &length); err != nil { + return err + } + var size uint8 + for i := 0; i < int(length); i++ { + // Read item size + if err := binary.Read(r, binary.LittleEndian, &size); err != nil { + return err + } + // Read item + buf := make([]byte, size) + if err := binary.Read(r, binary.LittleEndian, &buf); err != nil { + return err + } + t.Items = append(t.Items, buf) + } + return nil +} + +func domainRecordIPv4Decode(r *BytesReader) (net.IP, error) { + var ret net.IP + buf := make([]byte, 4) + if err := binary.Read(r, binary.LittleEndian, &buf); err != nil { + return ret, err + } + ret = net.IP(buf) + return ret, nil +} + +func domainRecordIPv6Decode(r *BytesReader) (net.IP, error) { + var ret net.IP + buf := make([]byte, 16) + if err := binary.Read(r, binary.LittleEndian, &buf); err != nil { + return ret, err + } + ret = net.IP(buf) + return ret, nil +} + +func domainRecordNameDecode(r *BytesReader) (string, error) { + // NOTE: this function is mostly ported straight from hnsd + var name string + for { + c, err := r.ReadByte() + if err != nil { + return "", err + } + if c == 0x00 { + break + } + switch c & 0xc0 { + case 0x00: + if c > DnsMaxLabel { + return "", errors.New("label too long") + } + for j := 0; j < int(c); j++ { + b, err := r.ReadByte() + if err != nil { + return "", err + } + // Replace NULL + // This is necessary for C-style strings, but probably not for Go + if b == 0x00 { + b = 0xff + } + // Replace period to prevent double-period + if b == 0x2e { + b = 0xfe + } + name += string([]byte{b}) + } + if len(name) > 0 { + name += "." + } + case 0xc0: + // Lookup name from earlier in the buffer + c1, err := r.ReadByte() + if err != nil { + return "", err + } + // Set new 16-bit offset value based on the lower 6 bits of our original + // byte and the additional byte we just read above + offset := (int(c^0xc0) << 8) | int(c1) + // Replace buffer with original bytes + data := r.OriginalBytes() + r = NewBytesReader(data) + // Read and discard bytes until we reach the calculated offset + for j := 0; j < offset; j++ { + if _, err := r.ReadByte(); err != nil { + return "", err + } + } + default: + return "", errors.New("unexpected value") + } + } + return name, nil +} diff --git a/internal/handshake/domain_test.go b/internal/handshake/domain_test.go new file mode 100644 index 0000000..f735cba --- /dev/null +++ b/internal/handshake/domain_test.go @@ -0,0 +1,84 @@ +// Copyright 2025 Blink Labs Software +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +package handshake_test + +import ( + "net" + "reflect" + "testing" + + "github.com/blinklabs-io/cdnsd/internal/handshake" +) + +func TestDomainResourceData(t *testing.T) { + // This data comes from mainnet TX 63ba84b6362724aa8fd484d3616c8d1bdea68240c8e0cd6a104fcf85a35d52fb + testResourceDataBytes := decodeHex("0002036e73310a69727677696c6c69616d002ce706b701c00202036e7332c00636d688f601c01a00d5580d0114402ed0125506f35ba249265f39b988d7028a28c300d5580d02200c6c45064c26b529b4ac074dff5de60a99d6025d5b0d7f32c2b8c7d40ec8b3de00d5580d043071cb0417852b08b965413f3b871b033996159d121a585e35111a335d4cfb79b67e49a99c3829f6a1f42e100f7f33d7d9") + expectedResourceData := &handshake.DomainResourceData{ + Version: 0, + Records: []handshake.DomainRecord{ + &handshake.Glue4DomainRecord{ + Name: "ns1.irvwilliam.", + Address: net.ParseIP("44.231.6.183").To4(), + }, + &handshake.NsDomainRecord{ + Name: "ns1.irvwilliam.", + }, + &handshake.Glue4DomainRecord{ + Name: "ns2.irvwilliam.", + Address: net.ParseIP("54.214.136.246").To4(), + }, + &handshake.NsDomainRecord{ + Name: "ns2.irvwilliam.", + }, + &handshake.DsDomainRecord{ + KeyTag: 54616, + Algorithm: 13, + DigestType: 1, + Digest: decodeHex("402ed0125506f35ba249265f39b988d7028a28c3"), + }, + &handshake.DsDomainRecord{ + KeyTag: 54616, + Algorithm: 13, + DigestType: 2, + Digest: decodeHex("0c6c45064c26b529b4ac074dff5de60a99d6025d5b0d7f32c2b8c7d40ec8b3de"), + }, + &handshake.DsDomainRecord{ + KeyTag: 54616, + Algorithm: 13, + DigestType: 4, + Digest: decodeHex("71cb0417852b08b965413f3b871b033996159d121a585e35111a335d4cfb79b67e49a99c3829f6a1f42e100f7f33d7d9"), + }, + }, + } + tmpResourceData, err := handshake.NewDomainResourceDataFromBytes(testResourceDataBytes) + if err != nil { + t.Fatalf("unexpected error decoding resource data: %s", err) + } + if tmpResourceData.Version != expectedResourceData.Version { + t.Fatalf( + "did not get expected version: got %d, wanted %d", + tmpResourceData.Version, + expectedResourceData.Version, + ) + } + if len(tmpResourceData.Records) != len(expectedResourceData.Records) { + t.Fatalf( + "did not get expected resource count: got %d, wanted %d", + len(tmpResourceData.Records), + len(expectedResourceData.Records), + ) + } + for idx, record := range tmpResourceData.Records { + if !reflect.DeepEqual(record, expectedResourceData.Records[idx]) { + t.Fatalf( + "did not get expected domain record\n got: %#v\n wanted: %#v", + record, + expectedResourceData.Records[idx], + ) + } + } +} diff --git a/internal/handshake/transaction.go b/internal/handshake/transaction.go index b433473..534dc11 100644 --- a/internal/handshake/transaction.go +++ b/internal/handshake/transaction.go @@ -99,7 +99,7 @@ func (i *TransactionInput) DecodeWitness(r io.Reader) error { type TransactionOutput struct { Value uint64 Address Address - Covenant Covenant + Covenant GenericCovenant } func (o *TransactionOutput) Decode(r io.Reader) error { @@ -154,30 +154,3 @@ func (a *Address) Decode(r io.Reader) error { } return nil } - -type Covenant struct { - Type uint8 - Items [][]byte -} - -func (c *Covenant) Decode(r io.Reader) error { - if err := binary.Read(r, binary.LittleEndian, &c.Type); err != nil { - return err - } - itemCount, err := binary.ReadUvarint(r.(io.ByteReader)) - if err != nil { - return err - } - for i := uint64(0); i < itemCount; i++ { - itemLength, err := binary.ReadUvarint(r.(io.ByteReader)) - if err != nil { - return err - } - item := make([]byte, itemLength) - if err := binary.Read(r, binary.LittleEndian, &item); err != nil { - return err - } - c.Items = append(c.Items, item) - } - return nil -}