diff --git a/actions.go b/actions.go new file mode 100644 index 0000000..a2c0b3f --- /dev/null +++ b/actions.go @@ -0,0 +1,240 @@ +// Copyright (c) 2017-2026 Lethean (https://lt.hn) +// SPDX-License-Identifier: EUPL-1.2 + +package blockchain + +import ( + "context" + + "dappco.re/go/core" + + "dappco.re/go/core/blockchain/chain" +) + +// RegisterActions registers all blockchain actions with a Core instance. +// Each action becomes available as CLI command, MCP tool, and API endpoint. +// +// blockchain.RegisterActions(c, chainInstance) +func RegisterActions(c *core.Core, ch *chain.Chain) { + // Chain state + c.Action("blockchain.chain.height", makeChainHeight(ch)) + c.Action("blockchain.chain.info", makeChainInfo(ch)) + c.Action("blockchain.chain.block", makeChainBlock(ch)) + c.Action("blockchain.chain.synced", makeChainSynced(ch)) + c.Action("blockchain.chain.hardforks", makeChainHardforks(ch)) + c.Action("blockchain.chain.stats", makeChainStats(ch)) + c.Action("blockchain.chain.search", makeChainSearch(ch)) + + // Aliases + c.Action("blockchain.alias.list", makeAliasList(ch)) + c.Action("blockchain.alias.get", makeAliasGet(ch)) + c.Action("blockchain.alias.capabilities", makeAliasCaps(ch)) + + // Service discovery + c.Action("blockchain.network.gateways", makeGateways(ch)) + c.Action("blockchain.network.topology", makeTopology(ch)) + c.Action("blockchain.network.vpn", makeVPNGateways(ch)) + c.Action("blockchain.network.dns", makeDNSGateways(ch)) + + // Supply + c.Action("blockchain.supply.total", makeSupplyTotal(ch)) + c.Action("blockchain.supply.hashrate", makeHashrate(ch)) +} + +func makeChainHeight(ch *chain.Chain) core.ActionHandler { + return func(ctx context.Context, opts core.Options) core.Result { + h, _ := ch.Height() + return core.Result{Value: h, OK: true} + } +} + +func makeChainInfo(ch *chain.Chain) core.ActionHandler { + return func(ctx context.Context, opts core.Options) core.Result { + h, _ := ch.Height() + _, meta, _ := ch.TopBlock() + if meta == nil { + meta = &chain.BlockMeta{} + } + aliases := ch.GetAllAliases() + return core.Result{Value: map[string]interface{}{ + "height": h, "difficulty": meta.Difficulty, + "aliases": len(aliases), "synced": true, + }, OK: true} + } +} + +func makeChainBlock(ch *chain.Chain) core.ActionHandler { + return func(ctx context.Context, opts core.Options) core.Result { + height := uint64(opts.Int("height")) + blk, meta, err := ch.GetBlockByHeight(height) + if err != nil { + return core.Result{OK: false} + } + return core.Result{Value: map[string]interface{}{ + "hash": meta.Hash.String(), "height": meta.Height, + "timestamp": blk.Timestamp, "difficulty": meta.Difficulty, + }, OK: true} + } +} + +func makeChainSynced(ch *chain.Chain) core.ActionHandler { + return func(ctx context.Context, opts core.Options) core.Result { + h, _ := ch.Height() + return core.Result{Value: h > 0, OK: true} + } +} + +func makeChainHardforks(ch *chain.Chain) core.ActionHandler { + return func(ctx context.Context, opts core.Options) core.Result { + h, _ := ch.Height() + return core.Result{Value: map[string]interface{}{ + "hf0": true, "hf1": true, "hf2": h >= 10000, + "hf3": h >= 10500, "hf4": h >= 11000, "hf5": h >= 11500, + }, OK: true} + } +} + +func makeChainStats(ch *chain.Chain) core.ActionHandler { + return func(ctx context.Context, opts core.Options) core.Result { + h, _ := ch.Height() + aliases := ch.GetAllAliases() + gw := 0 + for _, a := range aliases { + if core.Contains(a.Comment, "type=gateway") { gw++ } + } + return core.Result{Value: map[string]interface{}{ + "height": h, "aliases": len(aliases), + "gateways": gw, "services": len(aliases) - gw, + }, OK: true} + } +} + +func makeChainSearch(ch *chain.Chain) core.ActionHandler { + return func(ctx context.Context, opts core.Options) core.Result { + query := opts.String("query") + if alias, err := ch.GetAlias(query); err == nil { + return core.Result{Value: map[string]interface{}{ + "type": "alias", "name": alias.Name, "comment": alias.Comment, + }, OK: true} + } + return core.Result{Value: map[string]interface{}{"type": "not_found"}, OK: true} + } +} + +func makeAliasList(ch *chain.Chain) core.ActionHandler { + return func(ctx context.Context, opts core.Options) core.Result { + return core.Result{Value: ch.GetAllAliases(), OK: true} + } +} + +func makeAliasGet(ch *chain.Chain) core.ActionHandler { + return func(ctx context.Context, opts core.Options) core.Result { + name := opts.String("name") + alias, err := ch.GetAlias(name) + if err != nil { + return core.Result{OK: false} + } + return core.Result{Value: alias, OK: true} + } +} + +func makeAliasCaps(ch *chain.Chain) core.ActionHandler { + return func(ctx context.Context, opts core.Options) core.Result { + name := opts.String("name") + alias, err := ch.GetAlias(name) + if err != nil { + return core.Result{OK: false} + } + parsed := parseActionComment(alias.Comment) + return core.Result{Value: map[string]interface{}{ + "name": alias.Name, "type": parsed["type"], + "capabilities": parsed["cap"], "hns": parsed["hns"], + }, OK: true} + } +} + +func makeGateways(ch *chain.Chain) core.ActionHandler { + return func(ctx context.Context, opts core.Options) core.Result { + all := ch.GetAllAliases() + var gateways []map[string]string + for _, a := range all { + if core.Contains(a.Comment, "type=gateway") { + gateways = append(gateways, map[string]string{ + "name": a.Name, "comment": a.Comment, + }) + } + } + return core.Result{Value: gateways, OK: true} + } +} + +func makeTopology(ch *chain.Chain) core.ActionHandler { + return func(ctx context.Context, opts core.Options) core.Result { + all := ch.GetAllAliases() + topo := map[string]int{"total": len(all), "gateways": 0, "services": 0} + for _, a := range all { + p := parseActionComment(a.Comment) + if p["type"] == "gateway" { topo["gateways"]++ } else { topo["services"]++ } + } + return core.Result{Value: topo, OK: true} + } +} + +func makeVPNGateways(ch *chain.Chain) core.ActionHandler { + return func(ctx context.Context, opts core.Options) core.Result { + all := ch.GetAllAliases() + var vpns []string + for _, a := range all { + if core.Contains(a.Comment, "cap=vpn") || core.Contains(a.Comment, ",vpn") { + vpns = append(vpns, a.Name) + } + } + return core.Result{Value: vpns, OK: true} + } +} + +func makeDNSGateways(ch *chain.Chain) core.ActionHandler { + return func(ctx context.Context, opts core.Options) core.Result { + all := ch.GetAllAliases() + var dns []string + for _, a := range all { + if core.Contains(a.Comment, "dns") { + dns = append(dns, a.Name) + } + } + return core.Result{Value: dns, OK: true} + } +} + +func makeSupplyTotal(ch *chain.Chain) core.ActionHandler { + return func(ctx context.Context, opts core.Options) core.Result { + h, _ := ch.Height() + return core.Result{Value: map[string]interface{}{ + "total": PremineAmount + h, "premine": PremineAmount, + "mined": h, "unit": "LTHN", + }, OK: true} + } +} + +func makeHashrate(ch *chain.Chain) core.ActionHandler { + return func(ctx context.Context, opts core.Options) core.Result { + _, meta, _ := ch.TopBlock() + if meta == nil { meta = &chain.BlockMeta{} } + return core.Result{Value: meta.Difficulty / 120, OK: true} + } +} + +// parseActionComment parses a v=lthn1;type=gateway;cap=vpn comment. +func parseActionComment(comment string) map[string]string { + parsed := make(map[string]string) + for _, part := range core.Split(comment, ";") { + idx := -1 + for i, c := range part { + if c == '=' { idx = i; break } + } + if idx > 0 { + parsed[part[:idx]] = part[idx+1:] + } + } + return parsed +} diff --git a/chain/levinconn_test.go b/chain/levinconn_test.go new file mode 100644 index 0000000..59bd06b --- /dev/null +++ b/chain/levinconn_test.go @@ -0,0 +1,171 @@ +// 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 ( + "testing" + + "dappco.re/go/core/blockchain/p2p" + "dappco.re/go/core/blockchain/types" + levinpkg "dappco.re/go/core/p2p/node/levin" +) + +// --- Good (happy path) --- + +func TestLevinconn_NewLevinP2PConn_Good(t *testing.T) { + syncData := p2p.CoreSyncData{ + CurrentHeight: 1000, + TopID: types.Hash{0xab, 0xcd}, + } + conn := NewLevinP2PConn(nil, 5000, syncData) + if conn == nil { + t.Fatal("NewLevinP2PConn returned nil") + } + if conn.peerHeight != 5000 { + t.Errorf("peerHeight: got %d, want 5000", conn.peerHeight) + } +} + +func TestLevinconn_PeerHeight_Good(t *testing.T) { + syncData := p2p.CoreSyncData{CurrentHeight: 42} + conn := NewLevinP2PConn(nil, 12345, syncData) + + height := conn.PeerHeight() + if height != 12345 { + t.Errorf("PeerHeight: got %d, want 12345", height) + } +} + +func TestLevinconn_NewLevinP2PConn_PreservesLocalSync_Good(t *testing.T) { + syncData := p2p.CoreSyncData{ + CurrentHeight: 999, + TopID: types.Hash{0x01, 0x02, 0x03}, + ClientVersion: "test-v1.0", + } + conn := NewLevinP2PConn(nil, 500, syncData) + if conn.localSync.CurrentHeight != 999 { + t.Errorf("localSync.CurrentHeight: got %d, want 999", conn.localSync.CurrentHeight) + } + if conn.localSync.TopID != syncData.TopID { + t.Error("localSync.TopID mismatch") + } + if conn.localSync.ClientVersion != "test-v1.0" { + t.Errorf("localSync.ClientVersion: got %q, want %q", conn.localSync.ClientVersion, "test-v1.0") + } +} + +func TestLevinconn_handleMessage_SkipsUnknownCommand_Good(t *testing.T) { + syncData := p2p.CoreSyncData{} + conn := NewLevinP2PConn(nil, 100, syncData) + + // handleMessage with a non-timed-sync command should be silently ignored. + header := levinpkg.Header{ + Command: p2p.CommandNewBlock, + ExpectResponse: false, + } + err := conn.handleMessage(header, []byte{0x01, 0x02}) + if err != nil { + t.Fatalf("handleMessage should silently skip unknown commands, got: %v", err) + } +} + +// --- Bad (expected errors / wrong input) --- + +func TestLevinconn_PeerHeight_ZeroHeight_Bad(t *testing.T) { + // A peer that reports height 0 is valid but means no blocks synced. + conn := NewLevinP2PConn(nil, 0, p2p.CoreSyncData{}) + if conn.PeerHeight() != 0 { + t.Errorf("PeerHeight: got %d, want 0", conn.PeerHeight()) + } +} + +func TestLevinconn_handleMessage_SkipsPingCommand_Bad(t *testing.T) { + conn := NewLevinP2PConn(nil, 100, p2p.CoreSyncData{}) + + // Ping commands that are not timed_sync should be silently skipped. + header := levinpkg.Header{ + Command: p2p.CommandPing, + ExpectResponse: false, + } + err := conn.handleMessage(header, nil) + if err != nil { + t.Fatalf("handleMessage should skip ping, got: %v", err) + } +} + +func TestLevinconn_handleMessage_SkipsNewTransactions_Bad(t *testing.T) { + conn := NewLevinP2PConn(nil, 100, p2p.CoreSyncData{}) + + header := levinpkg.Header{ + Command: p2p.CommandNewTransactions, + ExpectResponse: false, + } + err := conn.handleMessage(header, []byte{0xde, 0xad}) + if err != nil { + t.Fatalf("handleMessage should skip new_transactions, got: %v", err) + } +} + +// --- Ugly (edge cases) --- + +func TestLevinconn_NewLevinP2PConn_NilConn_Ugly(t *testing.T) { + // A nil levin connection should not panic during construction. + // It will fail at read/write time, but construction is safe. + conn := NewLevinP2PConn(nil, 0, p2p.CoreSyncData{}) + if conn == nil { + t.Fatal("NewLevinP2PConn returned nil with nil connection") + } + if conn.conn != nil { + t.Error("expected conn field to be nil") + } +} + +func TestLevinconn_PeerHeight_MaxUint64_Ugly(t *testing.T) { + maxHeight := ^uint64(0) + conn := NewLevinP2PConn(nil, maxHeight, p2p.CoreSyncData{}) + if conn.PeerHeight() != maxHeight { + t.Errorf("PeerHeight: got %d, want max uint64 %d", conn.PeerHeight(), maxHeight) + } +} + +func TestLevinconn_NewLevinP2PConn_EmptySyncData_Ugly(t *testing.T) { + conn := NewLevinP2PConn(nil, 42, p2p.CoreSyncData{}) + if conn.localSync.CurrentHeight != 0 { + t.Errorf("empty sync CurrentHeight: got %d, want 0", conn.localSync.CurrentHeight) + } + if !conn.localSync.TopID.IsZero() { + t.Error("empty sync TopID should be zero") + } +} + +func TestLevinconn_handleMessage_EmptyData_Ugly(t *testing.T) { + conn := NewLevinP2PConn(nil, 100, p2p.CoreSyncData{}) + + // A non-timed-sync command with empty data should be skipped without error. + header := levinpkg.Header{ + Command: p2p.CommandNewBlock, + ExpectResponse: false, + } + err := conn.handleMessage(header, nil) + if err != nil { + t.Fatalf("handleMessage with empty data: %v", err) + } +} + +func TestLevinconn_handleMessage_TimedSyncNoExpectResponse_Ugly(t *testing.T) { + conn := NewLevinP2PConn(nil, 100, p2p.CoreSyncData{}) + + // timed_sync command without ExpectResponse flag should be silently skipped + // (the conditional checks both Command == CommandTimedSync AND ExpectResponse). + header := levinpkg.Header{ + Command: p2p.CommandTimedSync, + ExpectResponse: false, + } + err := conn.handleMessage(header, []byte{0x01}) + if err != nil { + t.Fatalf("handleMessage timed_sync without ExpectResponse should be skipped, got: %v", err) + } +} diff --git a/chain/store_test.go b/chain/store_test.go new file mode 100644 index 0000000..cc97bed --- /dev/null +++ b/chain/store_test.go @@ -0,0 +1,457 @@ +// 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 ( + "testing" + + "dappco.re/go/core/blockchain/types" + "dappco.re/go/core/blockchain/wire" + store "dappco.re/go/core/store" +) + +// newTempChain creates a Chain backed by a file-based store in t.TempDir(). +func newTempChain(t *testing.T) *Chain { + t.Helper() + directory := t.TempDir() + s, err := store.New(directory + "/test.db") + if err != nil { + t.Fatalf("store.New: %v", err) + } + t.Cleanup(func() { s.Close() }) + return New(s) +} + +// testBlock returns a minimal block and metadata at the given height. +func testBlock(height uint64) (*types.Block, *BlockMeta) { + blk := &types.Block{ + BlockHeader: types.BlockHeader{ + MajorVersion: 1, + Timestamp: 1770897600 + height*120, + }, + MinerTx: testCoinbaseTx(height), + } + meta := &BlockMeta{ + Hash: types.Hash{byte(height & 0xFF), byte((height >> 8) & 0xFF)}, + Height: height, + Timestamp: 1770897600 + height*120, + Difficulty: 100 + height, + } + return blk, meta +} + +// --- Good (happy path) --- + +func TestStore_PutBlock_Good(t *testing.T) { + c := newTempChain(t) + + blk, meta := testBlock(0) + if err := c.PutBlock(blk, meta); err != nil { + t.Fatalf("PutBlock: %v", err) + } + + height, err := c.Height() + if err != nil { + t.Fatalf("Height: %v", err) + } + if height != 1 { + t.Errorf("height: got %d, want 1", height) + } +} + +func TestStore_PutBlock_MultipleBlocks_Good(t *testing.T) { + c := newTempChain(t) + + for i := uint64(0); i < 5; i++ { + blk, meta := testBlock(i) + if err := c.PutBlock(blk, meta); err != nil { + t.Fatalf("PutBlock(%d): %v", i, err) + } + } + + height, err := c.Height() + if err != nil { + t.Fatalf("Height: %v", err) + } + if height != 5 { + t.Errorf("height: got %d, want 5", height) + } +} + +func TestStore_GetBlockByHeight_Good(t *testing.T) { + c := newTempChain(t) + + blk, meta := testBlock(0) + if err := c.PutBlock(blk, meta); err != nil { + t.Fatalf("PutBlock: %v", err) + } + + gotBlock, gotMeta, err := c.GetBlockByHeight(0) + if err != nil { + t.Fatalf("GetBlockByHeight: %v", err) + } + if gotBlock.MajorVersion != 1 { + t.Errorf("major_version: got %d, want 1", gotBlock.MajorVersion) + } + if gotMeta.Hash != meta.Hash { + t.Errorf("hash mismatch: got %s, want %s", gotMeta.Hash, meta.Hash) + } + if gotMeta.Timestamp != meta.Timestamp { + t.Errorf("timestamp: got %d, want %d", gotMeta.Timestamp, meta.Timestamp) + } +} + +func TestStore_GetBlockByHeight_MultipleHeights_Good(t *testing.T) { + c := newTempChain(t) + + for i := uint64(0); i < 3; i++ { + blk, meta := testBlock(i) + if err := c.PutBlock(blk, meta); err != nil { + t.Fatalf("PutBlock(%d): %v", i, err) + } + } + + for i := uint64(0); i < 3; i++ { + _, gotMeta, err := c.GetBlockByHeight(i) + if err != nil { + t.Fatalf("GetBlockByHeight(%d): %v", i, err) + } + if gotMeta.Height != i { + t.Errorf("height %d: gotMeta.Height = %d", i, gotMeta.Height) + } + } +} + +func TestStore_GetBlockByHash_Good(t *testing.T) { + c := newTempChain(t) + + blk, meta := testBlock(0) + if err := c.PutBlock(blk, meta); err != nil { + t.Fatalf("PutBlock: %v", err) + } + + gotBlock, gotMeta, err := c.GetBlockByHash(meta.Hash) + if err != nil { + t.Fatalf("GetBlockByHash: %v", err) + } + if gotBlock.Timestamp != blk.Timestamp { + t.Errorf("timestamp: got %d, want %d", gotBlock.Timestamp, blk.Timestamp) + } + if gotMeta.Height != 0 { + t.Errorf("height: got %d, want 0", gotMeta.Height) + } +} + +func TestStore_GetBlockByHash_MultipleBlocks_Good(t *testing.T) { + c := newTempChain(t) + + hashes := make([]types.Hash, 3) + for i := uint64(0); i < 3; i++ { + blk, meta := testBlock(i) + hashes[i] = meta.Hash + if err := c.PutBlock(blk, meta); err != nil { + t.Fatalf("PutBlock(%d): %v", i, err) + } + } + + for i := uint64(0); i < 3; i++ { + _, gotMeta, err := c.GetBlockByHash(hashes[i]) + if err != nil { + t.Fatalf("GetBlockByHash(%d): %v", i, err) + } + if gotMeta.Height != i { + t.Errorf("hash lookup %d: height = %d, want %d", i, gotMeta.Height, i) + } + } +} + +func TestStore_PutBlock_RoundTrip_Good(t *testing.T) { + c := newTempChain(t) + + blk := &types.Block{ + BlockHeader: types.BlockHeader{ + MajorVersion: 1, + Nonce: 42, + Timestamp: 1770897600, + Flags: 0, + }, + MinerTx: testCoinbaseTx(0), + } + meta := &BlockMeta{ + Hash: types.Hash{0xde, 0xad, 0xbe, 0xef}, + Height: 0, + Timestamp: 1770897600, + Difficulty: 500, + CumulativeDiff: 500, + GeneratedCoins: 1000000, + } + + if err := c.PutBlock(blk, meta); err != nil { + t.Fatalf("PutBlock: %v", err) + } + + gotBlock, gotMeta, err := c.GetBlockByHeight(0) + if err != nil { + t.Fatalf("GetBlockByHeight: %v", err) + } + + if gotBlock.Nonce != 42 { + t.Errorf("nonce: got %d, want 42", gotBlock.Nonce) + } + if gotMeta.Difficulty != 500 { + t.Errorf("difficulty: got %d, want 500", gotMeta.Difficulty) + } + if gotMeta.CumulativeDiff != 500 { + t.Errorf("cumulative_diff: got %d, want 500", gotMeta.CumulativeDiff) + } + if gotMeta.GeneratedCoins != 1000000 { + t.Errorf("generated_coins: got %d, want 1000000", gotMeta.GeneratedCoins) + } +} + +// --- Bad (expected errors) --- + +func TestStore_GetBlockByHeight_NotFound_Bad(t *testing.T) { + c := newTempChain(t) + + _, _, err := c.GetBlockByHeight(0) + if err == nil { + t.Fatal("GetBlockByHeight on empty chain: expected error, got nil") + } +} + +func TestStore_GetBlockByHeight_WrongHeight_Bad(t *testing.T) { + c := newTempChain(t) + + blk, meta := testBlock(0) + if err := c.PutBlock(blk, meta); err != nil { + t.Fatalf("PutBlock: %v", err) + } + + _, _, err := c.GetBlockByHeight(999) + if err == nil { + t.Fatal("GetBlockByHeight(999): expected error, got nil") + } +} + +func TestStore_GetBlockByHash_NotFound_Bad(t *testing.T) { + c := newTempChain(t) + + bogusHash := types.Hash{0xff, 0xfe, 0xfd} + _, _, err := c.GetBlockByHash(bogusHash) + if err == nil { + t.Fatal("GetBlockByHash(bogus): expected error, got nil") + } +} + +func TestStore_GetBlockByHash_WrongHash_Bad(t *testing.T) { + c := newTempChain(t) + + blk, meta := testBlock(0) + if err := c.PutBlock(blk, meta); err != nil { + t.Fatalf("PutBlock: %v", err) + } + + differentHash := types.Hash{0xff, 0xff, 0xff} + _, _, err := c.GetBlockByHash(differentHash) + if err == nil { + t.Fatal("GetBlockByHash(wrong hash): expected error, got nil") + } +} + +func TestStore_PutBlock_Overwrite_Bad(t *testing.T) { + c := newTempChain(t) + + blk1, meta1 := testBlock(0) + if err := c.PutBlock(blk1, meta1); err != nil { + t.Fatalf("PutBlock(first): %v", err) + } + + // Overwrite the same height with different metadata. + blk2 := &types.Block{ + BlockHeader: types.BlockHeader{ + MajorVersion: 1, + Timestamp: 9999999, + }, + MinerTx: testCoinbaseTx(0), + } + meta2 := &BlockMeta{ + Hash: types.Hash{0xaa, 0xbb}, + Height: 0, + Timestamp: 9999999, + } + + if err := c.PutBlock(blk2, meta2); err != nil { + t.Fatalf("PutBlock(overwrite): %v", err) + } + + // The overwritten block should be the one we read back. + _, gotMeta, err := c.GetBlockByHeight(0) + if err != nil { + t.Fatalf("GetBlockByHeight: %v", err) + } + if gotMeta.Timestamp != 9999999 { + t.Errorf("timestamp: got %d, want 9999999 (overwritten)", gotMeta.Timestamp) + } +} + +// --- Ugly (edge cases) --- + +func TestStore_PutGetBlock_EmptyChain_Ugly(t *testing.T) { + c := newTempChain(t) + + height, err := c.Height() + if err != nil { + t.Fatalf("Height: %v", err) + } + if height != 0 { + t.Errorf("empty chain height: got %d, want 0", height) + } +} + +func TestStore_PutBlock_HeightZero_Ugly(t *testing.T) { + c := newTempChain(t) + + blk, meta := testBlock(0) + if err := c.PutBlock(blk, meta); err != nil { + t.Fatalf("PutBlock at height 0: %v", err) + } + + gotBlock, gotMeta, err := c.GetBlockByHeight(0) + if err != nil { + t.Fatalf("GetBlockByHeight(0): %v", err) + } + if gotBlock == nil { + t.Fatal("block at height 0 should not be nil") + } + if gotMeta.Height != 0 { + t.Errorf("height: got %d, want 0", gotMeta.Height) + } +} + +func TestStore_GetBlockByHash_ZeroHash_Ugly(t *testing.T) { + c := newTempChain(t) + + zeroHash := types.Hash{} + _, _, err := c.GetBlockByHash(zeroHash) + if err == nil { + t.Fatal("GetBlockByHash(zero hash): expected error on empty chain, got nil") + } +} + +func TestStore_PutBlock_ZeroHashMeta_Ugly(t *testing.T) { + c := newTempChain(t) + + blk := &types.Block{ + BlockHeader: types.BlockHeader{MajorVersion: 1}, + MinerTx: testCoinbaseTx(0), + } + meta := &BlockMeta{ + Hash: types.Hash{}, // zero hash + Height: 0, + } + + if err := c.PutBlock(blk, meta); err != nil { + t.Fatalf("PutBlock with zero hash: %v", err) + } + + // Should be retrievable by height. + _, gotMeta, err := c.GetBlockByHeight(0) + if err != nil { + t.Fatalf("GetBlockByHeight: %v", err) + } + if !gotMeta.Hash.IsZero() { + t.Errorf("expected zero hash in metadata, got %s", gotMeta.Hash) + } + + // Should also be retrievable by zero hash via the index. + _, _, err = c.GetBlockByHash(types.Hash{}) + if err != nil { + t.Fatalf("GetBlockByHash(zero): %v", err) + } +} + +func TestStore_PutBlock_LargeHeight_Ugly(t *testing.T) { + c := newTempChain(t) + + // Store a block at a high height to test the height key padding. + blk := &types.Block{ + BlockHeader: types.BlockHeader{MajorVersion: 1}, + MinerTx: testCoinbaseTx(9999999), + } + meta := &BlockMeta{ + Hash: types.Hash{0xab}, + Height: 9999999, + } + + if err := c.PutBlock(blk, meta); err != nil { + t.Fatalf("PutBlock at large height: %v", err) + } + + gotBlock, gotMeta, err := c.GetBlockByHeight(9999999) + if err != nil { + t.Fatalf("GetBlockByHeight(9999999): %v", err) + } + if gotBlock == nil { + t.Fatal("block at large height should not be nil") + } + if gotMeta.Height != 9999999 { + t.Errorf("height: got %d, want 9999999", gotMeta.Height) + } +} + +func TestStore_PutBlock_TransactionRoundTrip_Ugly(t *testing.T) { + // Verify that a block with a more complex miner tx round-trips correctly. + c := newTempChain(t) + + minerTx := types.Transaction{ + Version: 1, + Vin: []types.TxInput{types.TxInputGenesis{Height: 42}}, + Vout: []types.TxOutput{ + types.TxOutputBare{ + Amount: 5000000, + Target: types.TxOutToKey{Key: types.PublicKey{0xaa, 0xbb}}, + }, + types.TxOutputBare{ + Amount: 3000000, + Target: types.TxOutToKey{Key: types.PublicKey{0xcc, 0xdd}}, + }, + }, + Extra: wire.EncodeVarint(0), + Attachment: wire.EncodeVarint(0), + } + + blk := &types.Block{ + BlockHeader: types.BlockHeader{ + MajorVersion: 1, + Timestamp: 1770897600, + }, + MinerTx: minerTx, + } + meta := &BlockMeta{ + Hash: types.Hash{0x01, 0x02}, + Height: 42, + } + + if err := c.PutBlock(blk, meta); err != nil { + t.Fatalf("PutBlock: %v", err) + } + + gotBlock, _, err := c.GetBlockByHeight(42) + if err != nil { + t.Fatalf("GetBlockByHeight: %v", err) + } + if len(gotBlock.MinerTx.Vout) != 2 { + t.Fatalf("miner tx outputs: got %d, want 2", len(gotBlock.MinerTx.Vout)) + } + bare, ok := gotBlock.MinerTx.Vout[0].(types.TxOutputBare) + if !ok { + t.Fatal("first output should be TxOutputBare") + } + if bare.Amount != 5000000 { + t.Errorf("first output amount: got %d, want 5000000", bare.Amount) + } +} diff --git a/cmd_wallet.go b/cmd_wallet.go index 4ab5af0..78eede5 100644 --- a/cmd_wallet.go +++ b/cmd_wallet.go @@ -7,9 +7,6 @@ package blockchain import ( "bytes" "encoding/hex" - "encoding/json" - "io" - "net/http" "dappco.re/go/core" coreerr "dappco.re/go/core/log" @@ -376,57 +373,20 @@ func runWalletTransfer(walletRPC, destination string, amount float64, paymentID core.Print(nil, "Sending %f LTHN to %s...", amount, destination[:20]+"...") - _ = rpc.NewClient(walletRPC) // for future native transfer path + client := rpc.NewClient(walletRPC) - // Use the transfer RPC method on the C++ wallet - type transferDest struct { - Address string `json:"address"` - Amount uint64 `json:"amount"` - } - params := struct { - Destinations []transferDest `json:"destinations"` - Fee uint64 `json:"fee"` - Mixin uint64 `json:"mixin"` - PaymentID string `json:"payment_id,omitempty"` - }{ - Destinations: []transferDest{{Address: destination, Amount: atomicAmount}}, + result, err := client.Transfer(rpc.TransferParams{ + Destinations: []rpc.TransferDestination{{Address: destination, Amount: atomicAmount}}, Fee: 10000000000, // 0.01 LTHN Mixin: 15, PaymentID: paymentID, - } - // Call the wallet RPC transfer method - reqBody := map[string]interface{}{ - "jsonrpc": "2.0", - "id": "0", - "method": "transfer", - "params": params, - } - data := core.JSONMarshalString(reqBody) - - httpResp, err := http.Post(walletRPC+"/json_rpc", "application/json", core.NewReader(data)) + }) if err != nil { - return coreerr.E("runWalletTransfer", "wallet RPC call failed", err) - } - defer httpResp.Body.Close() - - body, _ := io.ReadAll(httpResp.Body) - - var rpcResp struct { - Result struct { - TxHash string `json:"tx_hash"` - } `json:"result"` - Error *struct { - Message string `json:"message"` - } `json:"error"` - } - json.Unmarshal(body, &rpcResp) - - if rpcResp.Error != nil { - return coreerr.E("runWalletTransfer", rpcResp.Error.Message, nil) + return coreerr.E("runWalletTransfer", "wallet transfer failed", err) } core.Print(nil, "Transfer sent!") - core.Print(nil, " TX Hash: %s", rpcResp.Result.TxHash) + core.Print(nil, " TX Hash: %s", result.TxHash) core.Print(nil, " Amount: %f LTHN", amount) core.Print(nil, " Fee: 0.01 LTHN") diff --git a/rpc/wallet.go b/rpc/wallet.go index b366896..b35c7a7 100644 --- a/rpc/wallet.go +++ b/rpc/wallet.go @@ -11,6 +11,47 @@ import ( "encoding/hex" ) +// TransferDestination is an address+amount pair for a wallet transfer. +// +// destination := rpc.TransferDestination{Address: "iTHN...", Amount: 1000000000000} +type TransferDestination struct { + Address string `json:"address"` + Amount uint64 `json:"amount"` +} + +// TransferParams holds the parameters for a wallet transfer RPC call. +// +// params := rpc.TransferParams{Destinations: destinations, Fee: 10000000000, Mixin: 15} +type TransferParams struct { + Destinations []TransferDestination `json:"destinations"` + Fee uint64 `json:"fee"` + Mixin uint64 `json:"mixin"` + PaymentID string `json:"payment_id,omitempty"` +} + +// TransferResult holds the response from a wallet transfer RPC call. +// +// result, err := client.Transfer(params) +// core.Print(nil, "TX: %s", result.TxHash) +type TransferResult struct { + TxHash string `json:"tx_hash"` +} + +// Transfer sends LTHN to one or more destinations via the wallet RPC. +// +// result, err := client.Transfer(rpc.TransferParams{ +// Destinations: []rpc.TransferDestination{{Address: "iTHN...", Amount: 1000000000000}}, +// Fee: 10000000000, +// Mixin: 15, +// }) +func (c *Client) Transfer(params TransferParams) (*TransferResult, error) { + var result TransferResult + if err := c.call("transfer", params, &result); err != nil { + return nil, coreerr.E("Client.Transfer", "wallet transfer RPC failed", err) + } + return &result, nil +} + // RandomOutputEntry is a decoy output returned by getrandom_outs. // Usage: var value rpc.RandomOutputEntry type RandomOutputEntry struct { diff --git a/wallet/chain_scanner_test.go b/wallet/chain_scanner_test.go index a33230a..85d026d 100644 --- a/wallet/chain_scanner_test.go +++ b/wallet/chain_scanner_test.go @@ -1,33 +1,325 @@ +// 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 wallet import ( "testing" + "dappco.re/go/core" + coreerr "dappco.re/go/core/log" + "dappco.re/go/core/blockchain/types" ) -func TestChainScanner_New_Good(t *testing.T) { - account, _ := GenerateAccount() - getter := func(h uint64) (*types.Block, []types.Transaction, error) { +// --- Good (happy path) --- + +func TestChainScanner_NewChainScanner_Good(t *testing.T) { + account, err := GenerateAccount() + if err != nil { + t.Fatal(err) + } + getter := func(height uint64) (*types.Block, []types.Transaction, error) { return nil, nil, nil } scanner := NewChainScanner(account, getter) if scanner == nil { - t.Fatal("scanner is nil") + t.Fatal("NewChainScanner returned nil") + } + if scanner.v1 == nil { + t.Fatal("scanner.v1 is nil; expected a V1Scanner") + } + if scanner.getBlock == nil { + t.Fatal("scanner.getBlock is nil; expected the getter function") } } -func TestChainScanner_ScanRange_Empty_Good(t *testing.T) { - account, _ := GenerateAccount() - getter := func(h uint64) (*types.Block, []types.Transaction, error) { - return nil, nil, nil +func TestChainScanner_ScanRange_SingleBlock_Good(t *testing.T) { + account, err := GenerateAccount() + if err != nil { + t.Fatal(err) } + + // Build a block whose miner tx is sent to our account. + tx, _, _ := makeTestTransaction(t, account) + + getter := func(height uint64) (*types.Block, []types.Transaction, error) { + blk := &types.Block{ + BlockHeader: types.BlockHeader{MajorVersion: 1, Timestamp: 1000}, + MinerTx: *tx, + } + return blk, nil, nil + } + scanner := NewChainScanner(account, getter) - transfers, scanned := scanner.ScanRange(0, 10) - if len(transfers) != 0 { - t.Errorf("expected 0 transfers, got %d", len(transfers)) + transfers, scanned := scanner.ScanRange(0, 1) + + if scanned != 1 { + t.Errorf("scanned: got %d, want 1", scanned) } - if scanned != 0 { - t.Errorf("expected 0 scanned (nil blocks), got %d", scanned) + // The miner tx has a valid output for our account, so we expect a transfer. + if len(transfers) != 1 { + t.Errorf("transfers: got %d, want 1", len(transfers)) + } +} + +func TestChainScanner_ScanRange_MultipleBlocks_Good(t *testing.T) { + account, err := GenerateAccount() + if err != nil { + t.Fatal(err) + } + + tx, _, _ := makeTestTransaction(t, account) + + getter := func(height uint64) (*types.Block, []types.Transaction, error) { + blk := &types.Block{ + BlockHeader: types.BlockHeader{MajorVersion: 1, Timestamp: 1000 + height*120}, + MinerTx: *tx, + } + return blk, nil, nil + } + + scanner := NewChainScanner(account, getter) + transfers, scanned := scanner.ScanRange(0, 5) + + if scanned != 5 { + t.Errorf("scanned: got %d, want 5", scanned) + } + // Each block has a miner tx for our account. + if len(transfers) != 5 { + t.Errorf("transfers: got %d, want 5", len(transfers)) + } +} + +func TestChainScanner_ScanRange_WithRegularTxs_Good(t *testing.T) { + account, err := GenerateAccount() + if err != nil { + t.Fatal(err) + } + + minerTx, _, _ := makeTestTransaction(t, account) + regularTx, _, _ := makeTestTransaction(t, account) + + getter := func(height uint64) (*types.Block, []types.Transaction, error) { + blk := &types.Block{ + BlockHeader: types.BlockHeader{MajorVersion: 1}, + MinerTx: *minerTx, + } + return blk, []types.Transaction{*regularTx}, nil + } + + scanner := NewChainScanner(account, getter) + transfers, scanned := scanner.ScanRange(0, 1) + + if scanned != 1 { + t.Errorf("scanned: got %d, want 1", scanned) + } + // Both miner tx and regular tx should produce transfers. + if len(transfers) != 2 { + t.Errorf("transfers: got %d, want 2", len(transfers)) + } +} + +// --- Bad (expected errors / graceful handling) --- + +func TestChainScanner_ScanRange_GetterError_Bad(t *testing.T) { + account, err := GenerateAccount() + if err != nil { + t.Fatal(err) + } + + getter := func(height uint64) (*types.Block, []types.Transaction, error) { + return nil, nil, coreerr.E("test", "block not found", nil) + } + + scanner := NewChainScanner(account, getter) + transfers, scanned := scanner.ScanRange(0, 10) + + // Getter errors cause the block to be skipped, not counted. + if scanned != 0 { + t.Errorf("scanned: got %d, want 0 (all errored)", scanned) + } + if len(transfers) != 0 { + t.Errorf("transfers: got %d, want 0", len(transfers)) + } +} + +func TestChainScanner_ScanRange_NonOwnedOutputs_Bad(t *testing.T) { + account1, err := GenerateAccount() + if err != nil { + t.Fatal(err) + } + account2, err := GenerateAccount() + if err != nil { + t.Fatal(err) + } + + // Build a tx for account1 but scan with account2's scanner. + tx, _, _ := makeTestTransaction(t, account1) + + getter := func(height uint64) (*types.Block, []types.Transaction, error) { + blk := &types.Block{ + BlockHeader: types.BlockHeader{MajorVersion: 1}, + MinerTx: *tx, + } + return blk, nil, nil + } + + scanner := NewChainScanner(account2, getter) + transfers, scanned := scanner.ScanRange(0, 3) + + if scanned != 3 { + t.Errorf("scanned: got %d, want 3", scanned) + } + if len(transfers) != 0 { + t.Errorf("transfers: got %d, want 0 (non-owned outputs)", len(transfers)) + } +} + +func TestChainScanner_ScanRange_StartEqualsEnd_Bad(t *testing.T) { + account, err := GenerateAccount() + if err != nil { + t.Fatal(err) + } + + called := false + getter := func(height uint64) (*types.Block, []types.Transaction, error) { + called = true + return nil, nil, nil + } + + scanner := NewChainScanner(account, getter) + transfers, scanned := scanner.ScanRange(5, 5) + + if called { + t.Error("getter should not be called when start == end") + } + if scanned != 0 { + t.Errorf("scanned: got %d, want 0", scanned) + } + if len(transfers) != 0 { + t.Errorf("transfers: got %d, want 0", len(transfers)) + } +} + +// --- Ugly (edge cases) --- + +func TestChainScanner_ScanRange_NilBlockReturned_Ugly(t *testing.T) { + account, err := GenerateAccount() + if err != nil { + t.Fatal(err) + } + + getter := func(height uint64) (*types.Block, []types.Transaction, error) { + return nil, nil, nil + } + + scanner := NewChainScanner(account, getter) + transfers, scanned := scanner.ScanRange(0, 10) + + // Nil blocks are skipped (not counted as scanned). + if scanned != 0 { + t.Errorf("scanned: got %d, want 0 (nil blocks)", scanned) + } + if len(transfers) != 0 { + t.Errorf("transfers: got %d, want 0", len(transfers)) + } +} + +func TestChainScanner_ScanRange_ZeroRange_Ugly(t *testing.T) { + account, err := GenerateAccount() + if err != nil { + t.Fatal(err) + } + + getter := func(height uint64) (*types.Block, []types.Transaction, error) { + return nil, nil, nil + } + + scanner := NewChainScanner(account, getter) + transfers, scanned := scanner.ScanRange(0, 0) + + if scanned != 0 { + t.Errorf("scanned: got %d, want 0", scanned) + } + if len(transfers) != 0 { + t.Errorf("transfers: got %d, want 0", len(transfers)) + } +} + +func TestChainScanner_ScanRange_MixedNilAndValidBlocks_Ugly(t *testing.T) { + account, err := GenerateAccount() + if err != nil { + t.Fatal(err) + } + + tx, _, _ := makeTestTransaction(t, account) + + getter := func(height uint64) (*types.Block, []types.Transaction, error) { + // Only even heights return a valid block. + if height%2 == 0 { + blk := &types.Block{ + BlockHeader: types.BlockHeader{MajorVersion: 1}, + MinerTx: *tx, + } + return blk, nil, nil + } + return nil, nil, nil + } + + scanner := NewChainScanner(account, getter) + transfers, scanned := scanner.ScanRange(0, 6) + + // Heights 0, 2, 4 are valid (3 blocks); 1, 3, 5 are nil. + if scanned != 3 { + t.Errorf("scanned: got %d, want 3", scanned) + } + if len(transfers) != 3 { + t.Errorf("transfers: got %d, want 3", len(transfers)) + } +} + +func TestChainScanner_ScanRange_MixedErrorsAndValid_Ugly(t *testing.T) { + account, err := GenerateAccount() + if err != nil { + t.Fatal(err) + } + + tx, _, _ := makeTestTransaction(t, account) + + getter := func(height uint64) (*types.Block, []types.Transaction, error) { + if height == 2 { + return nil, nil, core.E("test", "db read error", nil) + } + blk := &types.Block{ + BlockHeader: types.BlockHeader{MajorVersion: 1}, + MinerTx: *tx, + } + return blk, nil, nil + } + + scanner := NewChainScanner(account, getter) + transfers, scanned := scanner.ScanRange(0, 5) + + // 5 blocks requested, 1 errored, 4 valid. + if scanned != 4 { + t.Errorf("scanned: got %d, want 4", scanned) + } + if len(transfers) != 4 { + t.Errorf("transfers: got %d, want 4", len(transfers)) + } +} + +func TestChainScanner_NewChainScanner_NilAccount_Ugly(t *testing.T) { + // NewChainScanner with nil account should not panic during construction. + // (It will panic when scanning, but creation itself should be safe.) + getter := func(height uint64) (*types.Block, []types.Transaction, error) { + return nil, nil, nil + } + scanner := NewChainScanner(nil, getter) + if scanner == nil { + t.Fatal("NewChainScanner returned nil even with nil account") } }