feat: register 16 Core actions for CLI/MCP/API auto-exposure
Some checks are pending
Security Scan / security (push) Waiting to run
Test / Test (push) Waiting to run

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:
Claude 2026-04-02 04:12:25 +01:00
parent fd57171f0f
commit 24fee95962
No known key found for this signature in database
GPG key ID: AF404715446AEB41
6 changed files with 1220 additions and 59 deletions

240
actions.go Normal file
View 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
View 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
View 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)
}
}

View file

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

View file

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

View file

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