diff --git a/dex/testing/dcrdex/harness.sh b/dex/testing/dcrdex/harness.sh index 6f758fcbe3..7d40d41477 100755 --- a/dex/testing/dcrdex/harness.sh +++ b/dex/testing/dcrdex/harness.sh @@ -23,7 +23,7 @@ TEST_DB=dcrdex_simnet_test sudo -u postgres -H psql -c "DROP DATABASE IF EXISTS ${TEST_DB}" \ -c "CREATE DATABASE ${TEST_DB} OWNER dcrdex" -EPOCH_DURATION=${EPOCH:-15000} +EPOCH_DURATION=${EPOCH:-15000} if [ "${EPOCH_DURATION}" -lt 1000 ]; then echo "epoch duration cannot be < 1000 ms" exit 1 @@ -39,6 +39,9 @@ BCH_ON=$? ~/dextest/ltc/harness-ctl/alpha getblockchaininfo &> /dev/null LTC_ON=$? +~/dextest/eth/harness-ctl/alpha attach --exec 'eth.blockNumber' > /dev/null +ETH_ON=$? + set -e # Write markets.json. @@ -129,6 +132,20 @@ if [ $BCH_ON -eq 0 ]; then EOF fi +if [ $ETH_ON -eq 0 ]; then + cat << EOF >> "./markets.json" + }, + "ETH_simnet": { + "bip44symbol": "eth", + "network": "simnet", + "lotSize": 1000000, + "rateStep": 1000000, + "maxFeeRate": 20, + "swapConf": 2, + "configPath": "${TEST_ROOT}/eth/alpha/node/geth.ipc" +EOF +fi + cat << EOF >> "./markets.json" } } @@ -156,7 +173,7 @@ abstakerlotlimit=1200 httpprof=1 EOF -# Set the postgres user pass if provided. +# Set the postgres user pass if provided. if [ -n "${PG_PASS}" ]; then echo pgpass="${PG_PASS}" >> ./dcrdex.conf fi diff --git a/dex/testing/eth/README.md b/dex/testing/eth/README.md index 7c991323b5..0022acb4e2 100644 --- a/dex/testing/eth/README.md +++ b/dex/testing/eth/README.md @@ -5,14 +5,18 @@ sandboxed environment for testing dex swap transactions. ## Dependencies -The harness depends on [geth](https://github.com/ethereum/go-ethereum/tree/master/cmd/geth) to run. +The harness depends on [geth](https://github.com/ethereum/go-ethereum/tree/master/cmd/geth) +to run. ## Using You must have `geth` in `PATH` to use the harness. -The harness script will create two connected private nodes both with mining -abilities and pre-funded addresses. +The harness script will create four connected private nodes. Two, alpha and +beta, have mining abilities and pre-funded addresses with syncmode set to +"fast". They are meant to be used with server functions. Two more, gamma and +delta, are "light" nodes without mining abilites and with addresses that have +been sent funds. They are intenended to be used with client functions. ## Harness control scripts @@ -20,8 +24,8 @@ The `./harness.sh` script will drop you into a tmux window in a directory called `harness-ctl`. Inside of this directory are a number of scripts to allow you to perform RPC calls against each wallet. -`./alpha` and `./beta` are just `geth` configured for their respective data -directories. +`./alpha`, `./beta`, `./gamma`, and `./delta` are just `geth` configured for +their respective data directories. Try `./alpha attach`, for example. This will put you in an interactive console with the alpha node. @@ -35,7 +39,7 @@ with the alpha node. If things aren't looking right, you may need to look at the node windows to see errors. In tmux, you can navigate between windows by typing `Ctrl+b` and then the window number. The window numbers are listed at the bottom -of the tmux window. `Ctrl+b` followed by the number `0`, for example, will +of the tmux window. `Ctrl+b` followed by the number `1`, for example, will change to the alpha node window. Examining the node output to look for errors is usually a good first debugging step. diff --git a/dex/testing/eth/create-node.sh b/dex/testing/eth/create-node.sh index b6048630aa..c9d8bf804b 100755 --- a/dex/testing/eth/create-node.sh +++ b/dex/testing/eth/create-node.sh @@ -2,7 +2,7 @@ # Script for creating eth nodes. set -e -# The following are required script arguments +# The following are required script arguments. TMUX_WIN_ID=$1 NAME=$2 NODE_PORT=$3 @@ -10,16 +10,17 @@ CHAIN_ADDRESS=$4 CHAIN_PASSWORD=$5 CHAIN_ADDRESS_JSON=$6 CHAIN_ADDRESS_JSON_FILE_NAME=$7 -ADDRESS=$8 -ADDRESS_PASSWORD=$9 -ADDRESS_JSON=${10} -ADDRESS_JSON_FILE_NAME=${11} -NODE_KEY=${12} +ADDRESS_JSON=$8 +ADDRESS_JSON_FILE_NAME=$9 +NODE_KEY=${10} +SYNC_MODE=${11} GROUP_DIR="${NODES_ROOT}/${NAME}" MINE_JS="${GROUP_DIR}/mine.js" NODE_DIR="${GROUP_DIR}/node" mkdir -p "${NODE_DIR}" +mkdir -p "${NODE_DIR}/keystore" +mkdir -p "${NODE_DIR}/geth" # Write node ctl script. cat > "${NODES_ROOT}/harness-ctl/${NAME}" < "${NODES_ROOT}/harness-ctl/mine-${NAME}" < "${NODES_ROOT}/harness-ctl/mine-${NAME}" < "${NODES_ROOT}/harness-ctl/mine-${NAME}" < "${MINE_JS}" < "${MINE_JS}" < "${GROUP_DIR}/password" < "${GROUP_DIR}/password" < "${NODE_DIR}/eth.conf" <> "${NODE_DIR}/eth.conf" < "${NODE_DIR}/keystore/$CHAIN_ADDRESS_JSON_FILE_NAME" < "${NODE_DIR}/keystore/$CHAIN_ADDRESS_JSON_FILE_NAME" < "${NODE_DIR}/keystore/$ADDRESS_JSON_FILE_NAME" < "${NODE_DIR}/geth/nodekey" < "${NODES_ROOT}/genesis.json" < "${NODES_ROOT}/genesis.json" < "${NODES_ROOT}/harness-ctl/send.js" < cache.best.height { + cache.best.height = height + cache.best.hash = hash + } + } + return blk, nil +} + +// Get the best known block height for the blockCache. +func (cache *blockCache) tipHeight() uint64 { + cache.mtx.Lock() + defer cache.mtx.Unlock() + return cache.best.height +} + +// Get the best known block hash in the blockCache. +func (cache *blockCache) tipHash() common.Hash { + cache.mtx.RLock() + defer cache.mtx.RUnlock() + return cache.best.hash +} + +// Get the best known block height in the blockCache. +func (cache *blockCache) tip() ethBlock { + cache.mtx.RLock() + defer cache.mtx.RUnlock() + return cache.best +} + +// Trigger a reorg, setting any blocks at or above the provided height as +// orphaned and removing them from mainchain, but not the blocks map. reorg +// clears the best block, so should always be followed with the addition of a +// new mainchain block. +func (cache *blockCache) reorg(from uint64) { + if from < 0 { + return + } + cache.mtx.Lock() + defer cache.mtx.Unlock() + for height := from; height <= cache.best.height; height++ { + block, found := cache.mainchain[height] + if !found { + cache.log.Errorf("reorg block not found on mainchain at height %d for a reorg from %d to %d", height, from, cache.best.height) + continue + } + // Delete the block from mainchain. + delete(cache.mainchain, block.height) + // Store an orphaned block in the blocks cache. + cache.blocks[block.hash] = ðBlock{ + hash: block.hash, + height: block.height, + orphaned: true, + } + } + // Set this to a zero block so that the new block will replace it even if + // it is of the same height as the previous best block. + cache.best = ethBlock{} +} diff --git a/server/asset/eth/config.go b/server/asset/eth/config.go new file mode 100644 index 0000000000..1f921820c9 --- /dev/null +++ b/server/asset/eth/config.go @@ -0,0 +1,47 @@ +// This code is available on the terms of the project LICENSE.md file, +// also available online at https://blueoakcouncil.org/license/1.0.0. + +package eth + +import ( + "fmt" + "path/filepath" + + "decred.org/dcrdex/dex" + "github.com/decred/dcrd/dcrutil/v3" +) + +var ( + ethHomeDir = dcrutil.AppDataDir("ethereum", false) + defaultIPC = filepath.Join(ethHomeDir, "geth/geth.ipc") +) + +type config struct { + // IPC is the location of the inner process communication socket. + IPC string `long:"ipc" description:"Location of the geth ipc socket."` +} + +// load checks the network and sets the ipc location if not supplied. +// +// TODO: Test this with windows. +func load(IPC string, network dex.Network) (*config, error) { + switch network { + case dex.Simnet: + case dex.Testnet: + case dex.Mainnet: + // TODO: Allow. + return nil, fmt.Errorf("eth cannot be used on mainnet") + default: + return nil, fmt.Errorf("unknown network ID: %d", uint8(network)) + } + + cfg := &config{ + IPC: IPC, + } + + if cfg.IPC == "" { + cfg.IPC = defaultIPC + } + + return cfg, nil +} diff --git a/server/asset/eth/eth.go b/server/asset/eth/eth.go new file mode 100644 index 0000000000..080b483882 --- /dev/null +++ b/server/asset/eth/eth.go @@ -0,0 +1,391 @@ +// This code is available on the terms of the project LICENSE.md file, +// also available online at https://blueoakcouncil.org/license/1.0.0. + +package eth + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "sync" + "time" + + "decred.org/dcrdex/dex" + "decred.org/dcrdex/server/asset" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +func init() { + asset.Register(assetName, &Driver{}) +} + +const ( + version = 0 + assetName = "eth" + // coinIdSize = flags (2) + smart contract address where funds are locked (20) + secret + // hash map key (32) + coinIDSize = 54 + // The blockPollInterval is the delay between calls to bestBlockHash to + // check for new blocks. + blockPollInterval = time.Second +) + +var ( + zeroHash = common.Hash{} + notImplementedErr = errors.New("not implemented") + _ asset.Driver = (*Driver)(nil) +) + +// Driver implements asset.Driver. +type Driver struct{} + +// Version returns the Backend implementation's version number. +func (d *Driver) Version() uint32 { + return version +} + +// Setup creates the ETH backend. Start the backend with its Run method. +func (d *Driver) Setup(configPath string, logger dex.Logger, network dex.Network) (asset.Backend, error) { + return NewBackend(configPath, logger, network) +} + +// DecodeCoinID creates a human-readable representation of a coin ID for Ethereum. +func (d *Driver) DecodeCoinID(coinID []byte) (string, error) { + return coinIDToString(coinID) +} + +// ethFetcher represents a blockchain information fetcher. In practice, it is +// satisfied by rpcclient. For testing, it can be satisfied by a stub. +type ethFetcher interface { + shutdown() + connect(ctx context.Context, IPC string) error + bestBlockHash(ctx context.Context) (common.Hash, error) + block(ctx context.Context, hash common.Hash) (*types.Block, error) +} + +// Backend is an asset backend for Ethereum. It has methods for fetching output +// information and subscribing to block updates. It maintains a cache of block +// data for quick lookups. Backend implements asset.Backend, so provides +// exported methods for DEX-related blockchain info. +type Backend struct { + cfg *config + node ethFetcher + // The backend provides block notification channels through the BlockChannel + // method. signalMtx locks the blockChans array. + signalMtx sync.RWMutex + blockChans map[chan *asset.BlockUpdate]struct{} + // The block cache stores just enough info about the blocks to prevent future + // calls to Block. + blockCache *blockCache + // A logger will be provided by the DEX. All logging should use the provided + // logger. + log dex.Logger +} + +// Check that Backend satisfies the Backend interface. +var _ asset.Backend = (*Backend)(nil) + +// unconnectedETH returns a Backend without a node. The node should be set +// before use. +func unconnectedETH(logger dex.Logger, cfg *config) *Backend { + return &Backend{ + cfg: cfg, + blockCache: newBlockCache(logger), + log: logger, + blockChans: make(map[chan *asset.BlockUpdate]struct{}), + } +} + +// NewBackend is the exported constructor by which the DEX will import the +// Backend. +func NewBackend(ipc string, logger dex.Logger, network dex.Network) (*Backend, error) { + cfg, err := load(ipc, network) + if err != nil { + return nil, err + } + return unconnectedETH(logger, cfg), nil +} + +func (eth *Backend) shutdown() { + eth.node.shutdown() +} + +// Connect connects to the node RPC server. A dex.Connector. +func (eth *Backend) Connect(ctx context.Context) (*sync.WaitGroup, error) { + c := rpcclient{} + if err := c.connect(ctx, eth.cfg.IPC); err != nil { + return nil, err + } + eth.node = &c + + // Prime the cache with the best block. + bestHash, err := eth.node.bestBlockHash(ctx) + if err != nil { + eth.shutdown() + return nil, fmt.Errorf("error getting best block hash from geth: %w", err) + } + block, err := eth.node.block(ctx, bestHash) + if err != nil { + eth.shutdown() + return nil, fmt.Errorf("error getting best block from geth: %w", err) + } + _, err = eth.blockCache.add(block) + if err != nil { + eth.log.Errorf("error adding new best block to cache: %v", err) + } + + var wg sync.WaitGroup + wg.Add(1) + go func() { + eth.run(ctx) + wg.Done() + }() + return &wg, nil +} + +// TxData fetches the raw transaction data. +func (eth *Backend) TxData(coinID []byte) ([]byte, error) { + return nil, notImplementedErr +} + +// InitTxSize is an asset.Backend method that must produce the max size of a +// standardized atomic swap initialization transaction. +func (eth *Backend) InitTxSize() uint32 { + return 0 +} + +// InitTxSizeBase is InitTxSize not including an input. +func (eth *Backend) InitTxSizeBase() uint32 { + return 0 +} + +// FeeRate returns the current optimal fee rate in atoms / byte. +func (eth *Backend) FeeRate() (uint64, error) { + return 0, notImplementedErr +} + +// BlockChannel creates and returns a new channel on which to receive block +// updates. If the returned channel is ever blocking, there will be no error +// logged from the eth package. Part of the asset.Backend interface. +func (eth *Backend) BlockChannel(size int) <-chan *asset.BlockUpdate { + c := make(chan *asset.BlockUpdate, size) + eth.signalMtx.Lock() + defer eth.signalMtx.Unlock() + eth.blockChans[c] = struct{}{} + return c +} + +// Contract is part of the asset.Backend interface. +func (eth *Backend) Contract(coinID []byte, redeemScript []byte) (*asset.Contract, error) { + return nil, notImplementedErr +} + +// ValidateSecret checks that the secret satisfies the contract. +func (eth *Backend) ValidateSecret(secret, contract []byte) bool { + return false +} + +// Synced is true if the blockchain is ready for action. +func (eth *Backend) Synced() (bool, error) { + return false, notImplementedErr +} + +// Redemption is an input that redeems a swap contract. +func (eth *Backend) Redemption(redemptionID, contractID []byte) (asset.Coin, error) { + return nil, notImplementedErr +} + +// FundingCoin is an unspent output. +func (eth *Backend) FundingCoin(ctx context.Context, coinID []byte, redeemScript []byte) (asset.FundingCoin, error) { + return nil, notImplementedErr +} + +// ValidateCoinID attempts to decode the coinID. +func (eth *Backend) ValidateCoinID(coinID []byte) (string, error) { + return coinIDToString(coinID) +} + +// ValidateContract ensures that the swap contract is constructed properly, and +// contains valid sender and receiver addresses. +func (eth *Backend) ValidateContract(contract []byte) error { + return notImplementedErr +} + +// CheckAddress checks that the given address is parseable. +func (eth *Backend) CheckAddress(addr string) bool { + return common.IsHexAddress(addr) +} + +// VerifyUnspentCoin attempts to verify a coin ID is unspent. +func (eth *Backend) VerifyUnspentCoin(ctx context.Context, coinID []byte) error { + return notImplementedErr +} + +// run processes the queue and monitors the application context. +func (eth *Backend) run(ctx context.Context) { + var wg sync.WaitGroup + wg.Add(1) + // Shut down the RPC client on ctx.Done(). + go func() { + <-ctx.Done() + eth.shutdown() + wg.Done() + }() + + blockPoll := time.NewTicker(blockPollInterval) + defer blockPoll.Stop() + addBlock := func(block *types.Block, reorg bool) { + _, err := eth.blockCache.add(block) + if err != nil { + eth.log.Errorf("error adding new best block to cache: %v", err) + } + eth.signalMtx.Lock() + eth.log.Tracef("Notifying %d eth asset consumers of new block at height %d", + len(eth.blockChans), block.NumberU64()) + for c := range eth.blockChans { + select { + case c <- &asset.BlockUpdate{ + Err: nil, + Reorg: reorg, + }: + default: + // Commented to try sends on future blocks. + // close(c) + // delete(eth.blockChans, c) + // + // TODO: Allow the receiver (e.g. Swapper.Run) to inform done + // status so the channels can be retired cleanly rather than + // trying them forever. + } + } + eth.signalMtx.Unlock() + } + + sendErr := func(err error) { + eth.log.Error(err) + eth.signalMtx.Lock() + for c := range eth.blockChans { + select { + case c <- &asset.BlockUpdate{ + Err: err, + }: + default: + eth.log.Errorf("failed to send sending block update on blocking channel") + // close(c) + // delete(eth.blockChans, c) + } + } + eth.signalMtx.Unlock() + } + + sendErrFmt := func(s string, a ...interface{}) { + sendErr(fmt.Errorf(s, a...)) + } + +out: + for { + select { + case <-blockPoll.C: + tip := eth.blockCache.tip() + bestHash, err := eth.node.bestBlockHash(ctx) + if err != nil { + sendErr(asset.NewConnectionError("error retrieving best block: %w", err)) + continue + } + if bestHash == tip.hash { + continue + } + + block, err := eth.node.block(ctx, bestHash) + if err != nil { + sendErrFmt("error retrieving block %x: %w", bestHash, err) + continue + } + // If this doesn't build on the best known block, look for a reorg. + prevHash := block.ParentHash() + // If it builds on the best block or the cache is empty, it's good to add. + if prevHash == tip.hash || tip.height == 0 { + eth.log.Debugf("New block %x (%d)", bestHash, block.NumberU64()) + addBlock(block, false) + continue + } + + // It is either a reorg, or the previous block is not the cached + // best block. Crawl blocks backwards until finding a mainchain + // block, flagging blocks from the cache as orphans along the way. + // + // TODO: Fix this. The exact ethereum behavior here is yet unknown. + iHash := tip.hash + reorgHeight := uint64(0) + for { + if iHash == zeroHash { + break + } + iBlock, err := eth.node.block(ctx, iHash) + if err != nil { + sendErrFmt("error retrieving block %s: %w", iHash, err) + break + } + // TODO: It is yet unknown how to tell if we are + // on the main chain. Blocks do not contain confirmation + // information. + // if iBlock.Confirmations > -1 { + // // This is a mainchain block, nothing to do. + // break + // } + if iBlock.NumberU64() == 0 { + break + } + reorgHeight = iBlock.NumberU64() + iHash = iBlock.ParentHash() + } + + var reorg bool + if reorgHeight > 0 { + reorg = true + eth.log.Infof("Tip change from %s (%d) to %s (%d) detected (reorg or just fast blocks).", + tip.hash, tip.height, bestHash, block.NumberU64()) + eth.blockCache.reorg(reorgHeight) + } + + // Now add the new block. + addBlock(block, reorg) + + case <-ctx.Done(): + break out + } + } + // Wait for the RPC client to shut down. + wg.Wait() +} + +// decodeCoinID decodes the coin ID into flags, a contract address, and secret hash. +func decodeCoinID(coinID []byte) (uint16, common.Address, []byte, error) { + if len(coinID) != coinIDSize { + return 0, common.Address{}, nil, fmt.Errorf("coin ID wrong length. expected %d, got %d", + coinIDSize, len(coinID)) + } + secretHash := make([]byte, 32) + copy(secretHash, coinID[22:]) + return binary.BigEndian.Uint16(coinID[:2]), common.BytesToAddress(coinID[2:22]), secretHash, nil +} + +func coinIDToString(coinID []byte) (string, error) { + flags, addr, secretHash, err := decodeCoinID(coinID) + if err != nil { + return "", err + } + return fmt.Sprintf("%x:%x:%x", flags, addr, secretHash), nil +} + +// toCoinID converts the address and secret hash to a coin ID. +func toCoinID(flags uint16, addr *common.Address, secretHash []byte) []byte { + b := make([]byte, coinIDSize) + b[0] = byte(flags) + b[1] = byte(flags >> 8) + copy(b[2:], addr[:]) + copy(b[22:], secretHash[:]) + return b +} diff --git a/server/asset/eth/eth_test.go b/server/asset/eth/eth_test.go new file mode 100644 index 0000000000..65335f55ec --- /dev/null +++ b/server/asset/eth/eth_test.go @@ -0,0 +1,238 @@ +// +build !harness +// +// These tests will not be run if the harness build tag is set. + +package eth + +import ( + "context" + "math/big" + "reflect" + "testing" + + "decred.org/dcrdex/dex" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +var ( + _ ethFetcher = (*testNode)(nil) + tLogger = dex.StdOutLogger("ETHTEST", dex.LevelTrace) +) + +type testNode struct { + connectErr error + bestBlkHash common.Hash + bestBlkHashErr error + blk *types.Block + blkErr error +} + +func (n *testNode) connect(ctx context.Context, IPC string) error { + return n.connectErr +} + +func (n *testNode) shutdown() {} + +func (n *testNode) bestBlockHash(ctx context.Context) (common.Hash, error) { + return n.bestBlkHash, n.bestBlkHashErr +} + +func (n *testNode) block(ctx context.Context, hash common.Hash) (*types.Block, error) { + return n.blk, n.blkErr +} + +func TestLoad(t *testing.T) { + tests := []struct { + name, IPC, wantIPC string + network dex.Network + wantErr bool + }{{ + name: "ok ipc supplied", + IPC: "/home/john/bleh.ipc", + wantIPC: "/home/john/bleh.ipc", + network: dex.Simnet, + }, { + name: "ok ipc not supplied", + IPC: "", + wantIPC: defaultIPC, + network: dex.Simnet, + }, { + name: "mainnet not allowed", + IPC: "", + wantIPC: defaultIPC, + network: dex.Mainnet, + wantErr: true, + }} + + for _, test := range tests { + cfg, err := load(test.IPC, test.network) + if test.wantErr { + if err == nil { + t.Fatalf("expected error for test %v", test.name) + } + continue + } + if err != nil { + t.Fatalf("unexpected error for test %v: %v", test.name, err) + } + if cfg.IPC != test.wantIPC { + t.Fatalf("want ipc value of %v but got %v for test %v", test.wantIPC, cfg.IPC, test.name) + } + } +} + +func TestDecodeCoinID(t *testing.T) { + tests := []struct { + name string + wantFlags uint16 + wantAddr common.Address + coinID, wantSecretHash []byte + wantErr bool + }{{ + name: "ok", + coinID: []byte{ + 0xFF, 0x01, // 2 byte flags + 0x18, 0xd6, 0x5f, 0xb8, 0xd6, 0x0c, 0x11, 0x99, 0xbb, + 0x1a, 0xd3, 0x81, 0xbe, 0x47, 0xaa, 0x69, 0x2b, 0x48, + 0x26, 0x05, // 20 byte addr + 0x71, 0xd8, 0x10, 0xd3, 0x93, 0x33, 0x29, 0x6b, 0x51, + 0x8c, 0x84, 0x6a, 0x3e, 0x49, 0xec, 0xa5, 0x5f, 0x99, + 0x8f, 0xd7, 0x99, 0x49, 0x98, 0xbb, 0x3e, 0x50, 0x48, + 0x56, 0x7f, 0x2f, 0x07, 0x3c, // 32 byte secret hash + }, + wantFlags: 65281, + wantAddr: common.Address{ + 0x18, 0xd6, 0x5f, 0xb8, 0xd6, 0x0c, 0x11, 0x99, 0xbb, + 0x1a, 0xd3, 0x81, 0xbe, 0x47, 0xaa, 0x69, 0x2b, 0x48, + 0x26, 0x05, + }, + wantSecretHash: []byte{ + 0x71, 0xd8, 0x10, 0xd3, 0x93, 0x33, 0x29, 0x6b, 0x51, + 0x8c, 0x84, 0x6a, 0x3e, 0x49, 0xec, 0xa5, 0x5f, 0x99, + 0x8f, 0xd7, 0x99, 0x49, 0x98, 0xbb, 0x3e, 0x50, 0x48, + 0x56, 0x7f, 0x2f, 0x07, 0x3c, // 32 byte secret hash + }, + }, { + name: "wrong length", + coinID: []byte{ + 0xFF, 0x01, // 2 byte flags + 0x18, 0xd6, 0x5f, 0xb8, 0xd6, 0x0c, 0x11, 0x99, 0xbb, + 0x1a, 0xd3, 0x81, 0xbe, 0x47, 0xaa, 0x69, 0x2b, 0x48, + 0x26, 0x05, // 20 byte addr + 0x71, 0xd8, 0x10, 0xd3, 0x93, 0x33, 0x29, 0x6b, 0x51, + 0x8c, 0x84, 0x6a, 0x3e, 0x49, 0xec, 0xa5, 0x5f, 0x99, + 0x8f, 0xd7, 0x99, 0x49, 0x98, 0xbb, 0x3e, 0x50, 0x48, + 0x56, 0x7f, 0x2f, 0x07, // 31 bytes + }, + wantErr: true, + }} + + for _, test := range tests { + flags, addr, secretHash, err := decodeCoinID(test.coinID) + if test.wantErr { + if err == nil { + t.Fatalf("expected error for test %v", test.name) + } + continue + } + if err != nil { + t.Fatalf("unexpected error for test %v: %v", test.name, err) + } + if flags != test.wantFlags { + t.Fatalf("want flags value of %v but got %v for test %v", + test.wantFlags, flags, test.name) + } + if addr != test.wantAddr { + t.Fatalf("want addr value of %v but got %v for test %v", + test.wantAddr, addr, test.name) + } + if !reflect.DeepEqual(secretHash, test.wantSecretHash) { + t.Fatalf("want secret hash value of %v but got %v for test %v", + test.wantSecretHash, secretHash, test.name) + } + } +} + +func TestCoinIDToString(t *testing.T) { + flags := "ff01" + addr := "18d65fb8d60c1199bb1ad381be47aa692b482605" + secretHash := "71d810d39333296b518c846a3e49eca55f998fd7994998bb3e5048567f2f073c" + tests := []struct { + name, wantCoinID string + coinID []byte + wantErr bool + }{{ + name: "ok", + coinID: []byte{ + 0xFF, 0x01, // 2 byte flags + 0x18, 0xd6, 0x5f, 0xb8, 0xd6, 0x0c, 0x11, 0x99, 0xbb, + 0x1a, 0xd3, 0x81, 0xbe, 0x47, 0xaa, 0x69, 0x2b, 0x48, + 0x26, 0x05, // 20 byte addr + 0x71, 0xd8, 0x10, 0xd3, 0x93, 0x33, 0x29, 0x6b, 0x51, + 0x8c, 0x84, 0x6a, 0x3e, 0x49, 0xec, 0xa5, 0x5f, 0x99, + 0x8f, 0xd7, 0x99, 0x49, 0x98, 0xbb, 0x3e, 0x50, 0x48, + 0x56, 0x7f, 0x2f, 0x07, 0x3c, // 32 byte secret hash + }, + wantCoinID: flags + ":" + addr + ":" + secretHash, + }, { + name: "wrong length", + coinID: []byte{ + 0xFF, 0x01, // 2 byte flags + 0x18, 0xd6, 0x5f, 0xb8, 0xd6, 0x0c, 0x11, 0x99, 0xbb, + 0x1a, 0xd3, 0x81, 0xbe, 0x47, 0xaa, 0x69, 0x2b, 0x48, + 0x26, 0x05, // 20 byte addr + 0x71, 0xd8, 0x10, 0xd3, 0x93, 0x33, 0x29, 0x6b, 0x51, + 0x8c, 0x84, 0x6a, 0x3e, 0x49, 0xec, 0xa5, 0x5f, 0x99, + 0x8f, 0xd7, 0x99, 0x49, 0x98, 0xbb, 0x3e, 0x50, 0x48, + 0x56, 0x7f, 0x2f, 0x07, // 31 bytes + }, + wantErr: true, + }} + + for _, test := range tests { + coinID, err := coinIDToString(test.coinID) + if test.wantErr { + if err == nil { + t.Fatalf("expected error for test %v", test.name) + } + continue + } + if err != nil { + t.Fatalf("unexpected error for test %v: %v", test.name, err) + } + if coinID != test.wantCoinID { + t.Fatalf("want coinID value of %v but got %v for test %v", + test.wantCoinID, coinID, test.name) + } + } +} + +func TestRun(t *testing.T) { + // TODO: Test all paths. + ctx, cancel := context.WithCancel(context.Background()) + header1 := &types.Header{Number: big.NewInt(1)} + block1 := types.NewBlockWithHeader(header1) + blockHash1 := block1.Hash() + node := &testNode{} + node.bestBlkHash = blockHash1 + node.blk = block1 + backend := unconnectedETH(tLogger, nil) + ch := backend.BlockChannel(1) + blocker := make(chan struct{}) + backend.node = node + go func() { + <-ch + cancel() + close(blocker) + }() + backend.run(ctx) + <-blocker + backend.blockCache.mtx.Lock() + best := backend.blockCache.best + backend.blockCache.mtx.Unlock() + if best.hash != blockHash1 { + t.Fatalf("want header hash %x but got %x", blockHash1, best.hash) + } + cancel() +} diff --git a/server/asset/eth/rpcclient.go b/server/asset/eth/rpcclient.go new file mode 100644 index 0000000000..f957b3ee3e --- /dev/null +++ b/server/asset/eth/rpcclient.go @@ -0,0 +1,73 @@ +// This code is available on the terms of the project LICENSE.md file, +// also available online at https://blueoakcouncil.org/license/1.0.0. + +package eth + +import ( + "context" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" +) + +// Check that rpcclient satisfies the ethFetcher interface. +var _ ethFetcher = (*rpcclient)(nil) + +type rpcclient struct { + ec *ethclient.Client +} + +// connect connects to an ipc socket. It then wraps ethclient's client and +// bundles commands in a form we can easil use. +func (c *rpcclient) connect(ctx context.Context, IPC string) error { + client, err := rpc.DialIPC(ctx, IPC) + if err != nil { + return fmt.Errorf("unable to dial rpc: %v", err) + } + ec := ethclient.NewClient(client) + c.ec = ec + return nil +} + +// shutdown shuts down the client. +func (c *rpcclient) shutdown() { + if c.ec != nil { + c.ec.Close() + } +} + +// bestBlockHash gets the best blocks hash at the time of calling. Due to the +// speed of Ethereum blocks, this changes often. +func (c *rpcclient) bestBlockHash(ctx context.Context) (common.Hash, error) { + header, err := c.bestHeader(ctx) + if err != nil { + return common.Hash{}, err + } + return header.Hash(), nil +} + +// bestHeader gets the best header at the time of calling. +func (c *rpcclient) bestHeader(ctx context.Context) (*types.Header, error) { + bn, err := c.ec.BlockNumber(ctx) + if err != nil { + return nil, err + } + header, err := c.ec.HeaderByNumber(ctx, big.NewInt(int64(bn))) + if err != nil { + return nil, err + } + return header, nil +} + +// block gets the block identified by hash. +func (c *rpcclient) block(ctx context.Context, hash common.Hash) (*types.Block, error) { + block, err := c.ec.BlockByHash(ctx, hash) + if err != nil { + return nil, err + } + return block, nil +} diff --git a/server/asset/eth/rpcclient_harness_test.go b/server/asset/eth/rpcclient_harness_test.go new file mode 100644 index 0000000000..5e064cd88d --- /dev/null +++ b/server/asset/eth/rpcclient_harness_test.go @@ -0,0 +1,61 @@ +// +build harness +// +// This test requires that the testnet harness be running and the unix socket +// be located at $HOME/dextest/eth/alpha/node/geth.ipc + +package eth + +import ( + "fmt" + "os" + "path/filepath" + + "context" + "testing" +) + +var ( + homeDir = os.Getenv("HOME") + ipc = filepath.Join(homeDir, "dextest/eth/alpha/node/geth.ipc") + ethClient = new(rpcclient) + ctx context.Context +) + +func TestMain(m *testing.M) { + var cancel context.CancelFunc + ctx, cancel = context.WithCancel(context.Background()) + defer func() { + cancel() + ethClient.shutdown() + }() + if err := ethClient.connect(ctx, ipc); err != nil { + fmt.Printf("Connect error: %v\n", err) + os.Exit(1) + } + os.Exit(m.Run()) +} + +func TestBestBlockHash(t *testing.T) { + _, err := ethClient.bestBlockHash(ctx) + if err != nil { + t.Fatal(err) + } +} + +func TestBestHeader(t *testing.T) { + _, err := ethClient.bestHeader(ctx) + if err != nil { + t.Fatal(err) + } +} + +func TestBlock(t *testing.T) { + h, err := ethClient.bestBlockHash(ctx) + if err != nil { + t.Fatal(err) + } + _, err = ethClient.block(ctx, h) + if err != nil { + t.Fatal(err) + } +} diff --git a/server/cmd/dcrdex/main.go b/server/cmd/dcrdex/main.go index 9b3e22307f..c11a1baf85 100644 --- a/server/cmd/dcrdex/main.go +++ b/server/cmd/dcrdex/main.go @@ -21,6 +21,7 @@ import ( _ "decred.org/dcrdex/server/asset/bch" _ "decred.org/dcrdex/server/asset/btc" // register btc asset _ "decred.org/dcrdex/server/asset/dcr" // register dcr asset + _ "decred.org/dcrdex/server/asset/eth" // register eth asset _ "decred.org/dcrdex/server/asset/ltc" // register ltc asset dexsrv "decred.org/dcrdex/server/dex" "github.com/decred/dcrd/dcrec/secp256k1/v3"