Update go.mod module line, all require/replace directives, and every .go import path from forge.lthn.ai/core/go-blockchain to dappco.re/go/core/blockchain. Add replace directives to bridge dappco.re paths to existing forge.lthn.ai registry during migration. Update CLAUDE.md, README, and docs to reflect the new module path. Co-Authored-By: Virgil <virgil@lethean.io>
230 lines
6.5 KiB
Go
230 lines
6.5 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 (
|
|
"bytes"
|
|
"cmp"
|
|
"fmt"
|
|
"slices"
|
|
|
|
coreerr "dappco.re/go/core/log"
|
|
|
|
"dappco.re/go/core/blockchain/config"
|
|
"dappco.re/go/core/blockchain/crypto"
|
|
"dappco.re/go/core/blockchain/types"
|
|
"dappco.re/go/core/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, coreerr.E("V1Builder.Build", fmt.Sprintf("wallet: insufficient funds: have %d, need %d", sourceTotal, destTotal+req.Fee), nil)
|
|
}
|
|
change := sourceTotal - destTotal - req.Fee
|
|
|
|
// 2. Generate one-time TX key pair.
|
|
txPub, txSec, err := crypto.GenerateKeys()
|
|
if err != nil {
|
|
return nil, coreerr.E("V1Builder.Build", "wallet: generate tx keys", 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, coreerr.E("V1Builder.Build", fmt.Sprintf("wallet: input %d", 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, coreerr.E("V1Builder.Build", fmt.Sprintf("wallet: output %d", 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, coreerr.E("V1Builder.Build", "wallet: change output", 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, coreerr.E("V1Builder.Build", fmt.Sprintf("wallet: sign input %d", 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).
|
|
slices.SortFunc(ring, func(a, b RingMember) int {
|
|
return cmp.Compare(a.GlobalIndex, b.GlobalIndex)
|
|
})
|
|
|
|
// Find real index after sorting.
|
|
realIdx := slices.IndexFunc(ring, func(m RingMember) bool {
|
|
return m.GlobalIndex == src.GlobalIndex
|
|
})
|
|
if realIdx < 0 {
|
|
return types.TxInputToKey{}, inputMeta{}, coreerr.E("V1Builder.buildInput", "real output not found in ring", nil)
|
|
}
|
|
|
|
// 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{}, coreerr.E("deriveOutput", "key derivation", err)
|
|
}
|
|
|
|
ephPub, err := crypto.DerivePublicKey(
|
|
derivation, index, [32]byte(addr.SpendPublicKey))
|
|
if err != nil {
|
|
return types.TxOutputBare{}, coreerr.E("deriveOutput", "derive public key", 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, coreerr.E("SerializeTransaction", "wallet: encode tx", err)
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|