fix(p2p): correct network IDs and serialisation for C++ daemon compat

Three bugs found by integration testing against the C++ testnet daemon:

1. NetworkIDMainnet/Testnet had byte 10 swapped — the C++ #ifndef TESTNET
   branch (mainnet) sets P2P_NETWORK_ID_TESTNET_FLAG=1, and the #else
   (testnet) sets it to 0. Counter-intuitive but matches compiled binaries.

2. ClientVersion format "Lethean/go-blockchain 0.1.0" was rejected by the
   daemon's parse_client_version which expects "major.minor.rev.build[commit]".
   Changed to "6.0.1.2[go-blockchain]".

3. RequestChain, RequestGetObjects, and ResponseGetObjects used StringArrayVal
   for hash fields, but the C++ daemon uses KV_SERIALIZE_CONTAINER_POD_AS_BLOB
   which packs all 32-byte hashes into a single concatenated blob. Also,
   ResponseChainEntry.m_block_ids is an object array of block_context_info
   (hash + cumulative size), not a simple hash list.

Co-Authored-By: Charon <charon@lethean.io>
This commit is contained in:
Claude 2026-02-21 21:17:48 +00:00
parent d588f8f8d0
commit fcb867d53b
No known key found for this signature in database
GPG key ID: AF404715446AEB41
5 changed files with 121 additions and 31 deletions

View file

@ -276,22 +276,29 @@ const (
)
// NetworkIDMainnet is the 16-byte network UUID for mainnet P2P handshake.
// From net_node.inl: bytes 0-9 are fixed, byte 10 = testnet flag (0),
// From net_node.inl: bytes 0-9 are fixed, byte 10 = P2P_NETWORK_ID_TESTNET_FLAG,
// bytes 11-14 fixed, byte 15 = formation version (84 = 0x54).
// NOTE: In the C++ source, the #ifndef TESTNET branch (i.e. mainnet) sets
// P2P_NETWORK_ID_TESTNET_FLAG = 1 and the #else (testnet) sets it to 0.
// The naming is counter-intuitive but matches the compiled binaries.
var NetworkIDMainnet = [16]byte{
0x11, 0x10, 0x01, 0x11, 0x01, 0x01, 0x11, 0x01,
0x10, 0x11, 0x00, 0x11, 0x01, 0x11, 0x21, 0x54,
0x10, 0x11, 0x01, 0x11, 0x01, 0x11, 0x21, 0x54,
}
// NetworkIDTestnet is the 16-byte network UUID for testnet P2P handshake.
// Byte 10 = testnet flag (1), byte 15 = formation version (100 = 0x64).
// Byte 10 = 0x00 (P2P_NETWORK_ID_TESTNET_FLAG in testnet build),
// byte 15 = formation version (100 = 0x64).
var NetworkIDTestnet = [16]byte{
0x11, 0x10, 0x01, 0x11, 0x01, 0x01, 0x11, 0x01,
0x10, 0x11, 0x01, 0x11, 0x01, 0x11, 0x21, 0x64,
0x10, 0x11, 0x00, 0x11, 0x01, 0x11, 0x21, 0x64,
}
// ClientVersion is the version string sent in CORE_SYNC_DATA.
const ClientVersion = "Lethean/go-blockchain 0.1.0"
// The C++ daemon parses this as "major.minor.revision.build[commit]"
// and rejects connections where it cannot parse or the build number
// is below the minimum for the current hard-fork era.
const ClientVersion = "6.0.1.2[go-blockchain]"
// ---------------------------------------------------------------------------

View file

@ -168,20 +168,28 @@ func TestTransactionVersionConstants_Good(t *testing.T) {
}
func TestNetworkID_Good(t *testing.T) {
// Mainnet: byte 10 = 0 (not testnet), byte 15 = 84 (0x54)
if NetworkIDMainnet[10] != 0x00 {
t.Errorf("mainnet testnet flag: got %x, want 0x00", NetworkIDMainnet[10])
// In the C++ source, #ifndef TESTNET (mainnet) sets
// P2P_NETWORK_ID_TESTNET_FLAG=1 and #else (testnet) sets it to 0.
// The naming is counter-intuitive but matches the compiled binaries.
// Mainnet: byte 10 = 1 (flag from #ifndef branch), byte 15 = 84 (0x54)
if NetworkIDMainnet[10] != 0x01 {
t.Errorf("mainnet flag: got %x, want 0x01", NetworkIDMainnet[10])
}
if NetworkIDMainnet[15] != 0x54 {
t.Errorf("mainnet version: got %x, want 0x54", NetworkIDMainnet[15])
}
// Testnet: byte 10 = 1, byte 15 = 100 (0x64)
if NetworkIDTestnet[10] != 0x01 {
t.Errorf("testnet testnet flag: got %x, want 0x01", NetworkIDTestnet[10])
// Testnet: byte 10 = 0 (flag from #else branch), byte 15 = 100 (0x64)
if NetworkIDTestnet[10] != 0x00 {
t.Errorf("testnet flag: got %x, want 0x00", NetworkIDTestnet[10])
}
if NetworkIDTestnet[15] != 0x64 {
t.Errorf("testnet version: got %x, want 0x64", NetworkIDTestnet[15])
}
// The two IDs must differ.
if NetworkIDMainnet == NetworkIDTestnet {
t.Error("mainnet and testnet IDs must differ")
}
// ChainConfig should have them
if Mainnet.NetworkID != NetworkIDMainnet {
t.Error("Mainnet.NetworkID mismatch")

View file

@ -137,9 +137,10 @@ func TestNodeData_Good_NetworkIDBlob(t *testing.T) {
if len(blob) != 16 {
t.Fatalf("network_id blob: got %d bytes, want 16", len(blob))
}
// Byte 10 = testnet flag = 1
if blob[10] != 0x01 {
t.Errorf("testnet flag: got %x, want 0x01", blob[10])
// Byte 10 = P2P_NETWORK_ID_TESTNET_FLAG. In the C++ source, the
// #else (testnet) branch sets this to 0 (counter-intuitive naming).
if blob[10] != 0x00 {
t.Errorf("testnet flag: got %x, want 0x00", blob[10])
}
// Byte 15 = version = 0x64 (100)
if blob[15] != 0x64 {

View file

@ -86,12 +86,22 @@ type RequestGetObjects struct {
}
// Encode serialises the request.
// The C++ daemon uses KV_SERIALIZE_CONTAINER_POD_AS_BLOB for both blocks
// and txs, so we pack all hashes into single concatenated blobs.
func (r *RequestGetObjects) Encode() ([]byte, error) {
blocksBlob := make([]byte, 0, len(r.Blocks)*32)
for _, id := range r.Blocks {
blocksBlob = append(blocksBlob, id...)
}
s := levin.Section{
"blocks": levin.StringArrayVal(r.Blocks),
"blocks": levin.StringVal(blocksBlob),
}
if len(r.Txs) > 0 {
s["txs"] = levin.StringArrayVal(r.Txs)
txsBlob := make([]byte, 0, len(r.Txs)*32)
for _, id := range r.Txs {
txsBlob = append(txsBlob, id...)
}
s["txs"] = levin.StringVal(txsBlob)
}
return levin.EncodeStorage(s)
}
@ -103,10 +113,12 @@ func (r *RequestGetObjects) Decode(data []byte) error {
return err
}
if v, ok := s["blocks"]; ok {
r.Blocks, _ = v.AsStringArray()
blob, _ := v.AsString()
r.Blocks = splitHashes(blob, 32)
}
if v, ok := s["txs"]; ok {
r.Txs, _ = v.AsStringArray()
blob, _ := v.AsString()
r.Txs = splitHashes(blob, 32)
}
return nil
}
@ -132,7 +144,12 @@ func (r *ResponseGetObjects) Encode() ([]byte, error) {
"current_blockchain_height": levin.Uint64Val(r.CurrentHeight),
}
if len(r.MissedIDs) > 0 {
s["missed_ids"] = levin.StringArrayVal(r.MissedIDs)
// missed_ids uses KV_SERIALIZE_CONTAINER_POD_AS_BLOB in C++.
blob := make([]byte, 0, len(r.MissedIDs)*32)
for _, id := range r.MissedIDs {
blob = append(blob, id...)
}
s["missed_ids"] = levin.StringVal(blob)
}
return levin.EncodeStorage(s)
}
@ -159,7 +176,9 @@ func (r *ResponseGetObjects) Decode(data []byte) error {
}
}
if v, ok := s["missed_ids"]; ok {
r.MissedIDs, _ = v.AsStringArray()
// missed_ids uses KV_SERIALIZE_CONTAINER_POD_AS_BLOB in C++.
blob, _ := v.AsString()
r.MissedIDs = splitHashes(blob, 32)
}
return nil
}
@ -170,21 +189,37 @@ type RequestChain struct {
}
// Encode serialises the request.
// The C++ daemon uses KV_SERIALIZE_CONTAINER_POD_AS_BLOB for block_ids,
// so we pack all hashes into a single concatenated blob.
func (r *RequestChain) Encode() ([]byte, error) {
blob := make([]byte, 0, len(r.BlockIDs)*32)
for _, id := range r.BlockIDs {
blob = append(blob, id...)
}
s := levin.Section{
"block_ids": levin.StringArrayVal(r.BlockIDs),
"block_ids": levin.StringVal(blob),
}
return levin.EncodeStorage(s)
}
// BlockContextInfo holds a block hash and cumulative size from a chain
// entry response. Mirrors the C++ block_context_info struct.
type BlockContextInfo struct {
Hash []byte // 32-byte block hash (KV_SERIALIZE_VAL_POD_AS_BLOB)
CumulSize uint64 // Cumulative block size
}
// ResponseChainEntry is NOTIFY_RESPONSE_CHAIN_ENTRY (2007).
type ResponseChainEntry struct {
StartHeight uint64
TotalHeight uint64
BlockIDs [][]byte
BlockIDs [][]byte // Convenience: just the hashes
Blocks []BlockContextInfo // Full entries with cumulative sizes
}
// Decode parses a chain entry response.
// m_block_ids is an object array of block_context_info, each with
// "h" (hash blob) and "cumul_size" (uint64).
func (r *ResponseChainEntry) Decode(data []byte) error {
s, err := levin.DecodeStorage(data)
if err != nil {
@ -197,7 +232,28 @@ func (r *ResponseChainEntry) Decode(data []byte) error {
r.TotalHeight, _ = v.AsUint64()
}
if v, ok := s["m_block_ids"]; ok {
r.BlockIDs, _ = v.AsStringArray()
sections, _ := v.AsSectionArray()
r.Blocks = make([]BlockContextInfo, len(sections))
r.BlockIDs = make([][]byte, len(sections))
for i, sec := range sections {
if hv, ok := sec["h"]; ok {
r.Blocks[i].Hash, _ = hv.AsString()
r.BlockIDs[i] = r.Blocks[i].Hash
}
if cv, ok := sec["cumul_size"]; ok {
r.Blocks[i].CumulSize, _ = cv.AsUint64()
}
}
}
return nil
}
// splitHashes divides a concatenated blob into fixed-size hash slices.
func splitHashes(blob []byte, size int) [][]byte {
n := len(blob) / size
out := make([][]byte, n)
for i := 0; i < n; i++ {
out[i] = blob[i*size : (i+1)*size]
}
return out
}

View file

@ -66,18 +66,19 @@ func TestRequestChain_Good_Roundtrip(t *testing.T) {
t.Fatalf("encode: %v", err)
}
// Decode back via storage
// Decode back via storage — block_ids is a single concatenated blob
// (KV_SERIALIZE_CONTAINER_POD_AS_BLOB in C++).
s, err := levin.DecodeStorage(data)
if err != nil {
t.Fatalf("decode storage: %v", err)
}
if v, ok := s["block_ids"]; ok {
ids, err := v.AsStringArray()
blob, err := v.AsString()
if err != nil {
t.Fatalf("AsStringArray: %v", err)
t.Fatalf("AsString: %v", err)
}
if len(ids) != 1 || ids[0][0] != 0xFF {
t.Errorf("block_ids roundtrip failed")
if len(blob) != 32 || blob[0] != 0xFF {
t.Errorf("block_ids roundtrip failed: len=%d, first=%x", len(blob), blob[0])
}
} else {
t.Error("block_ids not found in decoded storage")
@ -87,10 +88,16 @@ func TestRequestChain_Good_Roundtrip(t *testing.T) {
func TestResponseChainEntry_Good_Decode(t *testing.T) {
hash := make([]byte, 32)
hash[31] = 0xAB
// m_block_ids is an object array of block_context_info,
// each with "h" (hash blob) and "cumul_size" (uint64).
entry := levin.Section{
"h": levin.StringVal(hash),
"cumul_size": levin.Uint64Val(1234),
}
s := levin.Section{
"start_height": levin.Uint64Val(100),
"total_height": levin.Uint64Val(6300),
"m_block_ids": levin.StringArrayVal([][]byte{hash}),
"m_block_ids": levin.ObjectArrayVal([]levin.Section{entry}),
}
data, err := levin.EncodeStorage(s)
if err != nil {
@ -113,6 +120,12 @@ func TestResponseChainEntry_Good_Decode(t *testing.T) {
if resp.BlockIDs[0][31] != 0xAB {
t.Errorf("block_ids[0][31]: got %x, want AB", resp.BlockIDs[0][31])
}
if len(resp.Blocks) != 1 {
t.Fatalf("blocks: got %d, want 1", len(resp.Blocks))
}
if resp.Blocks[0].CumulSize != 1234 {
t.Errorf("cumul_size: got %d, want 1234", resp.Blocks[0].CumulSize)
}
}
func TestRequestGetObjects_RoundTrip(t *testing.T) {
@ -144,9 +157,13 @@ func TestRequestGetObjects_RoundTrip(t *testing.T) {
}
func TestRequestGetObjects_WithTxs(t *testing.T) {
txHash := make([]byte, 32)
txHash[0] = 0xAA
txHash[1] = 0xBB
txHash[2] = 0xCC
req := RequestGetObjects{
Blocks: [][]byte{make([]byte, 32)},
Txs: [][]byte{{0xAA, 0xBB, 0xCC}},
Txs: [][]byte{txHash},
}
data, err := req.Encode()
if err != nil {
@ -197,9 +214,10 @@ func TestResponseGetObjects_Decode(t *testing.T) {
}
missedHash := make([]byte, 32)
missedHash[0] = 0xFF
// missed_ids uses KV_SERIALIZE_CONTAINER_POD_AS_BLOB in C++.
s := levin.Section{
"blocks": levin.ObjectArrayVal([]levin.Section{blockEntry1, blockEntry2}),
"missed_ids": levin.StringArrayVal([][]byte{missedHash}),
"missed_ids": levin.StringVal(missedHash),
"current_blockchain_height": levin.Uint64Val(6300),
}
data, err := levin.EncodeStorage(s)