1 // Package domain is the wallet's own state and the operations a person can ask of it:
2 // create a wallet from a seed, restore one from words, unlock it, derive addresses, and walk
3 // a chain into it.
4 //
5 // It is deliberately free of transport and of the browser: a chain is an interface, the store
6 // is the package, and both are satisfied by different things in a test and in a worker. That
7 // is what lets the setup flow be tested without a page, a relay, or a node.
8 package domain
9 10 import (
11 "encoding/binary"
12 "errors"
13 "fmt"
14 "sort"
15 "strconv"
16 "sync"
17 18 "git.smesh.lol/nostrino/pkg/block"
19 "git.smesh.lol/nostrino/pkg/chaindata"
20 "git.smesh.lol/tinywallet/pkg/mnemonic"
21 "git.smesh.lol/tinywallet/pkg/store"
22 "git.smesh.lol/tinywallet/pkg/utxo"
23 "git.smesh.lol/tinywallet/pkg/wallet"
24 )
25 26 // Errors the setup flow maps onto what a person is told.
27 var (
28 // ErrNoChain is a chain this domain does not drive.
29 ErrNoChain = errors.New("no such chain")
30 // ErrHasWallet is a setup attempt over a wallet that already exists.
31 ErrHasWallet = errors.New("a wallet already exists for this chain")
32 // ErrNoWallet is an unlock or a sync before a wallet was created.
33 ErrNoWallet = errors.New("no wallet for this chain")
34 // ErrLocked is a derivation asked of a locked wallet.
35 ErrLocked = errors.New("the wallet is locked")
36 // ErrSyncInProgress is a second sync of one chain.
37 ErrSyncInProgress = errors.New("a sync is already running for this chain")
38 )
39 40 // DefaultLookahead is how far past the last used address a sync looks.
41 //
42 // A wallet must not stop looking because nobody asked for a new address: the gap limit is
43 // what lets a restored seed find funds that were received after the last address the person
44 // ever saw. A hundred is the usual compromise between a rescan that finds everything and one
45 // that downloads filters for addresses nobody will use.
46 const DefaultLookahead uint32 = 100
47 48 // Chain is what the domain needs from a light client: where it is, advance it, and ask which
49 // of these scripts it saw. *chaindata.Client satisfies it, and so does a fake.
50 type Chain interface {
51 // Position reports the verified tip and the filter tip.
52 Position() (chaindata.Position, error)
53 // Sync advances the chain from a height and returns where it stopped.
54 Sync(from uint32) (chaindata.Position, error)
55 // Match returns the heights whose filters mention any of the scripts.
56 Match(scripts [][]byte) ([]uint32, error)
57 // Block returns the raw block at a height, which is what turns a match into a coin.
58 Block(height uint32) ([]byte, error)
59 }
60 61 // Link is one chain the domain drives: its derivation profile and its client.
62 type Link struct {
63 // Profile is the wallet profile the chain derives from.
64 Profile wallet.Profile
65 // Client is the light client for this chain.
66 Client Chain
67 }
68 69 // Domain is every wallet the process holds and the store they share.
70 type Domain struct {
71 mu sync.Mutex
72 73 store *store.Cache
74 links map[string]Link
75 // unlocked holds the derived wallets. The seed phrase itself is not kept: it is opened
76 // from the vault, used to derive, and dropped, so a locked domain has one secret copy at
77 // rest and none in memory.
78 unlocked map[string]*wallet.Wallet
79 // utxos is the coin table a verified block fills.
80 utxos *utxo.Table
81 // syncing refuses a second concurrent sync of one chain. Two syncs of one chain are two
82 // questions about one tip, and a light client answers them by interrupting itself.
83 syncing map[string]bool
84 // rounds is the vault's PBKDF2 work factor. It is a field so a test can seal and unlock
85 // without spending the real cost; New sets the shipped value.
86 rounds int
87 }
88 89 // New builds a domain over a store.
90 func New(cache *store.Cache) (*Domain, error) {
91 if cache == nil {
92 return nil, errors.New("domain: a domain needs a store")
93 }
94 95 table, err := utxo.Open(cache)
96 97 if err != nil {
98 return nil, err
99 }
100 101 return &Domain{
102 store: cache,
103 utxos: table,
104 links: map[string]Link{},
105 unlocked: map[string]*wallet.Wallet{},
106 syncing: map[string]bool{},
107 rounds: vaultRounds,
108 }, nil
109 }
110 111 // Add registers a chain.
112 //
113 // The name is the wallet's own chain name ("btc", "blake"), not the network profile: the two
114 // chains share a network in this build, and the wallet keeps them apart by account.
115 func (d *Domain) Add(name string, link Link) error {
116 if err := checkName(name); err != nil {
117 return err
118 }
119 120 if link.Client == nil {
121 return fmt.Errorf("domain: chain %q has no client", name)
122 }
123 124 if link.Profile.Name == "" {
125 return fmt.Errorf("domain: chain %q has no profile", name)
126 }
127 128 d.mu.Lock()
129 defer d.mu.Unlock()
130 131 if _, ok := d.links[name]; ok {
132 return fmt.Errorf("domain: chain %q is already added", name)
133 }
134 135 d.links[name] = link
136 137 return nil
138 }
139 140 // Names lists the registered chains in a stable order.
141 func (d *Domain) Names() []string {
142 d.mu.Lock()
143 defer d.mu.Unlock()
144 145 names := make([]string, 0, len(d.links))
146 147 for name := range d.links {
148 names = append(names, name)
149 }
150 151 sort.Strings(names)
152 153 return names
154 }
155 156 // link returns a registered chain or ErrNoChain.
157 func (d *Domain) link(name string) (Link, error) {
158 d.mu.Lock()
159 defer d.mu.Unlock()
160 161 link, ok := d.links[name]
162 163 if !ok {
164 return Link{}, fmt.Errorf("domain: %q: %w", name, ErrNoChain)
165 }
166 167 return link, nil
168 }
169 170 // Created is what the setup flow hands back: the words to write down (only for a new wallet)
171 // and the first address, so the person can see that something real happened.
172 type Created struct {
173 // Chain is the chain the wallet was made for.
174 Chain string
175 // Mnemonic is the phrase to write down. It is empty on a restore, where the person
176 // already has it.
177 Mnemonic string
178 // Address is the first receive address.
179 Address wallet.Address
180 }
181 182 // Create makes a new wallet for a chain from fresh words.
183 //
184 // The phrase is returned exactly once, to be shown and then forgotten: it is not stored in
185 // cleartext anywhere, and the sealed copy is only readable with the passphrase.
186 func (d *Domain) Create(name string, words int, passphrase string) (Created, error) {
187 phrase, err := mnemonic.New(words)
188 189 if err != nil {
190 return Created{}, fmt.Errorf("domain: %w", err)
191 }
192 193 created, err := d.restore(name, phrase, passphrase)
194 195 if err != nil {
196 return Created{}, err
197 }
198 199 created.Mnemonic = phrase
200 201 return created, nil
202 }
203 204 // Restore recreates a wallet from words the person already has.
205 func (d *Domain) Restore(name, phrase, passphrase string) (Created, error) {
206 return d.restore(name, phrase, passphrase)
207 }
208 209 // restore is the body Create and Restore share.
210 func (d *Domain) restore(name, phrase, passphrase string) (Created, error) {
211 link, err := d.link(name)
212 213 if err != nil {
214 return Created{}, err
215 }
216 217 if err := mnemonic.Validate(phrase); err != nil {
218 return Created{}, fmt.Errorf("domain: %w", err)
219 }
220 221 if _, exists := d.store.Get(vaultKey(name)); exists {
222 return Created{}, fmt.Errorf("domain: %q: %w", name, ErrHasWallet)
223 }
224 225 sealed, err := seal(passphrase, phrase, d.rounds)
226 227 if err != nil {
228 return Created{}, err
229 }
230 231 derived, err := derive(phrase, link.Profile)
232 233 if err != nil {
234 return Created{}, err
235 }
236 237 first, err := derived.Address(false, 0)
238 239 if err != nil {
240 return Created{}, err
241 }
242 243 if err := d.store.Put(vaultKey(name), sealed); err != nil {
244 return Created{}, err
245 }
246 247 meta := Meta{Account: link.Profile.Account, Receive: 1}
248 249 if err := d.store.Put(metaKey(name), PutMeta(meta)); err != nil {
250 return Created{}, err
251 }
252 253 d.mu.Lock()
254 d.unlocked[name] = derived
255 d.mu.Unlock()
256 257 return Created{Chain: name, Address: first}, nil
258 }
259 260 // Unlock opens the vault and derives the wallet.
261 //
262 // The phrase is opened, used, and dropped: what stays in memory is the account key, which is
263 // what addresses and, later, signatures come from.
264 func (d *Domain) Unlock(name, passphrase string) error {
265 link, err := d.link(name)
266 267 if err != nil {
268 return err
269 }
270 271 sealed, ok := d.store.Get(vaultKey(name))
272 273 if !ok {
274 return fmt.Errorf("domain: %q: %w", name, ErrNoWallet)
275 }
276 277 phrase, err := open(passphrase, sealed, d.rounds)
278 279 if err != nil {
280 return err
281 }
282 283 derived, err := derive(phrase, link.Profile)
284 285 if err != nil {
286 return err
287 }
288 289 d.mu.Lock()
290 d.unlocked[name] = derived
291 d.mu.Unlock()
292 293 return nil
294 }
295 296 // Lock forgets the derived wallet for a chain.
297 func (d *Domain) Lock(name string) {
298 d.mu.Lock()
299 delete(d.unlocked, name)
300 d.mu.Unlock()
301 }
302 303 // LockAll forgets every derived wallet.
304 func (d *Domain) LockAll() {
305 d.mu.Lock()
306 d.unlocked = map[string]*wallet.Wallet{}
307 d.mu.Unlock()
308 }
309 310 // Unlocked reports whether a chain's wallet is unlocked.
311 func (d *Domain) Unlocked(name string) bool {
312 d.mu.Lock()
313 defer d.mu.Unlock()
314 315 _, ok := d.unlocked[name]
316 317 return ok
318 }
319 320 // walletOf returns the unlocked wallet of a chain.
321 func (d *Domain) walletOf(name string) (*wallet.Wallet, error) {
322 if _, err := d.link(name); err != nil {
323 return nil, err
324 }
325 326 d.mu.Lock()
327 derived, ok := d.unlocked[name]
328 d.mu.Unlock()
329 330 if ok {
331 return derived, nil
332 }
333 334 // A chain with no vault is not locked, it is unset: the two are different things to tell
335 // a person, so they are different errors.
336 if _, hasWallet := d.store.Get(vaultKey(name)); !hasWallet {
337 return nil, fmt.Errorf("domain: %q: %w", name, ErrNoWallet)
338 }
339 340 return nil, fmt.Errorf("domain: %q: %w", name, ErrLocked)
341 }
342 343 // Addresses derives a run of addresses and moves the chain's cursor past them.
344 //
345 // Moving the cursor is what makes the lookahead useful: the next sync looks a hundred past
346 // the last address a person was shown, not past the last one they used.
347 func (d *Domain) Addresses(name string, change bool, from, count uint32) ([]wallet.Address, error) {
348 if count == 0 {
349 return nil, nil
350 }
351 352 derived, err := d.walletOf(name)
353 354 if err != nil {
355 return nil, err
356 }
357 358 addresses, err := derived.Addresses(change, from, count)
359 360 if err != nil {
361 return nil, err
362 }
363 364 meta, err := d.meta(name)
365 366 if err != nil {
367 return nil, err
368 }
369 370 high := from + count
371 372 if change && high > meta.Change {
373 meta.Change = high
374 375 if err := d.store.Put(metaKey(name), PutMeta(meta)); err != nil {
376 return nil, err
377 }
378 }
379 380 if !change && high > meta.Receive {
381 meta.Receive = high
382 383 if err := d.store.Put(metaKey(name), PutMeta(meta)); err != nil {
384 return nil, err
385 }
386 }
387 388 return addresses, nil
389 }
390 391 // Status is what a page needs to draw the wallet's state.
392 type Status struct {
393 // Chain is the chain name.
394 Chain string
395 // Profile is the derivation profile.
396 Profile wallet.Profile
397 // HasWallet reports whether a vault exists.
398 HasWallet bool
399 // Unlocked reports whether the account is in memory.
400 Unlocked bool
401 // Position is where the chain said it was when last asked.
402 Position chaindata.Position
403 // Receive and Change are how many addresses of each branch were handed out.
404 Receive uint32
405 Change uint32
406 // Matches is how many heights the chain matched and the store recorded.
407 Matches int
408 }
409 410 // Status reports a chain's state without asking the chain anything.
411 func (d *Domain) Status(name string) (Status, error) {
412 link, err := d.link(name)
413 414 if err != nil {
415 return Status{}, err
416 }
417 418 status := Status{Chain: name, Profile: link.Profile}
419 420 _, status.HasWallet = d.store.Get(vaultKey(name))
421 422 status.Unlocked = d.Unlocked(name)
423 424 if meta, err := d.meta(name); err == nil {
425 status.Receive = meta.Receive
426 status.Change = meta.Change
427 } else if !errors.Is(err, ErrNoWallet) {
428 return Status{}, err
429 }
430 431 if raw, ok := d.store.Get(positionKey(name)); ok {
432 if position, err := chaindata.ReadPosition(raw); err == nil {
433 status.Position = position
434 }
435 }
436 437 status.Matches = len(d.store.Keys(matchPrefix(name)))
438 439 return status, nil
440 }
441 442 // Report is what a sync did.
443 type Report struct {
444 // Chain is the chain name.
445 Chain string
446 // From is the height the sync was asked to start at.
447 From uint32
448 // Position is where the chain says it ended up.
449 Position chaindata.Position
450 // Scripts is how many scripts were sent to the matcher.
451 Scripts int
452 // Matched is every height the chain matched.
453 Matched []uint32
454 // New is the part of Matched the store had not seen before.
455 New []uint32
456 }
457 458 // Sync walks the chain forward and records the heights that mention the wallet.
459 //
460 // A wallet that already has a filter tip resumes from it, which is what makes a reload cheap:
461 // the store is the memory of what was verified, and the light client is asked for the rest.
462 // Nothing is proven twice.
463 func (d *Domain) Sync(name string, lookahead uint32) (Report, error) {
464 link, err := d.link(name)
465 466 if err != nil {
467 return Report{}, err
468 }
469 470 derived, err := d.walletOf(name)
471 472 if err != nil {
473 return Report{}, err
474 }
475 476 if lookahead == 0 {
477 lookahead = DefaultLookahead
478 }
479 480 d.mu.Lock()
481 482 if d.syncing[name] {
483 d.mu.Unlock()
484 485 return Report{}, fmt.Errorf("domain: %q: %w", name, ErrSyncInProgress)
486 }
487 488 d.syncing[name] = true
489 d.mu.Unlock()
490 491 defer func() {
492 d.mu.Lock()
493 d.syncing[name] = false
494 d.mu.Unlock()
495 }()
496 497 meta, err := d.meta(name)
498 499 if err != nil {
500 return Report{}, err
501 }
502 503 from := uint32(0)
504 505 if raw, ok := d.store.Get(positionKey(name)); ok {
506 if stored, err := chaindata.ReadPosition(raw); err == nil && stored.Filters > 0 {
507 from = stored.Filters + 1
508 }
509 }
510 511 position, err := link.Client.Sync(from)
512 513 if err != nil {
514 return Report{}, fmt.Errorf("domain: %q: syncing: %w", name, err)
515 }
516 517 report := Report{Chain: name, From: from, Position: position}
518 519 scripts, err := scriptsFor(derived, meta, lookahead)
520 521 if err != nil {
522 return Report{}, err
523 }
524 525 report.Scripts = len(scripts)
526 527 matched, err := link.Client.Match(scripts)
528 529 if err != nil {
530 return Report{}, fmt.Errorf("domain: %q: matching: %w", name, err)
531 }
532 533 for _, height := range matched {
534 report.Matched = append(report.Matched, height)
535 536 key := matchKey(name, height)
537 538 if _, scanned := d.store.Get(key); scanned {
539 // The height was matched and its block was scanned before. A second fetch would
540 // be a second download for a fact the store already holds.
541 continue
542 }
543 544 // A match is a claim about a block; the coins are in the block. The block is fetched
545 // and verified before the marker is stored, so a failure here leaves the height to be
546 // retried by the next sync instead of recording a scan that never happened.
547 raw, err := link.Client.Block(height)
548 549 if err != nil {
550 return Report{}, fmt.Errorf("domain: %q: fetching block %d: %w", name, height, err)
551 }
552 553 if _, err := d.scanBlock(derived, raw, height); err != nil {
554 return Report{}, fmt.Errorf("domain: %q: block %d: %w", name, height, err)
555 }
556 557 if err := d.store.Put(key, nil); err != nil {
558 return Report{}, err
559 }
560 561 report.New = append(report.New, height)
562 }
563 564 if err := d.store.Put(positionKey(name), chaindata.PutPosition(nil, position)); err != nil {
565 return Report{}, err
566 }
567 568 // One flush for the whole sync: the markers, the coins and the position land together, so a
569 // crash cannot leave a height marked as scanned with the coins it produced missing.
570 if err := d.store.Flush(); err != nil {
571 return report, fmt.Errorf("domain: %q: %w", name, err)
572 }
573 574 return report, nil
575 }
576 577 // Matches lists the recorded heights, ascending.
578 func (d *Domain) Matches(name string) []uint32 {
579 keys := d.store.Keys(matchPrefix(name))
580 out := make([]uint32, 0, len(keys))
581 582 for _, key := range keys {
583 if height, ok := parseMatchKey(key); ok {
584 out = append(out, height)
585 }
586 }
587 588 return out
589 }
590 591 // meta reads a chain's cursor.
592 func (d *Domain) meta(name string) (Meta, error) {
593 raw, ok := d.store.Get(metaKey(name))
594 595 if !ok {
596 return Meta{}, fmt.Errorf("domain: %q: %w", name, ErrNoWallet)
597 }
598 599 return ReadMeta(raw)
600 }
601 602 // scriptsFor derives the scripts a match asks about: both branches, from zero to the cursor
603 // plus the lookahead.
604 func scriptsFor(derived *wallet.Wallet, meta Meta, lookahead uint32) ([][]byte, error) {
605 scripts := make([][]byte, 0, (meta.Receive+meta.Change+2*lookahead)*2)
606 607 for _, branch := range []struct {
608 change bool
609 count uint32
610 }{
611 {change: false, count: meta.Receive + lookahead},
612 {change: true, count: meta.Change + lookahead},
613 } {
614 addresses, err := derived.Addresses(branch.change, 0, branch.count)
615 616 if err != nil {
617 return nil, err
618 }
619 620 for _, address := range addresses {
621 scripts = append(scripts, address.Script())
622 }
623 }
624 625 return scripts, nil
626 }
627 628 // derive turns a phrase into a chain's wallet.
629 func derive(phrase string, profile wallet.Profile) (*wallet.Wallet, error) {
630 seed, err := mnemonic.Seed(phrase, "")
631 632 if err != nil {
633 return nil, fmt.Errorf("domain: %w", err)
634 }
635 636 return wallet.New(seed, profile)
637 }
638 639 // Meta is a chain's address cursor and account.
640 type Meta struct {
641 // Account is the BIP32 account the wallet derives under.
642 Account uint32
643 // Receive is how many receive addresses were handed out.
644 Receive uint32
645 // Change is how many change addresses were handed out.
646 Change uint32
647 }
648 649 // metaLen is the encoded size of a Meta: version, account, receive, change.
650 const metaLen = 1 + 4 + 4 + 4
651 652 // metaVersion is the encoding version.
653 const metaVersion uint8 = 1
654 655 // PutMeta encodes a cursor.
656 func PutMeta(meta Meta) []byte {
657 out := make([]byte, 0, metaLen)
658 out = append(out, metaVersion)
659 out = binary.LittleEndian.AppendUint32(out, meta.Account)
660 out = binary.LittleEndian.AppendUint32(out, meta.Receive)
661 662 return binary.LittleEndian.AppendUint32(out, meta.Change)
663 }
664 665 // ReadMeta decodes a cursor.
666 func ReadMeta(raw []byte) (Meta, error) {
667 if len(raw) != metaLen {
668 return Meta{}, fmt.Errorf("domain: a cursor is %d bytes, not %d", metaLen, len(raw))
669 }
670 671 if raw[0] != metaVersion {
672 return Meta{}, fmt.Errorf("domain: cursor version %d", raw[0])
673 }
674 675 return Meta{
676 Account: binary.LittleEndian.Uint32(raw[1:5]),
677 Receive: binary.LittleEndian.Uint32(raw[5:9]),
678 Change: binary.LittleEndian.Uint32(raw[9:13]),
679 }, nil
680 }
681 682 // Storage keys. The prefixes are what make a chain's documents a range.
683 func vaultKey(name string) string { return "wallet/" + name + "/vault" }
684 685 func metaKey(name string) string { return "wallet/" + name + "/meta" }
686 687 func positionKey(name string) string { return "chain/" + name + "/position" }
688 689 func matchPrefix(name string) string { return "match/" + name + "/" }
690 691 // matchKey names one matched height. The height is zero-padded so the keys sort in chain
692 // order, which is how a later layer walks them.
693 func matchKey(name string, height uint32) string {
694 return matchPrefix(name) + pad(height)
695 }
696 697 // parseMatchKey reads the height out of a match key.
698 func parseMatchKey(key string) (uint32, bool) {
699 index := len(key) - 10
700 701 if index <= 0 || len(key) < 10 {
702 return 0, false
703 }
704 705 height, err := strconv.ParseUint(key[index:], 10, 32)
706 707 if err != nil {
708 return 0, false
709 }
710 711 return uint32(height), true
712 }
713 714 // pad renders a height as ten digits.
715 func pad(height uint32) string {
716 text := strconv.FormatUint(uint64(height), 10)
717 718 for len(text) < 10 {
719 text = "0" + text
720 }
721 722 return text
723 }
724 725 // checkName refuses a chain name that would make an ambiguous key or an unreadable log.
726 func checkName(name string) error {
727 if name == "" {
728 return errors.New("domain: a chain needs a name")
729 }
730 731 if len(name) > 32 {
732 return fmt.Errorf("domain: the chain name %q is too long", name)
733 }
734 735 for index := 0; index < len(name); index++ {
736 character := name[index]
737 738 switch {
739 case character >= 'a' && character <= 'z':
740 case character >= '0' && character <= '9':
741 case character == '-' || character == '_':
742 default:
743 return fmt.Errorf("domain: the chain name %q must be lower-case letters, digits, '-' or '_'", name)
744 }
745 }
746 747 return nil
748 }
749 750 // ScanBlock adds the outputs of one verified block that pay this wallet.
751 //
752 // The block is checked before anything is read from it: a gateway can hand over any bytes it
753 // likes, and the merkle root is the only thing that ties them to the header the wallet already
754 // trusts. What comes out is a coin per matching output, with the height it was confirmed at.
755 func (d *Domain) ScanBlock(name string, raw []byte, height uint32) (int, error) {
756 derived, err := d.walletOf(name)
757 758 if err != nil {
759 return 0, err
760 }
761 762 added, err := d.scanBlock(derived, raw, height)
763 764 return added, err
765 }
766 767 // scanBlock verifies one block and adds the outputs that pay this wallet.
768 func (d *Domain) scanBlock(derived *wallet.Wallet, raw []byte, height uint32) (int, error) {
769 parsed, err := block.Parse(raw)
770 771 if err != nil {
772 return 0, err
773 }
774 775 if err := parsed.VerifyMerkle(); err != nil {
776 return 0, err
777 }
778 779 meta, err := d.meta(derived.Profile().Name)
780 781 if err != nil {
782 return 0, err
783 }
784 785 scripts, err := scriptsFor(derived, meta, DefaultLookahead)
786 787 if err != nil {
788 return 0, err
789 }
790 791 matches := parsed.Match(scripts)
792 added := 0
793 794 for _, match := range matches {
795 if err := d.utxos.Add(utxo.Coin{
796 TxID: match.TxID, Vout: match.Vout, Value: match.Value, Height: height,
797 }); err != nil {
798 return added, err
799 }
800 801 added++
802 }
803 804 return added, nil
805 }
806 807 // Unspent lists the coins the wallet can spend, newest first.
808 func (d *Domain) Unspent() ([]utxo.Coin, error) {
809 if d.utxos == nil {
810 return nil, errors.New("domain: no coin table")
811 }
812 813 return d.utxos.Unspent()
814 }
815