go-blockchain/chain/sync_test.go
Virgil 21c5d49ef9
Some checks are pending
Security Scan / security (push) Waiting to run
Test / Test (push) Waiting to run
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 <charon@lethean.io>
2026-04-04 19:01:07 +00:00

1162 lines
30 KiB
Go

// Copyright (c) 2017-2026 Lethean (https://lt.hn)
//
// Licensed under the European Union Public Licence (EUPL) version 1.2.
// SPDX-License-Identifier: EUPL-1.2
package chain
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"dappco.re/go/core/blockchain/config"
"dappco.re/go/core/blockchain/rpc"
"dappco.re/go/core/blockchain/types"
"dappco.re/go/core/blockchain/wire"
store "dappco.re/go/core/store"
)
// makeGenesisBlockBlob creates a minimal genesis block and returns its hex blob and hash.
func makeGenesisBlockBlob() (hexBlob string, hash types.Hash) {
blk := types.Block{
BlockHeader: types.BlockHeader{
MajorVersion: 1,
Nonce: 101011010221,
Timestamp: 1770897600,
},
MinerTx: types.Transaction{
Version: 1,
Vin: []types.TxInput{types.TxInputGenesis{Height: 0}},
Vout: []types.TxOutput{
types.TxOutputBare{
Amount: 1000000000000,
Target: types.TxOutToKey{Key: types.PublicKey{0x01}},
},
},
Extra: wire.EncodeVarint(0),
Attachment: wire.EncodeVarint(0),
},
}
var buf bytes.Buffer
enc := wire.NewEncoder(&buf)
wire.EncodeBlock(enc, &blk)
hexBlob = hex.EncodeToString(buf.Bytes())
hash = wire.BlockHash(&blk)
return
}
func TestSyncOptions_Default(t *testing.T) {
opts := DefaultSyncOptions()
assert.False(t, opts.VerifySignatures)
assert.Equal(t, config.MainnetForks, opts.Forks)
}
func TestSync_Good_SingleBlock(t *testing.T) {
genesisBlob, genesisHash := makeGenesisBlockBlob()
// Override genesis hash for this test.
orig := GenesisHash
GenesisHash = genesisHash.String()
t.Cleanup(func() { GenesisHash = orig })
// Mock RPC server.
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": 1,
"status": "OK",
})
return
}
// JSON-RPC dispatcher.
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":
result := map[string]any{
"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{},
}},
"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)
err := c.Sync(context.Background(), client, DefaultSyncOptions())
if err != nil {
t.Fatalf("Sync: %v", err)
}
h, _ := c.Height()
if h != 1 {
t.Errorf("height after sync: got %d, want 1", h)
}
blk, meta, err := c.GetBlockByHeight(0)
if err != nil {
t.Fatalf("GetBlockByHeight(0): %v", err)
}
if blk.MajorVersion != 1 {
t.Errorf("major_version: got %d, want 1", blk.MajorVersion)
}
if meta.Hash != genesisHash {
t.Errorf("hash: got %s, want %s", meta.Hash, genesisHash)
}
}
func TestSync_Good_TwoBlocks_WithRegularTx(t *testing.T) {
// --- Build genesis block (block 0) ---
genesisBlob, genesisHash := makeGenesisBlockBlob()
// --- Build regular transaction for block 1 ---
regularTx := types.Transaction{
Version: 1,
Vin: []types.TxInput{
types.TxInputToKey{
Amount: 1000000000000,
KeyOffsets: []types.TxOutRef{{
Tag: types.RefTypeGlobalIndex,
GlobalIndex: 0,
}},
KeyImage: types.KeyImage{0xaa, 0xbb, 0xcc},
EtcDetails: wire.EncodeVarint(0),
},
},
Vout: []types.TxOutput{
types.TxOutputBare{
Amount: 900000000000,
Target: types.TxOutToKey{Key: types.PublicKey{0x02}},
},
},
Extra: wire.EncodeVarint(0),
Attachment: wire.EncodeVarint(0),
}
// Wire-encode the regular tx to get its blob and hash.
var txBuf bytes.Buffer
txEnc := wire.NewEncoder(&txBuf)
wire.EncodeTransaction(txEnc, &regularTx)
regularTxBlob := hex.EncodeToString(txBuf.Bytes())
regularTxHash := wire.TransactionHash(&regularTx)
// --- Build block 1 ---
minerTx1 := testCoinbaseTx(1)
block1 := types.Block{
BlockHeader: types.BlockHeader{
MajorVersion: 1,
Nonce: 42,
PrevID: genesisHash,
Timestamp: 1770897720,
},
MinerTx: minerTx1,
TxHashes: []types.Hash{regularTxHash},
}
var blk1Buf bytes.Buffer
blk1Enc := wire.NewEncoder(&blk1Buf)
wire.EncodeBlock(blk1Enc, &block1)
block1Blob := hex.EncodeToString(blk1Buf.Bytes())
block1Hash := wire.BlockHash(&block1)
// Override genesis hash for this test.
orig := GenesisHash
GenesisHash = genesisHash.String()
t.Cleanup(func() { GenesisHash = orig })
// Track which batch the server has served (to handle the sync loop).
callCount := 0
// Mock RPC server returning 2 blocks.
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
}
// JSON-RPC dispatcher.
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":
var params struct {
HeightStart uint64 `json:"height_start"`
Count uint64 `json:"count"`
}
json.Unmarshal(req.Params, &params)
var blocks []map[string]any
// Return blocks based on the requested start height.
if params.HeightStart == 0 {
callCount++
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": regularTxHash.String(),
"blob": regularTxBlob,
"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)
err := c.Sync(context.Background(), client, DefaultSyncOptions())
if err != nil {
t.Fatalf("Sync: %v", err)
}
// Verify height is 2.
h, _ := c.Height()
if h != 2 {
t.Errorf("height after sync: got %d, want 2", h)
}
// Verify block 1 can be retrieved by height.
gotBlk, gotMeta, err := c.GetBlockByHeight(1)
if err != nil {
t.Fatalf("GetBlockByHeight(1): %v", err)
}
if gotBlk.MajorVersion != 1 {
t.Errorf("block 1 major_version: got %d, want 1", gotBlk.MajorVersion)
}
if gotMeta.Hash != block1Hash {
t.Errorf("block 1 hash: got %s, want %s", gotMeta.Hash, block1Hash)
}
if gotMeta.CumulativeDiff != 101 { // genesis(1) + block1(100)
t.Errorf("block 1 cumulative_diff: got %d, want 101", gotMeta.CumulativeDiff)
}
// Verify block 1 can be retrieved by hash.
_, gotMeta2, err := c.GetBlockByHash(block1Hash)
if err != nil {
t.Fatalf("GetBlockByHash(block1): %v", err)
}
if gotMeta2.Height != 1 {
t.Errorf("block 1 height from hash lookup: got %d, want 1", gotMeta2.Height)
}
// Verify the regular transaction was stored.
gotTx, gotTxMeta, err := c.GetTransaction(regularTxHash)
if err != nil {
t.Fatalf("GetTransaction(regularTx): %v", err)
}
if gotTx.Version != 1 {
t.Errorf("regular tx version: got %d, want 1", gotTx.Version)
}
if gotTxMeta.KeeperBlock != 1 {
t.Errorf("regular tx keeper_block: got %d, want 1", gotTxMeta.KeeperBlock)
}
if !c.HasTransaction(regularTxHash) {
t.Error("HasTransaction(regularTx): got false, want true")
}
// Verify the key image was marked as spent.
ki := types.KeyImage{0xaa, 0xbb, 0xcc}
spent, err := c.IsSpent(ki)
if err != nil {
t.Fatalf("IsSpent: %v", err)
}
if !spent {
t.Error("IsSpent(key_image): got false, want true")
}
// Verify output indexing: genesis miner tx output + block 1 miner tx output
// + regular tx output.
// Genesis miner tx has 1 output at amount 1000000000000.
// Regular tx has 1 output at amount 900000000000.
count, err := c.OutputCount(1000000000000)
if err != nil {
t.Fatalf("OutputCount(1000000000000): %v", err)
}
if count != 1 { // only genesis miner tx
t.Errorf("output count for 1000000000000: got %d, want 1", count)
}
count, err = c.OutputCount(900000000000)
if err != nil {
t.Fatalf("OutputCount(900000000000): %v", err)
}
if count != 1 { // regular tx output
t.Errorf("output count for 900000000000: got %d, want 1", count)
}
// Verify top block is block 1.
_, topMeta, err := c.TopBlock()
if err != nil {
t.Fatalf("TopBlock: %v", err)
}
if topMeta.Height != 1 {
t.Errorf("top block height: got %d, want 1", topMeta.Height)
}
}
func TestSync_Good_AlreadySynced(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"height": 0,
"status": "OK",
})
}))
defer srv.Close()
s, _ := store.New(":memory:")
defer s.Close()
c := New(s)
client := rpc.NewClient(srv.URL)
err := c.Sync(context.Background(), client, DefaultSyncOptions())
if err != nil {
t.Fatalf("Sync on empty: %v", err)
}
h, _ := c.Height()
if h != 0 {
t.Errorf("height: got %d, want 0", h)
}
}
func TestSync_Bad_GetHeightError(t *testing.T) {
// Server that returns HTTP 500 on /getheight.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
s, _ := store.New(":memory:")
defer s.Close()
c := New(s)
client := rpc.NewClient(srv.URL)
err := c.Sync(context.Background(), client, DefaultSyncOptions())
if err == nil {
t.Fatal("Sync: expected error from bad getheight, got nil")
}
}
func TestSync_Bad_FetchBlocksError(t *testing.T) {
// Server that succeeds on /getheight but fails on JSON-RPC.
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": 1,
"status": "OK",
})
return
}
// Return a JSON-RPC error for get_blocks_details.
json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": "0",
"error": map[string]any{
"code": -1,
"message": "internal error",
},
})
}))
defer srv.Close()
s, _ := store.New(":memory:")
defer s.Close()
c := New(s)
client := rpc.NewClient(srv.URL)
err := c.Sync(context.Background(), client, DefaultSyncOptions())
if err == nil {
t.Fatal("Sync: expected error from bad get_blocks_details, got nil")
}
}
func TestSync_Bad_GenesisHashMismatch(t *testing.T) {
genesisBlob, genesisHash := makeGenesisBlockBlob()
// Deliberately do NOT override GenesisHash, so it mismatches.
// The default GenesisHash is a different value.
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": 1,
"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":
result := map[string]any{
"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{},
}},
"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)
err := c.Sync(context.Background(), client, DefaultSyncOptions())
if err == nil {
t.Fatal("Sync: expected genesis hash mismatch error, got nil")
}
}
func TestSync_Bad_BlockHashMismatch(t *testing.T) {
genesisBlob, genesisHash := makeGenesisBlockBlob()
// Set genesis hash to a value that differs from the actual computed hash.
wrongHash := "0000000000000000000000000000000000000000000000000000000000000001"
orig := GenesisHash
GenesisHash = wrongHash
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": 1,
"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":
result := map[string]any{
"blocks": []map[string]any{{
"height": uint64(0),
"timestamp": uint64(1770897600),
"base_reward": uint64(0),
"id": wrongHash, // wrong: doesn't match computed hash
"difficulty": "1",
"type": uint64(1),
"blob": genesisBlob,
"transactions_details": []any{},
}},
"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)
err := c.Sync(context.Background(), client, DefaultSyncOptions())
if err == nil {
t.Fatal("Sync: expected block hash mismatch error, got nil")
}
// Verify the real genesis hash is unaffected.
_ = genesisHash
}
func TestSync_Bad_InvalidRegularTxBlob(t *testing.T) {
genesisBlob, genesisHash := makeGenesisBlockBlob()
// Build block 1 with a tx hash but the RPC will return a bad tx blob.
fakeTxHash := types.Hash{0xde, 0xad}
minerTx1 := testCoinbaseTx(1)
block1 := types.Block{
BlockHeader: types.BlockHeader{
MajorVersion: 1,
Nonce: 42,
PrevID: genesisHash,
Timestamp: 1770897720,
},
MinerTx: minerTx1,
TxHashes: []types.Hash{fakeTxHash},
}
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":
result := map[string]any{
"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": fakeTxHash.String(),
"blob": "badc0de", // invalid: odd length hex
"fee": uint64(0),
},
},
},
},
"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)
err := c.Sync(context.Background(), client, DefaultSyncOptions())
if err == nil {
t.Fatal("Sync: expected error from invalid tx blob, got nil")
}
}
func TestSync_Bad_InvalidBlockBlob(t *testing.T) {
// Override genesis hash to a value that matches a fake block ID.
fakeHash := "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
orig := GenesisHash
GenesisHash = fakeHash
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": 1,
"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":
result := map[string]any{
"blocks": []map[string]any{{
"height": uint64(0),
"timestamp": uint64(1770897600),
"base_reward": uint64(0),
"id": fakeHash,
"difficulty": "1",
"type": uint64(1),
"blob": "deadbeef", // invalid wire data
"transactions_details": []any{},
}},
"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)
err := c.Sync(context.Background(), client, DefaultSyncOptions())
if err == nil {
t.Fatal("Sync: expected error from invalid block blob, got nil")
}
}
func TestSync_Bad_PreHardforkFreeze(t *testing.T) {
genesisBlob, genesisHash := makeGenesisBlockBlob()
regularTx := types.Transaction{
Version: 1,
Vin: []types.TxInput{
types.TxInputToKey{
Amount: 1000000000000,
KeyOffsets: []types.TxOutRef{{
Tag: types.RefTypeGlobalIndex,
GlobalIndex: 0,
}},
KeyImage: types.KeyImage{0xaa, 0xbb, 0xcc},
EtcDetails: wire.EncodeVarint(0),
},
},
Vout: []types.TxOutput{
types.TxOutputBare{
Amount: 900000000000,
Target: types.TxOutToKey{Key: types.PublicKey{0x02}},
},
},
Extra: wire.EncodeVarint(0),
Attachment: wire.EncodeVarint(0),
}
var txBuf bytes.Buffer
txEnc := wire.NewEncoder(&txBuf)
wire.EncodeTransaction(txEnc, &regularTx)
regularTxBlob := txBuf.Bytes()
regularTxHash := wire.TransactionHash(&regularTx)
minerTx1 := testCoinbaseTx(1)
block1 := types.Block{
BlockHeader: types.BlockHeader{
MajorVersion: 1,
Nonce: 42,
PrevID: genesisHash,
Timestamp: 1770897720,
},
MinerTx: minerTx1,
TxHashes: []types.Hash{regularTxHash},
}
var blk1Buf bytes.Buffer
blk1Enc := wire.NewEncoder(&blk1Buf)
wire.EncodeBlock(blk1Enc, &block1)
block1Blob := blk1Buf.Bytes()
orig := GenesisHash
GenesisHash = genesisHash.String()
t.Cleanup(func() { GenesisHash = orig })
s, _ := store.New(":memory:")
defer s.Close()
c := New(s)
opts := SyncOptions{
Forks: []config.HardFork{
{Version: config.HF0Initial, Height: 0, Mandatory: true},
{Version: config.HF5, Height: 2, Mandatory: true},
},
}
if err := c.processBlockBlobs(genesisBlob, nil, 0, 1, opts); err != nil {
t.Fatalf("process genesis: %v", err)
}
err := c.processBlockBlobs(block1Blob, [][]byte{regularTxBlob}, 1, 100, opts)
if err == nil {
t.Fatal("expected freeze rejection, got nil")
}
if !strings.Contains(err.Error(), "freeze") {
t.Fatalf("expected freeze error, got %v", err)
}
}
// testCoinbaseTxV2 creates a v2 (post-HF4) coinbase transaction with Zarcanum outputs.
func testCoinbaseTxV2(height uint64) types.Transaction {
return types.Transaction{
Version: types.VersionPostHF4,
Vin: []types.TxInput{types.TxInputGenesis{Height: height}},
Vout: []types.TxOutput{
types.TxOutputZarcanum{
StealthAddress: types.PublicKey{0x01},
ConcealingPoint: types.PublicKey{0x02},
AmountCommitment: types.PublicKey{0x03},
BlindedAssetID: types.PublicKey{0x04},
EncryptedAmount: 1000000,
MixAttr: 0,
},
types.TxOutputZarcanum{
StealthAddress: types.PublicKey{0x05},
ConcealingPoint: types.PublicKey{0x06},
AmountCommitment: types.PublicKey{0x07},
BlindedAssetID: types.PublicKey{0x08},
EncryptedAmount: 2000000,
MixAttr: 0,
},
},
Extra: wire.EncodeVarint(0),
Attachment: wire.EncodeVarint(0),
SignaturesRaw: wire.EncodeVarint(0),
Proofs: wire.EncodeVarint(0),
}
}
func TestSync_Good_ZCInputKeyImageMarkedSpent(t *testing.T) {
// --- Build genesis block (block 0) ---
genesisBlob, genesisHash := makeGenesisBlockBlob()
// --- Build a v2 transaction with a TxInputZC for block 1 ---
zcKeyImage := types.KeyImage{0xdd, 0xee, 0xff}
zcTx := types.Transaction{
Version: types.VersionPostHF4,
Vin: []types.TxInput{
types.TxInputZC{
KeyOffsets: []types.TxOutRef{{
Tag: types.RefTypeGlobalIndex,
GlobalIndex: 0,
}},
KeyImage: zcKeyImage,
EtcDetails: wire.EncodeVarint(0),
},
},
Vout: []types.TxOutput{
types.TxOutputZarcanum{
StealthAddress: types.PublicKey{0x10},
ConcealingPoint: types.PublicKey{0x11},
AmountCommitment: types.PublicKey{0x12},
BlindedAssetID: types.PublicKey{0x13},
EncryptedAmount: 500000,
MixAttr: 0,
},
types.TxOutputZarcanum{
StealthAddress: types.PublicKey{0x14},
ConcealingPoint: types.PublicKey{0x15},
AmountCommitment: types.PublicKey{0x16},
BlindedAssetID: types.PublicKey{0x17},
EncryptedAmount: 400000,
MixAttr: 0,
},
},
Extra: wire.EncodeVarint(0),
Attachment: wire.EncodeVarint(0),
SignaturesRaw: wire.EncodeVarint(0),
Proofs: wire.EncodeVarint(0),
}
var txBuf bytes.Buffer
txEnc := wire.NewEncoder(&txBuf)
wire.EncodeTransaction(txEnc, &zcTx)
zcTxBlob := hex.EncodeToString(txBuf.Bytes())
zcTxHash := wire.TransactionHash(&zcTx)
// --- Build block 1 (v2 block) ---
minerTx1 := testCoinbaseTxV2(1)
block1 := types.Block{
BlockHeader: types.BlockHeader{
MajorVersion: 2,
Nonce: 42,
PrevID: genesisHash,
Timestamp: 1770897720,
},
MinerTx: minerTx1,
TxHashes: []types.Hash{zcTxHash},
}
var blk1Buf bytes.Buffer
blk1Enc := wire.NewEncoder(&blk1Buf)
wire.EncodeBlock(blk1Enc, &block1)
block1Blob := hex.EncodeToString(blk1Buf.Bytes())
block1Hash := wire.BlockHash(&block1)
// Override genesis hash for this test.
orig := GenesisHash
GenesisHash = genesisHash.String()
t.Cleanup(func() { GenesisHash = orig })
// Mock RPC server returning 2 blocks.
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": zcTxHash.String(),
"blob": zcTxBlob,
"fee": uint64(0),
},
},
},
}
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)
// Use custom forks where HF4 is active from height 0 so ZC inputs pass validation.
opts := SyncOptions{
VerifySignatures: false,
Forks: []config.HardFork{
{Version: config.HF1, Height: 0, Mandatory: true},
{Version: config.HF2, Height: 0, Mandatory: true},
{Version: config.HF3, Height: 0, Mandatory: true},
{Version: config.HF4Zarcanum, Height: 0, Mandatory: true},
},
}
err := c.Sync(context.Background(), client, opts)
if err != nil {
t.Fatalf("Sync: %v", err)
}
// Verify height is 2.
h, _ := c.Height()
if h != 2 {
t.Errorf("height after sync: got %d, want 2", h)
}
// Verify the ZC key image was marked as spent.
spent, err := c.IsSpent(zcKeyImage)
if err != nil {
t.Fatalf("IsSpent: %v", err)
}
if !spent {
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")
}
}