diff --git a/client/asset/eth/eth.go b/client/asset/eth/eth.go index 8666eb6b0e..476b0173d3 100644 --- a/client/asset/eth/eth.go +++ b/client/asset/eth/eth.go @@ -21,11 +21,13 @@ import ( "decred.org/dcrdex/client/asset" "decred.org/dcrdex/dex" + "decred.org/dcrdex/dex/encode" swap "decred.org/dcrdex/dex/networks/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" + "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" @@ -42,6 +44,8 @@ const ( BipID = 60 defaultGasFee = 82 // gwei defaultGasFeeLimit = 200 // gwei + + RedeemGas = 63000 // gas ) var ( @@ -120,6 +124,7 @@ type ethFetcher interface { importAccount(pw string, privKeyB []byte) (*accounts.Account, error) listWallets(ctx context.Context) ([]rawWallet, error) initiate(opts *bind.TransactOpts, netID int64, refundTimestamp int64, secretHash [32]byte, participant *common.Address) (*types.Transaction, error) + estimateGas(ctx context.Context, msg ethereum.CallMsg) (uint64, error) lock(ctx context.Context, acct *accounts.Account) error nodeInfo(ctx context.Context) (*p2p.NodeInfo, error) pendingTransactions(ctx context.Context) ([]*types.Transaction, error) @@ -295,13 +300,63 @@ func (eth *ExchangeWallet) Balance() (*asset.Balance, error) { return bal, nil } +// getInitGas gets an estimate for the gas required for initiating a swap. +func (eth *ExchangeWallet) getInitGas() (uint64, error) { + var secretHash [32]byte + copy(secretHash[:], encode.RandomBytes(32)) + parsedAbi, err := abi.JSON(strings.NewReader(swap.ETHSwapABI)) + if err != nil { + return 0, err + } + data, err := parsedAbi.Pack("initiate", big.NewInt(1), secretHash, ð.acct.Address) + if err != nil { + return 0, err + } + msg := ethereum.CallMsg{ + From: eth.acct.Address, + To: &mainnetContractAddr, + Value: big.NewInt(1), + Gas: 0, + Data: data, + } + return eth.node.estimateGas(eth.ctx, msg) +} + // 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 +func (eth *ExchangeWallet) MaxOrder(lotSize uint64, feeSuggestion uint64, nfo *dex.Asset) (*asset.SwapEstimate, error) { + balance, err := eth.Balance() + if err != nil { + return nil, err + } + initGas, err := eth.getInitGas() + if err != nil { + eth.log.Warnf("error getting init gas, falling back to server's value: %v", err) + initGas = nfo.SwapSize + } + availableBalance := balance.Available + maxTxFee := initGas * nfo.MaxFeeRate + realisticTxFee := initGas * feeSuggestion + lots := availableBalance / (lotSize + maxTxFee) + if lots < 1 { + return &asset.SwapEstimate{}, nil + } + value := lots * lotSize + maxFees := lots * maxTxFee + realisticWorstCase := realisticTxFee * lots + realisticBestCase := realisticTxFee + locked := value + maxFees + return &asset.SwapEstimate{ + Lots: lots, + Value: value, + MaxFees: maxFees, + RealisticWorstCase: realisticWorstCase, + RealisticBestCase: realisticBestCase, + Locked: locked, + }, nil } // PreSwap gets order estimates based on the available funds and the wallet diff --git a/client/asset/eth/eth_test.go b/client/asset/eth/eth_test.go index 9d785a7141..6e0cdac651 100644 --- a/client/asset/eth/eth_test.go +++ b/client/asset/eth/eth_test.go @@ -45,6 +45,8 @@ type testNode struct { peersErr error bal *big.Int balErr error + initGas uint64 + initGasErr error } func (n *testNode) connect(ctx context.Context, node *node.Node, addr *common.Address) error { @@ -117,6 +119,9 @@ func (n *testNode) transactionReceipt(ctx context.Context, txHash common.Hash) ( func (n *testNode) peers(ctx context.Context) ([]*p2p.PeerInfo, error) { return n.peerInfo, n.peersErr } +func (n *testNode) estimateGas(ctx context.Context, callMsg ethereum.CallMsg) (uint64, error) { + return n.initGas, n.initGasErr +} func TestLoadConfig(t *testing.T) { tests := []struct { @@ -349,3 +354,152 @@ func TestBalance(t *testing.T) { } } } + +func TestMaxOrder(t *testing.T) { + ethToGwei := func(eth uint64) uint64 { + return eth * dexeth.GweiFactor + } + + ethToWei := func(eth int64) *big.Int { + return big.NewInt(0).Mul(big.NewInt(eth*dexeth.GweiFactor), big.NewInt(dexeth.GweiFactor)) + } + + estimatedInitGas := uint64(180000) + hardcodedInitGas := uint64(170000) + tests := []struct { + name string + bal *big.Int + balErr error + initGasErr error + lotSize uint64 + maxFeeRate uint64 + feeSuggestion uint64 + wantErr bool + wantLots uint64 + wantValue uint64 + wantMaxFees uint64 + wantWorstCase uint64 + wantBestCase uint64 + wantLocked uint64 + }{ + { + name: "no balance", + bal: big.NewInt(0), + lotSize: ethToGwei(10), + feeSuggestion: 90, + maxFeeRate: 100, + }, + { + name: "not enough for fees", + bal: ethToWei(10), + lotSize: ethToGwei(10), + feeSuggestion: 90, + maxFeeRate: 100, + }, + { + name: "one lot enough for fees", + bal: ethToWei(11), + lotSize: ethToGwei(10), + feeSuggestion: 90, + maxFeeRate: 100, + wantLots: 1, + wantValue: ethToGwei(10), + wantMaxFees: 100 * estimatedInitGas, + wantBestCase: 90 * estimatedInitGas, + wantWorstCase: 90 * estimatedInitGas, + wantLocked: ethToGwei(10) + (100 * estimatedInitGas), + }, + { + name: "multiple lots", + bal: ethToWei(51), + lotSize: ethToGwei(10), + feeSuggestion: 90, + maxFeeRate: 100, + wantLots: 5, + wantValue: ethToGwei(50), + wantMaxFees: 5 * 100 * estimatedInitGas, + wantBestCase: 90 * estimatedInitGas, + wantWorstCase: 5 * 90 * estimatedInitGas, + wantLocked: ethToGwei(50) + (5 * 100 * estimatedInitGas), + }, + { + name: "balanceError", + bal: ethToWei(51), + lotSize: ethToGwei(10), + feeSuggestion: 90, + maxFeeRate: 100, + balErr: errors.New(""), + wantErr: true, + }, + { + name: "initGasError", + bal: ethToWei(51), + lotSize: ethToGwei(10), + feeSuggestion: 90, + maxFeeRate: 100, + initGasErr: errors.New(""), + wantLots: 5, + wantValue: ethToGwei(50), + wantMaxFees: 5 * 100 * hardcodedInitGas, + wantBestCase: 90 * hardcodedInitGas, + wantWorstCase: 5 * 90 * hardcodedInitGas, + wantLocked: ethToGwei(50) + (5 * 100 * hardcodedInitGas), + }, + } + + dexAsset := dex.Asset{ + ID: 60, + Symbol: "ETH", + MaxFeeRate: 100, + SwapSize: hardcodedInitGas, + SwapSizeBase: 0, + SwapConf: 1, + } + + for _, test := range tests { + ctx, cancel := context.WithCancel(context.Background()) + node := &testNode{} + node.bal = test.bal + node.balErr = test.balErr + node.initGasErr = test.initGasErr + node.initGas = estimatedInitGas + eth := &ExchangeWallet{ + node: node, + ctx: ctx, + log: tLogger, + acct: new(accounts.Account), + } + dexAsset.MaxFeeRate = test.maxFeeRate + maxOrder, err := eth.MaxOrder(test.lotSize, test.feeSuggestion, &dexAsset) + 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 maxOrder.Lots != test.wantLots { + t.Fatalf("want lots %v got %v for test %q", test.wantLots, maxOrder.Lots, test.name) + } + if maxOrder.Value != test.wantValue { + t.Fatalf("want value %v got %v for test %q", test.wantValue, maxOrder.Value, test.name) + } + if maxOrder.MaxFees != test.wantMaxFees { + t.Fatalf("want maxFees %v got %v for test %q", test.wantMaxFees, maxOrder.MaxFees, test.name) + } + if maxOrder.RealisticBestCase != test.wantBestCase { + t.Fatalf("want best case %v got %v for test %q", test.wantBestCase, maxOrder.RealisticBestCase, test.name) + } + if maxOrder.RealisticWorstCase != test.wantWorstCase { + t.Fatalf("want worst case %v got %v for test %q", test.wantWorstCase, maxOrder.RealisticWorstCase, test.name) + } + if maxOrder.Locked != test.wantLocked { + t.Fatalf("want locked %v got %v for test %q", test.wantLocked, maxOrder.Locked, test.name) + } + } +} diff --git a/client/asset/eth/rpcclient.go b/client/asset/eth/rpcclient.go index 7f628c62e1..c93dedb053 100644 --- a/client/asset/eth/rpcclient.go +++ b/client/asset/eth/rpcclient.go @@ -259,6 +259,11 @@ func (c *rpcclient) initiate(txOpts *bind.TransactOpts, netID int64, refundTimes return c.es.Initiate(txOpts, big.NewInt(refundTimestamp), secretHash, *participant) } +// estimateGas checks the amount of gas that is used for a function call. +func (c *rpcclient) estimateGas(ctx context.Context, msg ethereum.CallMsg) (uint64, error) { + return c.ec.EstimateGas(ctx, msg) +} + // redeem redeems a swap contract. The redeemer will be the account at txOpts.From. // Any on-chain failure, such as this secret not matching the hash, will not cause // this to error. diff --git a/client/asset/eth/rpcclient_harness_test.go b/client/asset/eth/rpcclient_harness_test.go index f861120bed..bdc71c0ec9 100644 --- a/client/asset/eth/rpcclient_harness_test.go +++ b/client/asset/eth/rpcclient_harness_test.go @@ -33,6 +33,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" "time" @@ -40,10 +41,13 @@ import ( "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/encode" dexeth "decred.org/dcrdex/dex/networks/eth" + swap "decred.org/dcrdex/dex/networks/eth" "decred.org/dcrdex/internal/eth/reentryattack" "decred.org/dcrdex/server/asset/eth" "github.com/davecgh/go-spew/spew" + "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts" + "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" ) @@ -372,6 +376,37 @@ func TestPeers(t *testing.T) { spew.Dump(peers) } +func TestInitiateGas(t *testing.T) { + var secretHash [32]byte + copy(secretHash[:], encode.RandomBytes(32)) + parsedAbi, err := abi.JSON(strings.NewReader(swap.ETHSwapABI)) + if err != nil { + t.Fatalf("unexpected error parsing abi: %v", err) + } + data, err := parsedAbi.Pack("initiate", big.NewInt(1), secretHash, &participantAddr) + if err != nil { + t.Fatalf("unexpected error packing abi: %v", err) + } + msg := ethereum.CallMsg{ + From: participantAddr, + To: &contractAddr, + Value: big.NewInt(1), + Gas: 0, + Data: data, + } + gas, err := ethClient.estimateGas(ctx, msg) + if err != nil { + t.Fatalf("unexpected error from estimateGas: %v", err) + } + if gas > eth.InitGas { + t.Fatalf("actual gas %v is greater than eth.InitGas %v", gas, eth.InitGas) + } + if gas+10000 < eth.InitGas { + t.Fatalf("actual gas %v is much less than eth.InitGas %v", gas, eth.InitGas) + } + fmt.Printf("Gas used for initiate: %v \n", gas) +} + func TestInitiate(t *testing.T) { now := time.Now().Unix() var secretHash [32]byte @@ -458,6 +493,48 @@ func TestInitiate(t *testing.T) { } } +func TestRedeemGas(t *testing.T) { + now := time.Now().Unix() + amt := big.NewInt(1e18) + txOpts := newTxOpts(ctx, &simnetAddr, amt) + var secret [32]byte + copy(secret[:], encode.RandomBytes(32)) + secretHash := sha256.Sum256(secret[:]) + _, err := ethClient.initiate(txOpts, simnetID, now, secretHash, &participantAddr) + if err != nil { + t.Fatalf("Unable to initiate swap: %v ", err) + } + if err := waitForMined(t, time.Second*8, true); err != nil { + t.Fatalf("unexpected error while waiting to mine: %v", err) + } + parsedAbi, err := abi.JSON(strings.NewReader(swap.ETHSwapABI)) + if err != nil { + t.Fatalf("unexpected error parsing abi: %v", err) + } + + data, err := parsedAbi.Pack("redeem", secret, secretHash) + if err != nil { + t.Fatalf("unexpected error packing abi: %v", err) + } + msg := ethereum.CallMsg{ + From: participantAddr, + To: &contractAddr, + Gas: 0, + Data: data, + } + gas, err := ethClient.estimateGas(ctx, msg) + if err != nil { + t.Fatalf("Error estimating gas for redeem function: %v", err) + } + if gas > RedeemGas { + t.Fatalf("actual gas %v is greater than RedeemGas %v", gas, RedeemGas) + } + if gas+3000 < RedeemGas { + t.Fatalf("actual gas %v is much less than RedeemGas %v", gas, RedeemGas) + } + fmt.Printf("Gas used for redeem: %v \n", gas) +} + func TestRedeem(t *testing.T) { amt := big.NewInt(1e18) locktime := time.Second * 12