// Package utxo is the wallet's own coin table: what a verified block says it can spend. // // A filter match says a block mentions a script; pkg/block says which output and for how much; // this is where that becomes a coin the wallet remembers, survives a reload, and can mark // spent. It stores only what a spender needs — outpoint, value, height — because the script is // re-derived from the seed rather than trusted from the table. package utxo import ( "encoding/binary" "encoding/hex" "errors" "fmt" "sort" "strconv" "strings" "git.smesh.lol/tinywallet/pkg/store" ) // Errors the table refuses. var ( // ErrSpent is a coin that is not in the table. ErrSpent = errors.New("utxo: no such coin") // ErrShort is a truncated record. ErrShort = errors.New("utxo: a record is too short") ) // Coin is one spendable output. type Coin struct { // TxID is the creating transaction, in wire order. TxID [32]byte // Vout is the output's position. Vout uint32 // Value is the amount in satoshis. Value uint64 // Height is the block that confirmed it, or zero for an unconfirmed coin. Height uint32 } // Outpoint is the coin's identity. func (c Coin) Outpoint() string { return hex.EncodeToString(c.TxID[:]) + ":" + strconv.FormatUint(uint64(c.Vout), 10) } // Table is the coin table, persisted through the store. type Table struct { cache *store.Cache } // Open wraps a cache. func Open(cache *store.Cache) (*Table, error) { if cache == nil { return nil, errors.New("utxo: a table needs a store") } return &Table{cache: cache}, nil } // Add remembers a coin. Adding the same outpoint twice replaces it, because a reorg can confirm // the same output at a different height and the newer answer is the one that stands. func (t *Table) Add(coin Coin) error { record := make([]byte, 0, 12) record = binary.LittleEndian.AppendUint64(record, coin.Value) return t.cache.Put(key(coin), binary.LittleEndian.AppendUint32(record, coin.Height)) } // Spend forgets a coin, reporting whether there was one. func (t *Table) Spend(txid [32]byte, vout uint32) error { coin := Coin{TxID: txid, Vout: vout} if _, ok := t.cache.Get(key(coin)); !ok { return fmt.Errorf("%w: %s", ErrSpent, coin.Outpoint()) } return t.cache.Delete(key(coin)) } // Unspent lists the coins, newest height first and then by outpoint, so two runs agree. func (t *Table) Unspent() ([]Coin, error) { coins := make([]Coin, 0, 16) for _, stored := range t.cache.Keys(prefix) { coin, err := parse(stored) if err != nil { return nil, err } record, ok := t.cache.Get(stored) if !ok { continue } if len(record) < 12 { return nil, ErrShort } coin.Value = binary.LittleEndian.Uint64(record[0:8]) coin.Height = binary.LittleEndian.Uint32(record[8:12]) coins = append(coins, coin) } sort.Slice(coins, func(i, j int) bool { if coins[i].Height != coins[j].Height { return coins[i].Height > coins[j].Height } return coins[i].Outpoint() < coins[j].Outpoint() }) return coins, nil } // Len is how many coins are held. func (t *Table) Len() int { return len(t.cache.Keys(prefix)) } // prefix is the key space. const prefix = "utxo/" // key names one coin. func key(coin Coin) string { return prefix + hex.EncodeToString(coin.TxID[:]) + "/" + strconv.FormatUint(uint64(coin.Vout), 10) } // parse reads a key back into a coin. func parse(stored string) (Coin, error) { rest, found := strings.CutPrefix(stored, prefix) if !found { return Coin{}, ErrShort } hashText, voutText, found := strings.Cut(rest, "/") if !found { return Coin{}, ErrShort } raw, err := hex.DecodeString(hashText) if err != nil || len(raw) != 32 { return Coin{}, ErrShort } vout, err := strconv.ParseUint(voutText, 10, 32) if err != nil { return Coin{}, ErrShort } var coin Coin copy(coin.TxID[:], raw) coin.Vout = uint32(vout) return coin, nil }