Skip to content
This repository was archived by the owner on Aug 20, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package funder

import (
"sort"

"github.com/bsv-blockchain/go-wallet-toolbox/pkg/internal/storage/database/models"
"github.com/bsv-blockchain/go-wallet-toolbox/pkg/wdk"
)

const (
tierMined = 0
tierUnproven = 1
tierSending = 2
tierCount = 3
)

// utxoPool partitions UTXOs by status tier and supports 3-stage best-fit selection:
// exact match → smallest sufficient → largest insufficient, evaluated tier by tier
// (mined first, then unproven, then sending).
type utxoPool struct {
tiers [tierCount][]*models.UserUTXO
}

func newUTXOPool(utxos []*models.UserUTXO) *utxoPool {
p := &utxoPool{}
for _, u := range utxos {
tier := statusToTier(u.UTXOStatus)
p.tiers[tier] = append(p.tiers[tier], u)
}
for i := range p.tiers {
sort.Slice(p.tiers[i], func(a, b int) bool {
return p.tiers[i][a].Satoshis < p.tiers[i][b].Satoshis
})
}
return p
}

// selectBest finds the optimal UTXO for the given target using 3-stage selection
// across all tiers (safest first):
// 1. Exact match (satoshis == target)
// 2. Smallest sufficient (smallest satoshis >= target)
// 3. Largest insufficient (largest satoshis < target)
func (p *utxoPool) selectBest(targetSatoshis uint64) *models.UserUTXO {
for tierIdx := range p.tiers {
if len(p.tiers[tierIdx]) == 0 {
continue
}

// Stage 1: Exact match
for i, u := range p.tiers[tierIdx] {
if u.Satoshis == targetSatoshis {
return p.removeAt(tierIdx, i)
}
}

// Stage 2: Smallest sufficient (tier sorted ASC, first >= target)
for i, u := range p.tiers[tierIdx] {
if u.Satoshis >= targetSatoshis {
return p.removeAt(tierIdx, i)
}
}

// Stage 3: Largest insufficient (last element in ASC-sorted tier)
last := len(p.tiers[tierIdx]) - 1
return p.removeAt(tierIdx, last)
}
return nil
}

// all returns every remaining UTXO across all tiers (for sweep mode).
func (p *utxoPool) all() []*models.UserUTXO {
var result []*models.UserUTXO
for _, tier := range p.tiers {
result = append(result, tier...)
}
return result
}

func (p *utxoPool) removeAt(tierIdx, i int) *models.UserUTXO {
tier := p.tiers[tierIdx]
u := tier[i]
p.tiers[tierIdx] = append(tier[:i], tier[i+1:]...)
return u
}

func statusToTier(status wdk.UTXOStatus) int {
switch status {
case wdk.UTXOStatusMined:
return tierMined
case wdk.UTXOStatusUnproven:
return tierUnproven
case wdk.UTXOStatusSending:
return tierSending
default:
return tierSending
}
}
164 changes: 100 additions & 64 deletions packages/wallet/go-wallet-toolbox/pkg/internal/storage/funder/sql.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,11 @@ package funder
import (
"context"
"fmt"
"iter"
"log/slog"
"math"
"sync/atomic"

"github.com/go-softwarelab/common/pkg/must"
"github.com/go-softwarelab/common/pkg/seqerr"
"github.com/go-softwarelab/common/pkg/to"

"github.com/bsv-blockchain/go-wallet-toolbox/pkg/defs"
Expand Down Expand Up @@ -95,76 +93,119 @@ func (f *SQL) Fund(
return nil, fmt.Errorf("failed to start collecting utxo: %w", err)
}

utxos := f.loadUTXOs(ctx, userID, basket.Name, forbiddenOutputIDs, priorityOutputs, includeSending)
// Phase 1: Allocate priority outputs (noSend change from prior TXs in same batch).
if err = f.allocatePriorityOutputs(collector, priorityOutputs, forbiddenOutputIDs, isSweep); err != nil {
return nil, err
}
if !isSweep && collector.IsFunded() {
return collector.GetResult()
}

err = collector.Allocate(utxos)
// Phase 2: Load all eligible UTXOs into a tiered pool.
pool, err := f.loadUTXOPool(ctx, userID, basket.Name, forbiddenOutputIDs, includeSending)
if err != nil {
return nil, fmt.Errorf("failed to allocate utxos: %w", err)
return nil, err
}

// Phase 3: Sweep mode — allocate everything.
if isSweep {
return f.allocateSweep(collector, pool)
}

// Phase 4: Tiered best-fit selection.
// Each round: compute remaining need, pick optimal UTXO (exact → best-fit → largest),
// allocate it (which recalculates fee), repeat until funded.
for !collector.IsFunded() {
remaining := collector.remaining()
if remaining <= 0 {
break
}

utxo := pool.selectBest(uint64(remaining))
if utxo == nil {
return nil, errfunder.ErrNotEnoughFunds
}
if err = collector.allocateUTXO(utxo); err != nil {
return nil, fmt.Errorf("failed to allocate utxo: %w", err)
}
}

return collector.GetResult()
}

func (f *SQL) loadUTXOs(ctx context.Context, userID int, basketName string, forbiddenOutputIDs []uint, priorityOutputs []*entity.Output, includeSending bool) iter.Seq2[*models.UserUTXO, error] {
batches := seqerr.ProduceWithArg(
func(page *queryopts.Paging) ([]*models.UserUTXO, *queryopts.Paging, error) {
utxos, err := f.utxoRepository.FindNotReservedUTXOs(ctx, userID, basketName, page, forbiddenOutputIDs, includeSending)
if err != nil {
return nil, nil, fmt.Errorf("failed to load utxos: %w", err)
}
page.Next()
return utxos, page, nil
},
&queryopts.Paging{
Limit: utxoBatchSize,
SortBy: "satoshis",
})

standardUTXOs := seqerr.FlattenSlices(batches)
if len(priorityOutputs) == 0 {
return seqerr.Concat(standardUTXOs)
func (f *SQL) allocateSweep(collector *utxoCollector, pool *utxoPool) (*Result, error) {
for _, utxo := range pool.all() {
if err := collector.allocateUTXO(utxo); err != nil {
return nil, fmt.Errorf("failed to allocate utxo in sweep: %w", err)
}
}

return seqerr.Concat(noSendChangeOutputsIterator(forbiddenOutputIDs, priorityOutputs), standardUTXOs)
return collector.GetResult()
}

func noSendChangeOutputsIterator(forbiddenOutputIDs []uint, priorityOutputs []*entity.Output) iter.Seq2[*models.UserUTXO, error] {
forbiddenIDsLookup := make(map[uint]struct{}, len(forbiddenOutputIDs))
func (f *SQL) allocatePriorityOutputs(collector *utxoCollector, priorityOutputs []*entity.Output, forbiddenOutputIDs []uint, isSweep bool) error {
forbiddenIDs := make(map[uint]struct{}, len(forbiddenOutputIDs))
for _, id := range forbiddenOutputIDs {
forbiddenIDsLookup[id] = struct{}{}
forbiddenIDs[id] = struct{}{}
}

for _, output := range priorityOutputs {
if _, ok := forbiddenIDs[output.ID]; ok {
continue
}

sats, err := to.UInt64(output.Satoshis)
if err != nil {
return fmt.Errorf("failed to convert output satoshis: %d to uint64: %w", output.Satoshis, err)
}

var basket string
if output.BasketName != nil {
basket = *output.BasketName
}

utxo := &models.UserUTXO{
UserID: output.UserID,
OutputID: output.ID,
BasketName: basket,
Satoshis: sats,
EstimatedInputSize: txutils.EstimatedInputSizeByType(wdk.OutputType(output.Type)),
CreatedAt: output.CreatedAt,
}

if err = collector.allocateUTXO(utxo); err != nil {
return fmt.Errorf("failed to allocate priority output: %w", err)
}

if !isSweep && collector.IsFunded() {
return nil
}
}
return nil
}

func (f *SQL) loadUTXOPool(ctx context.Context, userID int, basketName string, forbiddenOutputIDs []uint, includeSending bool) (*utxoPool, error) {
// Load all eligible UTXOs. The repository sorts by status tier + satoshis ASC,
// but the pool re-sorts internally per tier for 3-stage selection.
page := &queryopts.Paging{
Limit: utxoBatchSize,
SortBy: "satoshis",
}

return func(yield func(*models.UserUTXO, error) bool) {
for _, output := range priorityOutputs {
if _, ok := forbiddenIDsLookup[output.ID]; ok {
continue
}

userID := output.UserID

var basket string
if output.BasketName != nil {
basket = *output.BasketName
}

satoshis, err := to.UInt64(output.Satoshis)
if err != nil {
yield(nil, fmt.Errorf("failed to convert output satoshis: %d to uint64: %w", output.Satoshis, err))
break
}

if !yield(&models.UserUTXO{
UserID: userID,
OutputID: output.ID,
BasketName: basket,
Satoshis: satoshis,
EstimatedInputSize: txutils.EstimatedInputSizeByType(wdk.OutputType(output.Type)),
CreatedAt: output.CreatedAt,
}, nil) {
break
}
var allUTXOs []*models.UserUTXO
for {
utxos, err := f.utxoRepository.FindNotReservedUTXOs(ctx, userID, basketName, page, forbiddenOutputIDs, includeSending)
if err != nil {
return nil, fmt.Errorf("failed to load utxos: %w", err)
}

allUTXOs = append(allUTXOs, utxos...)
if len(utxos) < utxoBatchSize {
break
}
page.Next()
}

return newUTXOPool(allUTXOs), nil
}

type utxoCollector struct {
Expand Down Expand Up @@ -227,13 +268,8 @@ func newCollector(txSats satoshi.Value, txSize, outputCount uint64, numberOfDesi
return c, nil
}

func (c *utxoCollector) Allocate(utxos iter.Seq2[*models.UserUTXO, error]) error {
utxos = seqerr.TakeUntilTrue(utxos, c.IsFunded)
err := seqerr.ForEach(utxos, c.allocateUTXO)
if err != nil {
return fmt.Errorf("failed to allocate utxo: %w", err)
}
return nil
func (c *utxoCollector) remaining() satoshi.Value {
return satoshi.MustSubtract(c.satsToCover(), c.satsCovered)
}

func (c *utxoCollector) IsFunded() bool {
Expand Down
Loading
Loading