diff --git a/rpctest/README.md b/rpctest/README.md new file mode 100644 index 00000000..bc6e3547 --- /dev/null +++ b/rpctest/README.md @@ -0,0 +1,33 @@ +rpctest +======= + +[![Build Status](http://img.shields.io/travis/btcsuite/btcd.svg)] +(https://travis-ci.org/btcsuite/btcd) [![ISC License] +(http://img.shields.io/badge/license-ISC-blue.svg)](http://copyfree.org) +[![GoDoc](https://img.shields.io/badge/godoc-reference-blue.svg)] +(http://godoc.org/github.com/btcsuite/btcd/rpctest) + +Package rpctest provides a btcd-specific RPC testing harness crafting and +executing integration tests by driving a `btcd` instance via the `RPC` +interface. Each instance of an active harness comes equipped with a simple +in-memory HD wallet capable of properly syncing to the generated chain, +creating new addresses, and crafting fully signed transactions paying to an +arbitrary set of outputs. + +This package was designed specifically to act as an RPC testing harness for +`btcd`. However, the constructs presented are general enough to be adapted to +any project wishing to programmatically drive a `btcd` instance of its +systems/integration tests. + +## Installation and Updating + +```bash +$ go get -u github.com/btcsuite/btcd/rpctest +``` + +## License + + +Package rpctest is licensed under the [copyfree](http://copyfree.org) ISC +License. + diff --git a/rpctest/blockgen.go b/rpctest/blockgen.go new file mode 100644 index 00000000..fd834b63 --- /dev/null +++ b/rpctest/blockgen.go @@ -0,0 +1,173 @@ +// Copyright (c) 2016 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package rpctest + +import ( + "errors" + "math" + "math/big" + "runtime" + "time" + + "github.com/btcsuite/btcd/blockchain" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcutil" +) + +// solveBlock attempts to find a nonce which makes the passed block header hash +// to a value less than the target difficulty. When a successful solution is +// found true is returned and the nonce field of the passed header is updated +// with the solution. False is returned if no solution exists. +func solveBlock(header *wire.BlockHeader, targetDifficulty *big.Int) bool { + // sbResult is used by the solver goroutines to send results. + type sbResult struct { + found bool + nonce uint32 + } + + // solver accepts a block header and a nonce range to test. It is + // intended to be run as a goroutine. + quit := make(chan bool) + results := make(chan sbResult) + solver := func(hdr wire.BlockHeader, startNonce, stopNonce uint32) { + // We need to modify the nonce field of the header, so make sure + // we work with a copy of the original header. + for i := startNonce; i >= startNonce && i <= stopNonce; i++ { + select { + case <-quit: + return + default: + hdr.Nonce = i + hash := hdr.BlockHash() + if blockchain.HashToBig(&hash).Cmp(targetDifficulty) <= 0 { + results <- sbResult{true, i} + return + } + } + } + results <- sbResult{false, 0} + } + + startNonce := uint32(0) + stopNonce := uint32(math.MaxUint32) + numCores := uint32(runtime.NumCPU()) + noncesPerCore := (stopNonce - startNonce) / numCores + for i := uint32(0); i < numCores; i++ { + rangeStart := startNonce + (noncesPerCore * i) + rangeStop := startNonce + (noncesPerCore * (i + 1)) - 1 + if i == numCores-1 { + rangeStop = stopNonce + } + go solver(*header, rangeStart, rangeStop) + } + for i := uint32(0); i < numCores; i++ { + result := <-results + if result.found { + close(quit) + header.Nonce = result.nonce + return true + } + } + + return false +} + +// standardCoinbaseScript returns a standard script suitable for use as the +// signature script of the coinbase transaction of a new block. In particular, +// it starts with the block height that is required by version 2 blocks. +func standardCoinbaseScript(nextBlockHeight int32, extraNonce uint64) ([]byte, error) { + return txscript.NewScriptBuilder().AddInt64(int64(nextBlockHeight)). + AddInt64(int64(extraNonce)).Script() +} + +// createCoinbaseTx returns a coinbase transaction paying an appropriate +// subsidy based on the passed block height to the provided address. +func createCoinbaseTx(coinbaseScript []byte, nextBlockHeight int32, + addr btcutil.Address, net *chaincfg.Params) (*btcutil.Tx, error) { + + // Create the script to pay to the provided payment address. + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + return nil, err + } + + tx := wire.NewMsgTx() + tx.AddTxIn(&wire.TxIn{ + // Coinbase transactions have no inputs, so previous outpoint is + // zero hash and max index. + PreviousOutPoint: *wire.NewOutPoint(&chainhash.Hash{}, + wire.MaxPrevOutIndex), + SignatureScript: coinbaseScript, + Sequence: wire.MaxTxInSequenceNum, + }) + tx.AddTxOut(&wire.TxOut{ + Value: blockchain.CalcBlockSubsidy(nextBlockHeight, net), + PkScript: pkScript, + }) + return btcutil.NewTx(tx), nil +} + +// createBlock creates a new block building from the previous block. +func createBlock(prevBlock *btcutil.Block, inclusionTxs []*btcutil.Tx, + blockVersion int32, blockTime time.Time, + miningAddr btcutil.Address, net *chaincfg.Params) (*btcutil.Block, error) { + + prevHash := prevBlock.Hash() + blockHeight := prevBlock.Height() + 1 + + // If a target block time was specified, then use that as the header's + // timestamp. Otherwise, add one second to the previous block unless + // it's the genesis block in which case use the current time. + var ts time.Time + switch { + case !blockTime.IsZero(): + ts = blockTime + default: + ts = prevBlock.MsgBlock().Header.Timestamp.Add(time.Second) + } + + extraNonce := uint64(0) + coinbaseScript, err := standardCoinbaseScript(blockHeight, extraNonce) + if err != nil { + return nil, err + } + coinbaseTx, err := createCoinbaseTx(coinbaseScript, blockHeight, + miningAddr, net) + if err != nil { + return nil, err + } + + // Create a new block ready to be solved. + blockTxns := []*btcutil.Tx{coinbaseTx} + if inclusionTxs != nil { + blockTxns = append(blockTxns, inclusionTxs...) + } + merkles := blockchain.BuildMerkleTreeStore(blockTxns) + var block wire.MsgBlock + block.Header = wire.BlockHeader{ + Version: blockVersion, + PrevBlock: *prevHash, + MerkleRoot: *merkles[len(merkles)-1], + Timestamp: ts, + Bits: net.PowLimitBits, + } + for _, tx := range blockTxns { + if err := block.AddTransaction(tx.MsgTx()); err != nil { + return nil, err + } + } + + found := solveBlock(&block.Header, net.PowLimit) + if !found { + return nil, errors.New("Unable to solve block") + } + + utilBlock := btcutil.NewBlock(&block) + utilBlock.SetHeight(blockHeight) + return utilBlock, nil +} diff --git a/rpctest/doc.go b/rpctest/doc.go new file mode 100644 index 00000000..3601b556 --- /dev/null +++ b/rpctest/doc.go @@ -0,0 +1,12 @@ +// Package rpctest provides a btcd-specific RPC testing harness crafting and +// executing integration tests by driving a `btcd` instance via the `RPC` +// interface. Each instance of an active harness comes equipped with a simple +// in-memory HD wallet capable of properly syncing to the generated chain, +// creating new addresses, and crafting fully signed transactions paying to an +// arbitrary set of outputs. +// +// This package was designed specifically to act as an RPC testing harness for +// `btcd`. However, the constructs presented are general enough to be adapted to +// any project wishing to programmatically drive a `btcd` instance of its +// systems/integration tests. +package rpctest diff --git a/rpctest/memwallet.go b/rpctest/memwallet.go new file mode 100644 index 00000000..25f15722 --- /dev/null +++ b/rpctest/memwallet.go @@ -0,0 +1,543 @@ +// Copyright (c) 2016 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package rpctest + +import ( + "bytes" + "encoding/binary" + "fmt" + "sync" + "time" + + "github.com/btcsuite/btcd/blockchain" + "github.com/btcsuite/btcd/btcec" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcrpcclient" + "github.com/btcsuite/btcutil" + "github.com/btcsuite/btcutil/hdkeychain" +) + +var ( + // hdSeed is the BIP 32 seed used by the memWallet to initialize it's + // HD root key. This value is hard coded in order to ensure + // deterministic behavior across test runs. + hdSeed = [chainhash.HashSize]byte{ + 0x79, 0xa6, 0x1a, 0xdb, 0xc6, 0xe5, 0xa2, 0xe1, + 0x39, 0xd2, 0x71, 0x3a, 0x54, 0x6e, 0xc7, 0xc8, + 0x75, 0x63, 0x2e, 0x75, 0xf1, 0xdf, 0x9c, 0x3f, + 0xa6, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + } +) + +// utxo represents an unspent output spendable by the memWallet. The maturity +// height of the transaction is recorded in order to properly observe the +// maturity period of direct coinbase outputs. +type utxo struct { + pkScript []byte + value btcutil.Amount + keyIndex uint32 + maturityHeight int32 + isLocked bool +} + +// isMature returns true if the target utxo is considered "mature" at the +// passed block height. Otherwise, false is returned. +func (u *utxo) isMature(height int32) bool { + return height >= u.maturityHeight +} + +// chainUpdate encapsulates an update to the current main chain. This struct is +// used to sync up the memWallet each time a new block is connected to the main +// chain. +type chainUpdate struct { + blockHash *chainhash.Hash + blockHeight int32 +} + +// undoEntry is functionally the opposite of a chainUpdate. An undoEntry is +// created for each new block received, then stored in a log in order to +// properly handle block re-orgs. +type undoEntry struct { + utxosDestroyed map[wire.OutPoint]*utxo + utxosCreated []wire.OutPoint +} + +// memWallet is a simple in-memory wallet whose purpose is to provide basic +// wallet functionality to the harness. The wallet uses a hard-coded HD key +// hierarchy which promotes reproducibility between harness test runs. +type memWallet struct { + coinbaseKey *btcec.PrivateKey + coinbaseAddr btcutil.Address + + // hdRoot is the root master private key for the wallet. + hdRoot *hdkeychain.ExtendedKey + + // hdIndex is the next available key index offset from the hdRoot. + hdIndex uint32 + + // currentHeight is the latest height the wallet is known to be synced + // to. + currentHeight int32 + + // addrs tracks all addresses belonging to the wallet. The addresses + // are indexed by their keypath from the hdRoot. + addrs map[uint32]btcutil.Address + + // utxos is the set of utxos spendable by the wallet. + utxos map[wire.OutPoint]*utxo + + // reorgJournal is a map storing an undo entry for each new block + // received. Once a block is disconnected, the undo entry for the + // particular height is evaluated, thereby rewinding the effect of the + // disconnected block on the wallet's set of spendable utxos. + reorgJournal map[int32]*undoEntry + + chainUpdates []*chainUpdate + chainUpdateSignal chan struct{} + chainMtx sync.Mutex + + net *chaincfg.Params + + rpc *btcrpcclient.Client + + sync.RWMutex +} + +// newMemWallet creates and returns a fully initialized instance of the +// memWallet given a particular blockchain's parameters. +func newMemWallet(net *chaincfg.Params, harnessID uint32) (*memWallet, error) { + // The wallet's final HD seed is: hdSeed || harnessID. This method + // ensures that each harness instance uses a deterministic root seed + // based on its harness ID. + var harnessHDSeed [chainhash.HashSize + 4]byte + copy(harnessHDSeed[:], hdSeed[:]) + binary.BigEndian.PutUint32(harnessHDSeed[:chainhash.HashSize], harnessID) + + hdRoot, err := hdkeychain.NewMaster(harnessHDSeed[:], net) + if err != nil { + return nil, nil + } + + // The first child key from the hd root is reserved as the coinbase + // generation address. + coinbaseChild, err := hdRoot.Child(0) + if err != nil { + return nil, err + } + coinbaseKey, err := coinbaseChild.ECPrivKey() + if err != nil { + return nil, err + } + coinbaseAddr, err := keyToAddr(coinbaseKey, net) + if err != nil { + return nil, err + } + + // Track the coinbase generation address to ensure we properly track + // newly generated bitcoin we can spend. + addrs := make(map[uint32]btcutil.Address) + addrs[0] = coinbaseAddr + + return &memWallet{ + net: net, + coinbaseKey: coinbaseKey, + coinbaseAddr: coinbaseAddr, + hdIndex: 1, + hdRoot: hdRoot, + addrs: addrs, + utxos: make(map[wire.OutPoint]*utxo), + chainUpdateSignal: make(chan struct{}), + reorgJournal: make(map[int32]*undoEntry), + }, nil +} + +// Start launches all goroutines required for the wallet to function properly. +func (m *memWallet) Start() { + go m.chainSyncer() +} + +// SyncedHeight returns the height the wallet is known to be synced to. +// +// This function is safe for concurrent access. +func (m *memWallet) SyncedHeight() int32 { + m.RLock() + defer m.RUnlock() + return m.currentHeight +} + +// SetRPCClient saves the passed rpc connection to btcd as the wallet's +// personal rpc connection. +func (m *memWallet) SetRPCClient(rpcClient *btcrpcclient.Client) { + m.rpc = rpcClient +} + +// IngestBlock is a call-back which is to be triggered each time a new block is +// connected to the main chain. Ingesting a block updates the wallet's internal +// utxo state based on the outputs created and destroyed within each block. +func (m *memWallet) IngestBlock(blockHash *chainhash.Hash, height int32, t time.Time) { + // Append this new chain update to the end of the queue of new chain + // updates. + m.chainMtx.Lock() + m.chainUpdates = append(m.chainUpdates, &chainUpdate{blockHash, height}) + m.chainMtx.Unlock() + + // Launch a goroutine to signal the chainSyncer that a new update is + // available. We do this in a new goroutine in order to avoid blocking + // the main loop of the rpc client. + go func() { + m.chainUpdateSignal <- struct{}{} + }() +} + +// chainSyncer is a goroutine dedicated to processing new blocks in order to +// keep the wallet's utxo state up to date. +// +// NOTE: This MUST be run as a goroutine. +func (m *memWallet) chainSyncer() { + var update *chainUpdate + + for range m.chainUpdateSignal { + // A new update is available, so pop the new chain update from + // the front of the update queue. + m.chainMtx.Lock() + update = m.chainUpdates[0] + m.chainUpdates[0] = nil // Set to nil to prevent GC leak. + m.chainUpdates = m.chainUpdates[1:] + m.chainMtx.Unlock() + + // Fetch the new block so we can process it shortly below. + block, err := m.rpc.GetBlock(update.blockHash) + if err != nil { + return + } + + // Update the latest synced height, then process each + // transaction in the block creating and destroying utxos + // within the wallet as a result. + m.Lock() + m.currentHeight = update.blockHeight + undo := &undoEntry{ + utxosDestroyed: make(map[wire.OutPoint]*utxo), + } + for _, tx := range block.Transactions() { + mtx := tx.MsgTx() + isCoinbase := blockchain.IsCoinBaseTx(mtx) + + m.evalOutputs(mtx.TxOut, tx.Hash(), isCoinbase, undo) + m.evalInputs(mtx.TxIn, undo) + } + + // Finally, record the undo entry for this block so we can + // properly update our internal state in response to the block + // being re-org'd from the main chain. + m.reorgJournal[update.blockHeight] = undo + m.Unlock() + } +} + +// evalOutputs evaluates each of the passed outputs, creating a new matching +// utxo within the wallet if we're able to spend the output. +func (m *memWallet) evalOutputs(outputs []*wire.TxOut, txHash *chainhash.Hash, + isCoinbase bool, undo *undoEntry) { + + for i, output := range outputs { + pkScript := output.PkScript + + // Scan all the addresses we currently control to see if the + // output is paying to us. + for keyIndex, addr := range m.addrs { + pkHash := addr.ScriptAddress() + if !bytes.Contains(pkScript, pkHash) { + continue + } + + // If this is a coinbase output, then we mark the + // maturity height at the proper block height in the + // future. + var maturityHeight int32 + if isCoinbase { + maturityHeight = m.currentHeight + int32(m.net.CoinbaseMaturity) + } + + op := wire.OutPoint{Hash: *txHash, Index: uint32(i)} + m.utxos[op] = &utxo{ + value: btcutil.Amount(output.Value), + keyIndex: keyIndex, + maturityHeight: maturityHeight, + pkScript: pkScript, + } + undo.utxosCreated = append(undo.utxosCreated, op) + } + } +} + +// evalInputs scans all the passed inputs, destroying any utxos within the +// wallet which are spent by an input. +func (m *memWallet) evalInputs(inputs []*wire.TxIn, undo *undoEntry) { + for _, txIn := range inputs { + op := txIn.PreviousOutPoint + oldUtxo, ok := m.utxos[op] + if !ok { + continue + } + + undo.utxosDestroyed[op] = oldUtxo + delete(m.utxos, op) + } +} + +// UnwindBlock is a call-back which is to be executed each time a block is +// disconnected from the main chain. Unwinding a block undoes the effect that a +// particular block had on the wallet's internal utxo state. +func (m *memWallet) UnwindBlock(hash *chainhash.Hash, height int32, t time.Time) { + m.Lock() + defer m.Unlock() + + undo := m.reorgJournal[height] + + for _, utxo := range undo.utxosCreated { + delete(m.utxos, utxo) + } + + for outPoint, utxo := range undo.utxosDestroyed { + m.utxos[outPoint] = utxo + } + + delete(m.reorgJournal, height) +} + +// newAddress returns a new address from the wallet's hd key chain. +func (m *memWallet) newAddress() (btcutil.Address, error) { + index := m.hdIndex + + childKey, err := m.hdRoot.Child(index) + if err != nil { + return nil, err + } + privKey, err := childKey.ECPrivKey() + if err != nil { + return nil, err + } + + addr, err := keyToAddr(privKey, m.net) + if err != nil { + return nil, err + } + + m.addrs[index] = addr + + m.hdIndex++ + + return addr, nil +} + +// NewAddress returns a fresh address spendable by the wallet. +// +// This function is safe for concurrent access. +func (m *memWallet) NewAddress() (btcutil.Address, error) { + m.Lock() + defer m.Unlock() + + return m.newAddress() +} + +// fundTx attempts to fund a transaction sending amt bitcoin. The coins are +// selected such that the final amount spent pays enough fees as dictated by +// the passed fee rate. The passed fee rate should be expressed in +// satoshis-per-byte. +// +// NOTE: The memWallet's mutex must be held when this function is called. +func (m *memWallet) fundTx(tx *wire.MsgTx, amt btcutil.Amount, feeRate btcutil.Amount) error { + const ( + // spendSize is the largest number of bytes of a sigScript + // which spends a p2pkh output: OP_DATA_73 OP_DATA_33 + spendSize = 1 + 73 + 1 + 33 + ) + + var ( + amtSelected btcutil.Amount + txSize int + ) + + for outPoint, utxo := range m.utxos { + // Skip any outputs that are still currently immature or are + // currently locked. + if !utxo.isMature(m.currentHeight) || utxo.isLocked { + continue + } + + amtSelected += utxo.value + + // Add the selected output to the transaction, updating the + // current tx size while accounting for the size of the future + // sigScript. + tx.AddTxIn(wire.NewTxIn(&outPoint, nil)) + txSize = tx.SerializeSize() + spendSize*len(tx.TxIn) + + // Calculate the fee required for the txn at this point + // observing the specified fee rate. If we don't have enough + // coins from he current amount selected to pay the fee, then + // continue to grab more coins. + reqFee := btcutil.Amount(txSize * int(feeRate)) + if amtSelected-reqFee < amt { + continue + } + + // If we have any change left over, then add an additional + // output to the transaction reserved for change. + changeVal := amtSelected - amt - reqFee + if changeVal > 0 { + addr, err := m.newAddress() + if err != nil { + return err + } + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + return err + } + changeOutput := &wire.TxOut{ + Value: int64(changeVal), + PkScript: pkScript, + } + tx.AddTxOut(changeOutput) + } + + return nil + } + + // If we've reached this point, then coin selection failed due to an + // insufficient amount of coins. + return fmt.Errorf("not enough funds for coin selection") +} + +// SendOutputs creates, then sends a transaction paying to the specified output +// while observing the passed fee rate. The passed fee rate should be expressed +// in satoshis-per-byte. +func (m *memWallet) SendOutputs(outputs []*wire.TxOut, + feeRate btcutil.Amount) (*chainhash.Hash, error) { + + tx, err := m.CreateTransaction(outputs, feeRate) + if err != nil { + return nil, err + } + + return m.rpc.SendRawTransaction(tx, true) +} + +// CreateTransaction returns a fully signed transaction paying to the specified +// outputs while observing the desired fee rate. The passed fee rate should be +// expressed in satoshis-per-byte. +// +// This function is safe for concurrent access. +func (m *memWallet) CreateTransaction(outputs []*wire.TxOut, feeRate btcutil.Amount) (*wire.MsgTx, error) { + m.Lock() + defer m.Unlock() + + tx := wire.NewMsgTx() + + // Tally up the total amount to be sent in order to perform coin + // selection shortly below. + var outputAmt btcutil.Amount + for _, output := range outputs { + outputAmt += btcutil.Amount(output.Value) + tx.AddTxOut(output) + } + + // Attempt to fund the transaction with spendable utxos. + if err := m.fundTx(tx, outputAmt, btcutil.Amount(feeRate)); err != nil { + return nil, err + } + + // Populate all the selected inputs with valid sigScript for spending. + // Along the way record all outputs being spent in order to avoid a + // potential double spend. + spentOutputs := make([]*utxo, 0, len(tx.TxIn)) + for i, txIn := range tx.TxIn { + outPoint := txIn.PreviousOutPoint + utxo := m.utxos[outPoint] + + extendedKey, err := m.hdRoot.Child(utxo.keyIndex) + if err != nil { + return nil, err + } + + privKey, err := extendedKey.ECPrivKey() + if err != nil { + return nil, err + } + + sigScript, err := txscript.SignatureScript(tx, i, utxo.pkScript, + txscript.SigHashAll, privKey, true) + if err != nil { + return nil, err + } + + txIn.SignatureScript = sigScript + + spentOutputs = append(spentOutputs, utxo) + } + + // As these outputs are now being spent by this newly created + // transaction, mark the outputs are "locked". This action ensures + // these outputs won't be double spent by any subsequent transactions. + // These locked outputs can be freed via a call to UnlockOutputs. + for _, utxo := range spentOutputs { + utxo.isLocked = true + } + + return tx, nil +} + +// UnlockOutputs unlocks any outputs which were previously locked due to +// being selected to fund a transaction via the CreateTransaction method. +// +// This function is safe for concurrent access. +func (m *memWallet) UnlockOutputs(inputs []*wire.TxIn) { + m.Lock() + defer m.Unlock() + + for _, input := range inputs { + utxo, ok := m.utxos[input.PreviousOutPoint] + if !ok { + continue + } + + utxo.isLocked = false + } +} + +// ConfirmedBalance returns the confirmed balance of the wallet. +// +// This function is safe for concurrent access. +func (m *memWallet) ConfirmedBalance() btcutil.Amount { + m.RLock() + defer m.RUnlock() + + var balance btcutil.Amount + for _, utxo := range m.utxos { + // Prevent any immature or locked outputs from contributing to + // the wallet's total confirmed balance. + if !utxo.isMature(m.currentHeight) || utxo.isLocked { + continue + } + + balance += utxo.value + } + + return balance +} + +// keyToAddr maps the passed private to corresponding p2pkh address. +func keyToAddr(key *btcec.PrivateKey, net *chaincfg.Params) (btcutil.Address, error) { + serializedKey := key.PubKey().SerializeCompressed() + pubKeyAddr, err := btcutil.NewAddressPubKey(serializedKey, net) + if err != nil { + return nil, err + } + return pubKeyAddr.AddressPubKeyHash(), nil +} diff --git a/rpctest/node.go b/rpctest/node.go new file mode 100644 index 00000000..c076a83a --- /dev/null +++ b/rpctest/node.go @@ -0,0 +1,292 @@ +// Copyright (c) 2016 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package rpctest + +import ( + "fmt" + "io/ioutil" + "log" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/btcsuite/btcd/wire" + + rpc "github.com/btcsuite/btcrpcclient" + "github.com/btcsuite/btcutil" +) + +// nodeConfig contains all the args, and data required to launch a btcd process +// and connect the rpc client to it. +type nodeConfig struct { + rpcUser string + rpcPass string + listen string + rpcListen string + rpcConnect string + dataDir string + logDir string + profile string + debugLevel string + extra []string + prefix string + + exe string + endpoint string + certFile string + keyFile string + certificates []byte +} + +// newConfig returns a newConfig with all default values. +func newConfig(prefix, certFile, keyFile string, extra []string) (*nodeConfig, error) { + a := &nodeConfig{ + listen: "127.0.0.1:18555", + rpcListen: "127.0.0.1:18556", + rpcUser: "user", + rpcPass: "pass", + extra: extra, + prefix: prefix, + + exe: "btcd", + endpoint: "ws", + certFile: certFile, + keyFile: keyFile, + } + if err := a.setDefaults(); err != nil { + return nil, err + } + return a, nil +} + +// setDefaults sets the default values of the config. It also creates the +// temporary data, and log directories which must be cleaned up with a call to +// cleanup(). +func (n *nodeConfig) setDefaults() error { + datadir, err := ioutil.TempDir("", n.prefix+"-data") + if err != nil { + return err + } + n.dataDir = datadir + logdir, err := ioutil.TempDir("", n.prefix+"-logs") + if err != nil { + return err + } + n.logDir = logdir + cert, err := ioutil.ReadFile(n.certFile) + if err != nil { + return err + } + n.certificates = cert + return nil +} + +// arguments returns an array of arguments that be used to launch the btcd +// process. +func (n *nodeConfig) arguments() []string { + args := []string{} + // --simnet + args = append(args, fmt.Sprintf("--%s", strings.ToLower(wire.SimNet.String()))) + if n.rpcUser != "" { + // --rpcuser + args = append(args, fmt.Sprintf("--rpcuser=%s", n.rpcUser)) + } + if n.rpcPass != "" { + // --rpcpass + args = append(args, fmt.Sprintf("--rpcpass=%s", n.rpcPass)) + } + if n.listen != "" { + // --listen + args = append(args, fmt.Sprintf("--listen=%s", n.listen)) + } + if n.rpcListen != "" { + // --rpclisten + args = append(args, fmt.Sprintf("--rpclisten=%s", n.rpcListen)) + } + if n.rpcConnect != "" { + // --rpcconnect + args = append(args, fmt.Sprintf("--rpcconnect=%s", n.rpcConnect)) + } + // --rpccert + args = append(args, fmt.Sprintf("--rpccert=%s", n.certFile)) + // --rpckey + args = append(args, fmt.Sprintf("--rpckey=%s", n.keyFile)) + // --txindex + args = append(args, "--txindex") + // --addrindex + args = append(args, "--addrindex") + if n.dataDir != "" { + // --datadir + args = append(args, fmt.Sprintf("--datadir=%s", n.dataDir)) + } + if n.logDir != "" { + // --logdir + args = append(args, fmt.Sprintf("--logdir=%s", n.logDir)) + } + if n.profile != "" { + // --profile + args = append(args, fmt.Sprintf("--profile=%s", n.profile)) + } + if n.debugLevel != "" { + // --debuglevel + args = append(args, fmt.Sprintf("--debuglevel=%s", n.debugLevel)) + } + args = append(args, n.extra...) + return args +} + +// command returns the exec.Cmd which will be used to start the btcd process. +func (n *nodeConfig) command() *exec.Cmd { + return exec.Command(n.exe, n.arguments()...) +} + +// rpcConnConfig returns the rpc connection config that can be used to connect +// to the btcd process that is launched via Start(). +func (n *nodeConfig) rpcConnConfig() rpc.ConnConfig { + return rpc.ConnConfig{ + Host: n.rpcListen, + Endpoint: n.endpoint, + User: n.rpcUser, + Pass: n.rpcPass, + Certificates: n.certificates, + DisableAutoReconnect: true, + } +} + +// String returns the string representation of this nodeConfig. +func (n *nodeConfig) String() string { + return n.prefix +} + +// cleanup removes the tmp data and log directories. +func (n *nodeConfig) cleanup() error { + dirs := []string{ + n.logDir, + n.dataDir, + } + var err error + for _, dir := range dirs { + if err = os.RemoveAll(dir); err != nil { + log.Printf("Cannot remove dir %s: %v", dir, err) + } + } + return err +} + +// node houses the necessary state required to configure, launch, and manage a +// btcd process. +type node struct { + config *nodeConfig + + cmd *exec.Cmd + pidFile string + + dataDir string +} + +// newNode creates a new node instance according to the passed config. dataDir +// will be used to hold a file recording the pid of the launched process, and +// as the base for the log and data directories for btcd. +func newNode(config *nodeConfig, dataDir string) (*node, error) { + return &node{ + config: config, + dataDir: dataDir, + cmd: config.command(), + }, nil +} + +// start creates a new btcd process, and writes its pid in a file reserved for +// recording the pid of the launched process. This file can be used to +// terminate the process in case of a hang, or panic. In the case of a failing +// test case, or panic, it is important that the process be stopped via stop(), +// otherwise, it will persist unless explicitly killed. +func (n *node) start() error { + if err := n.cmd.Start(); err != nil { + return err + } + + pid, err := os.Create(filepath.Join(n.dataDir, + fmt.Sprintf("%s.pid", n.config))) + if err != nil { + return err + } + + n.pidFile = pid.Name() + if _, err = fmt.Fprintf(pid, "%d\n", n.cmd.Process.Pid); err != nil { + return err + } + + if err := pid.Close(); err != nil { + return err + } + + return nil +} + +// stop interrupts the running btcd process process, and waits until it exits +// properly. On windows, interrupt is not supported, so a kill signal is used +// instead +func (n *node) stop() error { + if n.cmd == nil || n.cmd.Process == nil { + // return if not properly initialized + // or error starting the process + return nil + } + defer n.cmd.Wait() + if runtime.GOOS == "windows" { + return n.cmd.Process.Signal(os.Kill) + } + return n.cmd.Process.Signal(os.Interrupt) +} + +// cleanup cleanups process and args files. The file housing the pid of the +// created process will be deleted, as well as any directories created by the +// process. +func (n *node) cleanup() error { + if n.pidFile != "" { + if err := os.Remove(n.pidFile); err != nil { + log.Printf("unable to remove file %s: %v", n.pidFile, + err) + } + } + + return n.config.cleanup() +} + +// shutdown terminates the running btcd process, and cleans up all +// file/directories created by node. +func (n *node) shutdown() error { + if err := n.stop(); err != nil { + return err + } + if err := n.cleanup(); err != nil { + return err + } + return nil +} + +// genCertPair generates a key/cert pair to the paths provided. +func genCertPair(certFile, keyFile string) error { + org := "rpctest autogenerated cert" + validUntil := time.Now().Add(10 * 365 * 24 * time.Hour) + cert, key, err := btcutil.NewTLSCertPair(org, validUntil, nil) + if err != nil { + return err + } + + // Write cert and key files. + if err = ioutil.WriteFile(certFile, cert, 0666); err != nil { + return err + } + if err = ioutil.WriteFile(keyFile, key, 0600); err != nil { + os.Remove(certFile) + return err + } + + return nil +} diff --git a/rpctest/rpc_harness.go b/rpctest/rpc_harness.go new file mode 100644 index 00000000..0c5270fa --- /dev/null +++ b/rpctest/rpc_harness.go @@ -0,0 +1,409 @@ +// Copyright (c) 2016 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package rpctest + +import ( + "fmt" + "io/ioutil" + "net" + "os" + "path/filepath" + "strconv" + "sync" + "testing" + "time" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcrpcclient" + "github.com/btcsuite/btcutil" +) + +var ( + // current number of active test nodes. + numTestInstances = 0 + + // defaultP2pPort is the initial p2p port which will be used by the + // first created rpc harnesses to listen on for incoming p2p + // connections. Subsequent allocated ports for future rpc harness + // instances will be monotonically increasing odd numbers calculated as + // such: defaultP2pPort + (2 * harness.nodeNum). + defaultP2pPort = 18555 + + // defaultRPCPort is the initial rpc port which will be used by the + // first created rpc harnesses to listen on for incoming rpc + // connections. Subsequent allocated ports for future rpc harness + // instances will be monotonically increasing even numbers calculated + // as such: defaultP2pPort + (2 * harness.nodeNum). + defaultRPCPort = 18556 + + // testInstances is a private package-level slice used to keep track of + // all active test harnesses. This global can be used to perform + // various "joins", shutdown several active harnesses after a test, + // etc. + testInstances = make(map[string]*Harness) + + // Used to protest concurrent access to above declared variables. + harnessStateMtx sync.RWMutex +) + +// HarnessTestCase represents a test-case which utilizes an instance of the +// Harness to exercise functionality. +type HarnessTestCase func(r *Harness, t *testing.T) + +// Harness fully encapsulates an active btcd process to provide a unified +// platform for creating rpc driven integration tests involving btcd. The +// active btcd node will typically be run in simnet mode in order to allow for +// easy generation of test blockchains. The active btcd process is fully +// managed by Harness, which handles the necessary initialization, and teardown +// of the process along with any temporary directories created as a result. +// Multiple Harness instances may be run concurrently, in order to allow for +// testing complex scenarios involving multiple nodes. The harness also +// includes an in-memory wallet to streamline various classes of tests. +type Harness struct { + // ActiveNet is the parameters of the blockchain the Harness belongs + // to. + ActiveNet *chaincfg.Params + + Node *btcrpcclient.Client + node *node + handlers *btcrpcclient.NotificationHandlers + + wallet *memWallet + + testNodeDir string + maxConnRetries int + nodeNum int + + sync.Mutex +} + +// New creates and initializes new instance of the rpc test harness. +// Optionally, websocket handlers and a specified configuration may be passed. +// In the case that a nil config is passed, a default configuration will be +// used. +// +// NOTE: This function is safe for concurrent access. +func New(activeNet *chaincfg.Params, handlers *btcrpcclient.NotificationHandlers, + extraArgs []string) (*Harness, error) { + + harnessStateMtx.Lock() + defer harnessStateMtx.Unlock() + + harnessID := strconv.Itoa(int(numTestInstances)) + nodeTestData, err := ioutil.TempDir("", "rpctest-"+harnessID) + if err != nil { + return nil, err + } + + certFile := filepath.Join(nodeTestData, "rpc.cert") + keyFile := filepath.Join(nodeTestData, "rpc.key") + if err := genCertPair(certFile, keyFile); err != nil { + return nil, err + } + + wallet, err := newMemWallet(activeNet, uint32(numTestInstances)) + if err != nil { + return nil, err + } + + miningAddr := fmt.Sprintf("--miningaddr=%s", wallet.coinbaseAddr) + extraArgs = append(extraArgs, miningAddr) + + config, err := newConfig("rpctest", certFile, keyFile, extraArgs) + if err != nil { + return nil, err + } + + // Generate p2p+rpc listening addresses. + config.listen, config.rpcListen = generateListeningAddresses() + + // Create the testing node bounded to the simnet. + node, err := newNode(config, nodeTestData) + if err != nil { + return nil, err + } + + nodeNum := numTestInstances + numTestInstances++ + + if handlers == nil { + handlers = &btcrpcclient.NotificationHandlers{} + } + + // If a handler for the OnBlockConnected/OnBlockDisconnected callback + // has already been set, then we create a wrapper callback which + // executes both the currently registered callback, and the mem + // wallet's callback. + if handlers.OnBlockConnected != nil { + obc := handlers.OnBlockConnected + handlers.OnBlockConnected = func(hash *chainhash.Hash, height int32, t time.Time) { + wallet.IngestBlock(hash, height, t) + obc(hash, height, t) + } + } else { + // Otherwise, we can claim the callback ourselves. + handlers.OnBlockConnected = wallet.IngestBlock + } + if handlers.OnBlockDisconnected != nil { + obd := handlers.OnBlockConnected + handlers.OnBlockDisconnected = func(hash *chainhash.Hash, height int32, t time.Time) { + wallet.UnwindBlock(hash, height, t) + obd(hash, height, t) + } + } else { + handlers.OnBlockDisconnected = wallet.UnwindBlock + } + + h := &Harness{ + handlers: handlers, + node: node, + maxConnRetries: 20, + testNodeDir: nodeTestData, + ActiveNet: activeNet, + nodeNum: nodeNum, + wallet: wallet, + } + + // Track this newly created test instance within the package level + // global map of all active test instances. + testInstances[h.testNodeDir] = h + + return h, nil +} + +// SetUp initializes the rpc test state. Initialization includes: starting up a +// simnet node, creating a websockets client and connecting to the started +// node, and finally: optionally generating and submitting a testchain with a +// configurable number of mature coinbase outputs coinbase outputs. +// +// NOTE: This method and TearDown should always be called from the same +// goroutine as they are not concurrent safe. +func (h *Harness) SetUp(createTestChain bool, numMatureOutputs uint32) error { + // Start the btcd node itself. This spawns a new process which will be + // managed + if err := h.node.start(); err != nil { + return err + } + if err := h.connectRPCClient(); err != nil { + return err + } + + h.wallet.Start() + + // Ensure the btcd properly dispatches our registered call-back for + // each new block. Otherwise, the memWallet won't function properly. + if err := h.Node.NotifyBlocks(); err != nil { + return err + } + + // Create a test chain with the desired number of mature coinbase + // outputs. + if createTestChain && numMatureOutputs != 0 { + numToGenerate := (uint32(h.ActiveNet.CoinbaseMaturity) + + numMatureOutputs) + _, err := h.Node.Generate(numToGenerate) + if err != nil { + return err + } + } + + // Block until the wallet has fully synced up to the tip of the main + // chain. + _, height, err := h.Node.GetBestBlock() + if err != nil { + return err + } + ticker := time.NewTicker(time.Millisecond * 100) +out: + for { + select { + case <-ticker.C: + walletHeight := h.wallet.SyncedHeight() + if walletHeight == height { + break out + } + } + } + + return nil +} + +// TearDown stops the running rpc test instance. All created processes are +// killed, and temporary directories removed. +// +// NOTE: This method and SetUp should always be called from the same goroutine +// as they are not concurrent safe. +func (h *Harness) TearDown() error { + if h.Node != nil { + h.Node.Shutdown() + } + + if err := h.node.shutdown(); err != nil { + return err + } + + if err := os.RemoveAll(h.testNodeDir); err != nil { + return err + } + + delete(testInstances, h.testNodeDir) + + return nil +} + +// connectRPCClient attempts to establish an RPC connection to the created btcd +// process belonging to this Harness instance. If the initial connection +// attempt fails, this function will retry h.maxConnRetries times, backing off +// the time between subsequent attempts. If after h.maxConnRetries attempts, +// we're not able to establish a connection, this function returns with an +// error. +func (h *Harness) connectRPCClient() error { + var client *btcrpcclient.Client + var err error + + rpcConf := h.node.config.rpcConnConfig() + for i := 0; i < h.maxConnRetries; i++ { + if client, err = btcrpcclient.New(&rpcConf, h.handlers); err != nil { + time.Sleep(time.Duration(i) * 50 * time.Millisecond) + continue + } + break + } + + if client == nil { + return fmt.Errorf("connection timeout") + } + + h.Node = client + h.wallet.SetRPCClient(client) + return nil +} + +// NewAddress returns a fresh address spendable by the Harness' internal +// wallet. +// +// This function is safe for concurrent access. +func (h *Harness) NewAddress() (btcutil.Address, error) { + return h.wallet.NewAddress() +} + +// ConfirmedBalance returns the confirmed balance of the Harness' internal +// wallet. +// +// This function is safe for concurrent access. +func (h *Harness) ConfirmedBalance() btcutil.Amount { + return h.wallet.ConfirmedBalance() +} + +// SendOutputs creates, signs, and finally broadcasts a transaction spending +// the harness' available mature coinbase outputs creating new outputs +// according to targetOutputs. +// +// This function is safe for concurrent access. +func (h *Harness) SendOutputs(targetOutputs []*wire.TxOut, + feeRate btcutil.Amount) (*chainhash.Hash, error) { + + return h.wallet.SendOutputs(targetOutputs, feeRate) +} + +// CreateTransaction returns a fully signed transaction paying to the specified +// outputs while observing the desired fee rate. The passed fee rate should be +// expressed in satoshis-per-byte. Any unspent outputs selected as inputs for +// the crafted transaction are marked as unspendable in order to avoid +// potential double-spends by future calls to this method. If the created +// transaction is cancelled for any reason then the selected inputs MUST be +// freed via a call to UnlockOutputs. Otherwise, the locked inputs won't be +// returned to the pool of spendable outputs. +// +// This function is safe for concurrent access. +func (h *Harness) CreateTransaction(targetOutputs []*wire.TxOut, + feeRate btcutil.Amount) (*wire.MsgTx, error) { + + return h.wallet.CreateTransaction(targetOutputs, feeRate) +} + +// UnlockOutputs unlocks any outputs which were previously marked as +// unspendabe due to being selected to fund a transaction via the +// CreateTransaction method. +// +// This function is safe for concurrent access. +func (h *Harness) UnlockOutputs(inputs []*wire.TxIn) { + h.wallet.UnlockOutputs(inputs) +} + +// RPCConfig returns the harnesses current rpc configuration. This allows other +// potential RPC clients created within tests to connect to a given test +// harness instance. +func (h *Harness) RPCConfig() btcrpcclient.ConnConfig { + return h.node.config.rpcConnConfig() +} + +// GenerateAndSubmitBlock creates a block whose contents include the passed +// transactions and submits it to the running simnet node. For generating +// blocks with only a coinbase tx, callers can simply pass nil instead of +// transactions to be mined. Additionally, a custom block version can be set by +// the caller. A blockVersion of -1 indicates that the current default block +// version should be used. An uninitialized time.Time should be used for the +// blockTime parameter if one doesn't wish to set a custom time. +// +// This function is safe for concurrent access. +func (h *Harness) GenerateAndSubmitBlock(txns []*btcutil.Tx, blockVersion int32, + blockTime time.Time) (*btcutil.Block, error) { + + h.Lock() + defer h.Unlock() + + if blockVersion == -1 { + blockVersion = wire.BlockVersion + } + + prevBlockHash, prevBlockHeight, err := h.Node.GetBestBlock() + if err != nil { + return nil, err + } + prevBlock, err := h.Node.GetBlock(prevBlockHash) + if err != nil { + return nil, err + } + prevBlock.SetHeight(prevBlockHeight) + + // Create a new block including the specified transactions + newBlock, err := createBlock(prevBlock, txns, blockVersion, + blockTime, h.wallet.coinbaseAddr, h.ActiveNet) + if err != nil { + return nil, err + } + + // Submit the block to the simnet node. + if err := h.Node.SubmitBlock(newBlock, nil); err != nil { + return nil, err + } + + return newBlock, nil +} + +// generateListeningAddresses returns two strings representing listening +// addresses designated for the current rpc test. If there haven't been any +// test instances created, the default ports are used. Otherwise, in order to +// support multiple test nodes running at once, the p2p and rpc port are +// incremented after each initialization. +func generateListeningAddresses() (string, string) { + var p2p, rpc string + localhost := "127.0.0.1" + + if numTestInstances == 0 { + p2p = net.JoinHostPort(localhost, strconv.Itoa(defaultP2pPort)) + rpc = net.JoinHostPort(localhost, strconv.Itoa(defaultRPCPort)) + } else { + p2p = net.JoinHostPort(localhost, + strconv.Itoa(defaultP2pPort+(2*numTestInstances))) + rpc = net.JoinHostPort(localhost, + strconv.Itoa(defaultRPCPort+(2*numTestInstances))) + } + + return p2p, rpc +} diff --git a/rpctest/rpc_harness_test.go b/rpctest/rpc_harness_test.go new file mode 100644 index 00000000..2c0845d0 --- /dev/null +++ b/rpctest/rpc_harness_test.go @@ -0,0 +1,512 @@ +// Copyright (c) 2016 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. +package rpctest + +import ( + "fmt" + "net" + "os" + "strconv" + "testing" + "time" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcutil" +) + +func testSendOutputs(r *Harness, t *testing.T) { + genSpend := func(amt btcutil.Amount) *chainhash.Hash { + // Grab a fresh address from the wallet. + addr, err := r.NewAddress() + if err != nil { + t.Fatalf("unable to get new address: %v", err) + } + + // Next, send amt BTC to this address, spending from one of our mature + // coinbase outputs. + addrScript, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatalf("unable to generate pkscript to addr: %v", err) + } + output := wire.NewTxOut(int64(amt), addrScript) + txid, err := r.SendOutputs([]*wire.TxOut{output}, 10) + if err != nil { + t.Fatalf("coinbase spend failed: %v", err) + } + return txid + } + + assertTxMined := func(txid *chainhash.Hash, blockHash *chainhash.Hash) { + block, err := r.Node.GetBlock(blockHash) + if err != nil { + t.Fatalf("unable to get block: %v", err) + } + + numBlockTxns := len(block.Transactions()) + if numBlockTxns < 2 { + t.Fatalf("crafted transaction wasn't mined, block should have "+ + "at least %v transactions instead has %v", 2, numBlockTxns) + } + + minedTx := block.Transactions()[1] + txHash := minedTx.Hash() + if *txHash != *txid { + t.Fatalf("txid's don't match, %v vs %v", txHash, txid) + } + } + + // First, generate a small spend which will require only a single + // input. + txid := genSpend(btcutil.Amount(5 * btcutil.SatoshiPerBitcoin)) + + // Generate a single block, the transaction the wallet created should + // be found in this block. + blockHashes, err := r.Node.Generate(1) + if err != nil { + t.Fatalf("unable to generate single block: %v", err) + } + assertTxMined(txid, blockHashes[0]) + + // Next, generate a spend much greater than the block reward. This + // transaction should also have been mined properly. + txid = genSpend(btcutil.Amount(500 * btcutil.SatoshiPerBitcoin)) + blockHashes, err = r.Node.Generate(1) + if err != nil { + t.Fatalf("unable to generate single block: %v", err) + } + assertTxMined(txid, blockHashes[0]) +} + +func assertConnectedTo(t *testing.T, nodeA *Harness, nodeB *Harness) { + nodePort := defaultP2pPort + (2 * nodeB.nodeNum) + nodeAddr := net.JoinHostPort("127.0.0.1", strconv.Itoa(nodePort)) + + nodeAPeers, err := nodeA.Node.GetPeerInfo() + if err != nil { + t.Fatalf("unable to get nodeA's peer info") + } + + addrFound := false + for _, peerInfo := range nodeAPeers { + if peerInfo.Addr == nodeAddr { + addrFound = true + break + } + } + + if !addrFound { + t.Fatal("nodeA not connected to nodeB") + } +} + +func testConnectNode(r *Harness, t *testing.T) { + // Create a fresh test harnesses. + harness, err := New(&chaincfg.SimNetParams, nil, nil) + if err != nil { + t.Fatal(err) + } + if err := harness.SetUp(true, 0); err != nil { + t.Fatalf("unable to complete rpctest setup: %v", err) + } + defer harness.TearDown() + + // Establish a p2p connection the main harness to our new local + // harness. + if err := ConnectNode(r, harness); err != nil { + t.Fatalf("unable to connect harness1 to harness2: %v", err) + } + + // The main harness should show up in our loca harness' peer's list, + // and vice verse. + assertConnectedTo(t, r, harness) +} + +func testTearDownAll(t *testing.T) { + // Grab a local copy of the currently active harnesses before + // attempting to tear them all down. + initialActiveHarnesses := ActiveHarnesses() + + // Tear down all currently active harnesses. + if err := TearDownAll(); err != nil { + t.Fatalf("unable to teardown all harnesses: %v", err) + } + + // The global testInstances map should now be fully purged with no + // active test harnesses remaining. + if len(ActiveHarnesses()) != 0 { + t.Fatalf("test harnesses still active after TearDownAll") + } + + for _, harness := range initialActiveHarnesses { + // Ensure all test directories have been deleted. + if _, err := os.Stat(harness.testNodeDir); err == nil { + t.Errorf("created test datadir was not deleted.") + } + } +} + +func testActiveHarnesses(r *Harness, t *testing.T) { + numInitialHarnesses := len(ActiveHarnesses()) + + // Create a single test harness. + harness1, err := New(&chaincfg.SimNetParams, nil, nil) + if err != nil { + t.Fatal(err) + } + defer harness1.TearDown() + + // With the harness created above, a single harness should be detected + // as active. + numActiveHarnesses := len(ActiveHarnesses()) + if !(numActiveHarnesses > numInitialHarnesses) { + t.Fatalf("ActiveHarnesses not updated, should have an " + + "additional test harness listed.") + } +} + +func testJoinMempools(r *Harness, t *testing.T) { + // Create a new local test harnesses, starting at the same height. + harness, err := New(&chaincfg.SimNetParams, nil, nil) + if err != nil { + t.Fatal(err) + } + if err := harness.SetUp(true, 25); err != nil { + t.Fatalf("unable to complete rpctest setup: %v", err) + } + defer harness.TearDown() + + nodeSlice := []*Harness{r, harness} + + // Both mempools should be considered synced as they are empty. + // Therefore, this should return instantly. + if err := JoinNodes(nodeSlice, Mempools); err != nil { + t.Fatalf("unable to join node on block height: %v", err) + } + + // Generate a coinbase spend to a new address within harness1's + // mempool. + addr, err := harness.NewAddress() + addrScript, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatalf("unable to generate pkscript to addr: %v", err) + } + output := wire.NewTxOut(5e8, addrScript) + if _, err = harness.SendOutputs([]*wire.TxOut{output}, 10); err != nil { + t.Fatalf("coinbase spend failed: %v", err) + } + + poolsSynced := make(chan struct{}) + go func() { + if err := JoinNodes(nodeSlice, Mempools); err != nil { + t.Fatalf("unable to join node on node mempools: %v", err) + } + poolsSynced <- struct{}{} + }() + + // This select case should fall through to the default as the goroutine + // should be blocked on the JoinNodes calls. + select { + case <-poolsSynced: + t.Fatalf("mempools detected as synced yet harness1 has a new tx") + default: + } + + // Establish an outbound connection from harness1 to harness2. After + // the initial handshake both nodes should exchange inventory resulting + // in a synced mempool. + if err := ConnectNode(r, harness); err != nil { + t.Fatalf("unable to connect harnesses: %v", err) + } + + // Select once again with a special timeout case after 1 minute. The + // goroutine above should now be blocked on sending into the unbuffered + // channel. The send should immediately succeed. In order to avoid the + // test hanging indefinitely, a 1 minute timeout is in place. + select { + case <-poolsSynced: + // fall through + case <-time.After(time.Minute): + t.Fatalf("block heights never detected as synced") + } + +} + +func testJoinBlocks(r *Harness, t *testing.T) { + // Create two test harnesses, with one being 5 block ahead of the other + // with respect to block height. + harness1, err := New(&chaincfg.SimNetParams, nil, nil) + if err != nil { + t.Fatal(err) + } + if err := harness1.SetUp(true, 30); err != nil { + t.Fatalf("unable to complete rpctest setup: %v", err) + } + defer harness1.TearDown() + harness2, err := New(&chaincfg.SimNetParams, nil, nil) + if err != nil { + t.Fatal(err) + } + if err := harness2.SetUp(true, 25); err != nil { + t.Fatalf("unable to complete rpctest setup: %v", err) + } + defer harness2.TearDown() + + nodeSlice := []*Harness{harness1, harness2} + blocksSynced := make(chan struct{}) + go func() { + if err := JoinNodes(nodeSlice, Blocks); err != nil { + t.Fatalf("unable to join node on block height: %v", err) + } + blocksSynced <- struct{}{} + }() + + // This select case should fall through to the default as the goroutine + // should be blocked on the JoinNodes calls. + select { + case <-blocksSynced: + t.Fatalf("blocks detected as synced yet harness2 is 5 blocks behind") + default: + } + + // Extend harness2's chain by 5 blocks, this should cause JoinNodes to + // finally unblock and return. + if _, err := harness2.Node.Generate(5); err != nil { + t.Fatalf("unable to generate blocks: %v", err) + } + + // Select once again with a special timeout case after 1 minute. The + // goroutine above should now be blocked on sending into the unbuffered + // channel. The send should immediately succeed. In order to avoid the + // test hanging indefinitely, a 1 minute timeout is in place. + select { + case <-blocksSynced: + // fall through + case <-time.After(time.Minute): + t.Fatalf("block heights never detected as synced") + } +} + +func testGenerateAndSubmitBlock(r *Harness, t *testing.T) { + // Generate a few test spend transactions. + addr, err := r.NewAddress() + if err != nil { + t.Fatalf("unable to generate new address: %v", err) + } + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatalf("unable to create script: %v", err) + } + output := wire.NewTxOut(btcutil.SatoshiPerBitcoin, pkScript) + + const numTxns = 5 + txns := make([]*btcutil.Tx, 0, numTxns) + for i := 0; i < numTxns; i++ { + tx, err := r.CreateTransaction([]*wire.TxOut{output}, 10) + if err != nil { + t.Fatalf("unable to create tx: %v", err) + } + + txns = append(txns, btcutil.NewTx(tx)) + } + + // Now generate a block with the default block version, and a zero'd + // out time. + block, err := r.GenerateAndSubmitBlock(txns, -1, time.Time{}) + if err != nil { + t.Fatalf("unable to generate block: %v", err) + } + + // Ensure that all created transactions were included, and that the + // block version was properly set to the default. + numBlocksTxns := len(block.Transactions()) + if numBlocksTxns != numTxns+1 { + t.Fatalf("block did not include all transactions: "+ + "expected %v, got %v", numTxns+1, numBlocksTxns) + } + blockVersion := block.MsgBlock().Header.Version + if blockVersion != wire.BlockVersion { + t.Fatalf("block version is not default: expected %v, got %v", + wire.BlockVersion, blockVersion) + } + + // Next generate a block with a "non-standard" block version along with + // time stamp a minute after the previous block's timestamp. + timestamp := block.MsgBlock().Header.Timestamp.Add(time.Minute) + targetBlockVersion := int32(1337) + block, err = r.GenerateAndSubmitBlock(nil, targetBlockVersion, timestamp) + if err != nil { + t.Fatalf("unable to generate block: %v", err) + } + + // Finally ensure that the desired block version and timestamp were set + // properly. + header := block.MsgBlock().Header + blockVersion = header.Version + if blockVersion != targetBlockVersion { + t.Fatalf("block version mismatch: expected %v, got %v", + targetBlockVersion, blockVersion) + } + if !timestamp.Equal(header.Timestamp) { + t.Fatalf("header time stamp mismatch: expected %v, got %v", + timestamp, header.Timestamp) + } +} + +func testMemWalletReorg(r *Harness, t *testing.T) { + // Create a fresh harness, we'll be using the main harness to force a + // re-org on this local harness. + harness, err := New(&chaincfg.SimNetParams, nil, nil) + if err != nil { + t.Fatal(err) + } + if err := harness.SetUp(true, 5); err != nil { + t.Fatalf("unable to complete rpctest setup: %v", err) + } + defer harness.TearDown() + + // The internal wallet of this harness should now have 250 BTC. + expectedBalance := btcutil.Amount(250 * btcutil.SatoshiPerBitcoin) + walletBalance := harness.ConfirmedBalance() + if expectedBalance != walletBalance { + t.Fatalf("wallet balance incorrect: expected %v, got %v", + expectedBalance, walletBalance) + } + + // Now connect this local harness to the main harness then wait for + // their chains to synchronize. + if err := ConnectNode(r, harness); err != nil { + t.Fatalf("unable to connect harnesses: %v", err) + } + nodeSlice := []*Harness{r, harness} + if err := JoinNodes(nodeSlice, Blocks); err != nil { + t.Fatalf("unable to join node on block height: %v", err) + } + + // The original wallet should now have a balance of 0 BTC as its entire + // chain should have been decimated in favor of the main harness' + // chain. + expectedBalance = btcutil.Amount(0) + walletBalance = harness.ConfirmedBalance() + if expectedBalance != walletBalance { + t.Fatalf("wallet balance incorrect: expected %v, got %v", + expectedBalance, walletBalance) + } +} + +func testMemWalletLockedOutputs(r *Harness, t *testing.T) { + // Obtain the initial balance of the wallet at this point. + startingBalance := r.ConfirmedBalance() + + // First, create a signed transaction spending some outputs. + addr, err := r.NewAddress() + if err != nil { + t.Fatalf("unable to generate new address: %v", err) + } + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatalf("unable to create script: %v", err) + } + outputAmt := btcutil.Amount(50 * btcutil.SatoshiPerBitcoin) + output := wire.NewTxOut(int64(outputAmt), pkScript) + tx, err := r.CreateTransaction([]*wire.TxOut{output}, 10) + if err != nil { + t.Fatalf("unable to create transaction: %v", err) + } + + // The current wallet balance should now be at least 50 BTC less + // (accounting for fees) than the period balance + currentBalance := r.ConfirmedBalance() + if !(currentBalance <= startingBalance-outputAmt) { + t.Fatalf("spent outputs not locked: previous balance %v, "+ + "current balance %v", startingBalance, currentBalance) + } + + // Now unlocked all the spent inputs within the unbroadcast signed + // transaction. The current balance should now be exactly that of the + // starting balance. + r.UnlockOutputs(tx.TxIn) + currentBalance = r.ConfirmedBalance() + if currentBalance != startingBalance { + t.Fatalf("current and starting balance should now match: "+ + "expected %v, got %v", startingBalance, currentBalance) + } +} + +var harnessTestCases = []HarnessTestCase{ + testSendOutputs, + testConnectNode, + testActiveHarnesses, + testJoinMempools, + testJoinBlocks, + testGenerateAndSubmitBlock, + testMemWalletReorg, + testMemWalletLockedOutputs, +} + +var mainHarness *Harness + +const ( + numMatureOutputs = 25 +) + +func TestMain(m *testing.M) { + var err error + mainHarness, err = New(&chaincfg.SimNetParams, nil, nil) + if err != nil { + fmt.Println("unable to create main harness: ", err) + os.Exit(1) + } + + // Initialize the main mining node with a chain of length 125, + // providing 25 mature coinbases to allow spending from for testing + // purposes. + if err = mainHarness.SetUp(true, numMatureOutputs); err != nil { + fmt.Println("unable to setup test chain: ", err) + os.Exit(1) + } + + exitCode := m.Run() + + // Clean up any active harnesses that are still currently running. + if len(ActiveHarnesses()) > 0 { + if err := TearDownAll(); err != nil { + fmt.Println("unable to tear down chain: ", err) + os.Exit(1) + } + } + + os.Exit(exitCode) +} + +func TestHarness(t *testing.T) { + // We should have (numMatureOutputs * 50 BTC) of mature unspendable + // outputs. + expectedBalance := btcutil.Amount(numMatureOutputs * 50 * btcutil.SatoshiPerBitcoin) + harnessBalance := mainHarness.ConfirmedBalance() + if harnessBalance != expectedBalance { + t.Fatalf("expected wallet balance of %v instead have %v", + expectedBalance, harnessBalance) + } + + // Current tip should be at a height of numMatureOutputs plus the + // required number of blocks for coinbase maturity. + nodeInfo, err := mainHarness.Node.GetInfo() + if err != nil { + t.Fatalf("unable to execute getinfo on node: %v", err) + } + expectedChainHeight := numMatureOutputs + uint32(mainHarness.ActiveNet.CoinbaseMaturity) + if uint32(nodeInfo.Blocks) != expectedChainHeight { + t.Errorf("Chain height is %v, should be %v", + nodeInfo.Blocks, expectedChainHeight) + } + + for _, testCase := range harnessTestCases { + testCase(mainHarness, t) + } + + testTearDownAll(t) +} diff --git a/rpctest/utils.go b/rpctest/utils.go new file mode 100644 index 00000000..8d5ce5d9 --- /dev/null +++ b/rpctest/utils.go @@ -0,0 +1,169 @@ +// Copyright (c) 2016 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package rpctest + +import ( + "net" + "reflect" + "strconv" + "time" + + "github.com/btcsuite/btcrpcclient" +) + +// JoinType is an enum representing a particular type of "node join". A node +// join is a synchronization tool used to wait until a subset of nodes have a +// consistent state with respect to an attribute. +type JoinType uint8 + +const ( + // Blocks is a JoinType which waits until all nodes share the same + // block height. + Blocks JoinType = iota + + // Mempools is a JoinType which blocks until all nodes have identical + // mempool. + Mempools +) + +// JoinNodes is a synchronization tool used to block until all passed nodes are +// fully synced with respect to an attribute. This function will block for a +// period of time, finally returning once all nodes are synced according to the +// passed JoinType. This function be used to to ensure all active test +// harnesses are at a consistent state before proceeding to an assertion or +// check within rpc tests. +func JoinNodes(nodes []*Harness, joinType JoinType) error { + switch joinType { + case Blocks: + return syncBlocks(nodes) + case Mempools: + return syncMempools(nodes) + } + return nil +} + +// syncMempools blocks until all nodes have identical mempools. +func syncMempools(nodes []*Harness) error { + poolsMatch := false + + for !poolsMatch { + retry: + firstPool, err := nodes[0].Node.GetRawMempool() + if err != nil { + return err + } + + // If all nodes have an identical mempool with respect to the + // first node, then we're done. Otherwise, drop back to the top + // of the loop and retry after a short wait period. + for _, node := range nodes[:1] { + nodePool, err := node.Node.GetRawMempool() + if err != nil { + return err + } + + if !reflect.DeepEqual(firstPool, nodePool) { + time.Sleep(time.Millisecond * 100) + goto retry + } + } + + poolsMatch = true + } + + return nil +} + +// syncBlocks blocks until all nodes report the same block height. +func syncBlocks(nodes []*Harness) error { + blocksMatch := false + + for !blocksMatch { + retry: + blockHeights := make(map[int64]struct{}) + + for _, node := range nodes { + blockHeight, err := node.Node.GetBlockCount() + if err != nil { + return err + } + + blockHeights[blockHeight] = struct{}{} + if len(blockHeights) > 1 { + time.Sleep(time.Millisecond * 100) + goto retry + } + } + + blocksMatch = true + } + + return nil +} + +// ConnectNode establishes a new peer-to-peer connection between the "from" +// harness and the "to" harness. The connection made is flagged as persistent, +// therefore in the case of disconnects, "from" will attempt to reestablish a +// connection to the "to" harness. +func ConnectNode(from *Harness, to *Harness) error { + // Calculate the target p2p addr+port for the node to be connected to. + // p2p ports uses within the package are always even, so we multiply + // the node number by two before offsetting from the defaultP2pPort. + targetPort := defaultP2pPort + (2 * to.nodeNum) + targetAddr := net.JoinHostPort("127.0.0.1", strconv.Itoa(targetPort)) + + peerInfo, err := from.Node.GetPeerInfo() + if err != nil { + return err + } + numPeers := len(peerInfo) + + if err := from.Node.AddNode(targetAddr, btcrpcclient.ANAdd); err != nil { + return err + } + + // Block until a new connection has been established. + peerInfo, err = from.Node.GetPeerInfo() + if err != nil { + return err + } + for len(peerInfo) <= numPeers { + peerInfo, err = from.Node.GetPeerInfo() + if err != nil { + return err + } + } + + return nil +} + +// TearDownAll tears down all active test harnesses. +func TearDownAll() error { + harnessStateMtx.Lock() + defer harnessStateMtx.Unlock() + + for _, harness := range testInstances { + if err := harness.TearDown(); err != nil { + return err + } + } + + return nil +} + +// ActiveHarnesses returns a slice of all currently active test harnesses. A +// test harness if considered "active" if it has been created, but not yet torn +// down. +func ActiveHarnesses() []*Harness { + harnessStateMtx.RLock() + defer harnessStateMtx.RUnlock() + + activeNodes := make([]*Harness, 0, len(testInstances)) + for _, harness := range testInstances { + activeNodes = append(activeNodes, harness) + } + + return activeNodes +}