// Package domain is the wallet's own state and the operations a person can ask of it: // create a wallet from a seed, restore one from words, unlock it, derive addresses, and walk // a chain into it. // // It is deliberately free of transport and of the browser: a chain is an interface, the store // is the package, and both are satisfied by different things in a test and in a worker. That // is what lets the setup flow be tested without a page, a relay, or a node. package domain import ( "encoding/binary" "errors" "fmt" "sort" "strconv" "sync" "git.smesh.lol/nostrino/pkg/block" "git.smesh.lol/nostrino/pkg/chaindata" "git.smesh.lol/tinywallet/pkg/mnemonic" "git.smesh.lol/tinywallet/pkg/store" "git.smesh.lol/tinywallet/pkg/utxo" "git.smesh.lol/tinywallet/pkg/wallet" ) // Errors the setup flow maps onto what a person is told. var ( // ErrNoChain is a chain this domain does not drive. ErrNoChain = errors.New("no such chain") // ErrHasWallet is a setup attempt over a wallet that already exists. ErrHasWallet = errors.New("a wallet already exists for this chain") // ErrNoWallet is an unlock or a sync before a wallet was created. ErrNoWallet = errors.New("no wallet for this chain") // ErrLocked is a derivation asked of a locked wallet. ErrLocked = errors.New("the wallet is locked") // ErrSyncInProgress is a second sync of one chain. ErrSyncInProgress = errors.New("a sync is already running for this chain") ) // DefaultLookahead is how far past the last used address a sync looks. // // A wallet must not stop looking because nobody asked for a new address: the gap limit is // what lets a restored seed find funds that were received after the last address the person // ever saw. A hundred is the usual compromise between a rescan that finds everything and one // that downloads filters for addresses nobody will use. const DefaultLookahead uint32 = 100 // Chain is what the domain needs from a light client: where it is, advance it, and ask which // of these scripts it saw. *chaindata.Client satisfies it, and so does a fake. type Chain interface { // Position reports the verified tip and the filter tip. Position() (chaindata.Position, error) // Sync advances the chain from a height and returns where it stopped. Sync(from uint32) (chaindata.Position, error) // Match returns the heights whose filters mention any of the scripts. Match(scripts [][]byte) ([]uint32, error) // Block returns the raw block at a height, which is what turns a match into a coin. Block(height uint32) ([]byte, error) } // Link is one chain the domain drives: its derivation profile and its client. type Link struct { // Profile is the wallet profile the chain derives from. Profile wallet.Profile // Client is the light client for this chain. Client Chain } // Domain is every wallet the process holds and the store they share. type Domain struct { mu sync.Mutex store *store.Cache links map[string]Link // unlocked holds the derived wallets. The seed phrase itself is not kept: it is opened // from the vault, used to derive, and dropped, so a locked domain has one secret copy at // rest and none in memory. unlocked map[string]*wallet.Wallet // utxos is the coin table a verified block fills. utxos *utxo.Table // syncing refuses a second concurrent sync of one chain. Two syncs of one chain are two // questions about one tip, and a light client answers them by interrupting itself. syncing map[string]bool // rounds is the vault's PBKDF2 work factor. It is a field so a test can seal and unlock // without spending the real cost; New sets the shipped value. rounds int } // New builds a domain over a store. func New(cache *store.Cache) (*Domain, error) { if cache == nil { return nil, errors.New("domain: a domain needs a store") } table, err := utxo.Open(cache) if err != nil { return nil, err } return &Domain{ store: cache, utxos: table, links: map[string]Link{}, unlocked: map[string]*wallet.Wallet{}, syncing: map[string]bool{}, rounds: vaultRounds, }, nil } // Add registers a chain. // // The name is the wallet's own chain name ("btc", "blake"), not the network profile: the two // chains share a network in this build, and the wallet keeps them apart by account. func (d *Domain) Add(name string, link Link) error { if err := checkName(name); err != nil { return err } if link.Client == nil { return fmt.Errorf("domain: chain %q has no client", name) } if link.Profile.Name == "" { return fmt.Errorf("domain: chain %q has no profile", name) } d.mu.Lock() defer d.mu.Unlock() if _, ok := d.links[name]; ok { return fmt.Errorf("domain: chain %q is already added", name) } d.links[name] = link return nil } // Names lists the registered chains in a stable order. func (d *Domain) Names() []string { d.mu.Lock() defer d.mu.Unlock() names := make([]string, 0, len(d.links)) for name := range d.links { names = append(names, name) } sort.Strings(names) return names } // link returns a registered chain or ErrNoChain. func (d *Domain) link(name string) (Link, error) { d.mu.Lock() defer d.mu.Unlock() link, ok := d.links[name] if !ok { return Link{}, fmt.Errorf("domain: %q: %w", name, ErrNoChain) } return link, nil } // Created is what the setup flow hands back: the words to write down (only for a new wallet) // and the first address, so the person can see that something real happened. type Created struct { // Chain is the chain the wallet was made for. Chain string // Mnemonic is the phrase to write down. It is empty on a restore, where the person // already has it. Mnemonic string // Address is the first receive address. Address wallet.Address } // Create makes a new wallet for a chain from fresh words. // // The phrase is returned exactly once, to be shown and then forgotten: it is not stored in // cleartext anywhere, and the sealed copy is only readable with the passphrase. func (d *Domain) Create(name string, words int, passphrase string) (Created, error) { phrase, err := mnemonic.New(words) if err != nil { return Created{}, fmt.Errorf("domain: %w", err) } created, err := d.restore(name, phrase, passphrase) if err != nil { return Created{}, err } created.Mnemonic = phrase return created, nil } // Restore recreates a wallet from words the person already has. func (d *Domain) Restore(name, phrase, passphrase string) (Created, error) { return d.restore(name, phrase, passphrase) } // restore is the body Create and Restore share. func (d *Domain) restore(name, phrase, passphrase string) (Created, error) { link, err := d.link(name) if err != nil { return Created{}, err } if err := mnemonic.Validate(phrase); err != nil { return Created{}, fmt.Errorf("domain: %w", err) } if _, exists := d.store.Get(vaultKey(name)); exists { return Created{}, fmt.Errorf("domain: %q: %w", name, ErrHasWallet) } sealed, err := seal(passphrase, phrase, d.rounds) if err != nil { return Created{}, err } derived, err := derive(phrase, link.Profile) if err != nil { return Created{}, err } first, err := derived.Address(false, 0) if err != nil { return Created{}, err } if err := d.store.Put(vaultKey(name), sealed); err != nil { return Created{}, err } meta := Meta{Account: link.Profile.Account, Receive: 1} if err := d.store.Put(metaKey(name), PutMeta(meta)); err != nil { return Created{}, err } d.mu.Lock() d.unlocked[name] = derived d.mu.Unlock() return Created{Chain: name, Address: first}, nil } // Unlock opens the vault and derives the wallet. // // The phrase is opened, used, and dropped: what stays in memory is the account key, which is // what addresses and, later, signatures come from. func (d *Domain) Unlock(name, passphrase string) error { link, err := d.link(name) if err != nil { return err } sealed, ok := d.store.Get(vaultKey(name)) if !ok { return fmt.Errorf("domain: %q: %w", name, ErrNoWallet) } phrase, err := open(passphrase, sealed, d.rounds) if err != nil { return err } derived, err := derive(phrase, link.Profile) if err != nil { return err } d.mu.Lock() d.unlocked[name] = derived d.mu.Unlock() return nil } // Lock forgets the derived wallet for a chain. func (d *Domain) Lock(name string) { d.mu.Lock() delete(d.unlocked, name) d.mu.Unlock() } // LockAll forgets every derived wallet. func (d *Domain) LockAll() { d.mu.Lock() d.unlocked = map[string]*wallet.Wallet{} d.mu.Unlock() } // Unlocked reports whether a chain's wallet is unlocked. func (d *Domain) Unlocked(name string) bool { d.mu.Lock() defer d.mu.Unlock() _, ok := d.unlocked[name] return ok } // walletOf returns the unlocked wallet of a chain. func (d *Domain) walletOf(name string) (*wallet.Wallet, error) { if _, err := d.link(name); err != nil { return nil, err } d.mu.Lock() derived, ok := d.unlocked[name] d.mu.Unlock() if ok { return derived, nil } // A chain with no vault is not locked, it is unset: the two are different things to tell // a person, so they are different errors. if _, hasWallet := d.store.Get(vaultKey(name)); !hasWallet { return nil, fmt.Errorf("domain: %q: %w", name, ErrNoWallet) } return nil, fmt.Errorf("domain: %q: %w", name, ErrLocked) } // Addresses derives a run of addresses and moves the chain's cursor past them. // // Moving the cursor is what makes the lookahead useful: the next sync looks a hundred past // the last address a person was shown, not past the last one they used. func (d *Domain) Addresses(name string, change bool, from, count uint32) ([]wallet.Address, error) { if count == 0 { return nil, nil } derived, err := d.walletOf(name) if err != nil { return nil, err } addresses, err := derived.Addresses(change, from, count) if err != nil { return nil, err } meta, err := d.meta(name) if err != nil { return nil, err } high := from + count if change && high > meta.Change { meta.Change = high if err := d.store.Put(metaKey(name), PutMeta(meta)); err != nil { return nil, err } } if !change && high > meta.Receive { meta.Receive = high if err := d.store.Put(metaKey(name), PutMeta(meta)); err != nil { return nil, err } } return addresses, nil } // Status is what a page needs to draw the wallet's state. type Status struct { // Chain is the chain name. Chain string // Profile is the derivation profile. Profile wallet.Profile // HasWallet reports whether a vault exists. HasWallet bool // Unlocked reports whether the account is in memory. Unlocked bool // Position is where the chain said it was when last asked. Position chaindata.Position // Receive and Change are how many addresses of each branch were handed out. Receive uint32 Change uint32 // Matches is how many heights the chain matched and the store recorded. Matches int } // Status reports a chain's state without asking the chain anything. func (d *Domain) Status(name string) (Status, error) { link, err := d.link(name) if err != nil { return Status{}, err } status := Status{Chain: name, Profile: link.Profile} _, status.HasWallet = d.store.Get(vaultKey(name)) status.Unlocked = d.Unlocked(name) if meta, err := d.meta(name); err == nil { status.Receive = meta.Receive status.Change = meta.Change } else if !errors.Is(err, ErrNoWallet) { return Status{}, err } if raw, ok := d.store.Get(positionKey(name)); ok { if position, err := chaindata.ReadPosition(raw); err == nil { status.Position = position } } status.Matches = len(d.store.Keys(matchPrefix(name))) return status, nil } // Report is what a sync did. type Report struct { // Chain is the chain name. Chain string // From is the height the sync was asked to start at. From uint32 // Position is where the chain says it ended up. Position chaindata.Position // Scripts is how many scripts were sent to the matcher. Scripts int // Matched is every height the chain matched. Matched []uint32 // New is the part of Matched the store had not seen before. New []uint32 } // Sync walks the chain forward and records the heights that mention the wallet. // // A wallet that already has a filter tip resumes from it, which is what makes a reload cheap: // the store is the memory of what was verified, and the light client is asked for the rest. // Nothing is proven twice. func (d *Domain) Sync(name string, lookahead uint32) (Report, error) { link, err := d.link(name) if err != nil { return Report{}, err } derived, err := d.walletOf(name) if err != nil { return Report{}, err } if lookahead == 0 { lookahead = DefaultLookahead } d.mu.Lock() if d.syncing[name] { d.mu.Unlock() return Report{}, fmt.Errorf("domain: %q: %w", name, ErrSyncInProgress) } d.syncing[name] = true d.mu.Unlock() defer func() { d.mu.Lock() d.syncing[name] = false d.mu.Unlock() }() meta, err := d.meta(name) if err != nil { return Report{}, err } from := uint32(0) if raw, ok := d.store.Get(positionKey(name)); ok { if stored, err := chaindata.ReadPosition(raw); err == nil && stored.Filters > 0 { from = stored.Filters + 1 } } position, err := link.Client.Sync(from) if err != nil { return Report{}, fmt.Errorf("domain: %q: syncing: %w", name, err) } report := Report{Chain: name, From: from, Position: position} scripts, err := scriptsFor(derived, meta, lookahead) if err != nil { return Report{}, err } report.Scripts = len(scripts) matched, err := link.Client.Match(scripts) if err != nil { return Report{}, fmt.Errorf("domain: %q: matching: %w", name, err) } for _, height := range matched { report.Matched = append(report.Matched, height) key := matchKey(name, height) if _, scanned := d.store.Get(key); scanned { // The height was matched and its block was scanned before. A second fetch would // be a second download for a fact the store already holds. continue } // A match is a claim about a block; the coins are in the block. The block is fetched // and verified before the marker is stored, so a failure here leaves the height to be // retried by the next sync instead of recording a scan that never happened. raw, err := link.Client.Block(height) if err != nil { return Report{}, fmt.Errorf("domain: %q: fetching block %d: %w", name, height, err) } if _, err := d.scanBlock(derived, raw, height); err != nil { return Report{}, fmt.Errorf("domain: %q: block %d: %w", name, height, err) } if err := d.store.Put(key, nil); err != nil { return Report{}, err } report.New = append(report.New, height) } if err := d.store.Put(positionKey(name), chaindata.PutPosition(nil, position)); err != nil { return Report{}, err } // One flush for the whole sync: the markers, the coins and the position land together, so a // crash cannot leave a height marked as scanned with the coins it produced missing. if err := d.store.Flush(); err != nil { return report, fmt.Errorf("domain: %q: %w", name, err) } return report, nil } // Matches lists the recorded heights, ascending. func (d *Domain) Matches(name string) []uint32 { keys := d.store.Keys(matchPrefix(name)) out := make([]uint32, 0, len(keys)) for _, key := range keys { if height, ok := parseMatchKey(key); ok { out = append(out, height) } } return out } // meta reads a chain's cursor. func (d *Domain) meta(name string) (Meta, error) { raw, ok := d.store.Get(metaKey(name)) if !ok { return Meta{}, fmt.Errorf("domain: %q: %w", name, ErrNoWallet) } return ReadMeta(raw) } // scriptsFor derives the scripts a match asks about: both branches, from zero to the cursor // plus the lookahead. func scriptsFor(derived *wallet.Wallet, meta Meta, lookahead uint32) ([][]byte, error) { scripts := make([][]byte, 0, (meta.Receive+meta.Change+2*lookahead)*2) for _, branch := range []struct { change bool count uint32 }{ {change: false, count: meta.Receive + lookahead}, {change: true, count: meta.Change + lookahead}, } { addresses, err := derived.Addresses(branch.change, 0, branch.count) if err != nil { return nil, err } for _, address := range addresses { scripts = append(scripts, address.Script()) } } return scripts, nil } // derive turns a phrase into a chain's wallet. func derive(phrase string, profile wallet.Profile) (*wallet.Wallet, error) { seed, err := mnemonic.Seed(phrase, "") if err != nil { return nil, fmt.Errorf("domain: %w", err) } return wallet.New(seed, profile) } // Meta is a chain's address cursor and account. type Meta struct { // Account is the BIP32 account the wallet derives under. Account uint32 // Receive is how many receive addresses were handed out. Receive uint32 // Change is how many change addresses were handed out. Change uint32 } // metaLen is the encoded size of a Meta: version, account, receive, change. const metaLen = 1 + 4 + 4 + 4 // metaVersion is the encoding version. const metaVersion uint8 = 1 // PutMeta encodes a cursor. func PutMeta(meta Meta) []byte { out := make([]byte, 0, metaLen) out = append(out, metaVersion) out = binary.LittleEndian.AppendUint32(out, meta.Account) out = binary.LittleEndian.AppendUint32(out, meta.Receive) return binary.LittleEndian.AppendUint32(out, meta.Change) } // ReadMeta decodes a cursor. func ReadMeta(raw []byte) (Meta, error) { if len(raw) != metaLen { return Meta{}, fmt.Errorf("domain: a cursor is %d bytes, not %d", metaLen, len(raw)) } if raw[0] != metaVersion { return Meta{}, fmt.Errorf("domain: cursor version %d", raw[0]) } return Meta{ Account: binary.LittleEndian.Uint32(raw[1:5]), Receive: binary.LittleEndian.Uint32(raw[5:9]), Change: binary.LittleEndian.Uint32(raw[9:13]), }, nil } // Storage keys. The prefixes are what make a chain's documents a range. func vaultKey(name string) string { return "wallet/" + name + "/vault" } func metaKey(name string) string { return "wallet/" + name + "/meta" } func positionKey(name string) string { return "chain/" + name + "/position" } func matchPrefix(name string) string { return "match/" + name + "/" } // matchKey names one matched height. The height is zero-padded so the keys sort in chain // order, which is how a later layer walks them. func matchKey(name string, height uint32) string { return matchPrefix(name) + pad(height) } // parseMatchKey reads the height out of a match key. func parseMatchKey(key string) (uint32, bool) { index := len(key) - 10 if index <= 0 || len(key) < 10 { return 0, false } height, err := strconv.ParseUint(key[index:], 10, 32) if err != nil { return 0, false } return uint32(height), true } // pad renders a height as ten digits. func pad(height uint32) string { text := strconv.FormatUint(uint64(height), 10) for len(text) < 10 { text = "0" + text } return text } // checkName refuses a chain name that would make an ambiguous key or an unreadable log. func checkName(name string) error { if name == "" { return errors.New("domain: a chain needs a name") } if len(name) > 32 { return fmt.Errorf("domain: the chain name %q is too long", name) } for index := 0; index < len(name); index++ { character := name[index] switch { case character >= 'a' && character <= 'z': case character >= '0' && character <= '9': case character == '-' || character == '_': default: return fmt.Errorf("domain: the chain name %q must be lower-case letters, digits, '-' or '_'", name) } } return nil } // ScanBlock adds the outputs of one verified block that pay this wallet. // // The block is checked before anything is read from it: a gateway can hand over any bytes it // likes, and the merkle root is the only thing that ties them to the header the wallet already // trusts. What comes out is a coin per matching output, with the height it was confirmed at. func (d *Domain) ScanBlock(name string, raw []byte, height uint32) (int, error) { derived, err := d.walletOf(name) if err != nil { return 0, err } added, err := d.scanBlock(derived, raw, height) return added, err } // scanBlock verifies one block and adds the outputs that pay this wallet. func (d *Domain) scanBlock(derived *wallet.Wallet, raw []byte, height uint32) (int, error) { parsed, err := block.Parse(raw) if err != nil { return 0, err } if err := parsed.VerifyMerkle(); err != nil { return 0, err } meta, err := d.meta(derived.Profile().Name) if err != nil { return 0, err } scripts, err := scriptsFor(derived, meta, DefaultLookahead) if err != nil { return 0, err } matches := parsed.Match(scripts) added := 0 for _, match := range matches { if err := d.utxos.Add(utxo.Coin{ TxID: match.TxID, Vout: match.Vout, Value: match.Value, Height: height, }); err != nil { return added, err } added++ } return added, nil } // Unspent lists the coins the wallet can spend, newest first. func (d *Domain) Unspent() ([]utxo.Coin, error) { if d.utxos == nil { return nil, errors.New("domain: no coin table") } return d.utxos.Unspent() }