fix(sync): validate peers and persist HTLC spends
Some checks are pending
Security Scan / security (push) Waiting to run
Test / Test (push) Waiting to run

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 <charon@lethean.io>
This commit is contained in:
Virgil 2026-04-04 19:01:07 +00:00
parent 0ba5bbe49c
commit 21c5d49ef9
5 changed files with 223 additions and 7 deletions

View file

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

View file

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

View file

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

View file

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

View file

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