domain_test.go raw
1 package domain
2
3 import (
4 "errors"
5 "sync"
6 "testing"
7
8 "git.smesh.lol/nostrino/pkg/chaindata"
9 "git.smesh.lol/tinywallet/pkg/mnemonic"
10 "git.smesh.lol/tinywallet/pkg/store"
11 "git.smesh.lol/tinywallet/pkg/wallet"
12 )
13
14 // memPersistence is the store's durable side in memory, so a test can close a domain and open
15 // it again the way a page reload does.
16 type memPersistence struct {
17 mu sync.Mutex
18 items map[string][]byte
19 // batches counts Write calls, to prove a sync is not one round trip per write.
20 batches int
21 }
22
23 // Load implements store.Persistence.
24 func (m *memPersistence) Load() (map[string][]byte, error) {
25 m.mu.Lock()
26 defer m.mu.Unlock()
27
28 out := make(map[string][]byte, len(m.items))
29
30 for key, value := range m.items {
31 out[key] = append([]byte{}, value...)
32 }
33
34 return out, nil
35 }
36
37 // Write implements store.Persistence.
38 func (m *memPersistence) Write(batch []store.Op) error {
39 m.mu.Lock()
40 defer m.mu.Unlock()
41
42 m.batches++
43
44 for _, op := range batch {
45 if op.Delete {
46 delete(m.items, op.Key)
47
48 continue
49 }
50
51 m.items[op.Key] = append([]byte{}, op.Value...)
52 }
53
54 return nil
55 }
56
57 // fakeChain is a light client that answers from a script.
58 type fakeChain struct {
59 mu sync.Mutex
60
61 // position is what Position reports.
62 position chaindata.Position
63 // syncTo is the position Sync returns.
64 syncTo chaindata.Position
65 // matched is what Match returns.
66 matched []uint32
67 // syncErr and matchErr fail the call when set.
68 syncErr error
69 matchErr error
70 // blocks are the raw blocks Block answers with, by height.
71 blocks map[uint32][]byte
72 // blockErr fails Block when set.
73 blockErr error
74 // askedBlock records the last height asked for.
75 askedBlock uint32
76 // blockCalls counts the fetches.
77 blockCalls int
78 // lastFrom and lastScripts record the last call.
79 lastFrom uint32
80 lastScripts int
81 // entered, when set, is signalled on entry to Sync; hold it to keep a sync in flight.
82 entered chan struct{}
83 hold chan struct{}
84 }
85
86 // Block implements Chain.
87 func (f *fakeChain) Block(height uint32) ([]byte, error) {
88 f.mu.Lock()
89 defer f.mu.Unlock()
90
91 f.askedBlock = height
92 f.blockCalls++
93
94 if f.blockErr != nil {
95 return nil, f.blockErr
96 }
97
98 raw, ok := f.blocks[height]
99
100 if !ok {
101 return nil, errors.New("no block at that height")
102 }
103
104 return raw, nil
105 }
106
107 // Position implements Chain.
108 func (f *fakeChain) Position() (chaindata.Position, error) {
109 f.mu.Lock()
110 defer f.mu.Unlock()
111
112 return f.position, nil
113 }
114
115 // Sync implements Chain.
116 func (f *fakeChain) Sync(from uint32) (chaindata.Position, error) {
117 f.mu.Lock()
118 f.lastFrom = from
119 entered, hold := f.entered, f.hold
120 f.mu.Unlock()
121
122 if entered != nil {
123 entered <- struct{}{}
124 }
125
126 if hold != nil {
127 <-hold
128 }
129
130 f.mu.Lock()
131 defer f.mu.Unlock()
132
133 if f.syncErr != nil {
134 return chaindata.Position{}, f.syncErr
135 }
136
137 f.position = f.syncTo
138
139 return f.position, nil
140 }
141
142 // Match implements Chain.
143 func (f *fakeChain) Match(scripts [][]byte) ([]uint32, error) {
144 f.mu.Lock()
145 defer f.mu.Unlock()
146
147 f.lastScripts = len(scripts)
148
149 if f.matchErr != nil {
150 return nil, f.matchErr
151 }
152
153 return append([]uint32{}, f.matched...), nil
154 }
155
156 // rig is one domain with a store that survives it.
157 type rig struct {
158 domain *Domain
159 cache *store.Cache
160 persist *memPersistence
161 chains map[string]*fakeChain
162 }
163
164 // newRig opens a domain over two chains, "btc" and "blake", on mainnet parameters so the
165 // vectors are the published ones.
166 func newRig(t *testing.T, networks ...string) *rig {
167 t.Helper()
168
169 network := "btc"
170
171 if len(networks) > 0 {
172 network = networks[0]
173 }
174
175 persist := &memPersistence{items: map[string][]byte{}}
176 cache, err := store.OpenManual(persist)
177
178 if err != nil {
179 t.Fatal(err)
180 }
181
182 domain, err := New(cache)
183
184 if err != nil {
185 t.Fatal(err)
186 }
187
188 // The vault's work factor is not what these tests are about.
189 domain.rounds = testRounds
190
191 chains := map[string]*fakeChain{}
192
193 for _, name := range []string{"btc", "blake"} {
194 profile, err := wallet.ProfileFor(name, network)
195
196 if err != nil {
197 t.Fatal(err)
198 }
199
200 client := &fakeChain{}
201
202 if err := domain.Add(name, Link{Profile: profile, Client: client}); err != nil {
203 t.Fatal(err)
204 }
205
206 chains[name] = client
207 }
208
209 t.Cleanup(func() { _ = cache.Close() })
210
211 return &rig{domain: domain, cache: cache, persist: persist, chains: chains}
212 }
213
214 // reopen closes the store and opens a fresh domain over the same durable state, which is what
215 // a reload does.
216 func (r *rig) reopen(t *testing.T) *rig {
217 t.Helper()
218
219 if err := r.cache.Close(); err != nil {
220 t.Fatal(err)
221 }
222
223 cache, err := store.OpenManual(r.persist)
224
225 if err != nil {
226 t.Fatal(err)
227 }
228
229 domain, err := New(cache)
230
231 if err != nil {
232 t.Fatal(err)
233 }
234
235 domain.rounds = testRounds
236
237 for name, client := range r.chains {
238 profile, err := wallet.ProfileFor(name, "btc")
239
240 if err != nil {
241 t.Fatal(err)
242 }
243
244 if err := domain.Add(name, Link{Profile: profile, Client: client}); err != nil {
245 t.Fatal(err)
246 }
247 }
248
249 t.Cleanup(func() { _ = cache.Close() })
250
251 return &rig{domain: domain, cache: cache, persist: r.persist, chains: r.chains}
252 }
253
254 // TestCreateShowsThePhraseOnce: a new wallet returns words to write down and an address that
255 // derives from them, and the words are not stored in the clear.
256 func TestCreateShowsThePhraseOnce(t *testing.T) {
257 r := newRig(t)
258
259 created, err := r.domain.Create("btc", 12, "pass")
260
261 if err != nil {
262 t.Fatal(err)
263 }
264
265 if len(created.Mnemonic) == 0 {
266 t.Fatal("a new wallet returned no words")
267 }
268
269 if created.Address.Text == "" {
270 t.Fatal("a new wallet returned no address")
271 }
272
273 if created.Address.Text != addressFor(t, created.Mnemonic, "btc") {
274 t.Fatalf("the address %q does not derive from the words returned", created.Address.Text)
275 }
276
277 sealed, _ := r.cache.Get(vaultKey("btc"))
278
279 if len(sealed) == 0 {
280 t.Fatal("no vault was written")
281 }
282
283 if string(sealed) == created.Mnemonic {
284 t.Fatal("the phrase was stored in the clear")
285 }
286 }
287
288 // TestCreateThenReloadNeedsThePassphrase: the wallet is still there after a reload, and opening
289 // it takes the passphrase, not the words.
290 func TestCreateThenReloadNeedsThePassphrase(t *testing.T) {
291 r := newRig(t)
292
293 created, err := r.domain.Create("btc", 12, "pass")
294
295 if err != nil {
296 t.Fatal(err)
297 }
298
299 reopened := r.reopen(t)
300
301 status, err := reopened.domain.Status("btc")
302
303 if err != nil {
304 t.Fatal(err)
305 }
306
307 if !status.HasWallet {
308 t.Fatal("the wallet did not survive the reload")
309 }
310
311 if status.Unlocked {
312 t.Fatal("a reloaded wallet started unlocked")
313 }
314
315 if _, err := reopened.domain.Addresses("btc", false, 0, 1); !errors.Is(err, ErrLocked) {
316 t.Fatalf("a locked wallet derived an address: %v", err)
317 }
318
319 if err := reopened.domain.Unlock("btc", "wrong"); !errors.Is(err, ErrVault) {
320 t.Fatalf("a wrong passphrase unlocked the wallet: %v", err)
321 }
322
323 if err := reopened.domain.Unlock("btc", "pass"); err != nil {
324 t.Fatal(err)
325 }
326
327 addresses, err := reopened.domain.Addresses("btc", false, 0, 1)
328
329 if err != nil {
330 t.Fatal(err)
331 }
332
333 if addresses[0].Text != created.Address.Text {
334 t.Fatalf("the reloaded wallet says %q, the created one said %q", addresses[0].Text, created.Address.Text)
335 }
336 }
337
338 // TestRestoreTheSeedAndYouAreBack: restoring the words gives the same first address.
339 func TestRestoreTheSeedAndYouAreBack(t *testing.T) {
340 r := newRig(t)
341
342 created, err := r.domain.Restore("btc", phrase, "pass")
343
344 if err != nil {
345 t.Fatal(err)
346 }
347
348 if created.Mnemonic != "" {
349 t.Fatal("a restore echoed the phrase back")
350 }
351
352 // m/84'/0'/0'/0/0 of the BIP84 test seed: the published vector.
353 if created.Address.Text != "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu" {
354 t.Fatalf("the first address is %q, not the BIP84 vector", created.Address.Text)
355 }
356 }
357
358 // TestOneSeedTwoChains: the same words make two wallets, and they do not share an address.
359 func TestOneSeedTwoChains(t *testing.T) {
360 r := newRig(t)
361
362 btc, err := r.domain.Restore("btc", phrase, "pass")
363
364 if err != nil {
365 t.Fatal(err)
366 }
367
368 blake, err := r.domain.Restore("blake", phrase, "pass")
369
370 if err != nil {
371 t.Fatal(err)
372 }
373
374 if btc.Address.Text == blake.Address.Text {
375 t.Fatalf("both chains derived %q", btc.Address.Text)
376 }
377
378 btcStatus, err := r.domain.Status("btc")
379
380 if err != nil {
381 t.Fatal(err)
382 }
383
384 blakeStatus, err := r.domain.Status("blake")
385
386 if err != nil {
387 t.Fatal(err)
388 }
389
390 if btcStatus.Profile.Account != wallet.AccountBTC || blakeStatus.Profile.Account != wallet.AccountBlake {
391 t.Fatalf("accounts are %d and %d", btcStatus.Profile.Account, blakeStatus.Profile.Account)
392 }
393 }
394
395 // TestASecondSetupIsRefused: setup does not overwrite a wallet that already exists.
396 func TestASecondSetupIsRefused(t *testing.T) {
397 r := newRig(t)
398
399 if _, err := r.domain.Restore("btc", phrase, "pass"); err != nil {
400 t.Fatal(err)
401 }
402
403 if _, err := r.domain.Restore("btc", phrase, "other"); !errors.Is(err, ErrHasWallet) {
404 t.Fatalf("a second setup was accepted: %v", err)
405 }
406
407 if _, err := r.domain.Create("btc", 12, "other"); !errors.Is(err, ErrHasWallet) {
408 t.Fatalf("a second create was accepted: %v", err)
409 }
410 }
411
412 // TestBadWordsAreRefused: a phrase that does not validate is a person's transcription error,
413 // not a wallet.
414 func TestBadWordsAreRefused(t *testing.T) {
415 r := newRig(t)
416
417 broken := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"
418
419 if _, err := r.domain.Restore("btc", broken, "pass"); err == nil {
420 t.Fatal("a phrase with a bad checksum was accepted")
421 }
422
423 if _, err := r.domain.Restore("btc", "not a phrase", "pass"); err == nil {
424 t.Fatal("a non-phrase was accepted")
425 }
426 }
427
428 // TestAddressesMoveTheCursor: the sync lookahead starts from the last address handed out.
429 func TestAddressesMoveTheCursor(t *testing.T) {
430 r := newRig(t)
431
432 if _, err := r.domain.Restore("btc", phrase, "pass"); err != nil {
433 t.Fatal(err)
434 }
435
436 first, err := r.domain.Addresses("btc", false, 0, 5)
437
438 if err != nil {
439 t.Fatal(err)
440 }
441
442 if len(first) != 5 {
443 t.Fatalf("derived %d addresses", len(first))
444 }
445
446 status, err := r.domain.Status("btc")
447
448 if err != nil {
449 t.Fatal(err)
450 }
451
452 if status.Receive != 5 || status.Change != 0 {
453 t.Fatalf("cursors are receive %d, change %d", status.Receive, status.Change)
454 }
455
456 change, err := r.domain.Addresses("btc", true, 0, 2)
457
458 if err != nil {
459 t.Fatal(err)
460 }
461
462 if !change[0].Change {
463 t.Fatal("a change address is not marked as one")
464 }
465
466 if change[0].Text == first[0].Text {
467 t.Fatal("a change address repeats a receive address")
468 }
469
470 status, err = r.domain.Status("btc")
471
472 if err != nil {
473 t.Fatal(err)
474 }
475
476 if status.Receive != 5 || status.Change != 2 {
477 t.Fatalf("cursors are receive %d, change %d", status.Receive, status.Change)
478 }
479
480 // A cursor never moves backwards.
481 if _, err := r.domain.Addresses("btc", false, 0, 3); err != nil {
482 t.Fatal(err)
483 }
484
485 if status, _ := r.domain.Status("btc"); status.Receive != 5 {
486 t.Fatalf("the receive cursor went back to %d", status.Receive)
487 }
488 }
489
490 // TestSyncRecordsMatchesAndResumes: a sync asks from where the store left off, records the
491 // heights, and the second one does not re-record them.
492 func TestSyncRecordsMatchesAndResumes(t *testing.T) {
493 r := newRig(t)
494
495 if _, err := r.domain.Restore("btc", phrase, "pass"); err != nil {
496 t.Fatal(err)
497 }
498
499 chain := r.chains["btc"]
500 chain.syncTo = chaindata.Position{Tip: 120, Filters: 120}
501 chain.matched = []uint32{110, 115}
502 chain.blocks = map[uint32][]byte{
503 110: buildBlock(t, []byte{0x00, 0x14, 0x01}, 1000),
504 115: buildBlock(t, []byte{0x00, 0x14, 0x02}, 2000),
505 }
506
507 report, err := r.domain.Sync("btc", 0)
508
509 if err != nil {
510 t.Fatal(err)
511 }
512
513 if report.From != 0 {
514 t.Fatalf("the first sync started at %d", report.From)
515 }
516
517 if chain.lastFrom != 0 {
518 t.Fatalf("the client was asked to sync from %d", chain.lastFrom)
519 }
520
521 if len(report.New) != 2 || report.New[0] != 110 || report.New[1] != 115 {
522 t.Fatalf("new matches are %v", report.New)
523 }
524
525 // The matcher is asked about both branches: a payment to a change address is a payment
526 // to this wallet.
527 if chain.lastScripts == 0 {
528 t.Fatal("the matcher was asked about no scripts")
529 }
530
531 matches := r.domain.Matches("btc")
532
533 if len(matches) != 2 || matches[0] != 110 || matches[1] != 115 {
534 t.Fatalf("the recorded matches are %v", matches)
535 }
536
537 status, err := r.domain.Status("btc")
538
539 if err != nil {
540 t.Fatal(err)
541 }
542
543 if status.Position.Tip != 120 || status.Position.Filters != 120 {
544 t.Fatalf("the stored position is %+v", status.Position)
545 }
546
547 // A reload resumes from the stored filter tip, not from genesis.
548 reopened := r.reopen(t)
549
550 if err := reopened.domain.Unlock("btc", "pass"); err != nil {
551 t.Fatal(err)
552 }
553
554 chain.syncTo = chaindata.Position{Tip: 130, Filters: 130}
555
556 report, err = reopened.domain.Sync("btc", 0)
557
558 if err != nil {
559 t.Fatal(err)
560 }
561
562 if report.From != 121 {
563 t.Fatalf("the resumed sync started at %d", report.From)
564 }
565
566 if len(report.Matched) != 2 || len(report.New) != 0 {
567 t.Fatalf("the resumed sync reported %v new of %v", report.New, report.Matched)
568 }
569
570 if len(reopened.domain.Matches("btc")) != 2 {
571 t.Fatal("a repeated match was recorded twice")
572 }
573 }
574
575 // TestSyncLookaheadIsUsed: the scripts asked about cover both branches past the cursor.
576 func TestSyncLookaheadIsUsed(t *testing.T) {
577 r := newRig(t)
578
579 if _, err := r.domain.Restore("btc", phrase, "pass"); err != nil {
580 t.Fatal(err)
581 }
582
583 chain := r.chains["btc"]
584 chain.syncTo = chaindata.Position{Tip: 5, Filters: 5}
585
586 if _, err := r.domain.Sync("btc", 10); err != nil {
587 t.Fatal(err)
588 }
589
590 // Receive: the one address setup derived plus ten of lookahead. Change: none handed
591 // out, ten.
592 if chain.lastScripts != 11+10 {
593 t.Fatalf("the matcher was asked about %d scripts, want 21", chain.lastScripts)
594 }
595 }
596
597 // TestSyncNeedsAWallet: there is nothing to match against before setup.
598 func TestSyncNeedsAWallet(t *testing.T) {
599 r := newRig(t)
600
601 if _, err := r.domain.Sync("btc", 0); !errors.Is(err, ErrNoWallet) {
602 t.Fatalf("a sync without a wallet: %v", err)
603 }
604
605 if _, err := r.domain.Sync("nope", 0); !errors.Is(err, ErrNoChain) {
606 t.Fatalf("a sync of an unknown chain: %v", err)
607 }
608 }
609
610 // TestASecondSyncIsRefused: one chain, one sync at a time.
611 func TestASecondSyncIsRefused(t *testing.T) {
612 r := newRig(t)
613
614 if _, err := r.domain.Restore("btc", phrase, "pass"); err != nil {
615 t.Fatal(err)
616 }
617
618 chain := r.chains["btc"]
619 chain.syncTo = chaindata.Position{Tip: 5, Filters: 5}
620 chain.entered = make(chan struct{}, 1)
621 chain.hold = make(chan struct{})
622
623 done := make(chan error, 1)
624
625 go func() {
626 _, err := r.domain.Sync("btc", 10)
627 done <- err
628 }()
629
630 <-chain.entered
631
632 if _, err := r.domain.Sync("btc", 10); !errors.Is(err, ErrSyncInProgress) {
633 t.Fatalf("a concurrent sync was accepted: %v", err)
634 }
635
636 close(chain.hold)
637
638 if err := <-done; err != nil {
639 t.Fatal(err)
640 }
641
642 // The guard is released, so the next one is accepted.
643 chain.hold = nil
644
645 if _, err := r.domain.Sync("btc", 10); err != nil {
646 t.Fatalf("a sync after a finished one: %v", err)
647 }
648 }
649
650 // TestChainFailuresAreReported: a chain that cannot answer is an error, and nothing is recorded.
651 func TestChainFailuresAreReported(t *testing.T) {
652 r := newRig(t)
653
654 if _, err := r.domain.Restore("btc", phrase, "pass"); err != nil {
655 t.Fatal(err)
656 }
657
658 chain := r.chains["btc"]
659 chain.syncErr = errors.New("no peer")
660
661 if _, err := r.domain.Sync("btc", 10); err == nil {
662 t.Fatal("a failed sync reported success")
663 }
664
665 chain.syncErr = nil
666 chain.syncTo = chaindata.Position{Tip: 5, Filters: 5}
667 chain.matchErr = errors.New("the filter is missing")
668
669 if _, err := r.domain.Sync("btc", 10); err == nil {
670 t.Fatal("a failed match reported success")
671 }
672
673 if len(r.domain.Matches("btc")) != 0 {
674 t.Fatal("a failed match recorded a height")
675 }
676
677 if status, _ := r.domain.Status("btc"); status.Position.Tip != 0 {
678 t.Fatalf("a failed match stored a position: %+v", status.Position)
679 }
680 }
681
682 // TestStatusBeforeSetup: an untouched chain reports itself, not an error.
683 func TestStatusBeforeSetup(t *testing.T) {
684 r := newRig(t)
685
686 status, err := r.domain.Status("btc")
687
688 if err != nil {
689 t.Fatal(err)
690 }
691
692 if status.HasWallet || status.Unlocked {
693 t.Fatalf("an unset wallet reports %+v", status)
694 }
695
696 if status.Receive != 0 || status.Change != 0 || status.Matches != 0 {
697 t.Fatalf("an unset wallet reports %+v", status)
698 }
699
700 if status.Profile.Name != "btc" {
701 t.Fatalf("the profile is %q", status.Profile.Name)
702 }
703 }
704
705 // TestAddRefusesNonsense: a chain name is a key prefix and a line in a log.
706 func TestAddRefusesNonsense(t *testing.T) {
707 cache, err := store.OpenManual(&memPersistence{items: map[string][]byte{}})
708
709 if err != nil {
710 t.Fatal(err)
711 }
712
713 defer func() { _ = cache.Close() }()
714
715 domain, err := New(cache)
716
717 if err != nil {
718 t.Fatal(err)
719 }
720
721 profile, err := wallet.ProfileFor("btc", "btc")
722
723 if err != nil {
724 t.Fatal(err)
725 }
726
727 for _, name := range []string{"", "with space", "UPPER", "../etc", "a/b"} {
728 if err := domain.Add(name, Link{Profile: profile, Client: &fakeChain{}}); err == nil {
729 t.Fatalf("the chain name %q was accepted", name)
730 }
731 }
732
733 if err := domain.Add("btc", Link{Profile: profile}); err == nil {
734 t.Fatal("a chain with no client was accepted")
735 }
736
737 if err := domain.Add("btc", Link{Client: &fakeChain{}}); err == nil {
738 t.Fatal("a chain with no profile was accepted")
739 }
740
741 link := Link{Profile: profile, Client: &fakeChain{}}
742
743 if err := domain.Add("btc", link); err != nil {
744 t.Fatal(err)
745 }
746
747 if err := domain.Add("btc", link); err == nil {
748 t.Fatal("a duplicate chain was accepted")
749 }
750
751 if names := domain.Names(); len(names) != 1 || names[0] != "btc" {
752 t.Fatalf("names are %v", names)
753 }
754 }
755
756 // TestMetaRoundTrip: the cursor is a fixed record, and a bad one is refused.
757 func TestMetaRoundTrip(t *testing.T) {
758 meta := Meta{Account: 1, Receive: 7, Change: 3}
759
760 decoded, err := ReadMeta(PutMeta(meta))
761
762 if err != nil {
763 t.Fatal(err)
764 }
765
766 if decoded != meta {
767 t.Fatalf("decoded %+v, want %+v", decoded, meta)
768 }
769
770 if _, err := ReadMeta(nil); err == nil {
771 t.Fatal("an empty cursor was accepted")
772 }
773
774 if _, err := ReadMeta([]byte{2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}); err == nil {
775 t.Fatal("a cursor with an unknown version was accepted")
776 }
777 }
778
779 // addressFor derives the first address of a phrase independently of the domain, so the test
780 // does not check the domain against itself.
781 func addressFor(t *testing.T, words, chainName string) string {
782 t.Helper()
783
784 seed, err := mnemonic.Seed(words, "")
785
786 if err != nil {
787 t.Fatal(err)
788 }
789
790 profile, err := wallet.ProfileFor(chainName, "btc")
791
792 if err != nil {
793 t.Fatal(err)
794 }
795
796 derived, err := wallet.New(seed, profile)
797
798 if err != nil {
799 t.Fatal(err)
800 }
801
802 address, err := derived.Address(false, 0)
803
804 if err != nil {
805 t.Fatal(err)
806 }
807
808 return address.Text
809 }
810
811 // compile-time proof that the fake is the interface.
812 var _ Chain = (*fakeChain)(nil)
813