go-blockchain/wallet/builder_test.go
Claude b8841a1a3b
feat(wallet): V1Builder for transaction construction with ring signatures
Builder interface with V1Builder that constructs signed v1 transactions.
Handles change outputs, ECDH output key derivation, ring construction
with sorted global indices, and NLSAG signing per input.

Co-Authored-By: Charon <charon@lethean.io>
2026-02-20 23:23:33 +00:00

224 lines
5.9 KiB
Go

// 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 (
"testing"
"forge.lthn.ai/core/go-blockchain/config"
"forge.lthn.ai/core/go-blockchain/crypto"
"forge.lthn.ai/core/go-blockchain/types"
)
// mockRingSelector returns fixed ring members for testing.
type mockRingSelector struct{}
func (m *mockRingSelector) SelectRing(amount uint64, realGlobalIndex uint64,
ringSize int) ([]RingMember, error) {
members := make([]RingMember, ringSize)
for i := range members {
pub, _, _ := crypto.GenerateKeys()
members[i] = RingMember{
PublicKey: types.PublicKey(pub),
GlobalIndex: uint64(i * 100),
}
}
return members, nil
}
// makeTestSource generates a Transfer with fresh ephemeral keys and a key image.
func makeTestSource(amount uint64, globalIndex uint64) Transfer {
pub, sec, _ := crypto.GenerateKeys()
ki, _ := crypto.GenerateKeyImage(pub, sec)
return Transfer{
Amount: amount,
GlobalIndex: globalIndex,
EphemeralKey: KeyPair{
Public: types.PublicKey(pub),
Secret: types.SecretKey(sec),
},
KeyImage: types.KeyImage(ki),
}
}
func TestV1BuilderBasic(t *testing.T) {
signer := &NLSAGSigner{}
selector := &mockRingSelector{}
builder := NewV1Builder(signer, selector)
sender, err := GenerateAccount()
if err != nil {
t.Fatalf("generate sender: %v", err)
}
recipient, err := GenerateAccount()
if err != nil {
t.Fatalf("generate recipient: %v", err)
}
source := makeTestSource(5000, 42)
req := &BuildRequest{
Sources: []Transfer{source},
Destinations: []Destination{{Address: recipient.Address(), Amount: 3000}},
Fee: 1000,
SenderAddress: sender.Address(),
}
tx, err := builder.Build(req)
if err != nil {
t.Fatalf("Build: %v", err)
}
// Version must be 1.
if tx.Version != types.VersionPreHF4 {
t.Errorf("version: got %d, want %d", tx.Version, types.VersionPreHF4)
}
// One input.
if len(tx.Vin) != 1 {
t.Fatalf("inputs: got %d, want 1", len(tx.Vin))
}
inp, ok := tx.Vin[0].(types.TxInputToKey)
if !ok {
t.Fatalf("input type: got %T, want TxInputToKey", tx.Vin[0])
}
if inp.Amount != 5000 {
t.Errorf("input amount: got %d, want 5000", inp.Amount)
}
// Ring size = decoys + real output.
expectedRingSize := int(config.DefaultDecoySetSize) + 1
if len(inp.KeyOffsets) != expectedRingSize {
t.Errorf("ring size: got %d, want %d", len(inp.KeyOffsets), expectedRingSize)
}
// Key offsets must be sorted by global index.
for j := 1; j < len(inp.KeyOffsets); j++ {
if inp.KeyOffsets[j].GlobalIndex < inp.KeyOffsets[j-1].GlobalIndex {
t.Errorf("key offsets not sorted at index %d: %d < %d",
j, inp.KeyOffsets[j].GlobalIndex, inp.KeyOffsets[j-1].GlobalIndex)
}
}
// Two outputs: destination (3000) + change (1000).
if len(tx.Vout) != 2 {
t.Fatalf("outputs: got %d, want 2", len(tx.Vout))
}
out0, ok := tx.Vout[0].(types.TxOutputBare)
if !ok {
t.Fatalf("output 0 type: got %T, want TxOutputBare", tx.Vout[0])
}
if out0.Amount != 3000 {
t.Errorf("output 0 amount: got %d, want 3000", out0.Amount)
}
out1, ok := tx.Vout[1].(types.TxOutputBare)
if !ok {
t.Fatalf("output 1 type: got %T, want TxOutputBare", tx.Vout[1])
}
if out1.Amount != 1000 {
t.Errorf("output 1 amount: got %d, want 1000", out1.Amount)
}
// Output keys must be non-zero and unique.
if out0.Target.Key == (types.PublicKey{}) {
t.Error("output 0 key is zero")
}
if out1.Target.Key == (types.PublicKey{}) {
t.Error("output 1 key is zero")
}
if out0.Target.Key == out1.Target.Key {
t.Error("output keys are identical; derivation broken")
}
// One signature set per input.
if len(tx.Signatures) != 1 {
t.Fatalf("signature sets: got %d, want 1", len(tx.Signatures))
}
if len(tx.Signatures[0]) != expectedRingSize {
t.Errorf("signatures[0] length: got %d, want %d",
len(tx.Signatures[0]), expectedRingSize)
}
// Extra must be non-empty.
if len(tx.Extra) == 0 {
t.Error("extra is empty")
}
// Attachment must be non-empty (varint(0) = 1 byte).
if len(tx.Attachment) == 0 {
t.Error("attachment is empty")
}
// Serialisation must succeed.
raw, err := SerializeTransaction(tx)
if err != nil {
t.Fatalf("SerializeTransaction: %v", err)
}
if len(raw) == 0 {
t.Error("serialised transaction is empty")
}
}
func TestV1BuilderInsufficientFunds(t *testing.T) {
signer := &NLSAGSigner{}
selector := &mockRingSelector{}
builder := NewV1Builder(signer, selector)
sender, _ := GenerateAccount()
recipient, _ := GenerateAccount()
source := makeTestSource(1000, 10)
req := &BuildRequest{
Sources: []Transfer{source},
Destinations: []Destination{{Address: recipient.Address(), Amount: 2000}},
Fee: 500,
SenderAddress: sender.Address(),
}
_, err := builder.Build(req)
if err == nil {
t.Fatal("expected error for insufficient funds")
}
}
func TestV1BuilderExactAmount(t *testing.T) {
signer := &NLSAGSigner{}
selector := &mockRingSelector{}
builder := NewV1Builder(signer, selector)
sender, _ := GenerateAccount()
recipient, _ := GenerateAccount()
// Source exactly covers destination + fee: no change output.
source := makeTestSource(3000, 55)
req := &BuildRequest{
Sources: []Transfer{source},
Destinations: []Destination{{Address: recipient.Address(), Amount: 2000}},
Fee: 1000,
SenderAddress: sender.Address(),
}
tx, err := builder.Build(req)
if err != nil {
t.Fatalf("Build: %v", err)
}
// Exactly one output (no change).
if len(tx.Vout) != 1 {
t.Fatalf("outputs: got %d, want 1", len(tx.Vout))
}
out, ok := tx.Vout[0].(types.TxOutputBare)
if !ok {
t.Fatalf("output type: got %T, want TxOutputBare", tx.Vout[0])
}
if out.Amount != 2000 {
t.Errorf("output amount: got %d, want 2000", out.Amount)
}
}