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>
224 lines
5.9 KiB
Go
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)
|
|
}
|
|
}
|