diff --git a/client/asset/eth/config.go b/client/asset/eth/config.go new file mode 100644 index 0000000000..c7776fec0c --- /dev/null +++ b/client/asset/eth/config.go @@ -0,0 +1,37 @@ +// 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, 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..f54857b8a8 --- /dev/null +++ b/client/asset/eth/eth.go @@ -0,0 +1,514 @@ +// 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" + "fmt" + "math/big" + "strings" + "sync" + "sync/atomic" + "time" + + "decred.org/dcrdex/client/asset" + "decred.org/dcrdex/dex" + dexeth "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" + "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 = 82_000_000_000 + defaultGasFeeLimit = 200_000_000_000 +) + +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, + }, + } + // 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, + } + 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 dexeth.CoinIDToString(coinID) +} + +// Info returns basic information about the wallet and asset. +func (d *Driver) Info() *asset.WalletInfo { + return WalletInfo +} + +// 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"` + 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 { + 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) + 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 + importAccount(pw string, privKeyB []byte) (*accounts.Account, error) + listWallets(ctx context.Context) ([]rawWallet, error) + 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() + syncProgress(ctx context.Context) (*ethereum.SyncProgress, 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 + + 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, + 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() + 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) + 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) { + return strings.ToLower(eth.acct.Address.String()) == strings.ToLower(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 accommodate this. +func (eth *ExchangeWallet) Balance() (*asset.Balance, error) { + 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, 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, 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, asset.ErrNotImplemented +} + +// 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, asset.ErrNotImplemented +} + +// ReturnCoins unlocks coins. This would be necessary in the case of a +// canceled order. +func (*ExchangeWallet) ReturnCoins(unspents asset.Coins) error { + 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, asset.ErrNotImplemented +} + +// 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, 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, 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, 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, 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{}, asset.ErrNotImplemented +} + +// 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, asset.ErrNotImplemented +} + +// 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, asset.ErrNotImplemented +} + +// Address returns an address for the exchange wallet. +func (eth *ExchangeWallet) Address() (string, error) { + return eth.acct.Address.String(), nil +} + +// Unlock unlocks the exchange wallet. +func (eth *ExchangeWallet) Unlock(pw string) error { + return eth.node.unlock(eth.ctx, pw, eth.acct) +} + +// Lock locks the exchange wallet. +func (eth *ExchangeWallet) Lock() error { + return eth.node.lock(eth.ctx, eth.acct) +} + +// 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 + } + 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 wallet.Status != "Unlocked" +} + +// 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, asset.ErrNotImplemented +} + +// 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. +// +// 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) { + _, err := eth.sendToAddr(common.HexToAddress(addr), + big.NewInt(0).SetUint64(value), big.NewInt(0).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, 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, + // 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 sp != nil { + ratio := float32(sp.CurrentBlock) / float32(sp.HighestBlock) + return false, ratio, nil + } + bh, err := eth.node.bestHeader(eth.ctx) + if err != nil { + return false, 0, err + } + // 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 + } + 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 "", asset.ErrNotImplemented +} + +// 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..ecf4ae38f7 --- /dev/null +++ b/client/asset/eth/eth_test.go @@ -0,0 +1,266 @@ +// +build !harness +// +// These tests will not be run if the harness build tag is set. + +package eth + +import ( + "context" + "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" + "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 + bestHdr *types.Header + bestHdrErr error + bestBlkHash common.Hash + bestBlkHashErr error + blk *types.Block + blkErr error + blkNum uint64 + 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 { + 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 +} +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) 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) { + 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) listWallets(ctx context.Context) ([]rawWallet, error) { + return nil, 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 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 +} +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 { + 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) + } + + } +} + +func TestSyncStatus(t *testing.T) { + fourthSyncProg := ðereum.SyncProgress{ + CurrentBlock: 25, + HighestBlock: 100, + } + tests := []struct { + name string + syncProg *ethereum.SyncProgress + subSecs uint64 + bestHdrErr, syncProgErr error + wantErr, wantSynced bool + wantRatio float32 + }{{ + name: "ok synced", + wantRatio: 1, + wantSynced: true, + }, { + name: "ok syncing", + syncProg: fourthSyncProg, + wantRatio: 0.25, + }, { + name: "ok header too old", + subSecs: dexeth.MaxBlockInterval, + }, { + name: "best header error", + bestHdrErr: errors.New(""), + wantErr: true, + }, { + name: "sync progress error", + 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, + bestHdr: &types.Header{Time: nowInSecs - test.subSecs}, + bestHdrErr: test.bestHdrErr, + } + eth := &ExchangeWallet{ + node: node, + ctx: ctx, + log: tLogger, + } + 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) + } + } +} diff --git a/client/asset/eth/node.go b/client/asset/eth/node.go new file mode 100644 index 0000000000..0fefeb1d1c --- /dev/null +++ b/client/asset/eth/node.go @@ -0,0 +1,307 @@ +// 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" + "io/ioutil" + "os" + "path/filepath" + + "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. It must be set before +// calling runNode, or a default will be used if found. +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: + 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: + // 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 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", err) + } + genLen := len(genBytes) + if genLen == 0 { + 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 simnet 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..b3a1bb3ea5 --- /dev/null +++ b/client/asset/eth/rpcclient.go @@ -0,0 +1,205 @@ +// This code is available on the terms of the project LICENSE.md file, +// also available online at https://blueoakcouncil.org/license/1.0.0. + +package eth + +import ( + "context" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum" + "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. +func (c *rpcclient) bestBlockHash(ctx context.Context) (common.Hash, error) { + header, err := c.bestHeader(ctx) + if err != nil { + return common.Hash{}, err + } + return header.Hash(), nil +} + +// bestHeader gets the best header at the time of calling. +func (c *rpcclient) bestHeader(ctx context.Context) (*types.Header, error) { + bn, err := c.ec.BlockNumber(ctx) + if err != nil { + return nil, err + } + header, err := c.ec.HeaderByNumber(ctx, big.NewInt(int64(bn))) + if err != nil { + return nil, err + } + return header, nil +} + +// block gets the block identified by hash. +func (c *rpcclient) block(ctx context.Context, hash common.Hash) (*types.Block, error) { + block, err := c.ec.BlockByHash(ctx, hash) + if err != nil { + return nil, err + } + return block, nil +} + +// 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() { + 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. +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 +} + +// 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 nil, err + } + return res, nil +} + +// 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 { + return common.Hash{}, err + } + return res, 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 +// 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 parsing private key: %v", 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 +} + +// 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 new file mode 100644 index 0000000000..be88c4bc03 --- /dev/null +++ b/client/asset/eth/rpcclient_harness_test.go @@ -0,0 +1,319 @@ +// +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") + 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) + 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 TestListWallets(t *testing.T) { + wallets, err := ethClient.listWallets(ctx) + if err != nil { + t.Fatal(err) + } + spew.Dump(wallets) +} + +func TestSendTransaction(t *testing.T) { + err := ethClient.unlock(ctx, pw, simnetAcct) + if err != nil { + t.Fatal(err) + } + 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) + } + 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) + } + 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) + } + 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 TestSyncProgress(t *testing.T) { + progress, err := ethClient.syncProgress(ctx) + if err != nil { + t.Fatal(err) + } + spew.Dump(progress) +} + +func TestPeers(t *testing.T) { + peers, err := ethClient.peers(ctx) + if err != nil { + t.Fatal(err) + } + spew.Dump(peers) +} 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/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 0000000000..9b0ececcc2 Binary files /dev/null and b/client/webserver/site/src/img/coins/eth.png differ 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..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" < "${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..1316633b29 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,16 +26,10 @@ 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 - // maxBlockInterval is the number of seconds since the last header came - // in over which we consider the chain to be out of sync. - maxBlockInterval = 180 - gweiFactor = 1e9 + gweiFactor = 1e9 ) var ( @@ -61,7 +54,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 @@ -225,7 +218,7 @@ func (eth *Backend) ValidateSecret(secret, contract []byte) bool { func (eth *Backend) Synced() (bool, error) { // node.SyncProgress will return nil both before syncing has begun and // after it has finished. In order to discern when syncing has begun, - // check that the best header came in under maxBlockInterval. + // check that the best header came in under MaxBlockInterval. sp, err := eth.node.syncProgress(eth.rpcCtx) if err != nil { return false, err @@ -240,7 +233,7 @@ func (eth *Backend) Synced() (bool, error) { // Time in the header is in seconds. nowInSecs := time.Now().Unix() / 1000 timeDiff := nowInSecs - int64(bh.Time) - return timeDiff < maxBlockInterval, nil + return timeDiff < MaxBlockInterval, nil } // Redemption is an input that redeems a swap contract. @@ -255,7 +248,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 +407,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..45f9bb5f0b 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) @@ -351,7 +351,7 @@ func TestSynced(t *testing.T) { syncProg: new(ethereum.SyncProgress), }, { name: "ok header too old", - subSecs: maxBlockInterval, + subSecs: MaxBlockInterval, }, { name: "best header error", bestHdrErr: errors.New(""),