package domain import ( "errors" "sync" "testing" "git.smesh.lol/nostrino/pkg/chaindata" "git.smesh.lol/tinywallet/pkg/mnemonic" "git.smesh.lol/tinywallet/pkg/store" "git.smesh.lol/tinywallet/pkg/wallet" ) // memPersistence is the store's durable side in memory, so a test can close a domain and open // it again the way a page reload does. type memPersistence struct { mu sync.Mutex items map[string][]byte // batches counts Write calls, to prove a sync is not one round trip per write. batches int } // Load implements store.Persistence. func (m *memPersistence) Load() (map[string][]byte, error) { m.mu.Lock() defer m.mu.Unlock() out := make(map[string][]byte, len(m.items)) for key, value := range m.items { out[key] = append([]byte{}, value...) } return out, nil } // Write implements store.Persistence. func (m *memPersistence) Write(batch []store.Op) error { m.mu.Lock() defer m.mu.Unlock() m.batches++ for _, op := range batch { if op.Delete { delete(m.items, op.Key) continue } m.items[op.Key] = append([]byte{}, op.Value...) } return nil } // fakeChain is a light client that answers from a script. type fakeChain struct { mu sync.Mutex // position is what Position reports. position chaindata.Position // syncTo is the position Sync returns. syncTo chaindata.Position // matched is what Match returns. matched []uint32 // syncErr and matchErr fail the call when set. syncErr error matchErr error // blocks are the raw blocks Block answers with, by height. blocks map[uint32][]byte // blockErr fails Block when set. blockErr error // askedBlock records the last height asked for. askedBlock uint32 // blockCalls counts the fetches. blockCalls int // lastFrom and lastScripts record the last call. lastFrom uint32 lastScripts int // entered, when set, is signalled on entry to Sync; hold it to keep a sync in flight. entered chan struct{} hold chan struct{} } // Block implements Chain. func (f *fakeChain) Block(height uint32) ([]byte, error) { f.mu.Lock() defer f.mu.Unlock() f.askedBlock = height f.blockCalls++ if f.blockErr != nil { return nil, f.blockErr } raw, ok := f.blocks[height] if !ok { return nil, errors.New("no block at that height") } return raw, nil } // Position implements Chain. func (f *fakeChain) Position() (chaindata.Position, error) { f.mu.Lock() defer f.mu.Unlock() return f.position, nil } // Sync implements Chain. func (f *fakeChain) Sync(from uint32) (chaindata.Position, error) { f.mu.Lock() f.lastFrom = from entered, hold := f.entered, f.hold f.mu.Unlock() if entered != nil { entered <- struct{}{} } if hold != nil { <-hold } f.mu.Lock() defer f.mu.Unlock() if f.syncErr != nil { return chaindata.Position{}, f.syncErr } f.position = f.syncTo return f.position, nil } // Match implements Chain. func (f *fakeChain) Match(scripts [][]byte) ([]uint32, error) { f.mu.Lock() defer f.mu.Unlock() f.lastScripts = len(scripts) if f.matchErr != nil { return nil, f.matchErr } return append([]uint32{}, f.matched...), nil } // rig is one domain with a store that survives it. type rig struct { domain *Domain cache *store.Cache persist *memPersistence chains map[string]*fakeChain } // newRig opens a domain over two chains, "btc" and "blake", on mainnet parameters so the // vectors are the published ones. func newRig(t *testing.T, networks ...string) *rig { t.Helper() network := "btc" if len(networks) > 0 { network = networks[0] } persist := &memPersistence{items: map[string][]byte{}} cache, err := store.OpenManual(persist) if err != nil { t.Fatal(err) } domain, err := New(cache) if err != nil { t.Fatal(err) } // The vault's work factor is not what these tests are about. domain.rounds = testRounds chains := map[string]*fakeChain{} for _, name := range []string{"btc", "blake"} { profile, err := wallet.ProfileFor(name, network) if err != nil { t.Fatal(err) } client := &fakeChain{} if err := domain.Add(name, Link{Profile: profile, Client: client}); err != nil { t.Fatal(err) } chains[name] = client } t.Cleanup(func() { _ = cache.Close() }) return &rig{domain: domain, cache: cache, persist: persist, chains: chains} } // reopen closes the store and opens a fresh domain over the same durable state, which is what // a reload does. func (r *rig) reopen(t *testing.T) *rig { t.Helper() if err := r.cache.Close(); err != nil { t.Fatal(err) } cache, err := store.OpenManual(r.persist) if err != nil { t.Fatal(err) } domain, err := New(cache) if err != nil { t.Fatal(err) } domain.rounds = testRounds for name, client := range r.chains { profile, err := wallet.ProfileFor(name, "btc") if err != nil { t.Fatal(err) } if err := domain.Add(name, Link{Profile: profile, Client: client}); err != nil { t.Fatal(err) } } t.Cleanup(func() { _ = cache.Close() }) return &rig{domain: domain, cache: cache, persist: r.persist, chains: r.chains} } // TestCreateShowsThePhraseOnce: a new wallet returns words to write down and an address that // derives from them, and the words are not stored in the clear. func TestCreateShowsThePhraseOnce(t *testing.T) { r := newRig(t) created, err := r.domain.Create("btc", 12, "pass") if err != nil { t.Fatal(err) } if len(created.Mnemonic) == 0 { t.Fatal("a new wallet returned no words") } if created.Address.Text == "" { t.Fatal("a new wallet returned no address") } if created.Address.Text != addressFor(t, created.Mnemonic, "btc") { t.Fatalf("the address %q does not derive from the words returned", created.Address.Text) } sealed, _ := r.cache.Get(vaultKey("btc")) if len(sealed) == 0 { t.Fatal("no vault was written") } if string(sealed) == created.Mnemonic { t.Fatal("the phrase was stored in the clear") } } // TestCreateThenReloadNeedsThePassphrase: the wallet is still there after a reload, and opening // it takes the passphrase, not the words. func TestCreateThenReloadNeedsThePassphrase(t *testing.T) { r := newRig(t) created, err := r.domain.Create("btc", 12, "pass") if err != nil { t.Fatal(err) } reopened := r.reopen(t) status, err := reopened.domain.Status("btc") if err != nil { t.Fatal(err) } if !status.HasWallet { t.Fatal("the wallet did not survive the reload") } if status.Unlocked { t.Fatal("a reloaded wallet started unlocked") } if _, err := reopened.domain.Addresses("btc", false, 0, 1); !errors.Is(err, ErrLocked) { t.Fatalf("a locked wallet derived an address: %v", err) } if err := reopened.domain.Unlock("btc", "wrong"); !errors.Is(err, ErrVault) { t.Fatalf("a wrong passphrase unlocked the wallet: %v", err) } if err := reopened.domain.Unlock("btc", "pass"); err != nil { t.Fatal(err) } addresses, err := reopened.domain.Addresses("btc", false, 0, 1) if err != nil { t.Fatal(err) } if addresses[0].Text != created.Address.Text { t.Fatalf("the reloaded wallet says %q, the created one said %q", addresses[0].Text, created.Address.Text) } } // TestRestoreTheSeedAndYouAreBack: restoring the words gives the same first address. func TestRestoreTheSeedAndYouAreBack(t *testing.T) { r := newRig(t) created, err := r.domain.Restore("btc", phrase, "pass") if err != nil { t.Fatal(err) } if created.Mnemonic != "" { t.Fatal("a restore echoed the phrase back") } // m/84'/0'/0'/0/0 of the BIP84 test seed: the published vector. if created.Address.Text != "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu" { t.Fatalf("the first address is %q, not the BIP84 vector", created.Address.Text) } } // TestOneSeedTwoChains: the same words make two wallets, and they do not share an address. func TestOneSeedTwoChains(t *testing.T) { r := newRig(t) btc, err := r.domain.Restore("btc", phrase, "pass") if err != nil { t.Fatal(err) } blake, err := r.domain.Restore("blake", phrase, "pass") if err != nil { t.Fatal(err) } if btc.Address.Text == blake.Address.Text { t.Fatalf("both chains derived %q", btc.Address.Text) } btcStatus, err := r.domain.Status("btc") if err != nil { t.Fatal(err) } blakeStatus, err := r.domain.Status("blake") if err != nil { t.Fatal(err) } if btcStatus.Profile.Account != wallet.AccountBTC || blakeStatus.Profile.Account != wallet.AccountBlake { t.Fatalf("accounts are %d and %d", btcStatus.Profile.Account, blakeStatus.Profile.Account) } } // TestASecondSetupIsRefused: setup does not overwrite a wallet that already exists. func TestASecondSetupIsRefused(t *testing.T) { r := newRig(t) if _, err := r.domain.Restore("btc", phrase, "pass"); err != nil { t.Fatal(err) } if _, err := r.domain.Restore("btc", phrase, "other"); !errors.Is(err, ErrHasWallet) { t.Fatalf("a second setup was accepted: %v", err) } if _, err := r.domain.Create("btc", 12, "other"); !errors.Is(err, ErrHasWallet) { t.Fatalf("a second create was accepted: %v", err) } } // TestBadWordsAreRefused: a phrase that does not validate is a person's transcription error, // not a wallet. func TestBadWordsAreRefused(t *testing.T) { r := newRig(t) broken := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon" if _, err := r.domain.Restore("btc", broken, "pass"); err == nil { t.Fatal("a phrase with a bad checksum was accepted") } if _, err := r.domain.Restore("btc", "not a phrase", "pass"); err == nil { t.Fatal("a non-phrase was accepted") } } // TestAddressesMoveTheCursor: the sync lookahead starts from the last address handed out. func TestAddressesMoveTheCursor(t *testing.T) { r := newRig(t) if _, err := r.domain.Restore("btc", phrase, "pass"); err != nil { t.Fatal(err) } first, err := r.domain.Addresses("btc", false, 0, 5) if err != nil { t.Fatal(err) } if len(first) != 5 { t.Fatalf("derived %d addresses", len(first)) } status, err := r.domain.Status("btc") if err != nil { t.Fatal(err) } if status.Receive != 5 || status.Change != 0 { t.Fatalf("cursors are receive %d, change %d", status.Receive, status.Change) } change, err := r.domain.Addresses("btc", true, 0, 2) if err != nil { t.Fatal(err) } if !change[0].Change { t.Fatal("a change address is not marked as one") } if change[0].Text == first[0].Text { t.Fatal("a change address repeats a receive address") } status, err = r.domain.Status("btc") if err != nil { t.Fatal(err) } if status.Receive != 5 || status.Change != 2 { t.Fatalf("cursors are receive %d, change %d", status.Receive, status.Change) } // A cursor never moves backwards. if _, err := r.domain.Addresses("btc", false, 0, 3); err != nil { t.Fatal(err) } if status, _ := r.domain.Status("btc"); status.Receive != 5 { t.Fatalf("the receive cursor went back to %d", status.Receive) } } // TestSyncRecordsMatchesAndResumes: a sync asks from where the store left off, records the // heights, and the second one does not re-record them. func TestSyncRecordsMatchesAndResumes(t *testing.T) { r := newRig(t) if _, err := r.domain.Restore("btc", phrase, "pass"); err != nil { t.Fatal(err) } chain := r.chains["btc"] chain.syncTo = chaindata.Position{Tip: 120, Filters: 120} chain.matched = []uint32{110, 115} chain.blocks = map[uint32][]byte{ 110: buildBlock(t, []byte{0x00, 0x14, 0x01}, 1000), 115: buildBlock(t, []byte{0x00, 0x14, 0x02}, 2000), } report, err := r.domain.Sync("btc", 0) if err != nil { t.Fatal(err) } if report.From != 0 { t.Fatalf("the first sync started at %d", report.From) } if chain.lastFrom != 0 { t.Fatalf("the client was asked to sync from %d", chain.lastFrom) } if len(report.New) != 2 || report.New[0] != 110 || report.New[1] != 115 { t.Fatalf("new matches are %v", report.New) } // The matcher is asked about both branches: a payment to a change address is a payment // to this wallet. if chain.lastScripts == 0 { t.Fatal("the matcher was asked about no scripts") } matches := r.domain.Matches("btc") if len(matches) != 2 || matches[0] != 110 || matches[1] != 115 { t.Fatalf("the recorded matches are %v", matches) } status, err := r.domain.Status("btc") if err != nil { t.Fatal(err) } if status.Position.Tip != 120 || status.Position.Filters != 120 { t.Fatalf("the stored position is %+v", status.Position) } // A reload resumes from the stored filter tip, not from genesis. reopened := r.reopen(t) if err := reopened.domain.Unlock("btc", "pass"); err != nil { t.Fatal(err) } chain.syncTo = chaindata.Position{Tip: 130, Filters: 130} report, err = reopened.domain.Sync("btc", 0) if err != nil { t.Fatal(err) } if report.From != 121 { t.Fatalf("the resumed sync started at %d", report.From) } if len(report.Matched) != 2 || len(report.New) != 0 { t.Fatalf("the resumed sync reported %v new of %v", report.New, report.Matched) } if len(reopened.domain.Matches("btc")) != 2 { t.Fatal("a repeated match was recorded twice") } } // TestSyncLookaheadIsUsed: the scripts asked about cover both branches past the cursor. func TestSyncLookaheadIsUsed(t *testing.T) { r := newRig(t) if _, err := r.domain.Restore("btc", phrase, "pass"); err != nil { t.Fatal(err) } chain := r.chains["btc"] chain.syncTo = chaindata.Position{Tip: 5, Filters: 5} if _, err := r.domain.Sync("btc", 10); err != nil { t.Fatal(err) } // Receive: the one address setup derived plus ten of lookahead. Change: none handed // out, ten. if chain.lastScripts != 11+10 { t.Fatalf("the matcher was asked about %d scripts, want 21", chain.lastScripts) } } // TestSyncNeedsAWallet: there is nothing to match against before setup. func TestSyncNeedsAWallet(t *testing.T) { r := newRig(t) if _, err := r.domain.Sync("btc", 0); !errors.Is(err, ErrNoWallet) { t.Fatalf("a sync without a wallet: %v", err) } if _, err := r.domain.Sync("nope", 0); !errors.Is(err, ErrNoChain) { t.Fatalf("a sync of an unknown chain: %v", err) } } // TestASecondSyncIsRefused: one chain, one sync at a time. func TestASecondSyncIsRefused(t *testing.T) { r := newRig(t) if _, err := r.domain.Restore("btc", phrase, "pass"); err != nil { t.Fatal(err) } chain := r.chains["btc"] chain.syncTo = chaindata.Position{Tip: 5, Filters: 5} chain.entered = make(chan struct{}, 1) chain.hold = make(chan struct{}) done := make(chan error, 1) go func() { _, err := r.domain.Sync("btc", 10) done <- err }() <-chain.entered if _, err := r.domain.Sync("btc", 10); !errors.Is(err, ErrSyncInProgress) { t.Fatalf("a concurrent sync was accepted: %v", err) } close(chain.hold) if err := <-done; err != nil { t.Fatal(err) } // The guard is released, so the next one is accepted. chain.hold = nil if _, err := r.domain.Sync("btc", 10); err != nil { t.Fatalf("a sync after a finished one: %v", err) } } // TestChainFailuresAreReported: a chain that cannot answer is an error, and nothing is recorded. func TestChainFailuresAreReported(t *testing.T) { r := newRig(t) if _, err := r.domain.Restore("btc", phrase, "pass"); err != nil { t.Fatal(err) } chain := r.chains["btc"] chain.syncErr = errors.New("no peer") if _, err := r.domain.Sync("btc", 10); err == nil { t.Fatal("a failed sync reported success") } chain.syncErr = nil chain.syncTo = chaindata.Position{Tip: 5, Filters: 5} chain.matchErr = errors.New("the filter is missing") if _, err := r.domain.Sync("btc", 10); err == nil { t.Fatal("a failed match reported success") } if len(r.domain.Matches("btc")) != 0 { t.Fatal("a failed match recorded a height") } if status, _ := r.domain.Status("btc"); status.Position.Tip != 0 { t.Fatalf("a failed match stored a position: %+v", status.Position) } } // TestStatusBeforeSetup: an untouched chain reports itself, not an error. func TestStatusBeforeSetup(t *testing.T) { r := newRig(t) status, err := r.domain.Status("btc") if err != nil { t.Fatal(err) } if status.HasWallet || status.Unlocked { t.Fatalf("an unset wallet reports %+v", status) } if status.Receive != 0 || status.Change != 0 || status.Matches != 0 { t.Fatalf("an unset wallet reports %+v", status) } if status.Profile.Name != "btc" { t.Fatalf("the profile is %q", status.Profile.Name) } } // TestAddRefusesNonsense: a chain name is a key prefix and a line in a log. func TestAddRefusesNonsense(t *testing.T) { cache, err := store.OpenManual(&memPersistence{items: map[string][]byte{}}) if err != nil { t.Fatal(err) } defer func() { _ = cache.Close() }() domain, err := New(cache) if err != nil { t.Fatal(err) } profile, err := wallet.ProfileFor("btc", "btc") if err != nil { t.Fatal(err) } for _, name := range []string{"", "with space", "UPPER", "../etc", "a/b"} { if err := domain.Add(name, Link{Profile: profile, Client: &fakeChain{}}); err == nil { t.Fatalf("the chain name %q was accepted", name) } } if err := domain.Add("btc", Link{Profile: profile}); err == nil { t.Fatal("a chain with no client was accepted") } if err := domain.Add("btc", Link{Client: &fakeChain{}}); err == nil { t.Fatal("a chain with no profile was accepted") } link := Link{Profile: profile, Client: &fakeChain{}} if err := domain.Add("btc", link); err != nil { t.Fatal(err) } if err := domain.Add("btc", link); err == nil { t.Fatal("a duplicate chain was accepted") } if names := domain.Names(); len(names) != 1 || names[0] != "btc" { t.Fatalf("names are %v", names) } } // TestMetaRoundTrip: the cursor is a fixed record, and a bad one is refused. func TestMetaRoundTrip(t *testing.T) { meta := Meta{Account: 1, Receive: 7, Change: 3} decoded, err := ReadMeta(PutMeta(meta)) if err != nil { t.Fatal(err) } if decoded != meta { t.Fatalf("decoded %+v, want %+v", decoded, meta) } if _, err := ReadMeta(nil); err == nil { t.Fatal("an empty cursor was accepted") } if _, err := ReadMeta([]byte{2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}); err == nil { t.Fatal("a cursor with an unknown version was accepted") } } // addressFor derives the first address of a phrase independently of the domain, so the test // does not check the domain against itself. func addressFor(t *testing.T, words, chainName string) string { t.Helper() seed, err := mnemonic.Seed(words, "") if err != nil { t.Fatal(err) } profile, err := wallet.ProfileFor(chainName, "btc") if err != nil { t.Fatal(err) } derived, err := wallet.New(seed, profile) if err != nil { t.Fatal(err) } address, err := derived.Address(false, 0) if err != nil { t.Fatal(err) } return address.Text } // compile-time proof that the fake is the interface. var _ Chain = (*fakeChain)(nil)