diff --git a/wallet/builder.go b/wallet/builder.go new file mode 100644 index 0000000..c376165 --- /dev/null +++ b/wallet/builder.go @@ -0,0 +1,232 @@ +// 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 ( + "bytes" + "fmt" + "sort" + + "forge.lthn.ai/core/go-blockchain/config" + "forge.lthn.ai/core/go-blockchain/crypto" + "forge.lthn.ai/core/go-blockchain/types" + "forge.lthn.ai/core/go-blockchain/wire" +) + +// Destination is a recipient address and amount. +type Destination struct { + Address types.Address + Amount uint64 +} + +// BuildRequest holds the parameters for building a transaction. +type BuildRequest struct { + Sources []Transfer + Destinations []Destination + Fee uint64 + SenderAddress types.Address +} + +// Builder constructs signed transactions. +type Builder interface { + Build(req *BuildRequest) (*types.Transaction, error) +} + +// V1Builder constructs v1 transactions with NLSAG ring signatures. +type V1Builder struct { + signer Signer + ringSelector RingSelector +} + +// inputMeta holds the signing context for a single transaction input. +type inputMeta struct { + ephemeral KeyPair + ring []types.PublicKey + realIndex int +} + +// NewV1Builder returns a Builder that produces version 1 transactions. +func NewV1Builder(signer Signer, ringSelector RingSelector) *V1Builder { + return &V1Builder{ + signer: signer, + ringSelector: ringSelector, + } +} + +// Build constructs a signed v1 transaction from the given request. +// +// Algorithm: +// 1. Validate that source amounts cover destinations plus fee. +// 2. Generate a one-time transaction key pair. +// 3. Build inputs with ring decoys sorted by global index. +// 4. Build outputs with ECDH-derived one-time keys. +// 5. Add a change output if there is surplus. +// 6. Compute the prefix hash and sign each input. +func (b *V1Builder) Build(req *BuildRequest) (*types.Transaction, error) { + // 1. Validate amounts. + var sourceTotal uint64 + for _, src := range req.Sources { + sourceTotal += src.Amount + } + var destTotal uint64 + for _, dst := range req.Destinations { + destTotal += dst.Amount + } + if sourceTotal < destTotal+req.Fee { + return nil, fmt.Errorf("wallet: insufficient funds: have %d, need %d", + sourceTotal, destTotal+req.Fee) + } + change := sourceTotal - destTotal - req.Fee + + // 2. Generate one-time TX key pair. + txPub, txSec, err := crypto.GenerateKeys() + if err != nil { + return nil, fmt.Errorf("wallet: generate tx keys: %w", err) + } + + tx := &types.Transaction{Version: types.VersionPreHF4} + + // 3. Build inputs. + metas := make([]inputMeta, len(req.Sources)) + + for i, src := range req.Sources { + input, meta, buildErr := b.buildInput(&src) + if buildErr != nil { + return nil, fmt.Errorf("wallet: input %d: %w", i, buildErr) + } + tx.Vin = append(tx.Vin, input) + metas[i] = meta + } + + // 4. Build destination outputs. + outputIdx := uint64(0) + for _, dst := range req.Destinations { + out, outErr := deriveOutput(txSec, dst.Address, outputIdx, dst.Amount) + if outErr != nil { + return nil, fmt.Errorf("wallet: output %d: %w", outputIdx, outErr) + } + tx.Vout = append(tx.Vout, out) + outputIdx++ + } + + // 5. Change output. + if change > 0 { + out, outErr := deriveOutput(txSec, req.SenderAddress, outputIdx, change) + if outErr != nil { + return nil, fmt.Errorf("wallet: change output: %w", outErr) + } + tx.Vout = append(tx.Vout, out) + } + + // 6. Extra and attachment. + tx.Extra = BuildTxExtra(types.PublicKey(txPub)) + tx.Attachment = wire.EncodeVarint(0) + + // 7. Compute prefix hash and sign. + prefixHash := wire.TransactionPrefixHash(tx) + for i, meta := range metas { + sigs, signErr := b.signer.SignInput(prefixHash, meta.ephemeral, meta.ring, meta.realIndex) + if signErr != nil { + return nil, fmt.Errorf("wallet: sign input %d: %w", i, signErr) + } + tx.Signatures = append(tx.Signatures, sigs) + } + + return tx, nil +} + +// buildInput constructs a single TxInputToKey with its decoy ring. +func (b *V1Builder) buildInput(src *Transfer) (types.TxInputToKey, inputMeta, error) { + decoys, err := b.ringSelector.SelectRing( + src.Amount, src.GlobalIndex, int(config.DefaultDecoySetSize)) + if err != nil { + return types.TxInputToKey{}, inputMeta{}, err + } + + // Insert the real output into the ring. + ring := append(decoys, RingMember{ + PublicKey: src.EphemeralKey.Public, + GlobalIndex: src.GlobalIndex, + }) + + // Sort by global index (consensus rule). + sort.Slice(ring, func(a, b int) bool { + return ring[a].GlobalIndex < ring[b].GlobalIndex + }) + + // Find real index after sorting. + realIdx := -1 + for j, m := range ring { + if m.GlobalIndex == src.GlobalIndex { + realIdx = j + break + } + } + if realIdx < 0 { + return types.TxInputToKey{}, inputMeta{}, fmt.Errorf("real output not found in ring") + } + + // Build key offsets and public key list. + offsets := make([]types.TxOutRef, len(ring)) + pubs := make([]types.PublicKey, len(ring)) + for j, m := range ring { + offsets[j] = types.TxOutRef{ + Tag: types.RefTypeGlobalIndex, + GlobalIndex: m.GlobalIndex, + } + pubs[j] = m.PublicKey + } + + input := types.TxInputToKey{ + Amount: src.Amount, + KeyOffsets: offsets, + KeyImage: src.KeyImage, + EtcDetails: wire.EncodeVarint(0), + } + + meta := inputMeta{ + ephemeral: src.EphemeralKey, + ring: pubs, + realIndex: realIdx, + } + + return input, meta, nil +} + +// deriveOutput creates a TxOutputBare with an ECDH-derived one-time key. +func deriveOutput(txSec [32]byte, addr types.Address, index uint64, amount uint64) (types.TxOutputBare, error) { + derivation, err := crypto.GenerateKeyDerivation( + [32]byte(addr.ViewPublicKey), txSec) + if err != nil { + return types.TxOutputBare{}, fmt.Errorf("key derivation: %w", err) + } + + ephPub, err := crypto.DerivePublicKey( + derivation, index, [32]byte(addr.SpendPublicKey)) + if err != nil { + return types.TxOutputBare{}, fmt.Errorf("derive public key: %w", err) + } + + return types.TxOutputBare{ + Amount: amount, + Target: types.TxOutToKey{Key: types.PublicKey(ephPub)}, + }, nil +} + +// SerializeTransaction encodes a transaction into its wire-format bytes. +func SerializeTransaction(tx *types.Transaction) ([]byte, error) { + var buf bytes.Buffer + enc := wire.NewEncoder(&buf) + wire.EncodeTransaction(enc, tx) + if err := enc.Err(); err != nil { + return nil, fmt.Errorf("wallet: encode tx: %w", err) + } + return buf.Bytes(), nil +} diff --git a/wallet/builder_test.go b/wallet/builder_test.go new file mode 100644 index 0000000..e7ac9ff --- /dev/null +++ b/wallet/builder_test.go @@ -0,0 +1,224 @@ +// 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) + } +}