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 <charon@lethean.io>
This commit is contained in:
parent
ee257baa83
commit
b7349a054d
2 changed files with 386 additions and 0 deletions
114
wallet/transfer.go
Normal file
114
wallet/transfer.go
Normal file
|
|
@ -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
|
||||
}
|
||||
272
wallet/transfer_test.go
Normal file
272
wallet/transfer_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue