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