utxo.go raw

   1  // Package utxo is the wallet's own coin table: what a verified block says it can spend.
   2  //
   3  // A filter match says a block mentions a script; pkg/block says which output and for how much;
   4  // this is where that becomes a coin the wallet remembers, survives a reload, and can mark
   5  // spent. It stores only what a spender needs — outpoint, value, height — because the script is
   6  // re-derived from the seed rather than trusted from the table.
   7  package utxo
   8  
   9  import (
  10  	"encoding/binary"
  11  	"encoding/hex"
  12  	"errors"
  13  	"fmt"
  14  	"sort"
  15  	"strconv"
  16  	"strings"
  17  
  18  	"git.smesh.lol/tinywallet/pkg/store"
  19  )
  20  
  21  // Errors the table refuses.
  22  var (
  23  	// ErrSpent is a coin that is not in the table.
  24  	ErrSpent = errors.New("utxo: no such coin")
  25  	// ErrShort is a truncated record.
  26  	ErrShort = errors.New("utxo: a record is too short")
  27  )
  28  
  29  // Coin is one spendable output.
  30  type Coin struct {
  31  	// TxID is the creating transaction, in wire order.
  32  	TxID [32]byte
  33  	// Vout is the output's position.
  34  	Vout uint32
  35  	// Value is the amount in satoshis.
  36  	Value uint64
  37  	// Height is the block that confirmed it, or zero for an unconfirmed coin.
  38  	Height uint32
  39  }
  40  
  41  // Outpoint is the coin's identity.
  42  func (c Coin) Outpoint() string {
  43  	return hex.EncodeToString(c.TxID[:]) + ":" + strconv.FormatUint(uint64(c.Vout), 10)
  44  }
  45  
  46  // Table is the coin table, persisted through the store.
  47  type Table struct {
  48  	cache *store.Cache
  49  }
  50  
  51  // Open wraps a cache.
  52  func Open(cache *store.Cache) (*Table, error) {
  53  	if cache == nil {
  54  		return nil, errors.New("utxo: a table needs a store")
  55  	}
  56  
  57  	return &Table{cache: cache}, nil
  58  }
  59  
  60  // Add remembers a coin. Adding the same outpoint twice replaces it, because a reorg can confirm
  61  // the same output at a different height and the newer answer is the one that stands.
  62  func (t *Table) Add(coin Coin) error {
  63  	record := make([]byte, 0, 12)
  64  	record = binary.LittleEndian.AppendUint64(record, coin.Value)
  65  
  66  	return t.cache.Put(key(coin), binary.LittleEndian.AppendUint32(record, coin.Height))
  67  }
  68  
  69  // Spend forgets a coin, reporting whether there was one.
  70  func (t *Table) Spend(txid [32]byte, vout uint32) error {
  71  	coin := Coin{TxID: txid, Vout: vout}
  72  
  73  	if _, ok := t.cache.Get(key(coin)); !ok {
  74  		return fmt.Errorf("%w: %s", ErrSpent, coin.Outpoint())
  75  	}
  76  
  77  	return t.cache.Delete(key(coin))
  78  }
  79  
  80  // Unspent lists the coins, newest height first and then by outpoint, so two runs agree.
  81  func (t *Table) Unspent() ([]Coin, error) {
  82  	coins := make([]Coin, 0, 16)
  83  
  84  	for _, stored := range t.cache.Keys(prefix) {
  85  		coin, err := parse(stored)
  86  
  87  		if err != nil {
  88  			return nil, err
  89  		}
  90  
  91  		record, ok := t.cache.Get(stored)
  92  
  93  		if !ok {
  94  			continue
  95  		}
  96  
  97  		if len(record) < 12 {
  98  			return nil, ErrShort
  99  		}
 100  
 101  		coin.Value = binary.LittleEndian.Uint64(record[0:8])
 102  		coin.Height = binary.LittleEndian.Uint32(record[8:12])
 103  
 104  		coins = append(coins, coin)
 105  	}
 106  
 107  	sort.Slice(coins, func(i, j int) bool {
 108  		if coins[i].Height != coins[j].Height {
 109  			return coins[i].Height > coins[j].Height
 110  		}
 111  
 112  		return coins[i].Outpoint() < coins[j].Outpoint()
 113  	})
 114  
 115  	return coins, nil
 116  }
 117  
 118  // Len is how many coins are held.
 119  func (t *Table) Len() int { return len(t.cache.Keys(prefix)) }
 120  
 121  // prefix is the key space.
 122  const prefix = "utxo/"
 123  
 124  // key names one coin.
 125  func key(coin Coin) string {
 126  	return prefix + hex.EncodeToString(coin.TxID[:]) + "/" + strconv.FormatUint(uint64(coin.Vout), 10)
 127  }
 128  
 129  // parse reads a key back into a coin.
 130  func parse(stored string) (Coin, error) {
 131  	rest, found := strings.CutPrefix(stored, prefix)
 132  
 133  	if !found {
 134  		return Coin{}, ErrShort
 135  	}
 136  
 137  	hashText, voutText, found := strings.Cut(rest, "/")
 138  
 139  	if !found {
 140  		return Coin{}, ErrShort
 141  	}
 142  
 143  	raw, err := hex.DecodeString(hashText)
 144  
 145  	if err != nil || len(raw) != 32 {
 146  		return Coin{}, ErrShort
 147  	}
 148  
 149  	vout, err := strconv.ParseUint(voutText, 10, 32)
 150  
 151  	if err != nil {
 152  		return Coin{}, ErrShort
 153  	}
 154  
 155  	var coin Coin
 156  	copy(coin.TxID[:], raw)
 157  
 158  	coin.Vout = uint32(vout)
 159  
 160  	return coin, nil
 161  }
 162