From 8f6922db174a9b2d75606a65407ee62323f6e11c Mon Sep 17 00:00:00 2001 From: JoeGruff Date: Fri, 5 Mar 2021 17:27:04 +0900 Subject: [PATCH 1/6] multi: Add internal ethereum clients. Adds an eth light node and wallet to client/asset. This is a bare bones implementation and most ExchangeWallet methods do not work. Mainnet use is disabled. --- client/asset/eth/config.go | 38 ++ client/asset/eth/eth.go | 475 ++++++++++++++++++++ client/asset/eth/eth_test.go | 179 ++++++++ client/asset/eth/node.go | 283 ++++++++++++ client/asset/eth/rpcclient.go | 247 ++++++++++ client/asset/eth/rpcclient_harness_test.go | 320 +++++++++++++ client/cmd/dexc/main.go | 1 + client/webserver/site/src/img/coins/eth.png | Bin 0 -> 1866 bytes client/webserver/site/src/js/doc.js | 3 +- dex/testing/eth/create-node.sh | 2 +- dex/testing/eth/harness.sh | 24 +- go.mod | 5 +- go.sum | 397 +++++++++++++++- server/asset/eth/common.go | 45 ++ server/asset/eth/eth.go | 37 +- server/asset/eth/eth_test.go | 4 +- 16 files changed, 1993 insertions(+), 67 deletions(-) create mode 100644 client/asset/eth/config.go create mode 100644 client/asset/eth/eth.go create mode 100644 client/asset/eth/eth_test.go create mode 100644 client/asset/eth/node.go create mode 100644 client/asset/eth/rpcclient.go create mode 100644 client/asset/eth/rpcclient_harness_test.go create mode 100644 client/webserver/site/src/img/coins/eth.png create mode 100644 server/asset/eth/common.go diff --git a/client/asset/eth/config.go b/client/asset/eth/config.go new file mode 100644 index 0000000000..739f6751d3 --- /dev/null +++ b/client/asset/eth/config.go @@ -0,0 +1,38 @@ +// 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" + + "decred.org/dcrdex/dex" + "decred.org/dcrdex/dex/config" +) + +// Config holds the parameters needed to initialize an ETH wallet. +type Config struct { + AppDir string `ini:"appdir"` + NodeListenAddr string `ini:"nodelistenaddr"` + GasFee float64 `ini:"gasfee"` +} + +// loadConfig loads the Config from a setting map and checks the network. +// +// TODO: Test this with windows. +func loadConfig(settings map[string]string, network dex.Network) (*Config, error) { + cfg := new(Config) + if err := config.Unmapify(settings, cfg); err != nil { + return nil, fmt.Errorf("error parsing config: %w", err) + } + 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)) + } + return cfg, nil +} diff --git a/client/asset/eth/eth.go b/client/asset/eth/eth.go new file mode 100644 index 0000000000..0513dea984 --- /dev/null +++ b/client/asset/eth/eth.go @@ -0,0 +1,475 @@ +// 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 ( + "bytes" + "context" + "crypto/sha256" + "errors" + "fmt" + "math/big" + "sync" + "sync/atomic" + "time" + + "decred.org/dcrdex/client/asset" + "decred.org/dcrdex/dex" + "decred.org/dcrdex/server/asset/eth" + "github.com/decred/dcrd/dcrutil/v4" + "github.com/ethereum/go-ethereum/accounts" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/node" + "github.com/ethereum/go-ethereum/p2p" +) + +func init() { + asset.Register(BipID, &Driver{}) +} + +const ( + // BipID is the BIP-0044 asset ID. + BipID = 60 + defaultGasFee = 8.2e10 + defaultGasFeeLimit = 2e11 +) + +var ( + defaultAppDir = dcrutil.AppDataDir("dexethclient", false) + // blockTicker is the delay between calls to check for new blocks. + blockTicker = time.Second + configOpts = []*asset.ConfigOption{ + // TODO: Use this limit. + { + Key: "gasfeelimit", + DisplayName: "Gas Fee Limit", + Description: "This is the highest network fee rate you are willing to " + + "pay on swap transactions. If gasfeelimit is lower than a market's " + + "maxfeerate, you will not be able to trade on that market with this " + + "wallet. Units: wei", + DefaultValue: defaultGasFeeLimit, + }, + { + Key: "appdir", + DisplayName: "DCR Dex Ethereum directory location.", + Description: "Location of the ethereum client data. This SHOULD NOT be a directory used by other ethereum applications. The default is recommended.", + DefaultValue: defaultAppDir, + }, + { + Key: "nodelistenaddr", + DisplayName: "Node Listening Address.", + Description: "The address the node will listen on when connecting to other ethereum nodes.", + DefaultValue: "0.0.0.0:30303", + }, + } + // WalletInfo defines some general information about a Ethereum wallet. + WalletInfo = &asset.WalletInfo{ + Name: "Ethereum", + Units: "wei", + DefaultConfigPath: defaultAppDir, // Incorrect if changed by user? + ConfigOpts: configOpts, + } + notImplementedErr = errors.New("not implemented") + mainnetContractAddr = common.HexToAddress("") +) + +// Check that Driver implements asset.Driver. +var _ asset.Driver = (*Driver)(nil) + +// Driver implements asset.Driver. +type Driver struct{} + +// Setup creates the ETH exchange wallet. Start the wallet with its Run method. +func (d *Driver) Setup(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) (asset.Wallet, error) { + return NewWallet(cfg, logger, network) +} + +// DecodeCoinID creates a human-readable representation of a coin ID for Ethereum. +func (d *Driver) DecodeCoinID(coinID []byte) (string, error) { + return eth.CoinIDToString(coinID) +} + +// Info returns basic information about the wallet and asset. +func (d *Driver) Info() *asset.WalletInfo { + return WalletInfo +} + +// 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 { + accounts() []*accounts.Account + addPeer(ctx context.Context, peer string) error + balance(ctx context.Context, acct *accounts.Account) (*big.Int, error) + bestBlockHash(ctx context.Context) (common.Hash, error) + block(ctx context.Context, hash common.Hash) (*types.Block, error) + blockNumber(ctx context.Context) (uint64, error) + connect(ctx context.Context, node *node.Node, contractAddr common.Address) error + importAccount(pw string, privKeyB []byte) (*accounts.Account, error) + lock(ctx context.Context, acct *accounts.Account) error + locked(ctx context.Context, acct *accounts.Account) (bool, error) + nodeInfo(ctx context.Context) (*p2p.NodeInfo, error) + pendingTransactions(ctx context.Context) ([]*types.Transaction, error) + transactionReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, error) + sendToAddr(ctx context.Context, acct *accounts.Account, addr common.Address, amt, gasFee *big.Int) (common.Hash, error) + shutdown() + syncStatus(ctx context.Context) (bool, float32, error) + unlock(ctx context.Context, pw string, acct *accounts.Account) error +} + +// Check that ExchangeWallet satisfies the asset.Wallet interface. +var _ asset.Wallet = (*ExchangeWallet)(nil) + +// ExchangeWallet is a wallet backend for Ethereum. The backend is how the DEX +// client app communicates with the Ethereum blockchain and wallet. ExchangeWallet +// satisfies the dex.Wallet interface. +type ExchangeWallet struct { + // 64-bit atomic variables first. See + // https://golang.org/pkg/sync/atomic/#pkg-note-BUG + tipAtConnect int64 + ctx context.Context // the asset subsystem starts with Connect(ctx) + node ethFetcher + log dex.Logger + tipChange func(error) + + internalNode *node.Node + + tipMtx sync.RWMutex + currentTip *types.Block + + acctMtx sync.RWMutex + acct *accounts.Account +} + +// Info returns basic information about the wallet and asset. +func (*ExchangeWallet) Info() *asset.WalletInfo { + return WalletInfo +} + +// NewWallet is the exported constructor by which the DEX will import the +// exchange wallet. It starts an internal light node. +func NewWallet(assetCFG *asset.WalletConfig, logger dex.Logger, network dex.Network) (*ExchangeWallet, error) { + cfg, err := loadConfig(assetCFG.Settings, network) + if err != nil { + return nil, err + } + nodeCFG := &nodeConfig{ + net: network, + listenAddr: cfg.NodeListenAddr, + appDir: cfg.AppDir, + } + node, err := runNode(nodeCFG) + if err != nil { + return nil, err + } + return &ExchangeWallet{ + log: logger, + tipChange: assetCFG.TipChange, + internalNode: node, + acct: new(accounts.Account), + }, nil +} + +func (eth *ExchangeWallet) shutdown() { + eth.node.shutdown() + eth.internalNode.Close() + eth.internalNode.Wait() +} + +// Connect connects to the node RPC server. A dex.Connector. +func (eth *ExchangeWallet) Connect(ctx context.Context) (*sync.WaitGroup, error) { + c := rpcclient{} + if err := c.connect(ctx, eth.internalNode, mainnetContractAddr); err != nil { + return nil, err + } + eth.node = &c + eth.ctx = ctx + + // Initialize the best block. + bestHash, err := eth.node.bestBlockHash(ctx) + if err != nil { + return nil, fmt.Errorf("error getting best block hash from geth: %w", err) + } + block, err := eth.node.block(ctx, bestHash) + if err != nil { + return nil, fmt.Errorf("error getting best block from geth: %w", err) + } + eth.tipMtx.Lock() + eth.currentTip = block + eth.tipMtx.Unlock() + atomic.StoreInt64(ð.tipAtConnect, int64(eth.currentTip.NumberU64())) + eth.log.Infof("Connected to geth.") + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + eth.monitorBlocks(ctx) + eth.shutdown() + }() + return &wg, nil +} + +// OwnsAddress indicates if an address belongs to the wallet. +// +// In Ethereum, an address is an account. +// +// TODO: Consider adding multiple accounts. +func (eth *ExchangeWallet) OwnsAddress(address string) (bool, error) { + eth.acctMtx.RLock() + defer eth.acctMtx.RUnlock() + return eth.acct.Address.String() == address, nil +} + +// Balance returns the total available funds in the account. +// +// NOTE: Ethereum balance does not return Immature or Locked values. +// +// TODO: Ethereum balances can easily go over the max value of a uint64. +// asset.Balance must be changed in a way to accomodate this. +func (eth *ExchangeWallet) Balance() (*asset.Balance, error) { + eth.acctMtx.RLock() + defer eth.acctMtx.RUnlock() + bigbal, err := eth.node.balance(eth.ctx, eth.acct) + if err != nil { + return nil, err + } + bal := &asset.Balance{ + Available: bigbal.Uint64(), + // Immature: , How to know? + // Locked: , Not lockable? + } + return bal, nil +} + +// MaxOrder generates information about the maximum order size and associated +// fees that the wallet can support for the given DEX configuration. The fees are an +// estimate based on current network conditions, and will be <= the fees +// associated with nfo.MaxFeeRate. For quote assets, the caller will have to +// calculate lotSize based on a rate conversion from the base asset's lot size. +func (*ExchangeWallet) MaxOrder(lotSize uint64, feeSuggestion uint64, nfo *dex.Asset) (*asset.SwapEstimate, error) { + return nil, notImplementedErr +} + +// PreSwap gets order estimates based on the available funds and the wallet +// configuration. +func (*ExchangeWallet) PreSwap(req *asset.PreSwapForm) (*asset.PreSwap, error) { + return nil, notImplementedErr +} + +// PreRedeem generates an estimate of the range of redemption fees that could +// be assessed. +func (*ExchangeWallet) PreRedeem(req *asset.PreRedeemForm) (*asset.PreRedeem, error) { + return nil, notImplementedErr +} + +// FundOrder selects coins for use in an order. The coins will be locked, and +// will not be returned in subsequent calls to FundOrder or calculated in calls +// to Available, unless they are unlocked with ReturnCoins. +// The returned []dex.Bytes contains the redeem scripts for the selected coins. +// Equal number of coins and redeemed scripts must be returned. A nil or empty +// dex.Bytes should be appended to the redeem scripts collection for coins with +// no redeem script. +func (*ExchangeWallet) FundOrder(ord *asset.Order) (asset.Coins, []dex.Bytes, error) { + return nil, nil, notImplementedErr +} + +// ReturnCoins unlocks coins. This would be necessary in the case of a +// canceled order. +func (*ExchangeWallet) ReturnCoins(unspents asset.Coins) error { + return notImplementedErr +} + +// FundingCoins gets funding coins for the coin IDs. The coins are locked. This +// method might be called to reinitialize an order from data stored externally. +// This method will only return funding coins, e.g. unspent transaction outputs. +func (*ExchangeWallet) FundingCoins(ids []dex.Bytes) (asset.Coins, error) { + return nil, notImplementedErr +} + +// Swap sends the swaps in a single transaction. The Receipts returned can be +// used to refund a failed transaction. The Input coins are manually unlocked +// because they're not auto-unlocked by the wallet and therefore inaccurately +// included as part of the locked balance despite being spent. +func (*ExchangeWallet) Swap(swaps *asset.Swaps) ([]asset.Receipt, asset.Coin, uint64, error) { + return nil, nil, 0, notImplementedErr +} + +// Redeem sends the redemption transaction, which may contain more than one +// redemption. +func (*ExchangeWallet) Redeem(form *asset.RedeemForm) ([]dex.Bytes, asset.Coin, uint64, error) { + return nil, nil, 0, notImplementedErr +} + +// SignMessage signs the message with the private key associated with the +// specified funding Coin. A slice of pubkeys required to spend the Coin and a +// signature for each pubkey are returned. +func (*ExchangeWallet) SignMessage(coin asset.Coin, msg dex.Bytes) (pubkeys, sigs []dex.Bytes, err error) { + return nil, nil, notImplementedErr +} + +// AuditContract retrieves information about a swap contract on the +// blockchain. This would be used to verify the counter-party's contract +// during a swap. +func (*ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes) (*asset.AuditInfo, error) { + return nil, notImplementedErr +} + +// LocktimeExpired returns true if the specified contract's locktime has +// expired, making it possible to issue a Refund. +func (*ExchangeWallet) LocktimeExpired(contract dex.Bytes) (bool, time.Time, error) { + return false, time.Time{}, notImplementedErr +} + +// FindRedemption watches for the input that spends the specified contract +// coin, and returns the spending input and the contract's secret key when it +// finds a spender. +// +// This method blocks until the redemption is found, an error occurs or the +// provided context is canceled. +func (*ExchangeWallet) FindRedemption(ctx context.Context, coinID dex.Bytes) (redemptionCoin, secret dex.Bytes, err error) { + return nil, nil, notImplementedErr +} + +// Refund refunds a contract. This can only be used after the time lock has +// expired. +// NOTE: The contract cannot be retrieved from the unspent coin info as the +// wallet does not store it, even though it was known when the init transaction +// was created. The client should store this information for persistence across +// sessions. +func (*ExchangeWallet) Refund(coinID, contract dex.Bytes) (dex.Bytes, error) { + return nil, notImplementedErr +} + +// Address returns an address for the exchange wallet. +func (eth *ExchangeWallet) Address() (string, error) { + eth.acctMtx.RLock() + defer eth.acctMtx.RUnlock() + return eth.acct.Address.String(), nil +} + +// Unlock unlocks the exchange wallet. +func (eth *ExchangeWallet) Unlock(pw string) error { + eth.acctMtx.RLock() + defer eth.acctMtx.RUnlock() + return eth.node.unlock(eth.ctx, pw, eth.acct) +} + +// Lock locks the exchange wallet. +func (eth *ExchangeWallet) Lock() error { + eth.acctMtx.RLock() + defer eth.acctMtx.RUnlock() + return eth.node.lock(eth.ctx, eth.acct) +} + +// Locked will be true if the wallet is currently locked. +func (eth *ExchangeWallet) Locked() bool { + eth.acctMtx.RLock() + defer eth.acctMtx.RUnlock() + locked, err := eth.node.locked(eth.ctx, eth.acct) + if err != nil { + eth.log.Errorf("unable to get locked status of account: %v", err) + // TODO: Not reliable if errored. Consider adding an error return. + return false + } + return locked +} + +// PayFee sends the dex registration fee. Transaction fees are in addition to +// the registration fee, and the fee rate is taken from the DEX configuration. +// +// NOTE: PayFee is not intended to be used with Ethereum at this time. +func (*ExchangeWallet) PayFee(address string, regFee uint64) (asset.Coin, error) { + return nil, notImplementedErr +} + +// Withdraw withdraws funds to the specified address. Fees are subtracted from +// the value. +// +// NOTE: This does not return a valid asset.Coin. +// +// TODO: Value could be larger than a uint64. Deal with it... +func (eth *ExchangeWallet) Withdraw(addr string, value uint64) (asset.Coin, error) { + eth.acctMtx.RLock() + defer eth.acctMtx.RUnlock() + _, err := eth.node.sendToAddr(eth.ctx, eth.acct, common.HexToAddress(addr), + big.NewInt(0).SetUint64(value), new(big.Int).SetUint64(defaultGasFee)) + if err != nil { + return nil, err + } + return nil, nil +} + +// ValidateSecret checks that the secret satisfies the contract. +func (*ExchangeWallet) ValidateSecret(secret, secretHash []byte) bool { + h := sha256.Sum256(secret) + return bytes.Equal(h[:], secretHash) +} + +// Confirmations gets the number of confirmations for the specified coin ID. +func (*ExchangeWallet) Confirmations(ctx context.Context, id dex.Bytes) (confs uint32, spent bool, err error) { + return 0, false, notImplementedErr +} + +// SyncStatus is information about the blockchain sync status. +func (eth *ExchangeWallet) SyncStatus() (bool, float32, error) { + return eth.node.syncStatus(eth.ctx) +} + +// RefundAddress extracts and returns the refund address from a contract. +func (eth *ExchangeWallet) RefundAddress(contract dex.Bytes) (string, error) { + return "", notImplementedErr +} + +// monitorBlocks pings for new blocks and runs the tipChange callback function +// when the block changes. New blocks are also scanned for potential contract +// redeems. +func (eth *ExchangeWallet) monitorBlocks(ctx context.Context) { + ticker := time.NewTicker(blockTicker) + defer ticker.Stop() + for { + select { + case <-ticker.C: + eth.checkForNewBlocks() + case <-ctx.Done(): + return + } + } +} + +// checkForNewBlocks checks for new blocks. When a tip change is detected, the +// tipChange callback function is invoked and a goroutine is started to check +// if any contracts in the findRedemptionQueue are redeemed in the new blocks. +func (eth *ExchangeWallet) checkForNewBlocks() { + ctx, cancel := context.WithTimeout(eth.ctx, 2*time.Second) + defer cancel() + bestHash, err := eth.node.bestBlockHash(ctx) + if err != nil { + go eth.tipChange(fmt.Errorf("failed to get best hash: %w", err)) + return + } + // This method is called frequently. Don't hold write lock + // unless tip has changed. + eth.tipMtx.RLock() + currentTipHash := eth.currentTip.Hash() + eth.tipMtx.RUnlock() + sameTip := bytes.Equal(currentTipHash[:], bestHash[:]) + if sameTip { + return + } + + newTip, err := eth.node.block(ctx, bestHash) + if err != nil { + go eth.tipChange(fmt.Errorf("failed to get best block: %w", err)) + return + } + + eth.tipMtx.Lock() + defer eth.tipMtx.Unlock() + + prevTip := eth.currentTip + eth.currentTip = newTip + eth.log.Debugf("tip change: %d (%s) => %d (%s)", prevTip.NumberU64(), prevTip.Hash(), newTip.NumberU64(), newTip.Hash()) + go eth.tipChange(nil) +} diff --git a/client/asset/eth/eth_test.go b/client/asset/eth/eth_test.go new file mode 100644 index 0000000000..6aa12d3c56 --- /dev/null +++ b/client/asset/eth/eth_test.go @@ -0,0 +1,179 @@ +// +build !harness +// +// These tests will not be run if the harness build tag is set. + +package eth + +import ( + "context" + "errors" + "math/big" + "testing" + + "decred.org/dcrdex/dex" + "github.com/ethereum/go-ethereum/accounts" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/node" + "github.com/ethereum/go-ethereum/p2p" +) + +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, node *node.Node, addr common.Address) 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 (n *testNode) accounts() []*accounts.Account { + return nil +} +func (n *testNode) balance(ctx context.Context, acct *accounts.Account) (*big.Int, error) { + return nil, nil +} +func (n *testNode) sendToAddr(ctx context.Context, acct *accounts.Account, addr common.Address, amt, gasFee *big.Int) (common.Hash, error) { + return common.Hash{}, nil +} +func (n *testNode) syncStatus(ctx context.Context) (bool, float32, error) { + return false, 0, nil +} +func (n *testNode) unlock(ctx context.Context, pw string, acct *accounts.Account) error { + return nil +} +func (n *testNode) lock(ctx context.Context, acct *accounts.Account) error { + return nil +} +func (n *testNode) locked(ctx context.Context, acct *accounts.Account) (bool, error) { + return false, nil +} +func (n *testNode) importAccount(pw string, privKeyB []byte) (*accounts.Account, error) { + return nil, nil +} +func (n *testNode) addPeer(ctx context.Context, peer string) error { + return nil +} +func (n *testNode) nodeInfo(ctx context.Context) (*p2p.NodeInfo, error) { + return nil, nil +} +func (n *testNode) blockNumber(ctx context.Context) (uint64, error) { + return 0, nil +} +func (n *testNode) pendingTransactions(ctx context.Context) ([]*types.Transaction, error) { + return nil, nil +} +func (n *testNode) transactionReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, error) { + return nil, nil +} + +func TestLoadConfig(t *testing.T) { + tests := []struct { + name string + network dex.Network + wantErr bool + }{{ + name: "ok", + network: dex.Simnet, + }, { + name: "mainnet not allowed", + network: dex.Mainnet, + wantErr: true, + }} + + for _, test := range tests { + _, err := loadConfig(nil, 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) + } + } +} + +func TestCheckForNewBlocks(t *testing.T) { + header0 := &types.Header{Number: big.NewInt(0)} + block0 := types.NewBlockWithHeader(header0) + header1 := &types.Header{Number: big.NewInt(1)} + block1 := types.NewBlockWithHeader(header1) + tests := []struct { + name string + hashErr, blockErr error + bestHash common.Hash + wantErr, hasTipChange bool + }{{ + name: "ok", + bestHash: block1.Hash(), + hasTipChange: true, + }, { + name: "ok same hash", + bestHash: block0.Hash(), + }, { + name: "best hash error", + hasTipChange: true, + hashErr: errors.New(""), + wantErr: true, + }, { + name: "block error", + bestHash: block1.Hash(), + hasTipChange: true, + blockErr: errors.New(""), + wantErr: true, + }} + + for _, test := range tests { + var err error + blocker := make(chan struct{}) + ctx, cancel := context.WithCancel(context.Background()) + tipChange := func(tipErr error) { + err = tipErr + close(blocker) + } + node := &testNode{} + node.bestBlkHash = test.bestHash + node.blk = block1 + node.bestBlkHashErr = test.hashErr + node.blkErr = test.blockErr + eth := &ExchangeWallet{ + node: node, + tipChange: tipChange, + ctx: ctx, + currentTip: block0, + log: tLogger, + } + eth.checkForNewBlocks() + + if test.hasTipChange { + <-blocker + } + cancel() + 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) + } + + } +} diff --git a/client/asset/eth/node.go b/client/asset/eth/node.go new file mode 100644 index 0000000000..d02c48e500 --- /dev/null +++ b/client/asset/eth/node.go @@ -0,0 +1,283 @@ +// 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 ( + "crypto/ecdsa" + "encoding/json" + "fmt" + + "decred.org/dcrdex/dex" + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/eth/downloader" + "github.com/ethereum/go-ethereum/eth/ethconfig" + "github.com/ethereum/go-ethereum/les" + "github.com/ethereum/go-ethereum/node" + "github.com/ethereum/go-ethereum/p2p/enode" + "github.com/ethereum/go-ethereum/p2p/nat" + "github.com/ethereum/go-ethereum/params" +) + +const ( + maxPeers = 10 +) + +var simnetGenesis string + +type nodeConfig struct { + net dex.Network + listenAddr, appDir string +} + +// SetSimnetGenesis should be set before using on simnet. +func SetSimnetGenesis(sng string) { + simnetGenesis = sng +} + +func runNode(cfg *nodeConfig) (*node.Node, error) { + stackConf := &node.Config{DataDir: cfg.appDir} + + stackConf.P2P.MaxPeers = maxPeers + var key *ecdsa.PrivateKey + var err error + if key, err = crypto.GenerateKey(); err != nil { + return nil, err + } + stackConf.P2P.PrivateKey = key + stackConf.P2P.ListenAddr = cfg.listenAddr + stackConf.P2P.NAT = nat.Any() + + var urls []string + switch cfg.net { + case dex.Simnet: + case dex.Testnet: + urls = params.GoerliBootnodes + case dex.Mainnet: + // urls = params.MainnetBootnodes + // TODO: Allow. + return nil, fmt.Errorf("eth cannot be used on mainnet") + default: + return nil, fmt.Errorf("unknown network ID: %d", uint8(cfg.net)) + } + + for _, url := range urls { + node, err := enode.Parse(enode.ValidSchemes, url) + if err != nil { + return nil, fmt.Errorf("Bootstrap URL %q invalid: %v", url, err) + } + stackConf.P2P.BootstrapNodes = append(stackConf.P2P.BootstrapNodes, node) + } + + if cfg.net != dex.Simnet { + for _, url := range params.V5Bootnodes { + node, err := enode.Parse(enode.ValidSchemes, url) + if err != nil { + return nil, fmt.Errorf("Bootstrap v5 URL %q invalid: %v", url, err) + } + stackConf.P2P.BootstrapNodesV5 = append(stackConf.P2P.BootstrapNodesV5, node) + } + } + + stack, err := node.New(stackConf) + if err != nil { + return nil, err + } + + ethCfg := ethconfig.Defaults + switch cfg.net { + case dex.Simnet: + var sp core.Genesis + if err := json.Unmarshal([]byte(simnetGenesis), &sp); err != nil { + return nil, fmt.Errorf("unable to unmarshal simnent genesis: %v", err) + } + ethCfg.Genesis = &sp + ethCfg.NetworkId = 42 + case dex.Testnet: + ethCfg.Genesis = core.DefaultGoerliGenesisBlock() + ethCfg.NetworkId = params.GoerliChainConfig.ChainID.Uint64() + case dex.Mainnet: + // urls = params.MainnetBootnodes + // TODO: Allow. + return nil, fmt.Errorf("eth cannot be used on mainnet") + default: + return nil, fmt.Errorf("unknown network ID: %d", uint8(cfg.net)) + } + + ethCfg.SyncMode = downloader.LightSync + + if _, err := les.New(stack, ðCfg); err != nil { + return nil, err + } + + if err := stack.Start(); err != nil { + return nil, err + } + + return stack, nil +} + +// +// type Ethereum struct { +// // unexported fields +// APIBackend *EthAPIBackend +// } +// +// --Methods-- +// APIs() []rpc.API +// ResetWithGenesisBlock(gb *types.Block) +// Etherbase() (eb common.Address, err error) +// SetEtherbase(etherbase common.Address) +// StartMining(threads int) error +// StopMining() +// StopMining() +// IsMining() bool +// AccountManager() *accounts.Manager +// BlockChain() *core.BlockChain +// TxPool() *core.TxPool +// EventMux() *event.TypeMux +// Engine() consensus.Engine +// ChainDb() ethdb.Database +// IsListening() bool +// Downloader() *downloader.Downloader +// Synced() bool +// ArchiveMode() bool +// BloomIndexer() *core.ChainIndexer +// Protocols() []p2p.Protocol +// Start() error +// Stop() error + +// + +// type account.Manager struct { +// no exported fields +// } +// +// --Methods-- +// Close() error +// Config() *Config +// Backends(kind reflect.Type) []Backend +// Wallets() []Wallet +// walletsNoLock() []Wallet +// Wallet(url string) (Wallet, error) +// Accounts() []common.Address +// Find(account Account) (Wallet, error) +// Subscribe(sink chan<- WalletEvent) event.Subscription + +// + +// type EthAPIBackend struct { +// no exported fields +// } +// +// --Methods-- +// ChainConfig() *params.ChainConfig +// CurrentBlock() *types.Block +// SetHead(number uint64) +// HeaderByNumber(ctx context.Context, number rpc.BlockNumber) (*types.Header, error) +// HeaderByNumberOrHash(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash) (*types.Header, error) +// HeaderByHash(ctx context.Context, hash common.Hash) (*types.Header, error) +// BlockByNumber(ctx context.Context, number rpc.BlockNumber) (*types.Block, error) +// BlockByHash(ctx context.Context, hash common.Hash) (*types.Block, error) +// BlockByNumberOrHash(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash) (*types.Block, error) +// StateAndHeaderByNumber(ctx context.Context, number rpc.BlockNumber) (*state.StateDB, *types.Header, error) +// StateAndHeaderByNumberOrHash(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash) (*state.StateDB, *types.Header, error) +// GetReceipts(ctx context.Context, hash common.Hash) (types.Receipts, error) +// GetLogs(ctx context.Context, hash common.Hash) ([][]*types.Log, error) +// GetTd(ctx context.Context, hash common.Hash) *big.Int +// GetEVM(ctx context.Context, msg core.Message, state *state.StateDB, header *types.Header) (*vm.EVM, func() error, error) +// SubscribeRemovedLogsEvent(ch chan<- core.RemovedLogsEvent) event.Subscription +// SubscribePendingLogsEvent(ch chan<- []*types.Log) event.Subscription +// SubscribeChainEvent(ch chan<- core.ChainEvent) event.Subscription +// SubscribeChainHeadEvent(ch chan<- core.ChainHeadEvent) event.Subscription +// SubscribeChainSideEvent(ch chan<- core.ChainSideEvent) event.Subscription +// SubscribeLogsEvent(ch chan<- []*types.Log) event.Subscription +// SendTx(ctx context.Context, signedTx *types.Transaction) error +// GetPoolTransactions() (types.Transactions, error) +// GetTransaction(ctx context.Context, txHash common.Hash) (*types.Transaction, common.Hash, uint64, uint64, error) +// GetPoolNonce(ctx context.Context, addr common.Address) (uint64, error) +// Stats() (pending int, queued int) +// TxPoolContent() (map[common.Address]types.Transactions, map[common.Address]types.Transactions) +// TxPool() *core.TxPool +// SubscribeNewTxsEvent(ch chan<- core.NewTxsEvent) event.Subscription +// Downloader() *downloader.Downloader +// SuggestPrice(ctx context.Context) (*big.Int, error) +// ChainDb() ethdb.Database +// EventMux() *event.TypeMux +// AccountManager() *accounts.Manager +// ExtRPCEnabled() bool +// UnprotectedAllowed() bool +// RPCGasCap() uint64 +// RPCTxFeeCap() float64 +// BloomStatus() (uint64, uint64) +// ServiceFilter(ctx context.Context, session *bloombits.MatcherSession) +// Engine() consensus.Engine +// CurrentHeader() *types.Header +// Miner() *miner.Miner +// StartMining(threads int) error +// StateAtBlock(ctx context.Context, block *types.Block, reexec uint64) (*state.StateDB, func(), error) +// StatesInRange(ctx context.Context, fromBlock *types.Block, toBlock *types.Block, reexec uint64) ([]*state.StateDB, func(), error) +// StateAtTransaction(ctx context.Context, block *types.Block, txIndex int, reexec uint64) (core.Message, vm.BlockContext, *state.StateDB, func(), error) + +// + +// type Wallet interface { +// URL() URL +// Status() (string, error) +// Open(passphrase string) error +// Close() error +// Accounts() []Account +// Contains(account Account) bool +// Derive(path DerivationPath, pin bool) (Account, error) +// SelfDerive(bases []DerivationPath, chain ethereum.ChainStateReader) +// SignData(account Account, mimeType string, data []byte) ([]byte, error) +// SignDataWithPassphrase(account Account, passphrase, mimeType string, data []byte) ([]byte, error) +// SignText(account Account, text []byte) ([]byte, error) +// SignTextWithPassphrase(account Account, passphrase string, hash []byte) ([]byte, error) +// SignTx(account Account, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error) +// SignTxWithPassphrase(account Account, passphrase string, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error) +// } + +// + +// type Account struct { +// Address common.Address `json:"address"` // Ethereum account address derived from the key +// URL URL `json:"url"` // Optional resource locator within a backend +// } +// + +// + +// type Client struct { // ethclient.client +// no exported fields +// } +// +// --Methods-- +// Close() +// ChainID(ctx context.Context) (*big.Int, error) +// BlockByHash(ctx context.Context, hash common.Hash) (*types.Block, error) +// BlockByNumber(ctx context.Context, number *big.Int) (*types.Block, error) +// BlockNumber(ctx context.Context) (uint64, error) +// TransactionCount(ctx context.Context, blockHash common.Hash) (uint, error) +// TransactionInBlock(ctx context.Context, blockHash common.Hash, index uint) (*types.Transaction, error) +// TransactionReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, error) +// SyncProgress(ctx context.Context) (*ethereum.SyncProgress, error) +// SubscribeNewHead(ctx context.Context, ch chan<- *types.Header) (ethereum.Subscription, error) +// NetworkID(ctx context.Context) (*big.Int, error) +// BalanceAt(ctx context.Context, account common.Address, blockNumber *big.Int) (*big.Int, error) +// StorageAt(ctx context.Context, account common.Address, key common.Hash, blockNumber *big.Int) ([]byte, error) +// CodeAt(ctx context.Context, account common.Address, blockNumber *big.Int) ([]byte, error) +// NonceAt(ctx context.Context, account common.Address, blockNumber *big.Int) (uint64, error) +// FilterLogs(ctx context.Context, q ethereum.FilterQuery) ([]types.Log, error) +// SubscribeFilterLogs(ctx context.Context, q ethereum.FilterQuery, ch chan<- types.Log) (ethereum.Subscription, error) +// PendingBalanceAt(ctx context.Context, account common.Address) (*big.Int, error) +// PendingStorageAt(ctx context.Context, account common.Address, key common.Hash) ([]byte, error) +// PendingCodeAt(ctx context.Context, account common.Address) ([]byte, error) +// PendingNonceAt(ctx context.Context, account common.Address) (uint64, error) +// PendingTransactionCount(ctx context.Context) (uint, error) +// CallContract(ctx context.Context, msg ethereum.CallMsg, blockNumber *big.Int) ([]byte, error) +// PendingCallContract(ctx context.Context, msg ethereum.CallMsg) ([]byte, error) +// SuggestGasPrice(ctx context.Context) (*big.Int, error) +// EstimateGas(ctx context.Context, msg ethereum.CallMsg) (uint64, error) +// SendTransaction(ctx context.Context, tx *types.Transaction) error diff --git a/client/asset/eth/rpcclient.go b/client/asset/eth/rpcclient.go new file mode 100644 index 0000000000..e727d58c54 --- /dev/null +++ b/client/asset/eth/rpcclient.go @@ -0,0 +1,247 @@ +// 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 ( + "bytes" + "context" + "errors" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/accounts" + "github.com/ethereum/go-ethereum/accounts/keystore" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/node" + "github.com/ethereum/go-ethereum/p2p" + "github.com/ethereum/go-ethereum/rpc" +) + +// Check that rpcclient satisfies the ethFetcher interface. +var _ ethFetcher = (*rpcclient)(nil) + +// rpcclient satisfies the ethFetcher interface. Do not use until Connect is +// called. +type rpcclient struct { + // c is a direct client for raw calls. + c *rpc.Client + // ec wraps the client with some useful calls. + ec *ethclient.Client + n *node.Node +} + +// connect connects to a node. It then wraps ethclient's client and +// bundles commands in a form we can easily use. +func (c *rpcclient) connect(ctx context.Context, node *node.Node, contractAddr common.Address) error { // contractAddr will be used soonTM + client, err := node.Attach() + if err != nil { + return fmt.Errorf("unable to dial rpc: %v", err) + } + c.c = client + c.ec = ethclient.NewClient(client) + c.n = node + return nil +} + +// Shutdown shuts down the client. +func (c *rpcclient) shutdown() { + if c.ec != nil { + // this will also close c.c + c.ec.Close() + } +} + +// bestBlockHash gets the best block's 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. Due to the speed of +// Ethereum blocks, this changes often. +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 +} + +// Accounts uses a raw request to obtain all accounts from personal.listAccounts. +func (c *rpcclient) accounts() []*accounts.Account { + var accts []*accounts.Account + for _, wallet := range c.n.AccountManager().Wallets() { + for _, acct := range wallet.Accounts() { + accts = append(accts, &acct) + } + } + return accts +} + +// Balance gets the current balance of an account. +func (c *rpcclient) balance(ctx context.Context, acct *accounts.Account) (*big.Int, error) { + return c.ec.BalanceAt(ctx, acct.Address, nil) +} + +// Unlock uses a raw request to unlock an account indefinitely. +func (c *rpcclient) unlock(ctx context.Context, pw string, acct *accounts.Account) error { + // Passing 0 as the last argument unlocks with not lock time. + return c.c.CallContext(ctx, nil, "personal_unlockAccount", acct.Address.String(), pw, 0) +} + +// Lock uses a raw request to unlock an account indefinitely. +func (c *rpcclient) lock(ctx context.Context, acct *accounts.Account) error { + return c.c.CallContext(ctx, nil, "personal_lockAccount", acct.Address.String()) +} + +// transactionReceipt uses a raw request to retrieve a transaction's receipt. +func (c *rpcclient) transactionReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, error) { + res := new(types.Receipt) + if err := c.c.CallContext(ctx, res, "eth_getTransactionReceipt", txHash.String()); err != nil { + return nil, err + } + return res, nil +} + +// pendingTransactions returns pending transactions. +func (c *rpcclient) pendingTransactions(ctx context.Context) ([]*types.Transaction, error) { + var ptxs []*types.Transaction + err := c.c.CallContext(ctx, &ptxs, "eth_pendingTransactions") + if err != nil { + return nil, err + } + return ptxs, nil +} + +// addPeer adds a peer. +func (c *rpcclient) addPeer(ctx context.Context, peer string) error { + return c.c.CallContext(ctx, nil, "admin_addPeer", peer) +} + +// blockNumber gets the block number at time of calling. Due to the speed of +// Ethereum blocks, this changes often. +func (c *rpcclient) blockNumber(ctx context.Context) (uint64, error) { + bn, err := c.ec.BlockNumber(ctx) + if err != nil { + return 0, err + } + return bn, nil +} + +// nodeInfo retrieves useful information about a node. +func (c *rpcclient) nodeInfo(ctx context.Context) (*p2p.NodeInfo, error) { + info := new(p2p.NodeInfo) + if err := c.c.CallContext(ctx, info, "admin_nodeInfo"); err != nil { + return nil, err + } + return info, nil +} + +// Locked uses a raw request to unlock an account indefinitely. +func (c *rpcclient) locked(ctx context.Context, acct *accounts.Account) (bool, error) { + type rawWallet struct { + URL string `json:"url"` + Status string `json:"status"` + Failure string `json:"failure,omitempty"` + Accounts []accounts.Account `json:"accounts,omitempty"` + } + var res []rawWallet + if err := c.c.CallContext(ctx, &res, "personal_listWallets"); err != nil { + return false, err + } + var wallet rawWallet + findWallet := func() bool { + for _, w := range res { + for _, a := range w.Accounts { + if bytes.Equal(a.Address[:], acct.Address[:]) { + wallet = w + return true + } + } + } + return false + } + if !findWallet() { + return false, errors.New("unable to find account") + } + return wallet.Status != "Unlocked", nil +} + +// SendToAddr uses a raw request to send funds to an addr from acct. +func (c *rpcclient) sendToAddr(ctx context.Context, acct *accounts.Account, addr common.Address, amt, gasFee *big.Int) (common.Hash, error) { + tx := map[string]string{ + "from": fmt.Sprintf("0x%x", acct.Address), + "to": fmt.Sprintf("0x%x", addr), + "value": fmt.Sprintf("0x%x", amt), + "gasPrice": fmt.Sprintf("0x%x", gasFee), + } + res := common.Hash{} + err := c.c.CallContext(ctx, &res, "eth_sendTransaction", tx) + if err != nil { + return common.Hash{}, err + } + return res, nil +} + +// SyncStatus gets the current sync status of a node. +// +// TODO: sync logic is most likely wrong and has been reported as not working +// correctly on testnet. Fix it. +func (c *rpcclient) syncStatus(ctx context.Context) (bool, float32, error) { + sync, err := c.ec.SyncProgress(ctx) + if err != nil { + return false, 0, err + } + // TODO: Ensure sync == nil means that the node is no longer syncing. + if sync == nil { + return true, 1, nil + } + ratio := float32(sync.CurrentBlock) / float32(sync.HighestBlock) + return false, ratio, nil +} + +// importAccount imports an account into the ethereum wallet by private key +// that can be unlocked with password. +func (c *rpcclient) importAccount(pw string, privKeyB []byte) (*accounts.Account, error) { + privKey, err := crypto.ToECDSA(privKeyB) + if err != nil { + return new(accounts.Account), fmt.Errorf("error restoring private key: %v \n", err) + } + ks := c.n.AccountManager().Backends(keystore.KeyStoreType)[0].(*keystore.KeyStore) + acct, err := ks.ImportECDSA(privKey, pw) + if err != nil { + return nil, err + } + return &acct, nil +} + +// wallet returns a wallet that owns acct from an ethereum wallet. +// accounts.Wallet is an interface. +func (c *rpcclient) wallet(acct accounts.Account) (accounts.Wallet, error) { + wallet, err := c.n.AccountManager().Find(acct) + if err != nil { + return nil, fmt.Errorf("error finding wallet for account %s: %v \n", acct.Address, err) + } + return wallet, nil +} diff --git a/client/asset/eth/rpcclient_harness_test.go b/client/asset/eth/rpcclient_harness_test.go new file mode 100644 index 0000000000..7a411a3116 --- /dev/null +++ b/client/asset/eth/rpcclient_harness_test.go @@ -0,0 +1,320 @@ +// +build harness +// +// This test requires that the testnet harness be running and the unix socket +// be located at $HOME/dextest/eth/gamma/node/geth.ipc +// +// These tests are expected to be run in descending as some depend on the tests before. They cannot +// be run in parallel. +// +// NOTE: Occationally tests will fail with "timed out". Please try again... +// +// TODO: Running these tests many times eventually results in all transactions +// returning "unexpeted error for test ok: exceeds block gas limit". Find out +// why that is. + +package eth + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "io/ioutil" + "math/big" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "decred.org/dcrdex/client/asset" + "decred.org/dcrdex/dex" + "github.com/davecgh/go-spew/spew" + "github.com/ethereum/go-ethereum/accounts" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" +) + +const ( + pw = "abc" + alphaAddr = "enode://897c84f6e4f18195413c1d02927e6a4093f5e7574b52bdec6f20844c4f1f6dd3f16036a9e600bd8681ab50fd8dd144df4a6ba9dd8722bb578a86aaa8222c964f@127.0.0.1:30304" +) + +var ( + gasPrice = big.NewInt(82e9) + homeDir = os.Getenv("HOME") + genesisFile = filepath.Join(homeDir, "dextest", "eth", "genesis.json") + alphaNodeDir = filepath.Join(homeDir, "dextest", "eth", "alpha", "node") + ethClient = new(rpcclient) + ctx context.Context + tLogger = dex.StdOutLogger("ETHTEST", dex.LevelTrace) + simnetAddr = common.HexToAddress("2b84C791b79Ee37De042AD2ffF1A253c3ce9bc27") + simnetAcct = &accounts.Account{Address: simnetAddr} + participantAddr = common.HexToAddress("345853e21b1d475582E71cC269124eD5e2dD3422") + participantAcct = &accounts.Account{Address: participantAddr} + simnetID = int64(42) + newTXOpts = func(ctx context.Context, from common.Address, value *big.Int) *bind.TransactOpts { + return &bind.TransactOpts{ + GasPrice: gasPrice, + GasLimit: 1e6, + Context: ctx, + From: from, + Value: value, + } + } +) + +func waitForMined(t *testing.T, timeLimit time.Duration, waitTimeLimit bool) error { + t.Helper() + err := exec.Command("geth", "--datadir="+alphaNodeDir, "attach", "--exec", "miner.start()").Run() + if err != nil { + return err + } + defer func() { + _ = exec.Command("geth", "--datadir="+alphaNodeDir, "attach", "--exec", "miner.stop()").Run() + }() + timesUp := time.After(timeLimit) +out: + for { + select { + case <-timesUp: + return errors.New("timed out") + case <-time.After(time.Second): + txs, err := ethClient.pendingTransactions(ctx) + if err != nil { + return err + } + if len(txs) == 0 { + break out + } + } + } + if waitTimeLimit { + <-timesUp + } + return nil +} + +func TestMain(m *testing.M) { + var cancel context.CancelFunc + ctx, cancel = context.WithCancel(context.Background()) + defer func() { + cancel() + ethClient.shutdown() + }() + tmpDir, err := ioutil.TempDir("", "dextest") + if err != nil { + fmt.Printf("error creating temp dir: %v\n", err) + os.Exit(1) + } + defer os.RemoveAll(tmpDir) + genBytes, err := ioutil.ReadFile(genesisFile) + if err != nil { + fmt.Printf("error reading genesis file: %v\n", err) + os.Exit(1) + } + genLen := len(genBytes) + if genLen == 0 { + fmt.Printf("no genesis found at %v\n", genesisFile) + os.Exit(1) + } + genBytes = genBytes[:genLen-1] + fmt.Printf("Genesis is:\n%v\n", string(genBytes)) + SetSimnetGenesis(string(genBytes)) + settings := map[string]string{ + "appdir": tmpDir, + "nodelistenaddr": "localhost:30355", + } + wallet, err := NewWallet(&asset.WalletConfig{Settings: settings}, tLogger, dex.Simnet) + if err != nil { + fmt.Printf("error starting node: %v\n", err) + os.Exit(1) + } + fmt.Printf("Node created at: %v\n", tmpDir) + defer func() { + wallet.internalNode.Close() + wallet.internalNode.Wait() + }() + if err := ethClient.connect(ctx, wallet.internalNode, common.Address{}); err != nil { + fmt.Printf("connect error: %v\n", err) + os.Exit(1) + } + os.Exit(m.Run()) +} + +func TestNodeInfo(t *testing.T) { + ni, err := ethClient.nodeInfo(ctx) + if err != nil { + t.Fatal(err) + } + spew.Dump(ni) +} + +func TestAddPeer(t *testing.T) { + if err := ethClient.addPeer(ctx, alphaAddr); err != nil { + t.Fatal(err) + } +} + +func TestBlockNumber(t *testing.T) { + var bn uint64 + for i := 0; ; i++ { + var err error + bn, err = ethClient.blockNumber(ctx) + if err != nil { + t.Fatal(err) + } + if bn != 0 { + break + } + if i == 60 { + t.Fatal("block count has not synced one minute") + } + time.Sleep(time.Second) + } + spew.Dump(bn) +} + +func TestBestBlockHash(t *testing.T) { + bbh, err := ethClient.bestBlockHash(ctx) + if err != nil { + t.Fatal(err) + } + spew.Dump(bbh) +} + +func TestBestHeader(t *testing.T) { + bh, err := ethClient.bestHeader(ctx) + if err != nil { + t.Fatal(err) + } + spew.Dump(bh) +} + +func TestBlock(t *testing.T) { + bh, err := ethClient.bestBlockHash(ctx) + if err != nil { + t.Fatal(err) + } + b, err := ethClient.block(ctx, bh) + if err != nil { + t.Fatal(err) + } + spew.Dump(b) +} + +func TestImportAccounts(t *testing.T) { + // The address of this will be 2b84C791b79Ee37De042AD2ffF1A253c3ce9bc27. + privB, err := hex.DecodeString("9447129055a25c8496fca9e5ee1b9463e47e6043ff0c288d07169e8284860e34") + if err != nil { + t.Fatal(err) + } + acct, err := ethClient.importAccount(pw, privB) + if err != nil { + t.Fatal(err) + } + spew.Dump(acct) + // The address of this will be 345853e21b1d475582E71cC269124eD5e2dD3422. + privB, err = hex.DecodeString("0695b9347a4dc096ae5c6f1935380ceba550c70b112f1323c211bade4d11651a") + if err != nil { + t.Fatal(err) + } + acct, err = ethClient.importAccount(pw, privB) + if err != nil { + t.Fatal(err) + } + spew.Dump(acct) +} + +func TestAccounts(t *testing.T) { + accts := ethClient.accounts() + spew.Dump(accts) +} + +func TestBalance(t *testing.T) { + bal, err := ethClient.balance(ctx, simnetAcct) + if err != nil { + t.Fatal(err) + } + spew.Dump(bal) +} + +func TestUnlock(t *testing.T) { + err := ethClient.unlock(ctx, pw, simnetAcct) + if err != nil { + t.Fatal(err) + } +} + +func TestLock(t *testing.T) { + err := ethClient.lock(ctx, simnetAcct) + if err != nil { + t.Fatal(err) + } +} + +func TestLocked(t *testing.T) { + err := ethClient.unlock(ctx, pw, simnetAcct) + if err != nil { + t.Fatal(err) + } + locked, err := ethClient.locked(ctx, simnetAcct) + if err != nil { + t.Fatal(err) + } + if locked { + t.Fatal("expected account to be unlocked") + } +} + +func TestSendToAddr(t *testing.T) { + err := ethClient.unlock(ctx, pw, simnetAcct) + if err != nil { + t.Fatal(err) + } + txHash, err := ethClient.sendToAddr(ctx, simnetAcct, simnetAddr, big.NewInt(1), gasPrice) + if err != nil { + t.Fatal(err) + } + spew.Dump(txHash) + if err := waitForMined(t, time.Second*10, false); err != nil { + t.Fatal("timeout") + } +} + +func TestTransactionReceipt(t *testing.T) { + err := ethClient.unlock(ctx, pw, simnetAcct) + if err != nil { + t.Fatal(err) + } + txHash, err := ethClient.sendToAddr(ctx, simnetAcct, simnetAddr, big.NewInt(1), gasPrice) + if err != nil { + t.Fatal(err) + } + if err := waitForMined(t, time.Second*10, false); err != nil { + t.Fatal("timeout") + } + receipt, err := ethClient.transactionReceipt(ctx, txHash) + if err != nil { + t.Fatal(err) + } + spew.Dump(receipt) +} + +func TestPendingTransactions(t *testing.T) { + txs, err := ethClient.pendingTransactions(ctx) + if err != nil { + t.Fatal(err) + } + // Should be empty. + spew.Dump(txs) +} + +func TestSyncStatus(t *testing.T) { + synced, ratio, err := ethClient.syncStatus(ctx) + if err != nil { + t.Fatal(err) + } + spew.Dump(synced) + spew.Dump(ratio) +} diff --git a/client/cmd/dexc/main.go b/client/cmd/dexc/main.go index b51f57bf51..3be6120ea2 100644 --- a/client/cmd/dexc/main.go +++ b/client/cmd/dexc/main.go @@ -19,6 +19,7 @@ import ( _ "decred.org/dcrdex/client/asset/bch" // register bch asset _ "decred.org/dcrdex/client/asset/btc" // register btc asset _ "decred.org/dcrdex/client/asset/dcr" // register dcr asset + _ "decred.org/dcrdex/client/asset/eth" // register eth asset _ "decred.org/dcrdex/client/asset/ltc" // register ltc asset "decred.org/dcrdex/client/cmd/dexc/version" "decred.org/dcrdex/client/core" diff --git a/client/webserver/site/src/img/coins/eth.png b/client/webserver/site/src/img/coins/eth.png new file mode 100644 index 0000000000000000000000000000000000000000..9b0ececcc235faec104a9cba986b60994810a8fc GIT binary patch literal 1866 zcmV-Q2etT#P)EX>4Tx04R}tkv&MmKpe$iQ>9ue4t5Z6$WWbH5EXIMDionYs1;guFuC*#ni!H4 z7e~Rh;NZt%)xpJCR|i)?5c~jfb8}L3krMxx6k5c1aNLh~_a1le0HIN3n$4LKlt6PRh$_2k|If<`^9lSMu5;R(5ySo_p#$NPk`VvaHY5X8x3Iklk|F9 ziyZ;|+rY(jTT}Lc%N=0w$&gLim4Y;dVi9;hqi@Os1GhkU)$6UbkJASrOI@XJfP+I| zv_#o!KJV`7?Csw(t^R%hBMowg;%rr|00006VoOIv00000008+zyMF)x010qNS#tmY zE+YT{E+YYWr9XB6000McNlirueSad^gZEa<4bO1wgWnpw> zWFU8GbZ8()Nlj2!fese{00kmRL_t(&-qo64Y*SSjz<-BT$a3pWFpm ztHx+h-$Wi1MTi;)GZ;5|A9aKv1kGVHKBy31Bxrn)KfV}Q%#7yVRkD(?_|U-W<`{0( zUb0wdr={!lfm>2X+qK=j-S$1*-gCbDo!|Y=_nq&YUZ{bzwzeLrg`GOL4M-_>PEJnl z1t!MF$IrQGYl+Jsjlco>=K)n!8y77Ma0s}=zRm|*Trg6~4Zs^S!$(zBbuoJ8NQL(o zCXAGFH}GJQ=0{Xj-L)uSW(0jPIp{_*aKkf)freT7+zx!Opkbtxeqei<-CtK#_0fgw znWHZEuMLcpvK_duLbY|Os=mFTV5F2Qf%mIacPtq=<(V!(sZ!Tx>(4)ol=5+4+Z-SB zs;a7w%_~fIceemObGc%u4b1fPbUV=G5KNP*s_pXvBc;4+4nbc`4m3A6ubdChbOYXU zI$D-h?lZlXWp!6OAW@}Po&z7C*7#SI1U|@h$DuvJIW9ibR<(POP91j2NI!sqr z*KOHscCb{e#N%;>hlja*`7!`kuU;h%(cG2pNXQY&? zfD8GtlF4Kk8yn-|#fx0KcCDaZzGrTEn;s9#moG^ynl(JJw=>_QP>pN{()_NgCr@byzBPrlr;GuXt{;dt+ z4Fvq1K%Z^bl|}Of-~=!N1T{@Nm1}EhX}R07ti!;T3Z~gNK+rVJOSv0?fPb^S@~;K9 z=(=&zwV07oevx;a)C06@ns#%3`~Ci>v)Sy&`6W*Qn1~Q!cRU{N&4XyN7s*Zbtz)`w z>~MPv&<2d!*PDRz@`iW;48!*;C5Q^Tsy&Fk|M1tXbEMa7|!rpiW?4fhHYz zOVhMp^Puhwg+hPjL97Qp0sIB@J9XXIU$f0w9?&nqh9b?M0-c(s<$I#h-fHhGwl?d! z(K260O)r*6$!$PHO1aYp*#$&OfTVz}a~X`7BMef?mw_)!^%)r%P7MwY&URj~Ro9KL z=k*X!)3mRElcf(dWN|AZNG=!{ zd%FDy9IR07pspJ~IW-1H|7;RM>;uaE3gaAzy-wP96ebu9rh!+=sGw6fvX`D&urT&? zy8v{|((N5xH-?s!F}0lS=K6GzuSiX18KJZWAni$s{jB107*qoM6N<$ Ef@m0G+5i9m literal 0 HcmV?d00001 diff --git a/client/webserver/site/src/js/doc.js b/client/webserver/site/src/js/doc.js index e0c93b63c0..385ad58f14 100644 --- a/client/webserver/site/src/js/doc.js +++ b/client/webserver/site/src/js/doc.js @@ -9,7 +9,8 @@ const BipIDs = { 22: 'mona', 28: 'vtc', 3: 'doge', - 145: 'bch' + 145: 'bch', + 60: 'eth' } const BipSymbols = Object.values(BipIDs) diff --git a/dex/testing/eth/create-node.sh b/dex/testing/eth/create-node.sh index a8037ffce1..c1486e1f85 100755 --- a/dex/testing/eth/create-node.sh +++ b/dex/testing/eth/create-node.sh @@ -131,7 +131,7 @@ if [ "${SYNC_MODE}" = "snap" ]; then tmux send-keys -t "$TMUX_WIN_ID" "${NODES_ROOT}/harness-ctl/${NAME} --nodiscover " \ "--config ${NODE_DIR}/eth.conf --unlock ${CHAIN_ADDRESS} " \ "--password ${GROUP_DIR}/password --light.serve 25 --datadir.ancient " \ - "${NODE_DIR}/geth-ancient" C-m + "${NODE_DIR}/geth-ancient --verbosity 5 --vmdebug" C-m else # Start the eth node listening restricted to localhost and our custom diff --git a/dex/testing/eth/harness.sh b/dex/testing/eth/harness.sh index cf2d020713..9df5f892a9 100755 --- a/dex/testing/eth/harness.sh +++ b/dex/testing/eth/harness.sh @@ -86,6 +86,12 @@ cat > "${NODES_ROOT}/genesis.json" < "${NODES_ROOT}/harness-ctl/send.js" <> 8) + copy(b[2:], addr[:]) + copy(b[22:], secretHash[:]) + return b +} diff --git a/server/asset/eth/eth.go b/server/asset/eth/eth.go index f60fb5f4ce..8b60708eed 100644 --- a/server/asset/eth/eth.go +++ b/server/asset/eth/eth.go @@ -5,7 +5,6 @@ package eth import ( "context" - "encoding/binary" "errors" "fmt" "math/big" @@ -27,9 +26,6 @@ func init() { 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 @@ -61,7 +57,7 @@ func (d *Driver) Setup(configPath string, logger dex.Logger, network dex.Network // DecodeCoinID creates a human-readable representation of a coin ID for Ethereum. func (d *Driver) DecodeCoinID(coinID []byte) (string, error) { - return coinIDToString(coinID) + return CoinIDToString(coinID) } // ethFetcher represents a blockchain information fetcher. In practice, it is @@ -255,7 +251,7 @@ func (eth *Backend) FundingCoin(ctx context.Context, coinID []byte, redeemScript // ValidateCoinID attempts to decode the coinID. func (eth *Backend) ValidateCoinID(coinID []byte) (string, error) { - return coinIDToString(coinID) + return CoinIDToString(coinID) } // ValidateContract ensures that the swap contract is constructed properly, and @@ -414,32 +410,3 @@ 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 index c3f22e1898..38c95eabe3 100644 --- a/server/asset/eth/eth_test.go +++ b/server/asset/eth/eth_test.go @@ -163,7 +163,7 @@ func TestDecodeCoinID(t *testing.T) { }} for _, test := range tests { - flags, addr, secretHash, err := decodeCoinID(test.coinID) + flags, addr, secretHash, err := DecodeCoinID(test.coinID) if test.wantErr { if err == nil { t.Fatalf("expected error for test %v", test.name) @@ -225,7 +225,7 @@ func TestCoinIDToString(t *testing.T) { }} for _, test := range tests { - coinID, err := coinIDToString(test.coinID) + coinID, err := CoinIDToString(test.coinID) if test.wantErr { if err == nil { t.Fatalf("expected error for test %v", test.name) From 525d10b2fc1dc7944c5f6755bd5812807d053221 Mon Sep 17 00:00:00 2001 From: JoeGruff Date: Tue, 11 May 2021 14:59:47 +0900 Subject: [PATCH 2/6] Fix SyncStatus TODO. --- client/asset/eth/eth.go | 105 +++++++++++++++--- client/asset/eth/eth_test.go | 122 ++++++++++++++++++++- client/asset/eth/rpcclient.go | 75 +++---------- client/asset/eth/rpcclient_harness_test.go | 37 ++++--- 4 files changed, 244 insertions(+), 95 deletions(-) diff --git a/client/asset/eth/eth.go b/client/asset/eth/eth.go index 0513dea984..58ed16a71d 100644 --- a/client/asset/eth/eth.go +++ b/client/asset/eth/eth.go @@ -18,6 +18,7 @@ import ( "decred.org/dcrdex/dex" "decred.org/dcrdex/server/asset/eth" "github.com/decred/dcrd/dcrutil/v4" + "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" @@ -96,6 +97,14 @@ func (d *Driver) Info() *asset.WalletInfo { return WalletInfo } +// rawWallet is a return type from the eth client. +type rawWallet struct { + URL string `json:"url"` + Status string `json:"status"` + Failure string `json:"failure,omitempty"` + Accounts []accounts.Account `json:"accounts,omitempty"` +} + // 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 { @@ -107,14 +116,14 @@ type ethFetcher interface { blockNumber(ctx context.Context) (uint64, error) connect(ctx context.Context, node *node.Node, contractAddr common.Address) error importAccount(pw string, privKeyB []byte) (*accounts.Account, error) + listWallets(ctx context.Context) ([]rawWallet, error) lock(ctx context.Context, acct *accounts.Account) error - locked(ctx context.Context, acct *accounts.Account) (bool, error) nodeInfo(ctx context.Context) (*p2p.NodeInfo, error) pendingTransactions(ctx context.Context) ([]*types.Transaction, error) transactionReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, error) - sendToAddr(ctx context.Context, acct *accounts.Account, addr common.Address, amt, gasFee *big.Int) (common.Hash, error) + sendTransaction(ctx context.Context, tx map[string]string) (common.Hash, error) shutdown() - syncStatus(ctx context.Context) (bool, float32, error) + syncProgress(ctx context.Context) (*ethereum.SyncProgress, error) unlock(ctx context.Context, pw string, acct *accounts.Account) error } @@ -128,10 +137,16 @@ type ExchangeWallet struct { // 64-bit atomic variables first. See // https://golang.org/pkg/sync/atomic/#pkg-note-BUG tipAtConnect int64 - ctx context.Context // the asset subsystem starts with Connect(ctx) - node ethFetcher - log dex.Logger - tipChange func(error) + + // syncingStarted and initBlockNum are atomics and are used to determine + // whether the node has started syncing. + initBlockNum uint64 + syncingStarted uint32 + + ctx context.Context // the asset subsystem starts with Connect(ctx) + node ethFetcher + log dex.Logger + tipChange func(error) internalNode *node.Node @@ -365,15 +380,30 @@ func (eth *ExchangeWallet) Lock() error { // Locked will be true if the wallet is currently locked. func (eth *ExchangeWallet) Locked() bool { + wallets, err := eth.node.listWallets(eth.ctx) + if err != nil { + eth.log.Errorf("list wallets error: %v", err) + return false + } eth.acctMtx.RLock() defer eth.acctMtx.RUnlock() - locked, err := eth.node.locked(eth.ctx, eth.acct) - if err != nil { - eth.log.Errorf("unable to get locked status of account: %v", err) - // TODO: Not reliable if errored. Consider adding an error return. + var wallet rawWallet + findWallet := func() bool { + for _, w := range wallets { + for _, a := range w.Accounts { + if bytes.Equal(a.Address[:], eth.acct.Address[:]) { + wallet = w + return true + } + } + } + return false + } + if !findWallet() { + eth.log.Error("unable to find wallet for account: %v", eth.acct) return false } - return locked + return wallet.Status != "Unlocked" } // PayFee sends the dex registration fee. Transaction fees are in addition to @@ -384,17 +414,27 @@ func (*ExchangeWallet) PayFee(address string, regFee uint64) (asset.Coin, error) return nil, notImplementedErr } +// sendToAddr sends funds from acct to addr. +func (eth *ExchangeWallet) sendToAddr(addr common.Address, amt, gasFee *big.Int) (common.Hash, error) { + tx := map[string]string{ + "from": fmt.Sprintf("0x%x", eth.acct.Address), + "to": fmt.Sprintf("0x%x", addr), + "value": fmt.Sprintf("0x%x", amt), + "gasPrice": fmt.Sprintf("0x%x", gasFee), + } + return eth.node.sendTransaction(eth.ctx, tx) +} + // Withdraw withdraws funds to the specified address. Fees are subtracted from // the value. // -// NOTE: This does not return a valid asset.Coin. -// // TODO: Value could be larger than a uint64. Deal with it... +// TODO: Return the asset.Coin. func (eth *ExchangeWallet) Withdraw(addr string, value uint64) (asset.Coin, error) { eth.acctMtx.RLock() defer eth.acctMtx.RUnlock() - _, err := eth.node.sendToAddr(eth.ctx, eth.acct, common.HexToAddress(addr), - big.NewInt(0).SetUint64(value), new(big.Int).SetUint64(defaultGasFee)) + _, err := eth.sendToAddr(common.HexToAddress(addr), + big.NewInt(0).SetUint64(value), big.NewInt(0).SetUint64(defaultGasFee)) if err != nil { return nil, err } @@ -414,7 +454,35 @@ func (*ExchangeWallet) Confirmations(ctx context.Context, id dex.Bytes) (confs u // SyncStatus is information about the blockchain sync status. func (eth *ExchangeWallet) SyncStatus() (bool, float32, error) { - return eth.node.syncStatus(eth.ctx) + // node.SyncProgress will return nil both before syncing has begun and + // after it has finished. In order to discern when syncing has begun, + // wait for at least one change in block count, then defer to + // node.SyncProgress for the lifetime of the eth backend. + syncingStarted := atomic.LoadUint32(ð.syncingStarted) + if syncingStarted != 1 { + ibn := atomic.LoadUint64(ð.initBlockNum) + bn, err := eth.node.blockNumber(eth.ctx) + if err != nil { + return false, 0, err + } + if ibn == 0 { + atomic.StoreUint64(ð.initBlockNum, bn) + return false, 0, nil + } + if bn == ibn { + return false, 0, nil + } + atomic.StoreUint32(ð.syncingStarted, 1) + } + sp, err := eth.node.syncProgress(eth.ctx) + if err != nil { + return false, 0, err + } + if sp == nil { + return true, 1, nil + } + ratio := float32(sp.CurrentBlock) / float32(sp.HighestBlock) + return false, ratio, nil } // RefundAddress extracts and returns the refund address from a contract. @@ -470,6 +538,7 @@ func (eth *ExchangeWallet) checkForNewBlocks() { prevTip := eth.currentTip eth.currentTip = newTip - eth.log.Debugf("tip change: %d (%s) => %d (%s)", prevTip.NumberU64(), prevTip.Hash(), newTip.NumberU64(), newTip.Hash()) + eth.log.Debugf("tip change: %d (%s) => %d (%s)", prevTip.NumberU64(), + prevTip.Hash(), newTip.NumberU64(), newTip.Hash()) go eth.tipChange(nil) } diff --git a/client/asset/eth/eth_test.go b/client/asset/eth/eth_test.go index 6aa12d3c56..e3628c1fc6 100644 --- a/client/asset/eth/eth_test.go +++ b/client/asset/eth/eth_test.go @@ -11,6 +11,7 @@ import ( "testing" "decred.org/dcrdex/dex" + "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" @@ -29,6 +30,10 @@ type testNode struct { bestBlkHashErr error blk *types.Block blkErr error + blkNum uint64 + blkNumErr error + syncProg *ethereum.SyncProgress + syncProgErr error } func (n *testNode) connect(ctx context.Context, node *node.Node, addr common.Address) error { @@ -47,7 +52,7 @@ func (n *testNode) accounts() []*accounts.Account { func (n *testNode) balance(ctx context.Context, acct *accounts.Account) (*big.Int, error) { return nil, nil } -func (n *testNode) sendToAddr(ctx context.Context, acct *accounts.Account, addr common.Address, amt, gasFee *big.Int) (common.Hash, error) { +func (n *testNode) sendTransaction(ctx context.Context, tx map[string]string) (common.Hash, error) { return common.Hash{}, nil } func (n *testNode) syncStatus(ctx context.Context) (bool, float32, error) { @@ -59,8 +64,8 @@ func (n *testNode) unlock(ctx context.Context, pw string, acct *accounts.Account func (n *testNode) lock(ctx context.Context, acct *accounts.Account) error { return nil } -func (n *testNode) locked(ctx context.Context, acct *accounts.Account) (bool, error) { - return false, nil +func (n *testNode) listWallets(ctx context.Context) ([]rawWallet, error) { + return nil, nil } func (n *testNode) importAccount(pw string, privKeyB []byte) (*accounts.Account, error) { return nil, nil @@ -72,7 +77,10 @@ func (n *testNode) nodeInfo(ctx context.Context) (*p2p.NodeInfo, error) { return nil, nil } func (n *testNode) blockNumber(ctx context.Context) (uint64, error) { - return 0, nil + return n.blkNum, n.blkNumErr +} +func (n *testNode) syncProgress(ctx context.Context) (*ethereum.SyncProgress, error) { + return n.syncProg, n.syncProgErr } func (n *testNode) pendingTransactions(ctx context.Context) ([]*types.Transaction, error) { return nil, nil @@ -177,3 +185,109 @@ func TestCheckForNewBlocks(t *testing.T) { } } + +func TestSyncStatus(t *testing.T) { + fourthSyncProg := ðereum.SyncProgress{ + CurrentBlock: 25, + HighestBlock: 100, + } + tests := []struct { + name string + SS, wantSS uint32 + IBN, wantIBN, blkNum uint64 + syncProg *ethereum.SyncProgress + blkNumErr, syncProgErr error + wantRatio float32 + wantErr, wantSynced bool + }{{ + name: "ok not initially synced ibm zero", + SS: 0, + wantSS: 0, + IBN: 0, + wantIBN: 100, + blkNum: 100, + }, { + name: "ok not initially synced no block number change", + SS: 0, + wantSS: 0, + IBN: 100, + wantIBN: 100, + blkNum: 100, + }, { + name: "ok not initially synced progress returned", + SS: 0, + wantSS: 1, + IBN: 100, + wantIBN: 100, + blkNum: 101, + syncProg: fourthSyncProg, + wantRatio: 0.25, + }, { + name: "ok not initially synced progress not returned", + SS: 0, + wantSS: 1, + IBN: 100, + wantIBN: 100, + blkNum: 101, + wantSynced: true, + wantRatio: 1, + }, { + name: "ok initially synced progress returned", + SS: 1, + wantSS: 1, + IBN: 100, + wantIBN: 100, + syncProg: fourthSyncProg, + wantRatio: 0.25, + }, { + name: "not initially synced blockNumber error", + SS: 0, + blkNumErr: errors.New(""), + wantErr: true, + }, { + name: "initially synced syncProgress error", + SS: 1, + syncProgErr: errors.New(""), + wantErr: true, + }} + + for _, test := range tests { + ctx, cancel := context.WithCancel(context.Background()) + node := &testNode{ + syncProg: test.syncProg, + syncProgErr: test.syncProgErr, + blkNum: test.blkNum, + blkNumErr: test.blkNumErr, + } + eth := &ExchangeWallet{ + node: node, + ctx: ctx, + log: tLogger, + syncingStarted: test.SS, + initBlockNum: test.IBN, + } + synced, ratio, err := eth.SyncStatus() + cancel() + if test.wantErr { + if err == nil { + t.Fatalf("expected error for test %q", test.name) + } + continue + } + if err != nil { + t.Fatalf("unexpected error for test %q: %v", test.name, err) + } + if synced != test.wantSynced { + t.Fatalf("want synced %v got %v for test %q", test.wantSynced, synced, test.name) + } + if ratio != test.wantRatio { + t.Fatalf("want ratio %v got %v for test %q", test.wantRatio, ratio, test.name) + } + if eth.syncingStarted != test.wantSS { + t.Fatalf("want syncing started %v got %v for test %q", test.wantSS, eth.syncingStarted, test.name) + } + if eth.initBlockNum != test.wantIBN { + t.Fatalf("want initial block number %v got %v for test %q", test.wantIBN, eth.initBlockNum, test.name) + } + } +} diff --git a/client/asset/eth/rpcclient.go b/client/asset/eth/rpcclient.go index e727d58c54..ae43e13f5c 100644 --- a/client/asset/eth/rpcclient.go +++ b/client/asset/eth/rpcclient.go @@ -4,12 +4,11 @@ package eth import ( - "bytes" "context" - "errors" "fmt" "math/big" + "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts" "github.com/ethereum/go-ethereum/accounts/keystore" "github.com/ethereum/go-ethereum/common" @@ -47,7 +46,7 @@ func (c *rpcclient) connect(ctx context.Context, node *node.Node, contractAddr c return nil } -// Shutdown shuts down the client. +// shutdown shuts down the client. func (c *rpcclient) shutdown() { if c.ec != nil { // this will also close c.c @@ -79,7 +78,7 @@ func (c *rpcclient) bestHeader(ctx context.Context) (*types.Header, error) { return header, nil } -// Block gets the block identified by hash. +// 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 { @@ -88,7 +87,7 @@ func (c *rpcclient) block(ctx context.Context, hash common.Hash) (*types.Block, return block, nil } -// Accounts uses a raw request to obtain all accounts from personal.listAccounts. +// accounts returns all accounts from the internal node. func (c *rpcclient) accounts() []*accounts.Account { var accts []*accounts.Account for _, wallet := range c.n.AccountManager().Wallets() { @@ -99,18 +98,18 @@ func (c *rpcclient) accounts() []*accounts.Account { return accts } -// Balance gets the current balance of an account. +// balance gets the current balance of an account. func (c *rpcclient) balance(ctx context.Context, acct *accounts.Account) (*big.Int, error) { return c.ec.BalanceAt(ctx, acct.Address, nil) } -// Unlock uses a raw request to unlock an account indefinitely. +// unlock uses a raw request to unlock an account indefinitely. func (c *rpcclient) unlock(ctx context.Context, pw string, acct *accounts.Account) error { // Passing 0 as the last argument unlocks with not lock time. return c.c.CallContext(ctx, nil, "personal_unlockAccount", acct.Address.String(), pw, 0) } -// Lock uses a raw request to unlock an account indefinitely. +// lock uses a raw request to unlock an account indefinitely. func (c *rpcclient) lock(ctx context.Context, acct *accounts.Account) error { return c.c.CallContext(ctx, nil, "personal_lockAccount", acct.Address.String()) } @@ -158,44 +157,18 @@ func (c *rpcclient) nodeInfo(ctx context.Context) (*p2p.NodeInfo, error) { return info, nil } -// Locked uses a raw request to unlock an account indefinitely. -func (c *rpcclient) locked(ctx context.Context, acct *accounts.Account) (bool, error) { - type rawWallet struct { - URL string `json:"url"` - Status string `json:"status"` - Failure string `json:"failure,omitempty"` - Accounts []accounts.Account `json:"accounts,omitempty"` - } +// listWallets list all of the wallet's wallets? and accounts along with details +// such as locked status. +func (c *rpcclient) listWallets(ctx context.Context) ([]rawWallet, error) { var res []rawWallet if err := c.c.CallContext(ctx, &res, "personal_listWallets"); err != nil { - return false, err - } - var wallet rawWallet - findWallet := func() bool { - for _, w := range res { - for _, a := range w.Accounts { - if bytes.Equal(a.Address[:], acct.Address[:]) { - wallet = w - return true - } - } - } - return false - } - if !findWallet() { - return false, errors.New("unable to find account") + return nil, err } - return wallet.Status != "Unlocked", nil + return res, nil } -// SendToAddr uses a raw request to send funds to an addr from acct. -func (c *rpcclient) sendToAddr(ctx context.Context, acct *accounts.Account, addr common.Address, amt, gasFee *big.Int) (common.Hash, error) { - tx := map[string]string{ - "from": fmt.Sprintf("0x%x", acct.Address), - "to": fmt.Sprintf("0x%x", addr), - "value": fmt.Sprintf("0x%x", amt), - "gasPrice": fmt.Sprintf("0x%x", gasFee), - } +// sendTransaction uses a raw request to send tx. +func (c *rpcclient) sendTransaction(ctx context.Context, tx map[string]string) (common.Hash, error) { res := common.Hash{} err := c.c.CallContext(ctx, &res, "eth_sendTransaction", tx) if err != nil { @@ -204,21 +177,9 @@ func (c *rpcclient) sendToAddr(ctx context.Context, acct *accounts.Account, addr return res, nil } -// SyncStatus gets the current sync status of a node. -// -// TODO: sync logic is most likely wrong and has been reported as not working -// correctly on testnet. Fix it. -func (c *rpcclient) syncStatus(ctx context.Context) (bool, float32, error) { - sync, err := c.ec.SyncProgress(ctx) - if err != nil { - return false, 0, err - } - // TODO: Ensure sync == nil means that the node is no longer syncing. - if sync == nil { - return true, 1, nil - } - ratio := float32(sync.CurrentBlock) / float32(sync.HighestBlock) - return false, ratio, nil +// syncProgress return the current sync progress. Returns no error and nil when not syncing. +func (c *rpcclient) syncProgress(ctx context.Context) (*ethereum.SyncProgress, error) { + return c.ec.SyncProgress(ctx) } // importAccount imports an account into the ethereum wallet by private key @@ -226,7 +187,7 @@ func (c *rpcclient) syncStatus(ctx context.Context) (bool, float32, error) { func (c *rpcclient) importAccount(pw string, privKeyB []byte) (*accounts.Account, error) { privKey, err := crypto.ToECDSA(privKeyB) if err != nil { - return new(accounts.Account), fmt.Errorf("error restoring private key: %v \n", err) + return new(accounts.Account), fmt.Errorf("error parsing private key: %v", err) } ks := c.n.AccountManager().Backends(keystore.KeyStoreType)[0].(*keystore.KeyStore) acct, err := ks.ImportECDSA(privKey, pw) diff --git a/client/asset/eth/rpcclient_harness_test.go b/client/asset/eth/rpcclient_harness_test.go index 7a411a3116..e2cddfdd51 100644 --- a/client/asset/eth/rpcclient_harness_test.go +++ b/client/asset/eth/rpcclient_harness_test.go @@ -253,26 +253,26 @@ func TestLock(t *testing.T) { } } -func TestLocked(t *testing.T) { - err := ethClient.unlock(ctx, pw, simnetAcct) - if err != nil { - t.Fatal(err) - } - locked, err := ethClient.locked(ctx, simnetAcct) +func TestListWallets(t *testing.T) { + wallets, err := ethClient.listWallets(ctx) if err != nil { t.Fatal(err) } - if locked { - t.Fatal("expected account to be unlocked") - } + spew.Dump(wallets) } -func TestSendToAddr(t *testing.T) { +func TestSendTransaction(t *testing.T) { err := ethClient.unlock(ctx, pw, simnetAcct) if err != nil { t.Fatal(err) } - txHash, err := ethClient.sendToAddr(ctx, simnetAcct, simnetAddr, big.NewInt(1), gasPrice) + tx := map[string]string{ + "from": fmt.Sprintf("0x%x", simnetAddr), + "to": fmt.Sprintf("0x%x", simnetAddr), + "value": fmt.Sprintf("0x%x", big.NewInt(1)), + "gasPrice": fmt.Sprintf("0x%x", gasPrice), + } + txHash, err := ethClient.sendTransaction(ctx, tx) if err != nil { t.Fatal(err) } @@ -287,7 +287,13 @@ func TestTransactionReceipt(t *testing.T) { if err != nil { t.Fatal(err) } - txHash, err := ethClient.sendToAddr(ctx, simnetAcct, simnetAddr, big.NewInt(1), gasPrice) + tx := map[string]string{ + "from": fmt.Sprintf("0x%x", simnetAddr), + "to": fmt.Sprintf("0x%x", simnetAddr), + "value": fmt.Sprintf("0x%x", big.NewInt(1)), + "gasPrice": fmt.Sprintf("0x%x", gasPrice), + } + txHash, err := ethClient.sendTransaction(ctx, tx) if err != nil { t.Fatal(err) } @@ -310,11 +316,10 @@ func TestPendingTransactions(t *testing.T) { spew.Dump(txs) } -func TestSyncStatus(t *testing.T) { - synced, ratio, err := ethClient.syncStatus(ctx) +func TestSyncProgress(t *testing.T) { + progress, err := ethClient.syncProgress(ctx) if err != nil { t.Fatal(err) } - spew.Dump(synced) - spew.Dump(ratio) + spew.Dump(progress) } From 0e471694e25595b08cc559a75a3a21653a7aaa53 Mon Sep 17 00:00:00 2001 From: JoeGruff Date: Wed, 26 May 2021 18:29:58 +0900 Subject: [PATCH 3/6] eth/node: Add default simnet genesis and urls. --- client/asset/eth/node.go | 26 +++++++++++++++++++++- client/asset/eth/rpcclient_harness_test.go | 14 ------------ 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/client/asset/eth/node.go b/client/asset/eth/node.go index d02c48e500..1901079d03 100644 --- a/client/asset/eth/node.go +++ b/client/asset/eth/node.go @@ -7,6 +7,9 @@ import ( "crypto/ecdsa" "encoding/json" "fmt" + "io/ioutil" + "os" + "path/filepath" "decred.org/dcrdex/dex" "github.com/ethereum/go-ethereum/core" @@ -31,7 +34,8 @@ type nodeConfig struct { listenAddr, appDir string } -// SetSimnetGenesis should be set before using on simnet. +// SetSimnetGenesis should be set before using on simnet. It must be set before +// calling runNode, or a default will be used if found. func SetSimnetGenesis(sng string) { simnetGenesis = sng } @@ -52,6 +56,12 @@ func runNode(cfg *nodeConfig) (*node.Node, error) { var urls []string switch cfg.net { case dex.Simnet: + urls = []string{ + "enode://897c84f6e4f18195413c1d02927e6a4093f5e7574b52bdec6f20844c4f1f6dd3f16036a9e600bd8681ab50fd8dd144df4a6ba9dd8722bb578a86aaa8222c964f@127.0.0.1:30304", // alpha + "enode://b1d3e358ee5c9b268e911f2cab47bc12d0e65c80a6d2b453fece34facc9ac3caed14aa3bc7578166bb08c5bc9719e5a2267ae14e0b42da393f4d86f6d5829061@127.0.0.1:30305", // beta + "enode://b1c14deee09b9d5549c90b7b30a35c812a56bf6afea5873b05d7a1bcd79c7b0848bcfa982faf80cc9e758a3a0d9b470f0a002840d365050fd5bf45052a6ec313@127.0.0.1:30306", // gamma + "enode://ca414c361d1a38716170923e4900d9dc9203dbaf8fdcaee73e1f861df9fdf20a1453b76fd218c18bc6f3c7e13cbca0b3416af02a53b8e31188faa45aab398d1c@127.0.0.1:30307", // delta + } case dex.Testnet: urls = params.GoerliBootnodes case dex.Mainnet: @@ -89,6 +99,20 @@ func runNode(cfg *nodeConfig) (*node.Node, error) { switch cfg.net { case dex.Simnet: var sp core.Genesis + if simnetGenesis == "" { + homeDir := os.Getenv("HOME") + genesisFile := filepath.Join(homeDir, "dextest", "eth", "genesis.json") + genBytes, err := ioutil.ReadFile(genesisFile) + if err != nil { + return nil, fmt.Errorf("error reading genesis file: %v\n", err) + } + genLen := len(genBytes) + if genLen == 0 { + return nil, fmt.Errorf("no genesis found at %v\n", genesisFile) + } + genBytes = genBytes[:genLen-1] + SetSimnetGenesis(string(genBytes)) + } if err := json.Unmarshal([]byte(simnetGenesis), &sp); err != nil { return nil, fmt.Errorf("unable to unmarshal simnent genesis: %v", err) } diff --git a/client/asset/eth/rpcclient_harness_test.go b/client/asset/eth/rpcclient_harness_test.go index e2cddfdd51..3105d058de 100644 --- a/client/asset/eth/rpcclient_harness_test.go +++ b/client/asset/eth/rpcclient_harness_test.go @@ -43,7 +43,6 @@ const ( var ( gasPrice = big.NewInt(82e9) homeDir = os.Getenv("HOME") - genesisFile = filepath.Join(homeDir, "dextest", "eth", "genesis.json") alphaNodeDir = filepath.Join(homeDir, "dextest", "eth", "alpha", "node") ethClient = new(rpcclient) ctx context.Context @@ -108,19 +107,6 @@ func TestMain(m *testing.M) { os.Exit(1) } defer os.RemoveAll(tmpDir) - genBytes, err := ioutil.ReadFile(genesisFile) - if err != nil { - fmt.Printf("error reading genesis file: %v\n", err) - os.Exit(1) - } - genLen := len(genBytes) - if genLen == 0 { - fmt.Printf("no genesis found at %v\n", genesisFile) - os.Exit(1) - } - genBytes = genBytes[:genLen-1] - fmt.Printf("Genesis is:\n%v\n", string(genBytes)) - SetSimnetGenesis(string(genBytes)) settings := map[string]string{ "appdir": tmpDir, "nodelistenaddr": "localhost:30355", From d6773e540802c673ee4f0f580e2c007550de6cd6 Mon Sep 17 00:00:00 2001 From: JoeGruff Date: Wed, 9 Jun 2021 11:44:37 +0900 Subject: [PATCH 4/6] Rely on peer count to determine if sync started. --- client/asset/eth/eth.go | 33 +++----- client/asset/eth/eth_test.go | 95 ++++++++-------------- client/asset/eth/rpcclient.go | 10 +++ client/asset/eth/rpcclient_harness_test.go | 8 ++ 4 files changed, 61 insertions(+), 85 deletions(-) diff --git a/client/asset/eth/eth.go b/client/asset/eth/eth.go index 58ed16a71d..6e1aecd4fd 100644 --- a/client/asset/eth/eth.go +++ b/client/asset/eth/eth.go @@ -35,6 +35,7 @@ const ( BipID = 60 defaultGasFee = 8.2e10 defaultGasFeeLimit = 2e11 + requiredNPeers = 2 ) var ( @@ -120,6 +121,7 @@ type ethFetcher interface { lock(ctx context.Context, acct *accounts.Account) error nodeInfo(ctx context.Context) (*p2p.NodeInfo, error) pendingTransactions(ctx context.Context) ([]*types.Transaction, error) + peers(ctx context.Context) ([]*p2p.PeerInfo, error) transactionReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, error) sendTransaction(ctx context.Context, tx map[string]string) (common.Hash, error) shutdown() @@ -138,11 +140,6 @@ type ExchangeWallet struct { // https://golang.org/pkg/sync/atomic/#pkg-note-BUG tipAtConnect int64 - // syncingStarted and initBlockNum are atomics and are used to determine - // whether the node has started syncing. - initBlockNum uint64 - syncingStarted uint32 - ctx context.Context // the asset subsystem starts with Connect(ctx) node ethFetcher log dex.Logger @@ -456,23 +453,15 @@ func (*ExchangeWallet) Confirmations(ctx context.Context, id dex.Bytes) (confs u func (eth *ExchangeWallet) SyncStatus() (bool, float32, error) { // node.SyncProgress will return nil both before syncing has begun and // after it has finished. In order to discern when syncing has begun, - // wait for at least one change in block count, then defer to - // node.SyncProgress for the lifetime of the eth backend. - syncingStarted := atomic.LoadUint32(ð.syncingStarted) - if syncingStarted != 1 { - ibn := atomic.LoadUint64(ð.initBlockNum) - bn, err := eth.node.blockNumber(eth.ctx) - if err != nil { - return false, 0, err - } - if ibn == 0 { - atomic.StoreUint64(ð.initBlockNum, bn) - return false, 0, nil - } - if bn == ibn { - return false, 0, nil - } - atomic.StoreUint32(ð.syncingStarted, 1) + // ensure we are connected to at least requiredNPeers, assume the node + // has started syncing from those peers were they ahead, and then defer + // to syncProgress. + peers, err := eth.node.peers(eth.ctx) + if err != nil { + return false, 0, err + } + if len(peers) < requiredNPeers { + return false, 0, nil } sp, err := eth.node.syncProgress(eth.ctx) if err != nil { diff --git a/client/asset/eth/eth_test.go b/client/asset/eth/eth_test.go index e3628c1fc6..a12ffcfe6c 100644 --- a/client/asset/eth/eth_test.go +++ b/client/asset/eth/eth_test.go @@ -34,6 +34,8 @@ type testNode struct { blkNumErr error syncProg *ethereum.SyncProgress syncProgErr error + peerInfo []*p2p.PeerInfo + peersErr error } func (n *testNode) connect(ctx context.Context, node *node.Node, addr common.Address) error { @@ -88,6 +90,9 @@ func (n *testNode) pendingTransactions(ctx context.Context) ([]*types.Transactio func (n *testNode) transactionReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, error) { return nil, nil } +func (n *testNode) peers(ctx context.Context) ([]*p2p.PeerInfo, error) { + return n.peerInfo, n.peersErr +} func TestLoadConfig(t *testing.T) { tests := []struct { @@ -192,61 +197,33 @@ func TestSyncStatus(t *testing.T) { HighestBlock: 100, } tests := []struct { - name string - SS, wantSS uint32 - IBN, wantIBN, blkNum uint64 - syncProg *ethereum.SyncProgress - blkNumErr, syncProgErr error - wantRatio float32 - wantErr, wantSynced bool + name string + syncProg *ethereum.SyncProgress + peerInfo []*p2p.PeerInfo + peersErr, syncProgErr error + wantErr, wantSynced bool + wantRatio float32 }{{ - name: "ok not initially synced ibm zero", - SS: 0, - wantSS: 0, - IBN: 0, - wantIBN: 100, - blkNum: 100, - }, { - name: "ok not initially synced no block number change", - SS: 0, - wantSS: 0, - IBN: 100, - wantIBN: 100, - blkNum: 100, - }, { - name: "ok not initially synced progress returned", - SS: 0, - wantSS: 1, - IBN: 100, - wantIBN: 100, - blkNum: 101, - syncProg: fourthSyncProg, - wantRatio: 0.25, - }, { - name: "ok not initially synced progress not returned", - SS: 0, - wantSS: 1, - IBN: 100, - wantIBN: 100, - blkNum: 101, - wantSynced: true, + name: "ok synced", + peerInfo: make([]*p2p.PeerInfo, 2), wantRatio: 1, + wantSynced: true, + }, { + name: "ok syncing", + peerInfo: make([]*p2p.PeerInfo, 2), + syncProg: fourthSyncProg, + wantRatio: 0.25, + wantSynced: false, }, { - name: "ok initially synced progress returned", - SS: 1, - wantSS: 1, - IBN: 100, - wantIBN: 100, - syncProg: fourthSyncProg, - wantRatio: 0.25, + name: "ok not enough peers", + peerInfo: make([]*p2p.PeerInfo, 1), }, { - name: "not initially synced blockNumber error", - SS: 0, - blkNumErr: errors.New(""), - wantErr: true, + name: "peers error", + peersErr: errors.New(""), + wantErr: true, }, { - name: "initially synced syncProgress error", - SS: 1, + name: "sync progress error", + peerInfo: make([]*p2p.PeerInfo, 2), syncProgErr: errors.New(""), wantErr: true, }} @@ -256,15 +233,13 @@ func TestSyncStatus(t *testing.T) { node := &testNode{ syncProg: test.syncProg, syncProgErr: test.syncProgErr, - blkNum: test.blkNum, - blkNumErr: test.blkNumErr, + peerInfo: test.peerInfo, + peersErr: test.peersErr, } eth := &ExchangeWallet{ - node: node, - ctx: ctx, - log: tLogger, - syncingStarted: test.SS, - initBlockNum: test.IBN, + node: node, + ctx: ctx, + log: tLogger, } synced, ratio, err := eth.SyncStatus() cancel() @@ -283,11 +258,5 @@ func TestSyncStatus(t *testing.T) { if ratio != test.wantRatio { t.Fatalf("want ratio %v got %v for test %q", test.wantRatio, ratio, test.name) } - if eth.syncingStarted != test.wantSS { - t.Fatalf("want syncing started %v got %v for test %q", test.wantSS, eth.syncingStarted, test.name) - } - if eth.initBlockNum != test.wantIBN { - t.Fatalf("want initial block number %v got %v for test %q", test.wantIBN, eth.initBlockNum, test.name) - } } } diff --git a/client/asset/eth/rpcclient.go b/client/asset/eth/rpcclient.go index ae43e13f5c..1e0e573645 100644 --- a/client/asset/eth/rpcclient.go +++ b/client/asset/eth/rpcclient.go @@ -206,3 +206,13 @@ func (c *rpcclient) wallet(acct accounts.Account) (accounts.Wallet, error) { } return wallet, nil } + +// peers returns connected peers. +func (c *rpcclient) peers(ctx context.Context) ([]*p2p.PeerInfo, error) { + var peers []*p2p.PeerInfo + err := c.c.CallContext(ctx, &peers, "admin_peers") + if err != nil { + return nil, err + } + return peers, nil +} diff --git a/client/asset/eth/rpcclient_harness_test.go b/client/asset/eth/rpcclient_harness_test.go index 3105d058de..be88c4bc03 100644 --- a/client/asset/eth/rpcclient_harness_test.go +++ b/client/asset/eth/rpcclient_harness_test.go @@ -309,3 +309,11 @@ func TestSyncProgress(t *testing.T) { } spew.Dump(progress) } + +func TestPeers(t *testing.T) { + peers, err := ethClient.peers(ctx) + if err != nil { + t.Fatal(err) + } + spew.Dump(peers) +} From fe46af035f0d82033660c45fde8593677a89a753 Mon Sep 17 00:00:00 2001 From: JoeGruff Date: Wed, 9 Jun 2021 13:11:35 +0900 Subject: [PATCH 5/6] Improve docs. --- client/asset/eth/eth.go | 7 ++++--- client/asset/eth/rpcclient.go | 9 +++------ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/client/asset/eth/eth.go b/client/asset/eth/eth.go index 6e1aecd4fd..c387029131 100644 --- a/client/asset/eth/eth.go +++ b/client/asset/eth/eth.go @@ -16,7 +16,7 @@ import ( "decred.org/dcrdex/client/asset" "decred.org/dcrdex/dex" - "decred.org/dcrdex/server/asset/eth" + dexeth "decred.org/dcrdex/server/asset/eth" "github.com/decred/dcrd/dcrutil/v4" "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts" @@ -90,7 +90,7 @@ func (d *Driver) Setup(cfg *asset.WalletConfig, logger dex.Logger, network dex.N // DecodeCoinID creates a human-readable representation of a coin ID for Ethereum. func (d *Driver) DecodeCoinID(coinID []byte) (string, error) { - return eth.CoinIDToString(coinID) + return dexeth.CoinIDToString(coinID) } // Info returns basic information about the wallet and asset. @@ -98,7 +98,8 @@ func (d *Driver) Info() *asset.WalletInfo { return WalletInfo } -// rawWallet is a return type from the eth client. +// rawWallet is an unexported return type from the eth client. Watch for changes at +// https://github.com/ethereum/go-ethereum/blob/c503f98f6d5e80e079c1d8a3601d188af2a899da/internal/ethapi/api.go#L227-L253 type rawWallet struct { URL string `json:"url"` Status string `json:"status"` diff --git a/client/asset/eth/rpcclient.go b/client/asset/eth/rpcclient.go index 1e0e573645..f43e967b3a 100644 --- a/client/asset/eth/rpcclient.go +++ b/client/asset/eth/rpcclient.go @@ -54,8 +54,7 @@ func (c *rpcclient) shutdown() { } } -// bestBlockHash gets the best block's hash at the time of calling. Due to the -// speed of Ethereum blocks, this changes often. +// bestBlockHash gets the best block's hash at the time of calling. func (c *rpcclient) bestBlockHash(ctx context.Context) (common.Hash, error) { header, err := c.bestHeader(ctx) if err != nil { @@ -64,8 +63,7 @@ func (c *rpcclient) bestBlockHash(ctx context.Context) (common.Hash, error) { return header.Hash(), nil } -// bestHeader gets the best header at the time of calling. Due to the speed of -// Ethereum blocks, this changes often. +// 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 { @@ -138,8 +136,7 @@ func (c *rpcclient) addPeer(ctx context.Context, peer string) error { return c.c.CallContext(ctx, nil, "admin_addPeer", peer) } -// blockNumber gets the block number at time of calling. Due to the speed of -// Ethereum blocks, this changes often. +// blockNumber gets the block number at time of calling. func (c *rpcclient) blockNumber(ctx context.Context) (uint64, error) { bn, err := c.ec.BlockNumber(ctx) if err != nil { From cc2b336ef5baa062a4811acead12d76bffcdd83e Mon Sep 17 00:00:00 2001 From: JoeGruff Date: Wed, 14 Jul 2021 15:53:29 +0900 Subject: [PATCH 6/6] Fix syncStatus. --- client/asset/eth/config.go | 3 +- client/asset/eth/eth.go | 102 ++++++++------------ client/asset/eth/eth_test.go | 44 +++++---- client/asset/eth/node.go | 6 +- client/asset/eth/rpcclient.go | 10 -- client/asset/interface.go | 1 + dex/testing/eth/harness.sh | 20 ++-- go.mod | 6 +- go.sum | 170 +++++++--------------------------- server/asset/eth/common.go | 7 +- server/asset/eth/eth.go | 9 +- server/asset/eth/eth_test.go | 2 +- 12 files changed, 131 insertions(+), 249 deletions(-) diff --git a/client/asset/eth/config.go b/client/asset/eth/config.go index 739f6751d3..c7776fec0c 100644 --- a/client/asset/eth/config.go +++ b/client/asset/eth/config.go @@ -26,8 +26,7 @@ func loadConfig(settings map[string]string, network dex.Network) (*Config, error return nil, fmt.Errorf("error parsing config: %w", err) } switch network { - case dex.Simnet: - case dex.Testnet: + case dex.Simnet, dex.Testnet: case dex.Mainnet: // TODO: Allow. return nil, fmt.Errorf("eth cannot be used on mainnet") diff --git a/client/asset/eth/eth.go b/client/asset/eth/eth.go index c387029131..f54857b8a8 100644 --- a/client/asset/eth/eth.go +++ b/client/asset/eth/eth.go @@ -7,9 +7,9 @@ import ( "bytes" "context" "crypto/sha256" - "errors" "fmt" "math/big" + "strings" "sync" "sync/atomic" "time" @@ -33,9 +33,8 @@ func init() { const ( // BipID is the BIP-0044 asset ID. BipID = 60 - defaultGasFee = 8.2e10 - defaultGasFeeLimit = 2e11 - requiredNPeers = 2 + defaultGasFee = 82_000_000_000 + defaultGasFeeLimit = 200_000_000_000 ) var ( @@ -59,12 +58,6 @@ var ( Description: "Location of the ethereum client data. This SHOULD NOT be a directory used by other ethereum applications. The default is recommended.", DefaultValue: defaultAppDir, }, - { - Key: "nodelistenaddr", - DisplayName: "Node Listening Address.", - Description: "The address the node will listen on when connecting to other ethereum nodes.", - DefaultValue: "0.0.0.0:30303", - }, } // WalletInfo defines some general information about a Ethereum wallet. WalletInfo = &asset.WalletInfo{ @@ -73,7 +66,6 @@ var ( DefaultConfigPath: defaultAppDir, // Incorrect if changed by user? ConfigOpts: configOpts, } - notImplementedErr = errors.New("not implemented") mainnetContractAddr = common.HexToAddress("") ) @@ -114,6 +106,7 @@ type ethFetcher interface { addPeer(ctx context.Context, peer string) error balance(ctx context.Context, acct *accounts.Account) (*big.Int, error) bestBlockHash(ctx context.Context) (common.Hash, error) + bestHeader(ctx context.Context) (*types.Header, error) block(ctx context.Context, hash common.Hash) (*types.Block, error) blockNumber(ctx context.Context) (uint64, error) connect(ctx context.Context, node *node.Node, contractAddr common.Address) error @@ -151,8 +144,7 @@ type ExchangeWallet struct { tipMtx sync.RWMutex currentTip *types.Block - acctMtx sync.RWMutex - acct *accounts.Account + acct *accounts.Account } // Info returns basic information about the wallet and asset. @@ -168,9 +160,8 @@ func NewWallet(assetCFG *asset.WalletConfig, logger dex.Logger, network dex.Netw return nil, err } nodeCFG := &nodeConfig{ - net: network, - listenAddr: cfg.NodeListenAddr, - appDir: cfg.AppDir, + net: network, + appDir: cfg.AppDir, } node, err := runNode(nodeCFG) if err != nil { @@ -211,8 +202,9 @@ func (eth *ExchangeWallet) Connect(ctx context.Context) (*sync.WaitGroup, error) eth.tipMtx.Lock() eth.currentTip = block eth.tipMtx.Unlock() - atomic.StoreInt64(ð.tipAtConnect, int64(eth.currentTip.NumberU64())) - eth.log.Infof("Connected to geth.") + height := eth.currentTip.NumberU64() + atomic.StoreInt64(ð.tipAtConnect, int64(height)) + eth.log.Infof("Connected to geth, at height %d", height) var wg sync.WaitGroup wg.Add(1) @@ -230,9 +222,7 @@ func (eth *ExchangeWallet) Connect(ctx context.Context) (*sync.WaitGroup, error) // // TODO: Consider adding multiple accounts. func (eth *ExchangeWallet) OwnsAddress(address string) (bool, error) { - eth.acctMtx.RLock() - defer eth.acctMtx.RUnlock() - return eth.acct.Address.String() == address, nil + return strings.ToLower(eth.acct.Address.String()) == strings.ToLower(address), nil } // Balance returns the total available funds in the account. @@ -240,10 +230,8 @@ func (eth *ExchangeWallet) OwnsAddress(address string) (bool, error) { // NOTE: Ethereum balance does not return Immature or Locked values. // // TODO: Ethereum balances can easily go over the max value of a uint64. -// asset.Balance must be changed in a way to accomodate this. +// asset.Balance must be changed in a way to accommodate this. func (eth *ExchangeWallet) Balance() (*asset.Balance, error) { - eth.acctMtx.RLock() - defer eth.acctMtx.RUnlock() bigbal, err := eth.node.balance(eth.ctx, eth.acct) if err != nil { return nil, err @@ -262,19 +250,19 @@ func (eth *ExchangeWallet) Balance() (*asset.Balance, error) { // associated with nfo.MaxFeeRate. For quote assets, the caller will have to // calculate lotSize based on a rate conversion from the base asset's lot size. func (*ExchangeWallet) MaxOrder(lotSize uint64, feeSuggestion uint64, nfo *dex.Asset) (*asset.SwapEstimate, error) { - return nil, notImplementedErr + return nil, asset.ErrNotImplemented } // PreSwap gets order estimates based on the available funds and the wallet // configuration. func (*ExchangeWallet) PreSwap(req *asset.PreSwapForm) (*asset.PreSwap, error) { - return nil, notImplementedErr + return nil, asset.ErrNotImplemented } // PreRedeem generates an estimate of the range of redemption fees that could // be assessed. func (*ExchangeWallet) PreRedeem(req *asset.PreRedeemForm) (*asset.PreRedeem, error) { - return nil, notImplementedErr + return nil, asset.ErrNotImplemented } // FundOrder selects coins for use in an order. The coins will be locked, and @@ -285,20 +273,20 @@ func (*ExchangeWallet) PreRedeem(req *asset.PreRedeemForm) (*asset.PreRedeem, er // dex.Bytes should be appended to the redeem scripts collection for coins with // no redeem script. func (*ExchangeWallet) FundOrder(ord *asset.Order) (asset.Coins, []dex.Bytes, error) { - return nil, nil, notImplementedErr + return nil, nil, asset.ErrNotImplemented } // ReturnCoins unlocks coins. This would be necessary in the case of a // canceled order. func (*ExchangeWallet) ReturnCoins(unspents asset.Coins) error { - return notImplementedErr + return asset.ErrNotImplemented } // FundingCoins gets funding coins for the coin IDs. The coins are locked. This // method might be called to reinitialize an order from data stored externally. // This method will only return funding coins, e.g. unspent transaction outputs. func (*ExchangeWallet) FundingCoins(ids []dex.Bytes) (asset.Coins, error) { - return nil, notImplementedErr + return nil, asset.ErrNotImplemented } // Swap sends the swaps in a single transaction. The Receipts returned can be @@ -306,33 +294,33 @@ func (*ExchangeWallet) FundingCoins(ids []dex.Bytes) (asset.Coins, error) { // because they're not auto-unlocked by the wallet and therefore inaccurately // included as part of the locked balance despite being spent. func (*ExchangeWallet) Swap(swaps *asset.Swaps) ([]asset.Receipt, asset.Coin, uint64, error) { - return nil, nil, 0, notImplementedErr + return nil, nil, 0, asset.ErrNotImplemented } // Redeem sends the redemption transaction, which may contain more than one // redemption. func (*ExchangeWallet) Redeem(form *asset.RedeemForm) ([]dex.Bytes, asset.Coin, uint64, error) { - return nil, nil, 0, notImplementedErr + return nil, nil, 0, asset.ErrNotImplemented } // SignMessage signs the message with the private key associated with the // specified funding Coin. A slice of pubkeys required to spend the Coin and a // signature for each pubkey are returned. func (*ExchangeWallet) SignMessage(coin asset.Coin, msg dex.Bytes) (pubkeys, sigs []dex.Bytes, err error) { - return nil, nil, notImplementedErr + return nil, nil, asset.ErrNotImplemented } // AuditContract retrieves information about a swap contract on the // blockchain. This would be used to verify the counter-party's contract // during a swap. func (*ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes) (*asset.AuditInfo, error) { - return nil, notImplementedErr + return nil, asset.ErrNotImplemented } // LocktimeExpired returns true if the specified contract's locktime has // expired, making it possible to issue a Refund. func (*ExchangeWallet) LocktimeExpired(contract dex.Bytes) (bool, time.Time, error) { - return false, time.Time{}, notImplementedErr + return false, time.Time{}, asset.ErrNotImplemented } // FindRedemption watches for the input that spends the specified contract @@ -342,7 +330,7 @@ func (*ExchangeWallet) LocktimeExpired(contract dex.Bytes) (bool, time.Time, err // This method blocks until the redemption is found, an error occurs or the // provided context is canceled. func (*ExchangeWallet) FindRedemption(ctx context.Context, coinID dex.Bytes) (redemptionCoin, secret dex.Bytes, err error) { - return nil, nil, notImplementedErr + return nil, nil, asset.ErrNotImplemented } // Refund refunds a contract. This can only be used after the time lock has @@ -352,27 +340,21 @@ func (*ExchangeWallet) FindRedemption(ctx context.Context, coinID dex.Bytes) (re // was created. The client should store this information for persistence across // sessions. func (*ExchangeWallet) Refund(coinID, contract dex.Bytes) (dex.Bytes, error) { - return nil, notImplementedErr + return nil, asset.ErrNotImplemented } // Address returns an address for the exchange wallet. func (eth *ExchangeWallet) Address() (string, error) { - eth.acctMtx.RLock() - defer eth.acctMtx.RUnlock() return eth.acct.Address.String(), nil } // Unlock unlocks the exchange wallet. func (eth *ExchangeWallet) Unlock(pw string) error { - eth.acctMtx.RLock() - defer eth.acctMtx.RUnlock() return eth.node.unlock(eth.ctx, pw, eth.acct) } // Lock locks the exchange wallet. func (eth *ExchangeWallet) Lock() error { - eth.acctMtx.RLock() - defer eth.acctMtx.RUnlock() return eth.node.lock(eth.ctx, eth.acct) } @@ -383,8 +365,6 @@ func (eth *ExchangeWallet) Locked() bool { eth.log.Errorf("list wallets error: %v", err) return false } - eth.acctMtx.RLock() - defer eth.acctMtx.RUnlock() var wallet rawWallet findWallet := func() bool { for _, w := range wallets { @@ -409,7 +389,7 @@ func (eth *ExchangeWallet) Locked() bool { // // NOTE: PayFee is not intended to be used with Ethereum at this time. func (*ExchangeWallet) PayFee(address string, regFee uint64) (asset.Coin, error) { - return nil, notImplementedErr + return nil, asset.ErrNotImplemented } // sendToAddr sends funds from acct to addr. @@ -429,8 +409,6 @@ func (eth *ExchangeWallet) sendToAddr(addr common.Address, amt, gasFee *big.Int) // TODO: Value could be larger than a uint64. Deal with it... // TODO: Return the asset.Coin. func (eth *ExchangeWallet) Withdraw(addr string, value uint64) (asset.Coin, error) { - eth.acctMtx.RLock() - defer eth.acctMtx.RUnlock() _, err := eth.sendToAddr(common.HexToAddress(addr), big.NewInt(0).SetUint64(value), big.NewInt(0).SetUint64(defaultGasFee)) if err != nil { @@ -447,37 +425,39 @@ func (*ExchangeWallet) ValidateSecret(secret, secretHash []byte) bool { // Confirmations gets the number of confirmations for the specified coin ID. func (*ExchangeWallet) Confirmations(ctx context.Context, id dex.Bytes) (confs uint32, spent bool, err error) { - return 0, false, notImplementedErr + return 0, false, asset.ErrNotImplemented } // SyncStatus is information about the blockchain sync status. func (eth *ExchangeWallet) SyncStatus() (bool, float32, error) { // node.SyncProgress will return nil both before syncing has begun and // after it has finished. In order to discern when syncing has begun, - // ensure we are connected to at least requiredNPeers, assume the node - // has started syncing from those peers were they ahead, and then defer - // to syncProgress. - peers, err := eth.node.peers(eth.ctx) + // check that the best header came in under dexeth.MaxBlockInterval. + sp, err := eth.node.syncProgress(eth.ctx) if err != nil { return false, 0, err } - if len(peers) < requiredNPeers { - return false, 0, nil + if sp != nil { + ratio := float32(sp.CurrentBlock) / float32(sp.HighestBlock) + return false, ratio, nil } - sp, err := eth.node.syncProgress(eth.ctx) + bh, err := eth.node.bestHeader(eth.ctx) if err != nil { return false, 0, err } - if sp == nil { - return true, 1, nil + // Time in the header is in seconds. + nowInSecs := time.Now().Unix() / 1000 + timeDiff := nowInSecs - int64(bh.Time) + var progress float32 + if timeDiff < dexeth.MaxBlockInterval { + progress = 1 } - ratio := float32(sp.CurrentBlock) / float32(sp.HighestBlock) - return false, ratio, nil + return progress == 1, progress, nil } // RefundAddress extracts and returns the refund address from a contract. func (eth *ExchangeWallet) RefundAddress(contract dex.Bytes) (string, error) { - return "", notImplementedErr + return "", asset.ErrNotImplemented } // monitorBlocks pings for new blocks and runs the tipChange callback function diff --git a/client/asset/eth/eth_test.go b/client/asset/eth/eth_test.go index a12ffcfe6c..ecf4ae38f7 100644 --- a/client/asset/eth/eth_test.go +++ b/client/asset/eth/eth_test.go @@ -9,8 +9,10 @@ import ( "errors" "math/big" "testing" + "time" "decred.org/dcrdex/dex" + dexeth "decred.org/dcrdex/server/asset/eth" "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts" "github.com/ethereum/go-ethereum/common" @@ -26,6 +28,8 @@ var ( type testNode struct { connectErr error + bestHdr *types.Header + bestHdrErr error bestBlkHash common.Hash bestBlkHashErr error blk *types.Block @@ -42,6 +46,9 @@ func (n *testNode) connect(ctx context.Context, node *node.Node, addr common.Add return n.connectErr } func (n *testNode) shutdown() {} +func (n *testNode) bestHeader(ctx context.Context) (*types.Header, error) { + return n.bestHdr, n.bestHdrErr +} func (n *testNode) bestBlockHash(ctx context.Context) (common.Hash, error) { return n.bestBlkHash, n.bestBlkHashErr } @@ -197,44 +204,41 @@ func TestSyncStatus(t *testing.T) { HighestBlock: 100, } tests := []struct { - name string - syncProg *ethereum.SyncProgress - peerInfo []*p2p.PeerInfo - peersErr, syncProgErr error - wantErr, wantSynced bool - wantRatio float32 + name string + syncProg *ethereum.SyncProgress + subSecs uint64 + bestHdrErr, syncProgErr error + wantErr, wantSynced bool + wantRatio float32 }{{ name: "ok synced", - peerInfo: make([]*p2p.PeerInfo, 2), wantRatio: 1, wantSynced: true, }, { - name: "ok syncing", - peerInfo: make([]*p2p.PeerInfo, 2), - syncProg: fourthSyncProg, - wantRatio: 0.25, - wantSynced: false, + name: "ok syncing", + syncProg: fourthSyncProg, + wantRatio: 0.25, }, { - name: "ok not enough peers", - peerInfo: make([]*p2p.PeerInfo, 1), + name: "ok header too old", + subSecs: dexeth.MaxBlockInterval, }, { - name: "peers error", - peersErr: errors.New(""), - wantErr: true, + name: "best header error", + bestHdrErr: errors.New(""), + wantErr: true, }, { name: "sync progress error", - peerInfo: make([]*p2p.PeerInfo, 2), syncProgErr: errors.New(""), wantErr: true, }} for _, test := range tests { + nowInSecs := uint64(time.Now().Unix() / 1000) ctx, cancel := context.WithCancel(context.Background()) node := &testNode{ syncProg: test.syncProg, syncProgErr: test.syncProgErr, - peerInfo: test.peerInfo, - peersErr: test.peersErr, + bestHdr: &types.Header{Time: nowInSecs - test.subSecs}, + bestHdrErr: test.bestHdrErr, } eth := &ExchangeWallet{ node: node, diff --git a/client/asset/eth/node.go b/client/asset/eth/node.go index 1901079d03..0fefeb1d1c 100644 --- a/client/asset/eth/node.go +++ b/client/asset/eth/node.go @@ -104,17 +104,17 @@ func runNode(cfg *nodeConfig) (*node.Node, error) { genesisFile := filepath.Join(homeDir, "dextest", "eth", "genesis.json") genBytes, err := ioutil.ReadFile(genesisFile) if err != nil { - return nil, fmt.Errorf("error reading genesis file: %v\n", err) + return nil, fmt.Errorf("error reading genesis file: %v", err) } genLen := len(genBytes) if genLen == 0 { - return nil, fmt.Errorf("no genesis found at %v\n", genesisFile) + return nil, fmt.Errorf("no genesis found at %v", genesisFile) } genBytes = genBytes[:genLen-1] SetSimnetGenesis(string(genBytes)) } if err := json.Unmarshal([]byte(simnetGenesis), &sp); err != nil { - return nil, fmt.Errorf("unable to unmarshal simnent genesis: %v", err) + return nil, fmt.Errorf("unable to unmarshal simnet genesis: %v", err) } ethCfg.Genesis = &sp ethCfg.NetworkId = 42 diff --git a/client/asset/eth/rpcclient.go b/client/asset/eth/rpcclient.go index f43e967b3a..b3a1bb3ea5 100644 --- a/client/asset/eth/rpcclient.go +++ b/client/asset/eth/rpcclient.go @@ -194,16 +194,6 @@ func (c *rpcclient) importAccount(pw string, privKeyB []byte) (*accounts.Account return &acct, nil } -// wallet returns a wallet that owns acct from an ethereum wallet. -// accounts.Wallet is an interface. -func (c *rpcclient) wallet(acct accounts.Account) (accounts.Wallet, error) { - wallet, err := c.n.AccountManager().Find(acct) - if err != nil { - return nil, fmt.Errorf("error finding wallet for account %s: %v \n", acct.Address, err) - } - return wallet, nil -} - // peers returns connected peers. func (c *rpcclient) peers(ctx context.Context) ([]*p2p.PeerInfo, error) { var peers []*p2p.PeerInfo diff --git a/client/asset/interface.go b/client/asset/interface.go index 120133d20f..e0f916a19c 100644 --- a/client/asset/interface.go +++ b/client/asset/interface.go @@ -18,6 +18,7 @@ const ( CoinNotFoundError = dex.ErrorKind("coin not found") ErrRequestTimeout = dex.ErrorKind("request timeout") ErrConnectionDown = dex.ErrorKind("wallet not connected") + ErrNotImplemented = dex.ErrorKind("not implemented") ) // WalletInfo is auxiliary information about an ExchangeWallet. diff --git a/dex/testing/eth/harness.sh b/dex/testing/eth/harness.sh index 9df5f892a9..d17809a1dc 100755 --- a/dex/testing/eth/harness.sh +++ b/dex/testing/eth/harness.sh @@ -1,5 +1,10 @@ #!/usr/bin/env bash -# Tmux script that sets up a simnet harness. +# tmux script that sets up an eth simnet harness. It sets up four separate nodes. +# alpha and beta nodes are synced in snap mode. They emulate nodes used by the +# dcrdex server. Either has the authority to mine blocks. They start with +# pre-allocated funds. gamma and delta are synced in light mode and emulate +# nodes used by dexc. They are sent some funds after being created. The harness +# waits for all nodes to sync before allowing tmux input. set -ex SESSION="eth-harness" @@ -60,7 +65,8 @@ echo "Writing ctl scripts" # can be mined per second with a signature belonging to the address in # "extradata". The addresses in the "alloc" field are allocated "balance". # Values are in wei. 1*10^18 wei is equal to one eth. Addresses are allocated -# 11,000 eth. +# 11,000 eth. The addresses belong to alpha and beta nodes and two others are +# used in tests. cat > "${NODES_ROOT}/genesis.json" <