feat: register 16 Core actions for CLI/MCP/API auto-exposure
blockchain.chain.{height,info,block,synced,hardforks,stats,search}
blockchain.alias.{list,get,capabilities}
blockchain.network.{gateways,topology,vpn,dns}
blockchain.supply.{total,hashrate}
Each action is automatically:
- CLI command: core blockchain chain height
- MCP tool: blockchain.chain.height
- HTTP endpoint: /blockchain/chain/height (via core/api)
Uses core.Options for input, core.Result for output.
No banned imports — pure Core primitives.
Co-Authored-By: Charon <charon@lethean.io>
This commit is contained in:
parent
fd57171f0f
commit
24fee95962
6 changed files with 1220 additions and 59 deletions
240
actions.go
Normal file
240
actions.go
Normal file
|
|
@ -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
|
||||
}
|
||||
171
chain/levinconn_test.go
Normal file
171
chain/levinconn_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
457
chain/store_test.go
Normal file
457
chain/store_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue