From 21c5d49ef979cc611865d68f124ba773c7876fd8 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 19:01:07 +0000 Subject: [PATCH] fix(sync): validate peers and persist HTLC spends Centralise handshake response validation so outbound sync checks both network identity and minimum peer build version through the p2p layer. Also record HTLC key images as spent during block processing, matching the HF1 input semantics and preventing those spends from being omitted from chain state. Co-Authored-By: Charon --- chain/sync.go | 4 ++ chain/sync_test.go | 145 ++++++++++++++++++++++++++++++++++++++++++ p2p/handshake.go | 18 ++++++ p2p/handshake_test.go | 54 ++++++++++++++++ sync_loop.go | 9 +-- 5 files changed, 223 insertions(+), 7 deletions(-) diff --git a/chain/sync.go b/chain/sync.go index 0ba28ed..c6841ef 100644 --- a/chain/sync.go +++ b/chain/sync.go @@ -242,6 +242,10 @@ func (c *Chain) processBlockBlobs(blockBlob []byte, txBlobs [][]byte, if err := c.MarkSpent(inp.KeyImage, height); err != nil { return coreerr.E("Chain.processBlockBlobs", fmt.Sprintf("mark spent %s", inp.KeyImage), err) } + case types.TxInputHTLC: + if err := c.MarkSpent(inp.KeyImage, height); err != nil { + return coreerr.E("Chain.processBlockBlobs", fmt.Sprintf("mark spent %s", inp.KeyImage), err) + } case types.TxInputZC: if err := c.MarkSpent(inp.KeyImage, height); err != nil { return coreerr.E("Chain.processBlockBlobs", fmt.Sprintf("mark spent %s", inp.KeyImage), err) diff --git a/chain/sync_test.go b/chain/sync_test.go index 5f37d2d..20d3558 100644 --- a/chain/sync_test.go +++ b/chain/sync_test.go @@ -1015,3 +1015,148 @@ func TestSync_Good_ZCInputKeyImageMarkedSpent(t *testing.T) { t.Error("IsSpent(zc_key_image): got false, want true") } } + +func TestSync_Good_HTLCInputKeyImageMarkedSpent(t *testing.T) { + genesisBlob, genesisHash := makeGenesisBlockBlob() + + htlcKeyImage := types.KeyImage{0x44, 0x55, 0x66} + htlcTx := types.Transaction{ + Version: 1, + Vin: []types.TxInput{ + types.TxInputHTLC{ + HTLCOrigin: "contract-1", + Amount: 1000000000000, + KeyOffsets: []types.TxOutRef{{ + Tag: types.RefTypeGlobalIndex, + GlobalIndex: 0, + }}, + KeyImage: htlcKeyImage, + EtcDetails: wire.EncodeVarint(0), + }, + }, + Vout: []types.TxOutput{ + types.TxOutputBare{ + Amount: 900000000000, + Target: types.TxOutToKey{Key: types.PublicKey{0x21}}, + }, + }, + Extra: wire.EncodeVarint(0), + Attachment: wire.EncodeVarint(0), + } + + var txBuf bytes.Buffer + txEnc := wire.NewEncoder(&txBuf) + wire.EncodeTransaction(txEnc, &htlcTx) + htlcTxBlob := hex.EncodeToString(txBuf.Bytes()) + htlcTxHash := wire.TransactionHash(&htlcTx) + + minerTx1 := testCoinbaseTx(1) + block1 := types.Block{ + BlockHeader: types.BlockHeader{ + MajorVersion: 1, + Nonce: 42, + PrevID: genesisHash, + Timestamp: 1770897720, + }, + MinerTx: minerTx1, + TxHashes: []types.Hash{htlcTxHash}, + } + + var blk1Buf bytes.Buffer + blk1Enc := wire.NewEncoder(&blk1Buf) + wire.EncodeBlock(blk1Enc, &block1) + block1Blob := hex.EncodeToString(blk1Buf.Bytes()) + block1Hash := wire.BlockHash(&block1) + + orig := GenesisHash + GenesisHash = genesisHash.String() + t.Cleanup(func() { GenesisHash = orig }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.URL.Path == "/getheight" { + json.NewEncoder(w).Encode(map[string]any{ + "height": 2, + "status": "OK", + }) + return + } + + var req struct { + Method string `json:"method"` + Params json.RawMessage `json:"params"` + } + json.NewDecoder(r.Body).Decode(&req) + + switch req.Method { + case "get_blocks_details": + blocks := []map[string]any{ + { + "height": uint64(0), + "timestamp": uint64(1770897600), + "base_reward": uint64(1000000000000), + "id": genesisHash.String(), + "difficulty": "1", + "type": uint64(1), + "blob": genesisBlob, + "transactions_details": []any{}, + }, + { + "height": uint64(1), + "timestamp": uint64(1770897720), + "base_reward": uint64(1000000), + "id": block1Hash.String(), + "difficulty": "100", + "type": uint64(1), + "blob": block1Blob, + "transactions_details": []map[string]any{ + { + "id": htlcTxHash.String(), + "blob": htlcTxBlob, + "fee": uint64(100000000000), + }, + }, + }, + } + + result := map[string]any{ + "blocks": blocks, + "status": "OK", + } + resultBytes, _ := json.Marshal(result) + json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": "0", + "result": json.RawMessage(resultBytes), + }) + } + })) + defer srv.Close() + + s, _ := store.New(":memory:") + defer s.Close() + c := New(s) + + client := rpc.NewClient(srv.URL) + opts := SyncOptions{ + VerifySignatures: false, + Forks: []config.HardFork{ + {Version: config.HF1, Height: 0, Mandatory: true}, + {Version: config.HF2, Height: 0, Mandatory: true}, + }, + } + + err := c.Sync(context.Background(), client, opts) + if err != nil { + t.Fatalf("Sync: %v", err) + } + + spent, err := c.IsSpent(htlcKeyImage) + if err != nil { + t.Fatalf("IsSpent: %v", err) + } + if !spent { + t.Error("IsSpent(htlc_key_image): got false, want true") + } +} diff --git a/p2p/handshake.go b/p2p/handshake.go index c451c0a..12e42a1 100644 --- a/p2p/handshake.go +++ b/p2p/handshake.go @@ -7,6 +7,7 @@ package p2p import ( "encoding/binary" + "fmt" "dappco.re/go/core/p2p/node/levin" ) @@ -173,3 +174,20 @@ func (r *HandshakeResponse) Decode(data []byte) error { } return nil } + +// ValidateHandshakeResponse verifies that a remote peer's handshake response +// matches the expected network and satisfies the minimum build version gate. +func ValidateHandshakeResponse(resp *HandshakeResponse, expectedNetworkID [16]byte, isTestnet bool) error { + if resp.NodeData.NetworkID != expectedNetworkID { + return fmt.Errorf("p2p: peer network id %x does not match expected %x", + resp.NodeData.NetworkID, expectedNetworkID) + } + + if !MeetsMinimumBuildVersion(resp.PayloadData.ClientVersion, isTestnet) { + minBuild := MinimumRequiredBuildVersion(isTestnet) + return fmt.Errorf("p2p: peer build %q below minimum %d", + resp.PayloadData.ClientVersion, minBuild) + } + + return nil +} diff --git a/p2p/handshake_test.go b/p2p/handshake_test.go index f9e14f1..7126ce9 100644 --- a/p2p/handshake_test.go +++ b/p2p/handshake_test.go @@ -7,6 +7,7 @@ package p2p import ( "encoding/binary" + "strings" "testing" "dappco.re/go/core/blockchain/config" @@ -154,3 +155,56 @@ func TestDecodePeerlist_Good_EmptyBlob(t *testing.T) { t.Errorf("empty peerlist: got %d entries, want 0", len(entries)) } } + +func TestValidateHandshakeResponse_Good(t *testing.T) { + resp := &HandshakeResponse{ + NodeData: NodeData{ + NetworkID: config.NetworkIDTestnet, + }, + PayloadData: CoreSyncData{ + ClientVersion: "6.0.1.2[go-blockchain]", + }, + } + + if err := ValidateHandshakeResponse(resp, config.NetworkIDTestnet, true); err != nil { + t.Fatalf("ValidateHandshakeResponse: %v", err) + } +} + +func TestValidateHandshakeResponse_BadNetwork(t *testing.T) { + resp := &HandshakeResponse{ + NodeData: NodeData{ + NetworkID: config.NetworkIDMainnet, + }, + PayloadData: CoreSyncData{ + ClientVersion: "6.0.1.2[go-blockchain]", + }, + } + + err := ValidateHandshakeResponse(resp, config.NetworkIDTestnet, true) + if err == nil { + t.Fatal("ValidateHandshakeResponse: expected network mismatch error") + } + if !strings.Contains(err.Error(), "network id") { + t.Fatalf("ValidateHandshakeResponse error: got %v, want network id mismatch", err) + } +} + +func TestValidateHandshakeResponse_BadBuildVersion(t *testing.T) { + resp := &HandshakeResponse{ + NodeData: NodeData{ + NetworkID: config.NetworkIDMainnet, + }, + PayloadData: CoreSyncData{ + ClientVersion: "0.0.1.0", + }, + } + + err := ValidateHandshakeResponse(resp, config.NetworkIDMainnet, false) + if err == nil { + t.Fatal("ValidateHandshakeResponse: expected build version error") + } + if !strings.Contains(err.Error(), "below minimum") { + t.Fatalf("ValidateHandshakeResponse error: got %v, want build minimum failure", err) + } +} diff --git a/sync_loop.go b/sync_loop.go index 524a68c..6ff1f77 100644 --- a/sync_loop.go +++ b/sync_loop.go @@ -102,13 +102,8 @@ func runChainSyncOnce(ctx context.Context, blockchain *chain.Chain, chainConfig return coreerr.E("runChainSyncOnce", "decode handshake", err) } - if !p2p.MeetsMinimumBuildVersion(handshakeResp.PayloadData.ClientVersion, chainConfig.IsTestnet) { - minBuild := p2p.MinimumRequiredBuildVersion(chainConfig.IsTestnet) - return coreerr.E( - "runChainSyncOnce", - fmt.Sprintf("peer build %q below minimum %d", handshakeResp.PayloadData.ClientVersion, minBuild), - nil, - ) + if err := p2p.ValidateHandshakeResponse(&handshakeResp, chainConfig.NetworkID, chainConfig.IsTestnet); err != nil { + return coreerr.E("runChainSyncOnce", "validate handshake", err) } localSync := p2p.CoreSyncData{