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>
This commit is contained in:
Claude 2026-02-20 23:23:33 +00:00
parent 7e31e706c5
commit b8841a1a3b
No known key found for this signature in database
GPG key ID: AF404715446AEB41
2 changed files with 456 additions and 0 deletions

232
wallet/builder.go Normal file
View file

@ -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
}

224
wallet/builder_test.go Normal file
View file

@ -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)
}
}