decred.org/dcrdex@v1.0.5/client/asset/btc/livetest/livetest.go (about)

     1  package livetest
     2  
     3  // Regnet tests expect the BTC test harness to be running.
     4  //
     5  // Sim harness info:
     6  // The harness has three wallets, alpha, beta, and gamma.
     7  // All three wallets have confirmed UTXOs.
     8  // The beta wallet has only coinbase outputs.
     9  // The alpha wallet has coinbase outputs too, but has sent some to the gamma
    10  //   wallet, so also has some change outputs.
    11  // The gamma wallet has regular transaction outputs of varying size and
    12  // confirmation count. Value:Confirmations =
    13  // 10:8, 18:7, 5:6, 7:5, 1:4, 15:3, 3:2, 25:1
    14  
    15  import (
    16  	"bytes"
    17  	"context"
    18  	"crypto/sha256"
    19  	"errors"
    20  	"fmt"
    21  	"math/rand"
    22  	"os"
    23  	"os/exec"
    24  	"os/user"
    25  	"path/filepath"
    26  	"strings"
    27  	"sync/atomic"
    28  	"testing"
    29  	"time"
    30  
    31  	"decred.org/dcrdex/client/asset"
    32  	"decred.org/dcrdex/dex"
    33  	"decred.org/dcrdex/dex/config"
    34  	"decred.org/dcrdex/dex/wait"
    35  )
    36  
    37  var tLogger dex.Logger
    38  
    39  type WalletConstructor func(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) (asset.Wallet, error)
    40  
    41  func tBackend(ctx context.Context, t *testing.T, cfg *Config, dir string, walletName *WalletName, blkFunc func(string)) *connectedWallet {
    42  	t.Helper()
    43  	user, err := user.Current()
    44  	if err != nil {
    45  		t.Fatalf("error getting current user: %v", err)
    46  	}
    47  
    48  	fileName := walletName.Filename
    49  	if fileName == "" {
    50  		fileName = walletName.Node + ".conf"
    51  	}
    52  
    53  	cfgPath := filepath.Join(user.HomeDir, "dextest", cfg.Asset.Symbol, walletName.Node, fileName)
    54  	settings, err := config.Parse(cfgPath)
    55  	if err != nil {
    56  		t.Fatalf("error reading config options: %v", err)
    57  	}
    58  	settings["walletname"] = walletName.Name
    59  	if cfg.SplitTx {
    60  		settings["txsplit"] = "1"
    61  	}
    62  
    63  	reportName := fmt.Sprintf("%s:%s-%s", cfg.Asset.Symbol, walletName.Node, walletName.Name)
    64  
    65  	notes := make(chan asset.WalletNotification, 1)
    66  	walletCfg := &asset.WalletConfig{
    67  		Type:     walletName.WalletType,
    68  		Settings: settings,
    69  		Emit:     asset.NewWalletEmitter(notes, cfg.Asset.ID, tLogger),
    70  		PeersChange: func(num uint32, err error) {
    71  			fmt.Printf("peer count = %d, err = %v", num, err)
    72  		},
    73  		DataDir: dir,
    74  	}
    75  
    76  	w, err := cfg.NewWallet(walletCfg, tLogger.SubLogger(walletName.Node+"."+walletName.Name), dex.Regtest)
    77  	if err != nil {
    78  		t.Fatalf("error creating backend: %v", err)
    79  	}
    80  
    81  	cm := dex.NewConnectionMaster(w)
    82  	err = cm.Connect(ctx)
    83  	if err != nil {
    84  		t.Fatalf("error connecting backend: %v", err)
    85  	}
    86  
    87  	go func() {
    88  		for {
    89  			select {
    90  			case ni := <-notes:
    91  				switch ni.(type) {
    92  				case *asset.TipChangeNote:
    93  					blkFunc(reportName)
    94  				}
    95  			case <-ctx.Done():
    96  				return
    97  			}
    98  		}
    99  	}()
   100  
   101  	return &connectedWallet{w, cm}
   102  }
   103  
   104  type connectedWallet struct {
   105  	asset.Wallet
   106  	cxn *dex.ConnectionMaster
   107  }
   108  
   109  type testRig struct {
   110  	t            *testing.T
   111  	symbol       string
   112  	firstWallet  *connectedWallet
   113  	secondWallet *connectedWallet
   114  }
   115  
   116  func (rig *testRig) close() {
   117  	closeConn := func(cm *dex.ConnectionMaster) {
   118  		closed := make(chan struct{})
   119  		go func() {
   120  			cm.Disconnect()
   121  			close(closed)
   122  		}()
   123  		select {
   124  		case <-closed:
   125  		case <-time.NewTimer(time.Second * 30).C:
   126  			rig.t.Fatalf("failed to disconnect")
   127  		}
   128  	}
   129  	closeConn(rig.firstWallet.cxn)
   130  	closeConn(rig.secondWallet.cxn)
   131  }
   132  
   133  func (rig *testRig) mineAlpha() error {
   134  	var tmuxWindow string
   135  	switch rig.symbol {
   136  	case "zec", "firo", "doge":
   137  		tmuxWindow = rig.symbol + "-harness:4"
   138  	default:
   139  		tmuxWindow = rig.symbol + "-harness:2"
   140  	}
   141  	return exec.Command("tmux", "send-keys", "-t", tmuxWindow, "./mine-alpha 1", "C-m").Run()
   142  }
   143  
   144  func randBytes(l int) []byte {
   145  	b := make([]byte, l)
   146  	rand.Read(b)
   147  	return b
   148  }
   149  
   150  type WalletName struct {
   151  	Node string
   152  	Name string
   153  	// WalletType is optional
   154  	WalletType string
   155  	// Filename is optional. If specified, it will be used instead of
   156  	// [node].conf.
   157  	Filename string
   158  }
   159  
   160  type Config struct {
   161  	NewWallet    WalletConstructor
   162  	LotSize      uint64
   163  	Asset        *dex.Asset
   164  	SplitTx      bool
   165  	SPV          bool
   166  	FirstWallet  *WalletName
   167  	SecondWallet *WalletName
   168  }
   169  
   170  func Run(t *testing.T, cfg *Config) {
   171  	tLogger = dex.StdOutLogger("TEST", dex.LevelDebug)
   172  	tCtx, shutdown := context.WithCancel(context.Background())
   173  	defer shutdown()
   174  
   175  	tStart := time.Now()
   176  
   177  	walletPassword := []byte("abc")
   178  
   179  	if cfg.FirstWallet == nil {
   180  		cfg.FirstWallet = &WalletName{Node: "alpha"}
   181  	}
   182  
   183  	if cfg.SecondWallet == nil {
   184  		cfg.SecondWallet = &WalletName{
   185  			Node: "alpha",
   186  			Name: "gamma",
   187  		}
   188  	}
   189  
   190  	var blockReported uint32
   191  	blkFunc := func(name string) {
   192  		atomic.StoreUint32(&blockReported, 1)
   193  		tLogger.Infof("%s has reported a new block", name)
   194  	}
   195  
   196  	rig := &testRig{
   197  		t:      t,
   198  		symbol: cfg.Asset.Symbol,
   199  	}
   200  
   201  	var expConfs uint32
   202  	blockWait := time.Second * 5
   203  	if cfg.SPV {
   204  		blockWait = time.Second * 6
   205  	}
   206  	mine := func() {
   207  		if cfg.SPV { // broadcast with spv client takes a bit longer
   208  			time.Sleep(blockWait)
   209  		}
   210  		rig.mineAlpha()
   211  		expConfs++
   212  		time.Sleep(blockWait)
   213  	}
   214  
   215  	tmpDir, err := os.MkdirTemp("", "")
   216  	if err != nil {
   217  		t.Fatalf("Error creating temporary directory: %v", err)
   218  	}
   219  	defer os.RemoveAll(tmpDir)
   220  	firstDir := filepath.Join(tmpDir, "first")
   221  	secondDir := filepath.Join(tmpDir, "second")
   222  
   223  	t.Log("Setting up alpha/beta/gamma wallet backends...")
   224  	rig.firstWallet = tBackend(tCtx, t, cfg, firstDir, cfg.FirstWallet, blkFunc)
   225  	rig.secondWallet = tBackend(tCtx, t, cfg, secondDir, cfg.SecondWallet, blkFunc)
   226  	defer rig.close()
   227  
   228  	// Unlocks a wallet for use.
   229  	unlock := func(w *connectedWallet, name string) {
   230  		a, isAuthenticator := w.Wallet.(asset.Authenticator)
   231  		if isAuthenticator {
   232  			err := a.Unlock(walletPassword)
   233  			if err != nil && !strings.Contains(err.Error(), "running with an unencrypted wallet, but walletpassphrase was called") {
   234  				t.Fatalf("error unlocking %s wallet: %v", name, err)
   235  			}
   236  		}
   237  	}
   238  
   239  	unlock(rig.firstWallet, cfg.FirstWallet.Name)
   240  	unlock(rig.secondWallet, cfg.SecondWallet.Name)
   241  
   242  	var lots uint64 = 2
   243  	contractValue := lots * cfg.LotSize
   244  
   245  	tLogger.Info("Wallets configured")
   246  
   247  	inUTXOs := func(utxo asset.Coin, utxos []asset.Coin) bool {
   248  		for _, u := range utxos {
   249  			if bytes.Equal(u.ID(), utxo.ID()) {
   250  				return true
   251  			}
   252  		}
   253  		return false
   254  	}
   255  
   256  	// Check available amount.
   257  	checkAmt := func(name string, wallet *connectedWallet) {
   258  		bal, err := wallet.Balance()
   259  		if err != nil {
   260  			t.Fatalf("error getting available balance: %v", err)
   261  		}
   262  		tLogger.Infof("%s %f available, %f immature, %f locked",
   263  			name, float64(bal.Available)/1e8, float64(bal.Immature)/1e8, float64(bal.Locked)/1e8)
   264  	}
   265  	checkAmt("first", rig.firstWallet)
   266  	checkAmt("second", rig.secondWallet)
   267  
   268  	ord := &asset.Order{
   269  		Version:      0,
   270  		Value:        contractValue * 3,
   271  		MaxSwapCount: lots * 3,
   272  		MaxFeeRate:   cfg.Asset.MaxFeeRate,
   273  		// Redeem vars omitted.
   274  	}
   275  	setOrderValue := func(v uint64) {
   276  		ord.Value = v
   277  		ord.MaxSwapCount = v / cfg.LotSize
   278  	}
   279  
   280  	tLogger.Info("Testing FundOrder")
   281  
   282  	// Gamma should only have 10 BTC utxos, so calling fund for less should only
   283  	// return 1 utxo.
   284  	utxos, _, _, err := rig.secondWallet.FundOrder(ord)
   285  	if err != nil {
   286  		t.Fatalf("Funding error: %v", err)
   287  	}
   288  	utxo := utxos[0]
   289  
   290  	// UTXOs should be locked
   291  	utxos, _, _, _ = rig.secondWallet.FundOrder(ord)
   292  	if inUTXOs(utxo, utxos) {
   293  		t.Fatalf("received locked output")
   294  	}
   295  	rig.secondWallet.ReturnCoins([]asset.Coin{utxo})
   296  	rig.secondWallet.ReturnCoins(utxos)
   297  	// Make sure we get the first utxo back with Fund.
   298  	utxos, _, _, _ = rig.secondWallet.FundOrder(ord)
   299  	if !cfg.SplitTx && !inUTXOs(utxo, utxos) {
   300  		t.Fatalf("unlocked output not returned")
   301  	}
   302  	rig.secondWallet.ReturnCoins(utxos)
   303  
   304  	// Get a separate set of UTXOs for each contract.
   305  	setOrderValue(contractValue)
   306  	utxos1, _, _, err := rig.secondWallet.FundOrder(ord)
   307  	if err != nil {
   308  		t.Fatalf("error funding first contract: %v", err)
   309  	}
   310  	// Get a separate set of UTXOs for each contract.
   311  	setOrderValue(contractValue * 2)
   312  	utxos2, _, _, err := rig.secondWallet.FundOrder(ord)
   313  	if err != nil {
   314  		t.Fatalf("error funding second contract: %v", err)
   315  	}
   316  
   317  	// For some reason, SwapConfirmations fails with a split tx in SPV mode in
   318  	// this test. It works fine in practice, so figuring this out is a TODO.
   319  	if cfg.SplitTx && cfg.SPV {
   320  		time.Sleep(blockWait)
   321  		rig.mineAlpha()
   322  		time.Sleep(blockWait)
   323  	}
   324  
   325  	address, err := rig.firstWallet.DepositAddress()
   326  	if err != nil {
   327  		t.Fatalf("error getting alpha address: %v", err)
   328  	}
   329  
   330  	secretKey1 := randBytes(32)
   331  	keyHash1 := sha256.Sum256(secretKey1)
   332  	secretKey2 := randBytes(32)
   333  	keyHash2 := sha256.Sum256(secretKey2)
   334  	lockTime := time.Now().Add(time.Hour * 8).UTC()
   335  	// Have gamma send a swap contract to the alpha address.
   336  	contract1 := &asset.Contract{
   337  		Address:    address,
   338  		Value:      contractValue,
   339  		SecretHash: keyHash1[:],
   340  		LockTime:   uint64(lockTime.Unix()),
   341  	}
   342  	contract2 := &asset.Contract{
   343  		Address:    address,
   344  		Value:      contractValue * 2,
   345  		SecretHash: keyHash2[:],
   346  		LockTime:   uint64(lockTime.Unix()),
   347  	}
   348  	swaps := &asset.Swaps{
   349  		Inputs:    append(utxos1, utxos2...),
   350  		Contracts: []*asset.Contract{contract1, contract2},
   351  		FeeRate:   cfg.Asset.MaxFeeRate,
   352  	}
   353  
   354  	tLogger.Info("Testing Swap")
   355  
   356  	receipts, _, _, err := rig.secondWallet.Swap(swaps)
   357  	if err != nil {
   358  		t.Fatalf("error sending swap transaction: %v", err)
   359  	}
   360  	if len(receipts) != 2 {
   361  		t.Fatalf("expected 1 receipt, got %d", len(receipts))
   362  	}
   363  
   364  	tLogger.Infof("Sent %d swaps", len(receipts))
   365  	for i, r := range receipts {
   366  		tLogger.Infof("      Swap # %d: %s", i+1, r.Coin())
   367  	}
   368  
   369  	// Don't check zero confs for SPV. Core deals with the failures until the
   370  	// tx is mined.
   371  
   372  	if cfg.SPV {
   373  		mine()
   374  	}
   375  
   376  	confCoin := receipts[0].Coin()
   377  	confContract := receipts[0].Contract()
   378  	checkConfs := func(minN uint32, expSpent bool) {
   379  		t.Helper()
   380  		confs, spent, err := rig.secondWallet.SwapConfirmations(context.Background(), confCoin.ID(), confContract, tStart)
   381  		if err != nil {
   382  			if minN > 0 || !errors.Is(err, asset.CoinNotFoundError) {
   383  				t.Fatalf("error getting %d confs: %v", minN, err)
   384  			}
   385  		}
   386  		if confs < minN {
   387  			t.Fatalf("expected %d confs, got %d", minN, confs)
   388  		}
   389  		if spent != expSpent {
   390  			t.Fatalf("checkConfs: expected spent = %t, got %t", expSpent, spent)
   391  		}
   392  	}
   393  
   394  	checkConfs(expConfs, false)
   395  
   396  	latencyQ := wait.NewTickerQueue(time.Millisecond * 500)
   397  	go latencyQ.Run(tCtx)
   398  
   399  	getAuditInfo := func(coinID, contract []byte) *asset.AuditInfo {
   400  		c := make(chan *asset.AuditInfo, 1)
   401  		latencyQ.Wait(&wait.Waiter{
   402  			Expiration: time.Now().Add(time.Second * 10),
   403  			TryFunc: func() wait.TryDirective {
   404  				ai, err := rig.firstWallet.AuditContract(coinID, contract, nil, false) // no TxData because server gets that for us in practice!
   405  				if err != nil {
   406  					if strings.Contains(err.Error(), "error finding unspent contract") {
   407  						return wait.TryAgain
   408  					}
   409  					c <- nil
   410  					t.Fatalf("error auditing contract: %v", err)
   411  				}
   412  				c <- ai
   413  				return wait.DontTryAgain
   414  			},
   415  			ExpireFunc: func() {
   416  				t.Fatalf("makeRedemption -> AuditContract timed out")
   417  			},
   418  		})
   419  
   420  		// Alpha should be able to redeem.
   421  		return <-c
   422  	}
   423  
   424  	makeRedemption := func(swapVal uint64, receipt asset.Receipt, secret []byte) *asset.Redemption {
   425  		t.Helper()
   426  
   427  		ai := getAuditInfo(receipt.Coin().ID(), receipt.Contract())
   428  		auditCoin := ai.Coin
   429  		if ai.Recipient != address {
   430  			t.Fatalf("wrong address. %s != %s", ai.Recipient, address)
   431  		}
   432  		if auditCoin.Value() != swapVal {
   433  			t.Fatalf("wrong contract value. wanted %d, got %d", swapVal, auditCoin.Value())
   434  		}
   435  		confs, spent, err := rig.firstWallet.SwapConfirmations(tCtx, receipt.Coin().ID(), receipt.Contract(), tStart)
   436  		if err != nil {
   437  			t.Fatalf("error getting confirmations: %v", err)
   438  		}
   439  		if confs < expConfs {
   440  			t.Fatalf("unexpected number of confirmations. wanted %d, got %d", expConfs, confs)
   441  		}
   442  		if spent {
   443  			t.Fatalf("makeRedemption: expected unspent, got spent")
   444  		}
   445  		if ai.Expiration.Equal(lockTime) {
   446  			t.Fatalf("wrong lock time. wanted %s, got %s", lockTime, ai.Expiration)
   447  		}
   448  		return &asset.Redemption{
   449  			Spends: ai,
   450  			Secret: secret,
   451  		}
   452  	}
   453  
   454  	tLogger.Info("Testing AuditContract")
   455  
   456  	redemptions := []*asset.Redemption{
   457  		makeRedemption(contractValue, receipts[0], secretKey1),
   458  		makeRedemption(contractValue*2, receipts[1], secretKey2),
   459  	}
   460  
   461  	tLogger.Info("Testing Redeem")
   462  
   463  	_, _, _, err = rig.firstWallet.Redeem(&asset.RedeemForm{
   464  		Redemptions: redemptions,
   465  	})
   466  	if err != nil {
   467  		t.Fatalf("redemption error: %v", err)
   468  	}
   469  
   470  	// Find the redemption
   471  
   472  	// Only do the mempool zero-conf redemption check when not spv.
   473  	if cfg.SPV {
   474  		mine()
   475  	}
   476  
   477  	swapReceipt := receipts[0]
   478  	ctx, cancelFind := context.WithDeadline(tCtx, time.Now().Add(time.Second*30))
   479  	defer cancelFind()
   480  
   481  	tLogger.Info("Testing FindRedemption")
   482  
   483  	found := make(chan struct{})
   484  	latencyQ.Wait(&wait.Waiter{
   485  		Expiration: time.Now().Add(time.Second * 10),
   486  		TryFunc: func() wait.TryDirective {
   487  			ctx, cancel := context.WithTimeout(tCtx, time.Second)
   488  			defer cancel()
   489  			_, _, err = rig.secondWallet.FindRedemption(ctx, swapReceipt.Coin().ID(), nil)
   490  			if err != nil {
   491  				return wait.TryAgain
   492  			}
   493  			close(found)
   494  			return wait.DontTryAgain
   495  		},
   496  		ExpireFunc: func() { t.Fatalf("mempool FindRedemption timed out") },
   497  	})
   498  	<-found
   499  
   500  	// Mine a block and find the redemption again.
   501  	mine()
   502  	if atomic.LoadUint32(&blockReported) == 0 {
   503  		t.Fatalf("no block reported")
   504  	}
   505  	// Check that there is 1 confirmation on the swap
   506  	checkConfs(expConfs, true)
   507  	_, checkKey, err := rig.secondWallet.FindRedemption(ctx, swapReceipt.Coin().ID(), nil)
   508  	if err != nil {
   509  		t.Fatalf("error finding confirmed redemption: %v", err)
   510  	}
   511  	if !bytes.Equal(checkKey, secretKey1) {
   512  		t.Fatalf("findRedemption (unconfirmed) key mismatch. %x != %x", checkKey, secretKey1)
   513  	}
   514  
   515  	// Now send another one with lockTime = now and try to refund it.
   516  	secretKey := randBytes(32)
   517  	keyHash := sha256.Sum256(secretKey)
   518  	lockTime = time.Now().Add(-8 * time.Hour)
   519  
   520  	// Have gamma send a swap contract to the alpha address.
   521  	setOrderValue(contractValue)
   522  	utxos, _, _, _ = rig.secondWallet.FundOrder(ord)
   523  	contract := &asset.Contract{
   524  		Address:    address,
   525  		Value:      contractValue,
   526  		SecretHash: keyHash[:],
   527  		LockTime:   uint64(lockTime.Unix()),
   528  	}
   529  	swaps = &asset.Swaps{
   530  		Inputs:    utxos,
   531  		Contracts: []*asset.Contract{contract},
   532  		FeeRate:   cfg.Asset.MaxFeeRate,
   533  	}
   534  
   535  	tLogger.Info("Testing Refund")
   536  
   537  	receipts, _, _, err = rig.secondWallet.Swap(swaps)
   538  	if err != nil {
   539  		t.Fatalf("error sending swap transaction: %v", err)
   540  	}
   541  
   542  	if len(receipts) != 1 {
   543  		t.Fatalf("expected 1 receipt, got %d", len(receipts))
   544  	}
   545  	swapReceipt = receipts[0]
   546  
   547  	// SPV doesn't recognize ownership of the swap output, so we need to mine
   548  	// the transaction in order to establish spent status. In theory, we could
   549  	// just yolo and refund regardless of spent status.
   550  	if cfg.SPV {
   551  		mine()
   552  	}
   553  
   554  	const defaultFee = 100
   555  	coinID, err := rig.secondWallet.Refund(swapReceipt.Coin().ID(), swapReceipt.Contract(), 100)
   556  	if err != nil {
   557  		t.Fatalf("refund error: %v", err)
   558  	}
   559  	c, _ := asset.DecodeCoinID(cfg.Asset.ID, coinID)
   560  	tLogger.Infof("Refunded with %s", c)
   561  
   562  	// Test Send.
   563  	tLogger.Info("Testing Send")
   564  	coin, err := rig.secondWallet.Send(address, cfg.LotSize, defaultFee)
   565  	if err != nil {
   566  		t.Fatalf("error sending: %v", err)
   567  	}
   568  	if coin.Value() != cfg.LotSize {
   569  		t.Fatalf("Expected %d got %d", cfg.LotSize, coin.Value())
   570  	}
   571  	tLogger.Infof("Sent with %s", coin.String())
   572  
   573  	// Test Withdraw.
   574  	withdrawer, _ := rig.secondWallet.Wallet.(asset.Withdrawer)
   575  	tLogger.Info("Testing Withdraw")
   576  	coin, err = withdrawer.Withdraw(address, cfg.LotSize, defaultFee)
   577  	if err != nil {
   578  		t.Fatalf("error withdrawing: %v", err)
   579  	}
   580  	if coin.Value() >= cfg.LotSize {
   581  		t.Fatalf("Expected less than %d got %d", cfg.LotSize, coin.Value())
   582  	}
   583  	tLogger.Infof("Withdrew with %s", coin.String())
   584  
   585  	if cfg.SPV {
   586  		mine()
   587  	}
   588  }