From b7349a054d47165c7d9e40773787c6d89bd1261d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Feb 2026 23:05:08 +0000 Subject: [PATCH] feat(wallet): transfer type and go-store persistence Transfer struct tracks owned outputs with key image, amount, block height, and spend status. Storage helpers use go-store JSON serialisation keyed by key image hex. IsSpendable checks coinbase maturity and unlock time. Co-Authored-By: Charon --- wallet/transfer.go | 114 +++++++++++++++++ wallet/transfer_test.go | 272 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 386 insertions(+) create mode 100644 wallet/transfer.go create mode 100644 wallet/transfer_test.go diff --git a/wallet/transfer.go b/wallet/transfer.go new file mode 100644 index 0000000..125c4da --- /dev/null +++ b/wallet/transfer.go @@ -0,0 +1,114 @@ +// Copyright (c) 2017-2026 Lethean (https://lt.hn) +// +// Licensed under the European Union Public Licence (EUPL) version 1.2. +// You may obtain a copy of the licence at: +// +// https://joinup.ec.europa.eu/software/page/eupl/licence-eupl +// +// SPDX-License-Identifier: EUPL-1.2 + +package wallet + +import ( + "encoding/json" + "fmt" + + store "forge.lthn.ai/core/go-store" + + "forge.lthn.ai/core/go-blockchain/config" + "forge.lthn.ai/core/go-blockchain/types" +) + +// groupTransfers is the go-store group name for wallet transfer records. +const groupTransfers = "transfers" + +// KeyPair holds an ephemeral public/secret key pair for an owned output. +type KeyPair struct { + Public types.PublicKey `json:"public"` + Secret types.SecretKey `json:"secret"` +} + +// Transfer represents an owned transaction output tracked by the wallet. +// Each transfer is keyed by its unique key image for double-spend detection. +type Transfer struct { + TxHash types.Hash `json:"tx_hash"` + OutputIndex uint32 `json:"output_index"` + Amount uint64 `json:"amount"` + GlobalIndex uint64 `json:"global_index"` + BlockHeight uint64 `json:"block_height"` + EphemeralKey KeyPair `json:"ephemeral_key"` + KeyImage types.KeyImage `json:"key_image"` + Spent bool `json:"spent"` + SpentHeight uint64 `json:"spent_height"` + Coinbase bool `json:"coinbase"` + UnlockTime uint64 `json:"unlock_time"` +} + +// IsSpendable reports whether the transfer can be used as an input at the +// given chain height. A transfer is not spendable if it has already been +// spent, if it is a coinbase output that has not yet matured, or if its +// unlock time has not been reached. +func (t *Transfer) IsSpendable(chainHeight uint64, _ bool) bool { + if t.Spent { + return false + } + if t.Coinbase && t.BlockHeight+config.MinedMoneyUnlockWindow > chainHeight { + return false + } + if t.UnlockTime > 0 && t.UnlockTime > chainHeight { + return false + } + return true +} + +// putTransfer serialises a transfer as JSON and stores it in the given store, +// keyed by the transfer's key image hex string. +func putTransfer(s *store.Store, tr *Transfer) error { + val, err := json.Marshal(tr) + if err != nil { + return fmt.Errorf("wallet: marshal transfer: %w", err) + } + return s.Set(groupTransfers, tr.KeyImage.String(), string(val)) +} + +// getTransfer retrieves and deserialises a transfer by its key image. +func getTransfer(s *store.Store, ki types.KeyImage) (*Transfer, error) { + val, err := s.Get(groupTransfers, ki.String()) + if err != nil { + return nil, fmt.Errorf("wallet: get transfer %s: %w", ki, err) + } + var tr Transfer + if err := json.Unmarshal([]byte(val), &tr); err != nil { + return nil, fmt.Errorf("wallet: unmarshal transfer: %w", err) + } + return &tr, nil +} + +// markTransferSpent sets the spent flag and records the height at which the +// transfer was consumed. +func markTransferSpent(s *store.Store, ki types.KeyImage, height uint64) error { + tr, err := getTransfer(s, ki) + if err != nil { + return err + } + tr.Spent = true + tr.SpentHeight = height + return putTransfer(s, tr) +} + +// listTransfers returns all transfers stored in the given store. +func listTransfers(s *store.Store) ([]Transfer, error) { + pairs, err := s.GetAll(groupTransfers) + if err != nil { + return nil, fmt.Errorf("wallet: list transfers: %w", err) + } + transfers := make([]Transfer, 0, len(pairs)) + for _, val := range pairs { + var tr Transfer + if err := json.Unmarshal([]byte(val), &tr); err != nil { + continue + } + transfers = append(transfers, tr) + } + return transfers, nil +} diff --git a/wallet/transfer_test.go b/wallet/transfer_test.go new file mode 100644 index 0000000..0a4ab55 --- /dev/null +++ b/wallet/transfer_test.go @@ -0,0 +1,272 @@ +// 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" + + store "forge.lthn.ai/core/go-store" + + "forge.lthn.ai/core/go-blockchain/types" +) + +func newTestStore(t *testing.T) *store.Store { + t.Helper() + s, err := store.New(":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { s.Close() }) + return s +} + +func TestTransferPutGet(t *testing.T) { + s := newTestStore(t) + + var ki types.KeyImage + ki[0] = 0x42 + tr := Transfer{ + TxHash: types.Hash{1}, + OutputIndex: 0, + Amount: 1000, + BlockHeight: 10, + KeyImage: ki, + } + + if err := putTransfer(s, &tr); err != nil { + t.Fatal(err) + } + + got, err := getTransfer(s, ki) + if err != nil { + t.Fatal(err) + } + if got.Amount != 1000 { + t.Fatalf("amount = %d, want 1000", got.Amount) + } + if got.TxHash != tr.TxHash { + t.Fatalf("tx hash mismatch: got %s", got.TxHash) + } + if got.KeyImage != ki { + t.Fatalf("key image mismatch: got %s", got.KeyImage) + } +} + +func TestTransferGetNotFound(t *testing.T) { + s := newTestStore(t) + + var ki types.KeyImage + ki[0] = 0xFF + _, err := getTransfer(s, ki) + if err == nil { + t.Fatal("expected error for missing transfer") + } +} + +func TestTransferOverwrite(t *testing.T) { + s := newTestStore(t) + + var ki types.KeyImage + ki[0] = 0x01 + tr := Transfer{Amount: 500, BlockHeight: 5, KeyImage: ki} + if err := putTransfer(s, &tr); err != nil { + t.Fatal(err) + } + + tr.Amount = 999 + if err := putTransfer(s, &tr); err != nil { + t.Fatal(err) + } + + got, err := getTransfer(s, ki) + if err != nil { + t.Fatal(err) + } + if got.Amount != 999 { + t.Fatalf("amount = %d, want 999 after overwrite", got.Amount) + } +} + +func TestTransferMarkSpent(t *testing.T) { + s := newTestStore(t) + + var ki types.KeyImage + ki[0] = 0x43 + tr := Transfer{Amount: 500, BlockHeight: 5, KeyImage: ki} + if err := putTransfer(s, &tr); err != nil { + t.Fatal(err) + } + + if err := markTransferSpent(s, ki, 20); err != nil { + t.Fatal(err) + } + + got, err := getTransfer(s, ki) + if err != nil { + t.Fatal(err) + } + if !got.Spent { + t.Fatal("should be spent") + } + if got.SpentHeight != 20 { + t.Fatalf("spent height = %d, want 20", got.SpentHeight) + } +} + +func TestTransferMarkSpentNotFound(t *testing.T) { + s := newTestStore(t) + + var ki types.KeyImage + ki[0] = 0xDE + if err := markTransferSpent(s, ki, 10); err == nil { + t.Fatal("expected error marking non-existent transfer as spent") + } +} + +func TestTransferList(t *testing.T) { + s := newTestStore(t) + + for i := byte(0); i < 3; i++ { + var ki types.KeyImage + ki[0] = i + 1 + tr := Transfer{ + Amount: uint64(i+1) * 100, + BlockHeight: uint64(i), + KeyImage: ki, + } + if err := putTransfer(s, &tr); err != nil { + t.Fatal(err) + } + } + + // Mark second transfer as spent. + var ki types.KeyImage + ki[0] = 2 + if err := markTransferSpent(s, ki, 10); err != nil { + t.Fatal(err) + } + + transfers, err := listTransfers(s) + if err != nil { + t.Fatal(err) + } + if len(transfers) != 3 { + t.Fatalf("got %d transfers, want 3", len(transfers)) + } + + unspent := 0 + for _, tr := range transfers { + if !tr.Spent { + unspent++ + } + } + if unspent != 2 { + t.Fatalf("got %d unspent, want 2", unspent) + } +} + +func TestTransferListEmpty(t *testing.T) { + s := newTestStore(t) + + transfers, err := listTransfers(s) + if err != nil { + t.Fatal(err) + } + if len(transfers) != 0 { + t.Fatalf("got %d transfers, want 0 for empty store", len(transfers)) + } +} + +func TestTransferSpendableBasic(t *testing.T) { + tr := Transfer{Amount: 1000, BlockHeight: 5} + if !tr.IsSpendable(20, false) { + t.Fatal("unspent transfer should be spendable") + } +} + +func TestTransferSpendableSpent(t *testing.T) { + tr := Transfer{Amount: 1000, BlockHeight: 5, Spent: true} + if tr.IsSpendable(20, false) { + t.Fatal("spent transfer should not be spendable") + } +} + +func TestTransferSpendableCoinbaseImmature(t *testing.T) { + tr := Transfer{Amount: 1000, BlockHeight: 5, Coinbase: true} + // MinedMoneyUnlockWindow is 10, so block 5 + 10 = 15 > 10. + if tr.IsSpendable(10, false) { + t.Fatal("immature coinbase should not be spendable") + } +} + +func TestTransferSpendableCoinbaseMature(t *testing.T) { + tr := Transfer{Amount: 1000, BlockHeight: 5, Coinbase: true} + // MinedMoneyUnlockWindow is 10, so block 5 + 10 = 15 <= 20. + if !tr.IsSpendable(20, false) { + t.Fatal("mature coinbase should be spendable") + } +} + +func TestTransferSpendableCoinbaseBoundary(t *testing.T) { + tr := Transfer{Amount: 1000, BlockHeight: 5, Coinbase: true} + // Exact boundary: 5 + 10 = 15 == 15, not greater, so spendable. + if !tr.IsSpendable(15, false) { + t.Fatal("coinbase at exact maturity boundary should be spendable") + } +} + +func TestTransferSpendableUnlockTime(t *testing.T) { + tr := Transfer{Amount: 1000, BlockHeight: 5, UnlockTime: 50} + if tr.IsSpendable(30, false) { + t.Fatal("transfer with future unlock time should not be spendable") + } + if !tr.IsSpendable(50, false) { + t.Fatal("transfer at exact unlock height should be spendable") + } + if !tr.IsSpendable(100, false) { + t.Fatal("transfer past unlock height should be spendable") + } +} + +func TestTransferSpendableUnlockTimeZero(t *testing.T) { + tr := Transfer{Amount: 1000, BlockHeight: 5, UnlockTime: 0} + if !tr.IsSpendable(1, false) { + t.Fatal("transfer with zero unlock time should be spendable") + } +} + +func TestTransferKeyPairFields(t *testing.T) { + s := newTestStore(t) + + var ki types.KeyImage + ki[0] = 0x99 + var pub types.PublicKey + pub[0] = 0xAA + var sec types.SecretKey + sec[0] = 0xBB + + tr := Transfer{ + Amount: 2000, + BlockHeight: 50, + KeyImage: ki, + EphemeralKey: KeyPair{Public: pub, Secret: sec}, + } + + if err := putTransfer(s, &tr); err != nil { + t.Fatal(err) + } + + got, err := getTransfer(s, ki) + if err != nil { + t.Fatal(err) + } + if got.EphemeralKey.Public != pub { + t.Fatal("ephemeral public key mismatch") + } + if got.EphemeralKey.Secret != sec { + t.Fatal("ephemeral secret key mismatch") + } +}