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