From b8841a1a3b9c5ae42b4942fb6abd3ca1db223153 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Feb 2026 23:23:33 +0000 Subject: [PATCH] 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 --- wallet/builder.go | 232 +++++++++++++++++++++++++++++++++++++++++ wallet/builder_test.go | 224 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 456 insertions(+) create mode 100644 wallet/builder.go create mode 100644 wallet/builder_test.go 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) + } +}