diff --git a/cliutil/log.go b/cliutil/log.go index 89242b3e6..9d8a8a5bb 100644 --- a/cliutil/log.go +++ b/cliutil/log.go @@ -47,6 +47,7 @@ func SetLogLevels(level string) { _ = logging.SetLogLevel("extethclient", level) _ = logging.SetLogLevel("ethereum/watcher", level) _ = logging.SetLogLevel("ethereum/block", level) + _ = logging.SetLogLevel("ethereum/extethclient", level) _ = logging.SetLogLevel("monero", level) _ = logging.SetLogLevel("net", level) _ = logging.SetLogLevel("offers", level) diff --git a/cliutil/utils.go b/cliutil/utils.go index 3b9fc304f..f20589448 100644 --- a/cliutil/utils.go +++ b/cliutil/utils.go @@ -13,6 +13,7 @@ import ( "strings" "github.com/cockroachdb/apd/v3" + ethcommon "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" ethcrypto "github.com/ethereum/go-ethereum/crypto" logging "github.com/ipfs/go-log" @@ -136,24 +137,54 @@ func GetVersion() string { return version.String() } -// ReadUnsignedDecimalFlag reads a string flag and parses it into an *apd.Decimal. +// ReadUnsignedDecimalFlag reads a string flag and parses it into an +// *apd.Decimal, verifying that the value is >= 0. func ReadUnsignedDecimalFlag(ctx *cli.Context, flagName string) (*apd.Decimal, error) { s := ctx.String(flagName) if s == "" { return nil, fmt.Errorf("flag --%s cannot be empty", flagName) } - bf, _, err := new(apd.Decimal).SetString(s) + + d, _, err := new(apd.Decimal).SetString(s) if err != nil { return nil, fmt.Errorf("invalid value %q for flag --%s", s, flagName) } - if bf.IsZero() { + + if d.Negative { + return nil, fmt.Errorf("value of flag --%s cannot be negative", flagName) + } + + return d, nil +} + +// ReadPositiveUnsignedDecimalFlag reads a string flag and parses it into an +// *apd.Decimal, verifying that the value is strictly > 0. +func ReadPositiveUnsignedDecimalFlag(ctx *cli.Context, flagName string) (*apd.Decimal, error) { + d, err := ReadUnsignedDecimalFlag(ctx, flagName) + if err != nil { + return nil, err + } + + if d.IsZero() { return nil, fmt.Errorf("value of flag --%s cannot be zero", flagName) } - if bf.Negative { - return nil, fmt.Errorf("value of flag --%s cannot be negative", flagName) + + return d, nil +} + +// ReadETHAddress reads a string flag and parses to an ethereum Address type +func ReadETHAddress(ctx *cli.Context, flagName string) (ethcommon.Address, error) { + s := ctx.String(flagName) + if s == "" { + return ethcommon.Address{}, fmt.Errorf("flag --%s cannot be empty", flagName) + } + + ok := ethcommon.IsHexAddress(s) + if !ok { + return ethcommon.Address{}, fmt.Errorf("invalid ETH address: %q", s) } - return bf, nil + return ethcommon.HexToAddress(s), nil } // ExpandBootnodes expands the boot nodes passed on the command line that diff --git a/cmd/swapcli/main.go b/cmd/swapcli/main.go index 828b5a96c..5e2670dd1 100644 --- a/cmd/swapcli/main.go +++ b/cmd/swapcli/main.go @@ -47,7 +47,7 @@ const ( flagDetached = "detached" flagTo = "to" flagAmount = "amount" - flagEnv = "env" + flagGasLimit = "gas-limit" ) func cliApp() *cli.App { @@ -337,11 +337,6 @@ func cliApp() *cli.App { Usage: "Amount of XMR to send", Required: true, }, - &cli.StringFlag{ - Name: flagEnv, - Usage: "Environment to use. Options are [mainnet, stagenet, dev]. Default = mainnet.", - Value: "mainnet", - }, swapdPortFlag, }, }, @@ -352,20 +347,15 @@ func cliApp() *cli.App { Flags: []cli.Flag{ &cli.StringFlag{ Name: flagTo, - Usage: "Address to send XMR to", + Usage: "Address to sweep the XMR to", Required: true, }, - &cli.StringFlag{ - Name: flagEnv, - Usage: "Environment to use. Options are [mainnet, stagenet, dev]. Default = mainnet.", - Value: "mainnet", - }, swapdPortFlag, }, }, { Name: "transfer-eth", - Usage: "Transfer ETH from the swap wallet to another address.", + Usage: "Transfer ETH from the swap wallet to an address.", Action: runTransferETH, Flags: []cli.Flag{ &cli.StringFlag{ @@ -378,6 +368,23 @@ func cliApp() *cli.App { Usage: "Amount of ETH to send", Required: true, }, + &cli.Uint64Flag{ + Name: flagGasLimit, + Usage: "Set the gas limit (required if transferring to contract, otherwise ignored)", + }, + swapdPortFlag, + }, + }, + { + Name: "sweep-eth", + Usage: "Sweep all ETH from the swap wallet to a non-contract address.", + Action: runSweepETH, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: flagTo, + Usage: "Address to sweep the ETH to", + Required: true, + }, swapdPortFlag, }, }, @@ -667,12 +674,12 @@ func runQueryAll(ctx *cli.Context) error { func runMake(ctx *cli.Context) error { c := newClient(ctx) - min, err := cliutil.ReadUnsignedDecimalFlag(ctx, flagMinAmount) + min, err := cliutil.ReadPositiveUnsignedDecimalFlag(ctx, flagMinAmount) if err != nil { return err } - max, err := cliutil.ReadUnsignedDecimalFlag(ctx, flagMaxAmount) + max, err := cliutil.ReadPositiveUnsignedDecimalFlag(ctx, flagMaxAmount) if err != nil { return err } @@ -683,7 +690,7 @@ func runMake(ctx *cli.Context) error { ethAsset = types.EthAsset(ethcommon.HexToAddress(ethAssetStr)) } - exchangeRateDec, err := cliutil.ReadUnsignedDecimalFlag(ctx, flagExchangeRate) + exchangeRateDec, err := cliutil.ReadPositiveUnsignedDecimalFlag(ctx, flagExchangeRate) if err != nil { return err } @@ -775,7 +782,7 @@ func runTake(ctx *cli.Context) error { return errInvalidFlagValue(flagOfferID, err) } - providesAmount, err := cliutil.ReadUnsignedDecimalFlag(ctx, flagProvidesAmount) + providesAmount, err := cliutil.ReadPositiveUnsignedDecimalFlag(ctx, flagProvidesAmount) if err != nil { return err } @@ -1159,7 +1166,9 @@ func runGetSwapSecret(ctx *cli.Context) error { } func runTransferXMR(ctx *cli.Context) error { - env, err := common.NewEnv(ctx.String(flagEnv)) + c := newClient(ctx) + + env, err := queryEnv(c) if err != nil { return err } @@ -1169,12 +1178,11 @@ func runTransferXMR(ctx *cli.Context) error { return err } - amount, err := cliutil.ReadUnsignedDecimalFlag(ctx, flagAmount) + amount, err := cliutil.ReadPositiveUnsignedDecimalFlag(ctx, flagAmount) if err != nil { return err } - c := newClient(ctx) req := &rpc.TransferXMRRequest{ To: to, Amount: amount, @@ -1186,13 +1194,14 @@ func runTransferXMR(ctx *cli.Context) error { return err } - fmt.Printf("Transferred %s XMR to %s\n", amount, to) - fmt.Printf("Transaction ID: %s\n", resp.TxID) + fmt.Printf("Success, TX ID: %s\n", resp.TxID) return nil } func runSweepXMR(ctx *cli.Context) error { - env, err := common.NewEnv(ctx.String(flagEnv)) + c := newClient(ctx) + + env, err := queryEnv(c) if err != nil { return err } @@ -1202,7 +1211,6 @@ func runSweepXMR(ctx *cli.Context) error { return err } - c := newClient(ctx) request := &rpctypes.BalancesRequest{} balances, err := c.Balances(request) if err != nil { @@ -1219,37 +1227,67 @@ func runSweepXMR(ctx *cli.Context) error { return err } - fmt.Printf("Transferred %s XMR to %s\n", balances.PiconeroBalance.AsMoneroString(), to) - fmt.Printf("Transaction IDs: %s\n", resp.TxIDs) + fmt.Printf("Success, TX ID(s): %s\n", resp.TxIDs) return nil } func runTransferETH(ctx *cli.Context) error { - ok := ethcommon.IsHexAddress(ctx.String(flagTo)) - if !ok { - return fmt.Errorf("invalid address: %s", ctx.String(flagTo)) + to, err := cliutil.ReadETHAddress(ctx, flagTo) + if err != nil { + return err } - to := ethcommon.HexToAddress(ctx.String(flagTo)) amount, err := cliutil.ReadUnsignedDecimalFlag(ctx, flagAmount) if err != nil { return err } + var gasLimit *uint64 + if ctx.IsSet(flagGasLimit) { + gasLimit = new(uint64) + *gasLimit = ctx.Uint64(flagGasLimit) + } + c := newClient(ctx) req := &rpc.TransferETHRequest{ - To: to, - Amount: amount, + To: to, + Amount: amount, + GasLimit: gasLimit, } - fmt.Printf("Transferring %s ETH to %s\n", amount, to) + fmt.Printf("Transferring %s ETH to %s and waiting for confirmation\n", amount, to) resp, err := c.TransferETH(req) if err != nil { return err } - fmt.Printf("Transferred %s ETH to %s\n", amount, to) - fmt.Printf("Transaction ID: %s\n", resp.TxHash) + printSuccessWithETHTxHash(c, resp.TxHash) + + return nil +} + +func runSweepETH(ctx *cli.Context) error { + to, err := cliutil.ReadETHAddress(ctx, flagTo) + if err != nil { + return err + } + + c := newClient(ctx) + request := &rpctypes.BalancesRequest{} + balances, err := c.Balances(request) + if err != nil { + return err + } + + fmt.Printf("Sweeping %s ETH to %s and waiting block for confirmation\n", balances.WeiBalance.AsEtherString(), to) + + resp, err := c.SweepETH(&rpc.SweepETHRequest{To: to}) + if err != nil { + return err + } + + printSuccessWithETHTxHash(c, resp.TxHash) + return nil } diff --git a/cmd/swapcli/main_test.go b/cmd/swapcli/main_test.go index 712626249..117a46cec 100644 --- a/cmd/swapcli/main_test.go +++ b/cmd/swapcli/main_test.go @@ -3,8 +3,16 @@ package main import ( "context" "fmt" + "time" + "github.com/MarinX/monerorpc/wallet" + "github.com/cockroachdb/apd/v3" + "github.com/ethereum/go-ethereum/params" "github.com/stretchr/testify/require" + + "github.com/athanorlabs/atomic-swap/coins" + contracts "github.com/athanorlabs/atomic-swap/ethereum" + "github.com/athanorlabs/atomic-swap/monero" ) func (s *swapCLITestSuite) Test_runGetVersions() { @@ -28,3 +36,124 @@ func (s *swapCLITestSuite) Test_runBalances() { err := cliApp().RunContext(context.Background(), args) require.NoError(s.T(), err) } + +func (s *swapCLITestSuite) Test_runRunETHTransfer() { + bobAddr := s.bobConf.EthereumClient.Address().String() + aliceAddr := s.aliceConf.EthereumClient.Address().String() + amount := coins.EtherToWei(coins.StrToDecimal("0.123")) + bobEC := s.bobConf.EthereumClient + + s.T().Logf("Alice is transferring %s ETH to Bob", amount.AsEtherString()) + args := []string{ + "swapcli", + "transfer-eth", + s.aliceSwapdPortFlag(), + fmt.Sprintf("--%s=%s", flagTo, bobAddr), + fmt.Sprintf("--%s=%s", flagAmount, amount.AsEtherString()), + } + err := cliApp().RunContext(context.Background(), args) + require.NoError(s.T(), err) + + bobBal, err := bobEC.Balance(context.Background()) + require.NoError(s.T(), err) + require.GreaterOrEqual(s.T(), bobBal.Cmp(amount), 0) + + s.T().Log("Bob is sweeping his entire ETH balance back to Alice") + args = []string{ + "swapcli", + "sweep-eth", + fmt.Sprintf("--%s=%d", flagSwapdPort, s.bobRPCPort()), + fmt.Sprintf("--%s=%s", flagTo, aliceAddr), + } + err = cliApp().RunContext(context.Background(), args) + require.NoError(s.T(), err) + + bobBal, err = bobEC.Balance(context.Background()) + require.NoError(s.T(), err) + require.True(s.T(), bobBal.Decimal().IsZero()) +} + +func (s *swapCLITestSuite) Test_runRunETHTransfer_toContract() { + ctx := context.Background() + ec := s.aliceConf.EthereumClient + zeroETH := new(apd.Decimal) + token := contracts.GetMockTether(s.T(), ec.Raw(), ec.PrivateKey()) + + startTokenBal, err := ec.ERC20Balance(ctx, token.Address) + require.NoError(s.T(), err) + + // transfer zero ETH to the token address. We'll fail the 1st attempt by not + // setting the gas limit. + args := []string{ + "swapcli", + "transfer-eth", + s.aliceSwapdPortFlag(), + fmt.Sprintf("--%s=%s", flagTo, token.Address), + fmt.Sprintf("--%s=%s", flagAmount, zeroETH), + } + err = cliApp().RunContext(context.Background(), args) + require.ErrorContains(s.T(), err, "gas limit is required when transferring to a contract") + + // 2nd attempt with the gas limit set will succeed + args = append(args, fmt.Sprintf("--%s=%d", flagGasLimit, 2*params.TxGas)) + err = cliApp().RunContext(context.Background(), args) + require.NoError(s.T(), err) + + // our test token contract mints you 100 standard token units when sending + // it a zero value transaction. + endTokenBal, err := ec.ERC20Balance(ctx, token.Address) + require.NoError(s.T(), err) + require.Greater(s.T(), endTokenBal.AsStd().Cmp(startTokenBal.AsStd()), 0) +} + +func (s *swapCLITestSuite) Test_runRunXMRTransfer() { + bobAddr := s.bobConf.MoneroClient.PrimaryAddress() + aliceAddr := s.aliceConf.MoneroClient.PrimaryAddress() + amount := coins.MoneroToPiconero(coins.StrToDecimal("0.123")) + aliceMC := s.aliceConf.MoneroClient + bobMC := s.bobConf.MoneroClient + + monero.MineMinXMRBalance(s.T(), bobMC, amount) + + s.T().Logf("Bob is transferring %s XMR to Alice", amount.AsMoneroString()) + args := []string{ + "swapcli", + "transfer-xmr", + s.bobSwapdPortFlag(), + fmt.Sprintf("--%s=%s", flagTo, aliceAddr), + fmt.Sprintf("--%s=0.123", flagAmount), + } + err := cliApp().RunContext(context.Background(), args) + require.NoError(s.T(), err) + + // Transfer only waits for 1 confirmation. Wait the remaining 9 blocks + // (should be around 9 seconds) for the balance to fully unlock. + var aliceBalResp *wallet.GetBalanceResponse + for i := 0; i < 12; i++ { + aliceBalResp, err = aliceMC.GetBalance(0) + require.NoError(s.T(), err) + if aliceBalResp.BlocksToUnlock == 0 { + break + } + s.T().Logf("Waiting for Alice's balance to unlock, %d blocks remaining", aliceBalResp.BlocksToUnlock) + time.Sleep(1 * time.Second) + } + + require.Zero(s.T(), aliceBalResp.BlocksToUnlock) + require.Equal(s.T(), aliceBalResp.Balance, aliceBalResp.UnlockedBalance) + require.GreaterOrEqual(s.T(), coins.NewPiconeroAmount(aliceBalResp.Balance).Cmp(amount), 0) + + s.T().Log("Alice is sweeping her entire XMR balance back to Bob") + args = []string{ + "swapcli", + "sweep-xmr", + s.aliceSwapdPortFlag(), + fmt.Sprintf("--%s=%s", flagTo, bobAddr), + } + err = cliApp().RunContext(context.Background(), args) + require.NoError(s.T(), err) + + aliaceBal, err := aliceMC.GetBalance(0) + require.NoError(s.T(), err) + require.Zero(s.T(), aliaceBal.Balance) +} diff --git a/cmd/swapcli/suite_test.go b/cmd/swapcli/suite_test.go index 4fae42a11..02e34a211 100644 --- a/cmd/swapcli/suite_test.go +++ b/cmd/swapcli/suite_test.go @@ -2,13 +2,17 @@ package main import ( "context" + "fmt" "strconv" "testing" "time" ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" + "github.com/athanorlabs/atomic-swap/cliutil" "github.com/athanorlabs/atomic-swap/coins" "github.com/athanorlabs/atomic-swap/daemon" contracts "github.com/athanorlabs/atomic-swap/ethereum" @@ -20,7 +24,8 @@ import ( // if they need access to a swapd daemon and some preconfigured ERC20 tokens. type swapCLITestSuite struct { suite.Suite - conf *daemon.SwapdConfig + aliceConf *daemon.SwapdConfig + bobConf *daemon.SwapdConfig mockTether *coins.ERC20TokenInfo mockDAI *coins.ERC20TokenInfo } @@ -28,18 +33,47 @@ type swapCLITestSuite struct { func TestRunSwapcliWithDaemonTests(t *testing.T) { s := new(swapCLITestSuite) - s.conf = daemon.CreateTestConf(t, tests.GetMakerTestKey(t)) - t.Setenv("SWAPD_PORT", strconv.Itoa(int(s.conf.RPCPort))) - daemon.LaunchDaemons(t, 10*time.Minute, s.conf) - ec := s.conf.EthereumClient.Raw() - pk := s.conf.EthereumClient.PrivateKey() + s.aliceConf = daemon.CreateTestConf(t, tests.GetMakerTestKey(t)) + + bobEthKey, err := crypto.GenerateKey() // Bob has no ETH + require.NoError(t, err) + s.bobConf = daemon.CreateTestConf(t, bobEthKey) + + // by default you'll get Alice's RPC endpoint, specifying flag has precedence + t.Setenv("SWAPD_PORT", strconv.Itoa(int(s.aliceConf.RPCPort))) + daemon.LaunchDaemons(t, 10*time.Minute, s.aliceConf, s.bobConf) + + ec := s.aliceConf.EthereumClient.Raw() + pk := s.aliceConf.EthereumClient.PrivateKey() s.mockTether = contracts.GetMockTether(t, ec, pk) s.mockDAI = contracts.GetMockDAI(t, ec, pk) + + cliutil.SetLogLevels("debug") // turn on logging after daemons are started suite.Run(t, s) } -func (s *swapCLITestSuite) rpcEndpoint() *rpcclient.Client { - return rpcclient.NewClient(context.Background(), s.conf.RPCPort) +func (s *swapCLITestSuite) aliceRPCPort() uint16 { + return s.aliceConf.RPCPort +} + +func (s *swapCLITestSuite) bobRPCPort() uint16 { + return s.bobConf.RPCPort +} + +func (s *swapCLITestSuite) aliceSwapdPortFlag() string { + return fmt.Sprintf("--%s=%d", flagSwapdPort, s.aliceRPCPort()) +} + +func (s *swapCLITestSuite) bobSwapdPortFlag() string { + return fmt.Sprintf("--%s=%d", flagSwapdPort, s.bobRPCPort()) +} + +func (s *swapCLITestSuite) aliceClient() *rpcclient.Client { + return rpcclient.NewClient(context.Background(), s.aliceRPCPort()) +} + +func (s *swapCLITestSuite) bobClient() *rpcclient.Client { + return rpcclient.NewClient(context.Background(), s.bobRPCPort()) } func (s *swapCLITestSuite) mockDaiAddr() ethcommon.Address { diff --git a/cmd/swapcli/util.go b/cmd/swapcli/util.go index 8edaf2cbd..e4418bedb 100644 --- a/cmd/swapcli/util.go +++ b/cmd/swapcli/util.go @@ -7,6 +7,7 @@ import ( ethcommon "github.com/ethereum/go-ethereum/common" "github.com/athanorlabs/atomic-swap/coins" + "github.com/athanorlabs/atomic-swap/common" "github.com/athanorlabs/atomic-swap/common/types" "github.com/athanorlabs/atomic-swap/rpcclient" ) @@ -124,3 +125,29 @@ func printOffer(c *rpcclient.Client, o *types.Offer, index int, indent string) e fmt.Printf("%sTaker Max: %s %s\n", indent, maxTake.Text('f'), receivedCoin) return nil } + +func queryEnv(c *rpcclient.Client) (common.Environment, error) { + verResp, err := c.Version() + if err != nil { + return common.Undefined, err + } + return verResp.Env, nil +} + +func printSuccessWithETHTxHash(c *rpcclient.Client, txHash ethcommon.Hash) { + env, err := queryEnv(c) + if err != nil { + // Not ideal, but all it means is that we'll print the transaction + // ID without an etherscan URL. + fmt.Printf("error obtaining environment: %s\n", err) + } + + switch env { + case common.Mainnet: + fmt.Printf("Success: https://etherscan.io/tx/%s\n", txHash) + case common.Stagenet: + fmt.Printf("Success: https://sepolia.etherscan.io/tx/%s\n", txHash) + default: + fmt.Printf("Success, TX Hash: %s\n", txHash) + } +} diff --git a/cmd/swapcli/util_test.go b/cmd/swapcli/util_test.go index eccd1968a..cd84d9eb4 100644 --- a/cmd/swapcli/util_test.go +++ b/cmd/swapcli/util_test.go @@ -9,7 +9,7 @@ import ( ) func (s *swapCLITestSuite) Test_lookupToken() { - c := s.rpcEndpoint() + c := s.aliceClient() // First call triggers a lookup (assuming not cached yet) token1, err := lookupToken(c, s.mockDaiAddr()) @@ -30,7 +30,7 @@ func (s *swapCLITestSuite) Test_lookupToken() { } func (s *swapCLITestSuite) Test_ethAssetSymbol() { - c := s.rpcEndpoint() + c := s.aliceClient() symbol, err := ethAssetSymbol(c, types.EthAssetETH) require.NoError(s.T(), err) require.Equal(s.T(), symbol, "ETH") @@ -41,7 +41,7 @@ func (s *swapCLITestSuite) Test_ethAssetSymbol() { } func (s *swapCLITestSuite) Test_providedAndReceivedSymbols() { - c := s.rpcEndpoint() + c := s.bobClient() // 2nd parameter says we are the maker providedSym, receivedSym, err := providedAndReceivedSymbols(c, coins.ProvidesXMR, types.EthAssetETH) @@ -58,7 +58,7 @@ func (s *swapCLITestSuite) Test_providedAndReceivedSymbols() { } func (s *swapCLITestSuite) Test_printOffer() { - c := s.rpcEndpoint() + c := s.aliceClient() o := types.NewOffer( coins.ProvidesXMR, diff --git a/daemon/swap_daemon_test.go b/daemon/swap_daemon_test.go index 90844d3b0..1bd7f0763 100644 --- a/daemon/swap_daemon_test.go +++ b/daemon/swap_daemon_test.go @@ -5,7 +5,6 @@ package daemon import ( "context" - "crypto/ecdsa" "fmt" "math/big" "sync" @@ -15,7 +14,6 @@ import ( "github.com/cockroachdb/apd/v3" ethcommon "github.com/ethereum/go-ethereum/common" - ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -24,7 +22,6 @@ import ( "github.com/athanorlabs/atomic-swap/coins" "github.com/athanorlabs/atomic-swap/common/types" contracts "github.com/athanorlabs/atomic-swap/ethereum" - "github.com/athanorlabs/atomic-swap/ethereum/block" "github.com/athanorlabs/atomic-swap/ethereum/extethclient" "github.com/athanorlabs/atomic-swap/monero" "github.com/athanorlabs/atomic-swap/net" @@ -32,66 +29,31 @@ import ( "github.com/athanorlabs/atomic-swap/tests" ) -const ( - // transferGas is the amount of gas to perform a standard ETH transfer - transferGas = 21000 -) - func init() { cliutil.SetLogLevels("debug") } -func privKeyToAddr(privKey *ecdsa.PrivateKey) ethcommon.Address { - return crypto.PubkeyToAddress(*privKey.Public().(*ecdsa.PublicKey)) -} - -func transfer(t *testing.T, fromKey *ecdsa.PrivateKey, toAddress ethcommon.Address, ethAmount *apd.Decimal) { - ctx := context.Background() - ec, chainID := tests.NewEthClient(t) - fromAddress := privKeyToAddr(fromKey) - - gasPrice, err := ec.SuggestGasPrice(ctx) - require.NoError(t, err) - - nonce, err := ec.PendingNonceAt(ctx, fromAddress) - require.NoError(t, err) - - weiAmount := coins.EtherToWei(ethAmount).BigInt() - - tx := ethtypes.NewTx(ðtypes.LegacyTx{ - Nonce: nonce, - To: &toAddress, - Value: weiAmount, - Gas: transferGas, - GasPrice: gasPrice, - }) - signedTx, err := ethtypes.SignTx(tx, ethtypes.LatestSignerForChainID(chainID), fromKey) - require.NoError(t, err) - - err = ec.SendTransaction(ctx, signedTx) - require.NoError(t, err) - _, err = block.WaitForReceipt(ctx, ec, signedTx.Hash()) - require.NoError(t, err) -} - // minimumFundAlice gives Alice enough ETH to do everything but relay a claim -func minimumFundAlice(t *testing.T, ec extethclient.EthClient, providesAmt *apd.Decimal) { +func minimumFundAlice(t *testing.T, aliceAddr ethcommon.Address, providesAmt *apd.Decimal) { + ctx := context.Background() fundingKey := tests.GetTakerTestKey(t) + ec := extethclient.CreateTestClient(t, fundingKey) const ( aliceGasRation = contracts.MaxNewSwapETHGas + contracts.MaxSetReadyGas + contracts.MaxRefundETHGas ) // We give Alice enough gas money to refund if needed, but not enough to // relay a claim - suggestedGasPrice, err := ec.Raw().SuggestGasPrice(context.Background()) + suggestedGasPrice, err := ec.Raw().SuggestGasPrice(ctx) require.NoError(t, err) gasCostWei := new(big.Int).Mul(suggestedGasPrice, big.NewInt(aliceGasRation)) fundAmt := new(apd.Decimal) _, err = coins.DecimalCtx().Add(fundAmt, providesAmt, coins.NewWeiAmount(gasCostWei).AsEther()) require.NoError(t, err) - transfer(t, fundingKey, ec.Address(), fundAmt) + _, err = ec.Transfer(ctx, aliceAddr, coins.EtherToWei(fundAmt), nil) + require.NoError(t, err) - bal, err := ec.Balance(context.Background()) + bal, err := ec.Balance(ctx) require.NoError(t, err) t.Logf("Alice's start balance is: %s ETH", bal.AsEtherString()) } @@ -201,7 +163,7 @@ func TestRunSwapDaemon_NoRelayersAvailable_Refund(t *testing.T) { aliceEthKey, err := crypto.GenerateKey() // Alice has non-ganache key that we fund require.NoError(t, err) aliceConf := CreateTestConf(t, aliceEthKey) - minimumFundAlice(t, aliceConf.EthereumClient, providesAmt) + minimumFundAlice(t, aliceConf.EthereumClient.Address(), providesAmt) timeout := 8 * time.Minute ctx, _ := LaunchDaemons(t, timeout, bobConf, aliceConf) @@ -278,7 +240,7 @@ func TestRunSwapDaemon_CharlieRelays(t *testing.T) { aliceEthKey, err := crypto.GenerateKey() // Alice gets a key without enough funds to relay require.NoError(t, err) aliceConf := CreateTestConf(t, aliceEthKey) - minimumFundAlice(t, aliceConf.EthereumClient, providesAmt) + minimumFundAlice(t, aliceConf.EthereumClient.Address(), providesAmt) // Charlie can safely use the taker key, as Alice is not using it. charlieConf := CreateTestConf(t, tests.GetTakerTestKey(t)) diff --git a/ethereum/consts.go b/ethereum/consts.go index f8f166e42..7b4db26e7 100644 --- a/ethereum/consts.go +++ b/ethereum/consts.go @@ -17,5 +17,5 @@ const ( // constants that are interesting to track, but not used by swaps const ( maxSwapCreatorDeployGas = 1094089 - maxTestERC20DeployGas = 798286 // using long token names or symbols will increase this + maxTestERC20DeployGas = 905727 // using long token names or symbols will increase this ) diff --git a/ethereum/contracts/TestERC20.sol b/ethereum/contracts/TestERC20.sol index 87f233366..0efddeba9 100644 --- a/ethereum/contracts/TestERC20.sol +++ b/ethereum/contracts/TestERC20.sol @@ -6,7 +6,7 @@ import {ERC20} from "./ERC20.sol"; // ERC20 token for testing purposes contract TestERC20 is ERC20 { - uint8 private _decimals; + uint8 private immutable _decimals; constructor( string memory name, @@ -38,4 +38,13 @@ contract TestERC20 is ERC20 { function approveInternal(address owner, address spender, uint256 value) public { _approve(owner, spender, value); } + + // You can send a zero-value transfer directly to the contract address to + // get a 100 standard unit tokens. + receive() external payable { + mint(msg.sender, 100 * 10 ** uint(_decimals)); + if (msg.value > 0) { + payable(msg.sender).transfer(msg.value); + } + } } diff --git a/ethereum/erc20_token.go b/ethereum/erc20_token.go index cea96f1b7..fd765a883 100644 --- a/ethereum/erc20_token.go +++ b/ethereum/erc20_token.go @@ -31,8 +31,8 @@ var ( // TestERC20MetaData contains all meta data concerning the TestERC20 contract. var TestERC20MetaData = &bind.MetaData{ - ABI: "[{\"inputs\":[{\"internalType\":\"string\",\"name\":\"name\",\"type\":\"string\"},{\"internalType\":\"string\",\"name\":\"symbol\",\"type\":\"string\"},{\"internalType\":\"uint8\",\"name\":\"numDecimals\",\"type\":\"uint8\"},{\"internalType\":\"address\",\"name\":\"initialAccount\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"initialBalance\",\"type\":\"uint256\"}],\"stateMutability\":\"payable\",\"type\":\"constructor\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"owner\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"spender\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"value\",\"type\":\"uint256\"}],\"name\":\"Approval\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"from\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"to\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"value\",\"type\":\"uint256\"}],\"name\":\"Transfer\",\"type\":\"event\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"owner\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"spender\",\"type\":\"address\"}],\"name\":\"allowance\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"spender\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"approve\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"owner\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"spender\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"value\",\"type\":\"uint256\"}],\"name\":\"approveInternal\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"account\",\"type\":\"address\"}],\"name\":\"balanceOf\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"account\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"burn\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"decimals\",\"outputs\":[{\"internalType\":\"uint8\",\"name\":\"\",\"type\":\"uint8\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"spender\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"subtractedValue\",\"type\":\"uint256\"}],\"name\":\"decreaseAllowance\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"spender\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"addedValue\",\"type\":\"uint256\"}],\"name\":\"increaseAllowance\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"account\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"mint\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"name\",\"outputs\":[{\"internalType\":\"string\",\"name\":\"\",\"type\":\"string\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"symbol\",\"outputs\":[{\"internalType\":\"string\",\"name\":\"\",\"type\":\"string\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"totalSupply\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"to\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"transfer\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"from\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"to\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"transferFrom\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"from\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"to\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"value\",\"type\":\"uint256\"}],\"name\":\"transferInternal\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"}]", - Bin: "0x608060405260405162000f5238038062000f528339810160408190526200002691620001fe565b848460036200003683826200033c565b5060046200004582826200033c565b50506005805460ff191660ff8616179055506200006382826200006e565b505050505062000430565b6001600160a01b038216620000c95760405162461bcd60e51b815260206004820152601f60248201527f45524332303a206d696e7420746f20746865207a65726f206164647265737300604482015260640160405180910390fd5b8060026000828254620000dd919062000408565b90915550506001600160a01b038216600081815260208181526040808320805486019055518481527fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a35050565b505050565b634e487b7160e01b600052604160045260246000fd5b600082601f8301126200016157600080fd5b81516001600160401b03808211156200017e576200017e62000139565b604051601f8301601f19908116603f01168101908282118183101715620001a957620001a962000139565b81604052838152602092508683858801011115620001c657600080fd5b600091505b83821015620001ea5785820183015181830184015290820190620001cb565b600093810190920192909252949350505050565b600080600080600060a086880312156200021757600080fd5b85516001600160401b03808211156200022f57600080fd5b6200023d89838a016200014f565b965060208801519150808211156200025457600080fd5b5062000263888289016200014f565b945050604086015160ff811681146200027b57600080fd5b60608701519093506001600160a01b03811681146200029957600080fd5b80925050608086015190509295509295909350565b600181811c90821680620002c357607f821691505b602082108103620002e457634e487b7160e01b600052602260045260246000fd5b50919050565b601f8211156200013457600081815260208120601f850160051c81016020861015620003135750805b601f850160051c820191505b8181101562000334578281556001016200031f565b505050505050565b81516001600160401b0381111562000358576200035862000139565b6200037081620003698454620002ae565b84620002ea565b602080601f831160018114620003a857600084156200038f5750858301515b600019600386901b1c1916600185901b17855562000334565b600085815260208120601f198616915b82811015620003d957888601518255948401946001909101908401620003b8565b5085821015620003f85787850151600019600388901b60f8161c191681555b5050505050600190811b01905550565b808201808211156200042a57634e487b7160e01b600052601160045260246000fd5b92915050565b610b1280620004406000396000f3fe608060405234801561001057600080fd5b50600436106100f55760003560e01c806340c10f19116100975780639dc29fac116100665780639dc29fac146101f4578063a457c2d714610207578063a9059cbb1461021a578063dd62ed3e1461022d57600080fd5b806340c10f191461019d57806356189cb4146101b057806370a08231146101c357806395d89b41146101ec57600080fd5b8063222f5be0116100d3578063222f5be01461014d57806323b872dd14610162578063313ce56714610175578063395093511461018a57600080fd5b806306fdde03146100fa578063095ea7b31461011857806318160ddd1461013b575b600080fd5b610102610240565b60405161010f919061095c565b60405180910390f35b61012b6101263660046109c6565b6102d2565b604051901515815260200161010f565b6002545b60405190815260200161010f565b61016061015b3660046109f0565b6102ec565b005b61012b6101703660046109f0565b6102fc565b60055460405160ff909116815260200161010f565b61012b6101983660046109c6565b610320565b6101606101ab3660046109c6565b610342565b6101606101be3660046109f0565b610350565b61013f6101d1366004610a2c565b6001600160a01b031660009081526020819052604090205490565b61010261035b565b6101606102023660046109c6565b61036a565b61012b6102153660046109c6565b610374565b61012b6102283660046109c6565b6103f4565b61013f61023b366004610a4e565b610402565b60606003805461024f90610a81565b80601f016020809104026020016040519081016040528092919081815260200182805461027b90610a81565b80156102c85780601f1061029d576101008083540402835291602001916102c8565b820191906000526020600020905b8154815290600101906020018083116102ab57829003601f168201915b5050505050905090565b6000336102e081858561042d565b60019150505b92915050565b6102f7838383610551565b505050565b60003361030a8582856106f7565b610315858585610551565b506001949350505050565b6000336102e08185856103338383610402565b61033d9190610abb565b61042d565b61034c828261076b565b5050565b6102f783838361042d565b60606004805461024f90610a81565b61034c828261082a565b600033816103828286610402565b9050838110156103e75760405162461bcd60e51b815260206004820152602560248201527f45524332303a2064656372656173656420616c6c6f77616e63652062656c6f77604482015264207a65726f60d81b60648201526084015b60405180910390fd5b610315828686840361042d565b6000336102e0818585610551565b6001600160a01b03918216600090815260016020908152604080832093909416825291909152205490565b6001600160a01b03831661048f5760405162461bcd60e51b8152602060048201526024808201527f45524332303a20617070726f76652066726f6d20746865207a65726f206164646044820152637265737360e01b60648201526084016103de565b6001600160a01b0382166104f05760405162461bcd60e51b815260206004820152602260248201527f45524332303a20617070726f766520746f20746865207a65726f206164647265604482015261737360f01b60648201526084016103de565b6001600160a01b0383811660008181526001602090815260408083209487168084529482529182902085905590518481527f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925910160405180910390a3505050565b6001600160a01b0383166105b55760405162461bcd60e51b815260206004820152602560248201527f45524332303a207472616e736665722066726f6d20746865207a65726f206164604482015264647265737360d81b60648201526084016103de565b6001600160a01b0382166106175760405162461bcd60e51b815260206004820152602360248201527f45524332303a207472616e7366657220746f20746865207a65726f206164647260448201526265737360e81b60648201526084016103de565b6001600160a01b0383166000908152602081905260409020548181101561068f5760405162461bcd60e51b815260206004820152602660248201527f45524332303a207472616e7366657220616d6f756e7420657863656564732062604482015265616c616e636560d01b60648201526084016103de565b6001600160a01b03848116600081815260208181526040808320878703905593871680835291849020805487019055925185815290927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a35b50505050565b60006107038484610402565b905060001981146106f1578181101561075e5760405162461bcd60e51b815260206004820152601d60248201527f45524332303a20696e73756666696369656e7420616c6c6f77616e636500000060448201526064016103de565b6106f1848484840361042d565b6001600160a01b0382166107c15760405162461bcd60e51b815260206004820152601f60248201527f45524332303a206d696e7420746f20746865207a65726f20616464726573730060448201526064016103de565b80600260008282546107d39190610abb565b90915550506001600160a01b038216600081815260208181526040808320805486019055518481527fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a35050565b6001600160a01b03821661088a5760405162461bcd60e51b815260206004820152602160248201527f45524332303a206275726e2066726f6d20746865207a65726f206164647265736044820152607360f81b60648201526084016103de565b6001600160a01b038216600090815260208190526040902054818110156108fe5760405162461bcd60e51b815260206004820152602260248201527f45524332303a206275726e20616d6f756e7420657863656564732062616c616e604482015261636560f01b60648201526084016103de565b6001600160a01b0383166000818152602081815260408083208686039055600280548790039055518581529192917fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a3505050565b600060208083528351808285015260005b818110156109895785810183015185820160400152820161096d565b506000604082860101526040601f19601f8301168501019250505092915050565b80356001600160a01b03811681146109c157600080fd5b919050565b600080604083850312156109d957600080fd5b6109e2836109aa565b946020939093013593505050565b600080600060608486031215610a0557600080fd5b610a0e846109aa565b9250610a1c602085016109aa565b9150604084013590509250925092565b600060208284031215610a3e57600080fd5b610a47826109aa565b9392505050565b60008060408385031215610a6157600080fd5b610a6a836109aa565b9150610a78602084016109aa565b90509250929050565b600181811c90821680610a9557607f821691505b602082108103610ab557634e487b7160e01b600052602260045260246000fd5b50919050565b808201808211156102e657634e487b7160e01b600052601160045260246000fdfea2646970667358221220e64cf87a27edc11a020470156015966c538c7ea1d1a8a6879dd867d9ceb9519864736f6c63430008130033", + ABI: "[{\"inputs\":[{\"internalType\":\"string\",\"name\":\"name\",\"type\":\"string\"},{\"internalType\":\"string\",\"name\":\"symbol\",\"type\":\"string\"},{\"internalType\":\"uint8\",\"name\":\"numDecimals\",\"type\":\"uint8\"},{\"internalType\":\"address\",\"name\":\"initialAccount\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"initialBalance\",\"type\":\"uint256\"}],\"stateMutability\":\"payable\",\"type\":\"constructor\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"owner\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"spender\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"value\",\"type\":\"uint256\"}],\"name\":\"Approval\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"from\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"to\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"value\",\"type\":\"uint256\"}],\"name\":\"Transfer\",\"type\":\"event\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"owner\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"spender\",\"type\":\"address\"}],\"name\":\"allowance\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"spender\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"approve\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"owner\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"spender\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"value\",\"type\":\"uint256\"}],\"name\":\"approveInternal\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"account\",\"type\":\"address\"}],\"name\":\"balanceOf\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"account\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"burn\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"decimals\",\"outputs\":[{\"internalType\":\"uint8\",\"name\":\"\",\"type\":\"uint8\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"spender\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"subtractedValue\",\"type\":\"uint256\"}],\"name\":\"decreaseAllowance\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"spender\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"addedValue\",\"type\":\"uint256\"}],\"name\":\"increaseAllowance\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"account\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"mint\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"name\",\"outputs\":[{\"internalType\":\"string\",\"name\":\"\",\"type\":\"string\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"symbol\",\"outputs\":[{\"internalType\":\"string\",\"name\":\"\",\"type\":\"string\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"totalSupply\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"to\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"transfer\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"from\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"to\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"transferFrom\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"from\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"to\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"value\",\"type\":\"uint256\"}],\"name\":\"transferInternal\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"stateMutability\":\"payable\",\"type\":\"receive\"}]", + Bin: "0x60a0604052604051620011b7380380620011b78339810160408190526200002691620001f6565b8484600362000036838262000334565b50600462000045828262000334565b50505060ff83166080526200005b828262000066565b505050505062000428565b6001600160a01b038216620000c15760405162461bcd60e51b815260206004820152601f60248201527f45524332303a206d696e7420746f20746865207a65726f206164647265737300604482015260640160405180910390fd5b8060026000828254620000d5919062000400565b90915550506001600160a01b038216600081815260208181526040808320805486019055518481527fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a35050565b505050565b634e487b7160e01b600052604160045260246000fd5b600082601f8301126200015957600080fd5b81516001600160401b038082111562000176576200017662000131565b604051601f8301601f19908116603f01168101908282118183101715620001a157620001a162000131565b81604052838152602092508683858801011115620001be57600080fd5b600091505b83821015620001e25785820183015181830184015290820190620001c3565b600093810190920192909252949350505050565b600080600080600060a086880312156200020f57600080fd5b85516001600160401b03808211156200022757600080fd5b6200023589838a0162000147565b965060208801519150808211156200024c57600080fd5b506200025b8882890162000147565b945050604086015160ff811681146200027357600080fd5b60608701519093506001600160a01b03811681146200029157600080fd5b80925050608086015190509295509295909350565b600181811c90821680620002bb57607f821691505b602082108103620002dc57634e487b7160e01b600052602260045260246000fd5b50919050565b601f8211156200012c57600081815260208120601f850160051c810160208610156200030b5750805b601f850160051c820191505b818110156200032c5782815560010162000317565b505050505050565b81516001600160401b0381111562000350576200035062000131565b6200036881620003618454620002a6565b84620002e2565b602080601f831160018114620003a05760008415620003875750858301515b600019600386901b1c1916600185901b1785556200032c565b600085815260208120601f198616915b82811015620003d157888601518255948401946001909101908401620003b0565b5085821015620003f05787850151600019600388901b60f8161c191681555b5050505050600190811b01905550565b808201808211156200042257634e487b7160e01b600052601160045260246000fd5b92915050565b608051610d6d6200044a6000396000818160fc015261023c0152610d6d6000f3fe6080604052600436106100ec5760003560e01c806340c10f191161008a5780639dc29fac116100595780639dc29fac1461030c578063a457c2d71461032c578063a9059cbb1461034c578063dd62ed3e1461036c57600080fd5b806340c10f191461028657806356189cb4146102a157806370a08231146102c157806395d89b41146102f757600080fd5b8063222f5be0116100c6578063222f5be0146101e857806323b872dd14610208578063313ce56714610228578063395093511461026657600080fd5b806306fdde031461016e578063095ea7b31461019957806318160ddd146101c957600080fd5b36610169576101333361012360ff7f000000000000000000000000000000000000000000000000000000000000000016600a610ba2565b61012e906064610bb5565b61038c565b34156101675760405133903480156108fc02916000818181858888f19350505050158015610165573d6000803e3d6000fd5b505b005b600080fd5b34801561017a57600080fd5b5061018361039a565b6040516101909190610bcc565b60405180910390f35b3480156101a557600080fd5b506101b96101b4366004610c36565b61042c565b6040519015158152602001610190565b3480156101d557600080fd5b506002545b604051908152602001610190565b3480156101f457600080fd5b50610167610203366004610c60565b610446565b34801561021457600080fd5b506101b9610223366004610c60565b610456565b34801561023457600080fd5b5060405160ff7f0000000000000000000000000000000000000000000000000000000000000000168152602001610190565b34801561027257600080fd5b506101b9610281366004610c36565b61047a565b34801561029257600080fd5b5061016761012e366004610c36565b3480156102ad57600080fd5b506101676102bc366004610c60565b61049c565b3480156102cd57600080fd5b506101da6102dc366004610c9c565b6001600160a01b031660009081526020819052604090205490565b34801561030357600080fd5b506101836104a7565b34801561031857600080fd5b50610167610327366004610c36565b6104b6565b34801561033857600080fd5b506101b9610347366004610c36565b6104c0565b34801561035857600080fd5b506101b9610367366004610c36565b610540565b34801561037857600080fd5b506101da610387366004610cb7565b61054e565b6103968282610579565b5050565b6060600380546103a990610cea565b80601f01602080910402602001604051908101604052809291908181526020018280546103d590610cea565b80156104225780601f106103f757610100808354040283529160200191610422565b820191906000526020600020905b81548152906001019060200180831161040557829003601f168201915b5050505050905090565b60003361043a818585610638565b60019150505b92915050565b61045183838361075c565b505050565b600033610464858285610902565b61046f85858561075c565b506001949350505050565b60003361043a81858561048d838361054e565b6104979190610d24565b610638565b610451838383610638565b6060600480546103a990610cea565b6103968282610976565b600033816104ce828661054e565b9050838110156105335760405162461bcd60e51b815260206004820152602560248201527f45524332303a2064656372656173656420616c6c6f77616e63652062656c6f77604482015264207a65726f60d81b60648201526084015b60405180910390fd5b61046f8286868403610638565b60003361043a81858561075c565b6001600160a01b03918216600090815260016020908152604080832093909416825291909152205490565b6001600160a01b0382166105cf5760405162461bcd60e51b815260206004820152601f60248201527f45524332303a206d696e7420746f20746865207a65726f206164647265737300604482015260640161052a565b80600260008282546105e19190610d24565b90915550506001600160a01b038216600081815260208181526040808320805486019055518481527fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a35050565b6001600160a01b03831661069a5760405162461bcd60e51b8152602060048201526024808201527f45524332303a20617070726f76652066726f6d20746865207a65726f206164646044820152637265737360e01b606482015260840161052a565b6001600160a01b0382166106fb5760405162461bcd60e51b815260206004820152602260248201527f45524332303a20617070726f766520746f20746865207a65726f206164647265604482015261737360f01b606482015260840161052a565b6001600160a01b0383811660008181526001602090815260408083209487168084529482529182902085905590518481527f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925910160405180910390a3505050565b6001600160a01b0383166107c05760405162461bcd60e51b815260206004820152602560248201527f45524332303a207472616e736665722066726f6d20746865207a65726f206164604482015264647265737360d81b606482015260840161052a565b6001600160a01b0382166108225760405162461bcd60e51b815260206004820152602360248201527f45524332303a207472616e7366657220746f20746865207a65726f206164647260448201526265737360e81b606482015260840161052a565b6001600160a01b0383166000908152602081905260409020548181101561089a5760405162461bcd60e51b815260206004820152602660248201527f45524332303a207472616e7366657220616d6f756e7420657863656564732062604482015265616c616e636560d01b606482015260840161052a565b6001600160a01b03848116600081815260208181526040808320878703905593871680835291849020805487019055925185815290927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a35b50505050565b600061090e848461054e565b905060001981146108fc57818110156109695760405162461bcd60e51b815260206004820152601d60248201527f45524332303a20696e73756666696369656e7420616c6c6f77616e6365000000604482015260640161052a565b6108fc8484848403610638565b6001600160a01b0382166109d65760405162461bcd60e51b815260206004820152602160248201527f45524332303a206275726e2066726f6d20746865207a65726f206164647265736044820152607360f81b606482015260840161052a565b6001600160a01b03821660009081526020819052604090205481811015610a4a5760405162461bcd60e51b815260206004820152602260248201527f45524332303a206275726e20616d6f756e7420657863656564732062616c616e604482015261636560f01b606482015260840161052a565b6001600160a01b0383166000818152602081815260408083208686039055600280548790039055518581529192917fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a3505050565b634e487b7160e01b600052601160045260246000fd5b600181815b80851115610af9578160001904821115610adf57610adf610aa8565b80851615610aec57918102915b93841c9390800290610ac3565b509250929050565b600082610b1057506001610440565b81610b1d57506000610440565b8160018114610b335760028114610b3d57610b59565b6001915050610440565b60ff841115610b4e57610b4e610aa8565b50506001821b610440565b5060208310610133831016604e8410600b8410161715610b7c575081810a610440565b610b868383610abe565b8060001904821115610b9a57610b9a610aa8565b029392505050565b6000610bae8383610b01565b9392505050565b808202811582820484141761044057610440610aa8565b600060208083528351808285015260005b81811015610bf957858101830151858201604001528201610bdd565b506000604082860101526040601f19601f8301168501019250505092915050565b80356001600160a01b0381168114610c3157600080fd5b919050565b60008060408385031215610c4957600080fd5b610c5283610c1a565b946020939093013593505050565b600080600060608486031215610c7557600080fd5b610c7e84610c1a565b9250610c8c60208501610c1a565b9150604084013590509250925092565b600060208284031215610cae57600080fd5b610bae82610c1a565b60008060408385031215610cca57600080fd5b610cd383610c1a565b9150610ce160208401610c1a565b90509250929050565b600181811c90821680610cfe57607f821691505b602082108103610d1e57634e487b7160e01b600052602260045260246000fd5b50919050565b8082018082111561044057610440610aa856fea2646970667358221220b2e5c55489841e6bbee8af7f0133986496e6cab2bdb54f8b25a430d62324a9fd64736f6c63430008130033", } // TestERC20ABI is the input ABI used to generate the binding from. @@ -577,6 +577,27 @@ func (_TestERC20 *TestERC20TransactorSession) TransferInternal(from common.Addre return _TestERC20.Contract.TransferInternal(&_TestERC20.TransactOpts, from, to, value) } +// Receive is a paid mutator transaction binding the contract receive function. +// +// Solidity: receive() payable returns() +func (_TestERC20 *TestERC20Transactor) Receive(opts *bind.TransactOpts) (*types.Transaction, error) { + return _TestERC20.contract.RawTransact(opts, nil) // calldata is disallowed for receive function +} + +// Receive is a paid mutator transaction binding the contract receive function. +// +// Solidity: receive() payable returns() +func (_TestERC20 *TestERC20Session) Receive() (*types.Transaction, error) { + return _TestERC20.Contract.Receive(&_TestERC20.TransactOpts) +} + +// Receive is a paid mutator transaction binding the contract receive function. +// +// Solidity: receive() payable returns() +func (_TestERC20 *TestERC20TransactorSession) Receive() (*types.Transaction, error) { + return _TestERC20.Contract.Receive(&_TestERC20.TransactOpts) +} + // TestERC20ApprovalIterator is returned from FilterApproval and is used to iterate over the raw logs and unpacked data for Approval events raised by the TestERC20 contract. type TestERC20ApprovalIterator struct { Event *TestERC20Approval // Event containing the contract specifics and raw log diff --git a/ethereum/extethclient/eth_wallet_client.go b/ethereum/extethclient/eth_wallet_client.go index 10b04a037..d487e76fe 100644 --- a/ethereum/extethclient/eth_wallet_client.go +++ b/ethereum/extethclient/eth_wallet_client.go @@ -8,6 +8,7 @@ package extethclient import ( "context" "crypto/ecdsa" + "errors" "fmt" "math/big" "sync" @@ -17,6 +18,7 @@ import ( ethcommon "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/params" logging "github.com/ipfs/go-log" "github.com/athanorlabs/atomic-swap/coins" @@ -25,7 +27,7 @@ import ( "github.com/athanorlabs/atomic-swap/ethereum/block" ) -var log = logging.Logger("extethclient") +var log = logging.Logger("ethereum/extethclient") // EthClient provides management of a private key and other convenience functions layered // on top of the go-ethereum client. You can still access the raw go-ethereum client via @@ -51,12 +53,26 @@ type EthClient interface { Lock() // Lock the wallet so only one transaction runs at at time Unlock() // Unlock the wallet after a transaction is complete - // transfers ETH to the given address - // does not need locking, as it locks internally - Transfer(ctx context.Context, to ethcommon.Address, amount *coins.WeiAmount) (ethcommon.Hash, error) - - // attempts to cancel a transaction with the given nonce by sending a zero-value tx to ourselves - CancelTxWithNonce(ctx context.Context, nonce uint64, gasPrice *big.Int) (ethcommon.Hash, error) + // Transfer transfers ETH to the given address, nonce Lock()/Unlock() + // handling is done internally. The gasLimit field when the destination + // address is not a contract. + Transfer( + ctx context.Context, + to ethcommon.Address, + amount *coins.WeiAmount, + gasLimit *uint64, + ) (*ethtypes.Receipt, error) + + // Sweep transfers all funds to the given address, nonce Lock()/Unlock() + // handling is done internally. Dust may be left is sending to a contract + // address, otherwise the balance afterward will be zero. + Sweep(ctx context.Context, to ethcommon.Address) (*ethtypes.Receipt, error) + + // CancelTxWithNonce attempts to cancel a transaction with the given nonce + // by sending a zero-value tx to ourselves. Since the nonce is fixed, no + // locking is done. You can even intentionally run this method to fail some + // other method that has the lock and is waiting for a receipt. + CancelTxWithNonce(ctx context.Context, nonce uint64, gasPrice *big.Int) (*ethtypes.Receipt, error) WaitForReceipt(ctx context.Context, txHash ethcommon.Hash) (*ethtypes.Receipt, error) WaitForTimestamp(ctx context.Context, ts time.Time) error @@ -301,62 +317,136 @@ func (c *ethClient) Raw() *ethclient.Client { return c.ec } -func (c *ethClient) CancelTxWithNonce( +// Transfer transfers ETH to the given address, nonce Lock()/Unlock() handling +// is done internally. The gasLimit parameter is required when transferring to a +// contract and ignored otherwise. +func (c *ethClient) Transfer( ctx context.Context, - nonce uint64, - gasPrice *big.Int, -) (ethcommon.Hash, error) { + to ethcommon.Address, + amount *coins.WeiAmount, + gasLimit *uint64, +) (*ethtypes.Receipt, error) { c.mu.Lock() defer c.mu.Unlock() - tx := ethtypes.NewTransaction(nonce, c.ethAddress, big.NewInt(0), 21000, gasPrice, nil) + nonce, err := c.ec.NonceAt(ctx, c.ethAddress, nil) + if err != nil { + return nil, fmt.Errorf("failed to get nonce: %w", err) + } - signer := ethtypes.LatestSignerForChainID(c.chainID) - signedTx, err := ethtypes.SignTx(tx, signer, c.ethPrivKey) + gasPrice, err := c.ec.SuggestGasPrice(ctx) if err != nil { - return ethcommon.Hash{}, fmt.Errorf("failed to sign tx: %w", err) + return nil, fmt.Errorf("failed to get gas price: %w", err) } - err = c.ec.SendTransaction(ctx, signedTx) + isContract, err := c.isContractAddress(ctx, to) if err != nil { - return ethcommon.Hash{}, fmt.Errorf("failed to send tx: %w", err) + return nil, fmt.Errorf("failed to determine if dest address is contract: %w", err) + } + + if !isContract { + gasLimit = new(uint64) + *gasLimit = params.TxGas + } else { + if gasLimit == nil { + return nil, errors.New("gas limit is required when transferring to a contract") + } } - return signedTx.Hash(), nil + return transfer(&transferConfig{ + ctx: ctx, + ec: c.ec, + pk: c.ethPrivKey, + destAddr: to, + amount: amount, + gasLimit: *gasLimit, + gasPrice: coins.NewWeiAmount(gasPrice), + nonce: nonce, + }) } -func (c *ethClient) Transfer( - ctx context.Context, - to ethcommon.Address, - amount *coins.WeiAmount, -) (ethcommon.Hash, error) { +func (c *ethClient) Sweep(ctx context.Context, to ethcommon.Address) (*ethtypes.Receipt, error) { c.mu.Lock() defer c.mu.Unlock() nonce, err := c.ec.NonceAt(ctx, c.ethAddress, nil) if err != nil { - return ethcommon.Hash{}, fmt.Errorf("failed to get nonce: %w", err) + return nil, fmt.Errorf("failed to get nonce: %w", err) } gasPrice, err := c.ec.SuggestGasPrice(ctx) if err != nil { - return ethcommon.Hash{}, fmt.Errorf("failed to get gas price: %w", err) + return nil, fmt.Errorf("failed to get gas price: %w", err) + } + + isContract, err := c.isContractAddress(ctx, to) + if err != nil { + return nil, fmt.Errorf("failed to determine if dest address is contract: %w", err) } - tx := ethtypes.NewTransaction(nonce, to, amount.BigInt(), 21000, gasPrice, nil) + // Sweeping to a contract address is problematic, as any overestimation of + // the needed gas leaves non-spendable dust in the wallet. If someone has a + // use-case in the future, we can add the feature. + if isContract { + return nil, errors.New("sweeping to contract addresses is not currently supported") + } - signer := ethtypes.LatestSignerForChainID(c.chainID) - signedTx, err := ethtypes.SignTx(tx, signer, c.ethPrivKey) + balance, err := c.Balance(ctx) if err != nil { - return ethcommon.Hash{}, fmt.Errorf("failed to sign tx: %w", err) + return nil, fmt.Errorf("failed to determine balance: %w", err) + } + + fees := coins.NewWeiAmount(new(big.Int).Mul(gasPrice, big.NewInt(int64(params.TxGas)))) + + if balance.Cmp(fees) <= 0 { + return nil, fmt.Errorf("balance of %s ETH too small for fees (%d gas * %s gas-price = %s ETH", + balance.AsEtherString(), params.TxGas, coins.NewWeiAmount(gasPrice).AsEtherString(), fees.AsEtherString()) } - err = c.ec.SendTransaction(ctx, signedTx) + amount := coins.NewWeiAmount(new(big.Int).Sub(balance.BigInt(), fees.BigInt())) + + return transfer(&transferConfig{ + ctx: ctx, + ec: c.ec, + pk: c.ethPrivKey, + destAddr: to, + amount: amount, + gasLimit: params.TxGas, + gasPrice: coins.NewWeiAmount(gasPrice), + nonce: nonce, + }) +} + +// CancelTxWithNonce attempts to cancel a transaction with the given nonce +// by sending a zero-value tx to ourselves. Since the nonce is fixed, no +// locking is done. You can even intentionally run this method to fail some +// other method that has the lock and is waiting for a receipt. +func (c *ethClient) CancelTxWithNonce( + ctx context.Context, + nonce uint64, + gasPrice *big.Int, +) (*ethtypes.Receipt, error) { + // no locking, nonce is fixed and we are not protecting it + return transfer(&transferConfig{ + ctx: ctx, + ec: c.ec, + pk: c.ethPrivKey, + destAddr: c.ethAddress, // ourself + amount: coins.NewWeiAmount(big.NewInt(0)), // zero ETH + gasLimit: params.TxGas, + gasPrice: coins.NewWeiAmount(gasPrice), + nonce: nonce, + }) +} + +func (c *ethClient) isContractAddress(ctx context.Context, addr ethcommon.Address) (bool, error) { + bytecode, err := c.Raw().CodeAt(ctx, addr, nil) if err != nil { - return ethcommon.Hash{}, fmt.Errorf("failed to send transaction: %w", err) + return false, err } - return signedTx.Hash(), nil + isContract := len(bytecode) > 0 + return isContract, nil } func validateChainID(env common.Environment, chainID *big.Int) error { diff --git a/ethereum/extethclient/eth_wallet_client_test.go b/ethereum/extethclient/eth_wallet_client_test.go index ae937d6e9..12458a18c 100644 --- a/ethereum/extethclient/eth_wallet_client_test.go +++ b/ethereum/extethclient/eth_wallet_client_test.go @@ -4,15 +4,122 @@ package extethclient import ( + "context" "math/big" "testing" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/params" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/athanorlabs/atomic-swap/cliutil" + "github.com/athanorlabs/atomic-swap/coins" "github.com/athanorlabs/atomic-swap/common" + contracts "github.com/athanorlabs/atomic-swap/ethereum" + "github.com/athanorlabs/atomic-swap/tests" ) +func init() { + cliutil.SetLogLevels("debug") +} + +func Test_ethClient_Transfer(t *testing.T) { + ctx := context.Background() + + senderKey := tests.GetTestKeyByIndex(t, 0) + receiverKey, err := crypto.GenerateKey() + require.NoError(t, err) + + senderEC := CreateTestClient(t, senderKey) + receiverEC := CreateTestClient(t, receiverKey) + + transferAmt := coins.EtherToWei(coins.StrToDecimal("0.123456789012345678")) + + receipt, err := senderEC.Transfer(ctx, receiverEC.Address(), transferAmt, nil) + require.NoError(t, err) + require.Equal(t, receipt.GasUsed, params.TxGas) + + // balance is exactly equal to the transferred amount + receiverBal, err := receiverEC.Balance(ctx) + require.NoError(t, err) + require.Equal(t, receiverBal.AsEtherString(), transferAmt.AsEtherString()) +} + +func Test_ethClient_Transfer_toContract(t *testing.T) { + ctx := context.Background() + pk := tests.GetTestKeyByIndex(t, 0) + ec := CreateTestClient(t, pk) + + token := contracts.GetMockTether(t, ec.Raw(), pk) + startTokenBal, err := ec.ERC20Balance(ctx, token.Address) + require.NoError(t, err) + + zero := new(coins.WeiAmount) + gasLimit := params.TxGas * 2 + + _, err = ec.Transfer(ctx, token.Address, zero, &gasLimit) + require.NoError(t, err) + + // our test token contract mints you 100 standard token units when sending + // it a zero value transaction. + endTokenBal, err := ec.ERC20Balance(ctx, token.Address) + require.NoError(t, err) + require.Greater(t, endTokenBal.AsStd().Cmp(startTokenBal.AsStd()), 0) +} + +func Test_ethClient_Sweep(t *testing.T) { + ctx := context.Background() + srcBal := coins.EtherToWei(coins.StrToDecimal("0.5")) + + // We don't want to completely drain a ganache key, so we need to generate a + // new key for the sweep sender and then fund the account. + testFunder := tests.GetTestKeyByIndex(t, 0) + sweepSrcKey, err := crypto.GenerateKey() + require.NoError(t, err) + sweepDestKey, err := crypto.GenerateKey() + require.NoError(t, err) + + funderEC := CreateTestClient(t, testFunder) + sourceEC := CreateTestClient(t, sweepSrcKey) + destEC := CreateTestClient(t, sweepDestKey) + + // fund the sweep source account with 0.5 ETH + _, err = funderEC.Transfer(ctx, sourceEC.Address(), srcBal, nil) + require.NoError(t, err) + + receipt, err := sourceEC.Sweep(ctx, destEC.Address()) + require.NoError(t, err) + require.Equal(t, receipt.GasUsed, params.TxGas) + + fees := new(big.Int).Mul(receipt.EffectiveGasPrice, big.NewInt(int64(receipt.GasUsed))) + expectedDestBal := coins.NewWeiAmount(new(big.Int).Sub(srcBal.BigInt(), fees)) + + destBal, err := destEC.Balance(ctx) + require.NoError(t, err) + require.Equal(t, expectedDestBal.AsEtherString(), destBal.AsEtherString()) +} + +// Unfortunately, ganache does not have a mempool, so we can't do a meaningful +// test that does actual cancellation. We just test it as sending a transaction +// that doesn't cancel a nonce in the mempool. +func Test_ethClient_CancelTxWithNonce(t *testing.T) { + ctx := context.Background() + pk := tests.GetTestKeyByIndex(t, 0) + ec := CreateTestClient(t, pk) + + nonce, err := ec.Raw().NonceAt(ctx, ec.Address(), nil) + require.NoError(t, err) + + gasPrice, err := ec.SuggestGasPrice(ctx) + require.NoError(t, err) + + receipt, err := ec.CancelTxWithNonce(ctx, nonce, gasPrice) + require.NoError(t, err) + + require.Equal(t, receipt.EffectiveGasPrice.String(), gasPrice.String()) +} + func Test_validateChainID_devSuccess(t *testing.T) { err := validateChainID(common.Development, big.NewInt(common.GanacheChainID)) require.NoError(t, err) diff --git a/ethereum/extethclient/transfer.go b/ethereum/extethclient/transfer.go new file mode 100644 index 000000000..19f091ada --- /dev/null +++ b/ethereum/extethclient/transfer.go @@ -0,0 +1,72 @@ +package extethclient + +import ( + "context" + "crypto/ecdsa" + "fmt" + + ethcommon "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + + "github.com/athanorlabs/atomic-swap/coins" + "github.com/athanorlabs/atomic-swap/common" + "github.com/athanorlabs/atomic-swap/ethereum/block" +) + +type transferConfig struct { + ctx context.Context + ec *ethclient.Client + pk *ecdsa.PrivateKey + destAddr ethcommon.Address + amount *coins.WeiAmount + gasLimit uint64 + gasPrice *coins.WeiAmount + nonce uint64 +} + +// transfer handles almost any use case for transferring ETH by having all the +// configurable values (nonce, gas-price, etc.) set by the caller. +func transfer(cfg *transferConfig) (*ethtypes.Receipt, error) { + ctx := cfg.ctx + ec := cfg.ec + + tx := ethtypes.NewTx(ðtypes.LegacyTx{ + Nonce: cfg.nonce, + To: &cfg.destAddr, + Value: cfg.amount.BigInt(), + Gas: cfg.gasLimit, + GasPrice: cfg.gasPrice.BigInt(), + }) + + chainID, err := ec.ChainID(ctx) + if err != nil { + return nil, err + } + + signer := ethtypes.LatestSignerForChainID(chainID) + signedTx, err := ethtypes.SignTx(tx, signer, cfg.pk) + + if err != nil { + return nil, fmt.Errorf("failed to sign tx: %w", err) + } + + txHash := signedTx.Hash() + + err = ec.SendTransaction(ctx, signedTx) + if err != nil { + return nil, fmt.Errorf("failed to send transfer transaction: %w", err) + } + + log.Infof("transfer of %s ETH to %s sent to mempool with txID %s, nonce %d, gas-price %s ETH", + cfg.amount.AsStdString(), cfg.destAddr, txHash, cfg.nonce, cfg.gasPrice.AsStdString()) + + receipt, err := block.WaitForReceipt(ctx, ec, txHash) + if err != nil { + return nil, fmt.Errorf("failed waiting for txID %s receipt: %w", txHash, err) + } + + log.Infof("transfer included in chain %s", common.ReceiptInfo(receipt)) + + return receipt, nil +} diff --git a/protocol/backend/backend.go b/protocol/backend/backend.go index f9e4ebcbd..0b2c3e63e 100644 --- a/protocol/backend/backend.go +++ b/protocol/backend/backend.go @@ -14,6 +14,7 @@ import ( "time" ethcommon "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" "github.com/libp2p/go-libp2p/core/peer" @@ -99,7 +100,8 @@ type Backend interface { // transfer helpers TransferXMR(to *mcrypto.Address, amount *coins.PiconeroAmount) (string, error) SweepXMR(to *mcrypto.Address) ([]string, error) - TransferETH(to ethcommon.Address, amount *coins.WeiAmount) (types.Hash, error) + TransferETH(to ethcommon.Address, amount *coins.WeiAmount, gasLimit *uint64) (*ethtypes.Receipt, error) + SweepETH(to ethcommon.Address) (*ethtypes.Receipt, error) } type backend struct { @@ -411,6 +413,14 @@ func (b *backend) SweepXMR(to *mcrypto.Address) ([]string, error) { return txIDs, nil } -func (b *backend) TransferETH(to ethcommon.Address, amount *coins.WeiAmount) (types.Hash, error) { - return b.ethClient.Transfer(b.ctx, to, amount) +func (b *backend) TransferETH( + to ethcommon.Address, + amount *coins.WeiAmount, + gasLimit *uint64, +) (*ethtypes.Receipt, error) { + return b.ethClient.Transfer(b.ctx, to, amount, gasLimit) +} + +func (b *backend) SweepETH(to ethcommon.Address) (*ethtypes.Receipt, error) { + return b.ethClient.Sweep(b.ctx, to) } diff --git a/protocol/xmrtaker/instance.go b/protocol/xmrtaker/instance.go index d5a341509..cbd122e59 100644 --- a/protocol/xmrtaker/instance.go +++ b/protocol/xmrtaker/instance.go @@ -11,7 +11,7 @@ import ( "sync" "github.com/ChainSafe/chaindb" - ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum" ethcommon "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" @@ -271,23 +271,12 @@ func (inst *Instance) maybeCancelNewSwap(txHash ethcommon.Hash) (bool, error) { // just double the gas price for now, this is higher than needed for a replacement tx though gasPrice := new(big.Int).Mul(tx.GasPrice(), big.NewInt(2)) - cancelTx, err := inst.backend.ETHClient().CancelTxWithNonce(inst.backend.Ctx(), tx.Nonce(), gasPrice) - if err != nil { - return false, fmt.Errorf("failed to create or send cancel tx: %w", err) - } - - log.Infof("submit cancel tx %s", cancelTx) - - // TODO: if newSwap is included instead of the cancel tx (unlikely), block.WaitForReceipt will actually - // loop for 1 hour and block as there will never be a receipt for the cancel tx. - // we should probably poll for our account nonce to increase, and when it does, check for receipts - // on both txs to see which one was successful. - receipt, err := block.WaitForReceipt(inst.backend.Ctx(), inst.backend.ETHClient().Raw(), cancelTx) + receipt, err := inst.backend.ETHClient().CancelTxWithNonce(inst.backend.Ctx(), tx.Nonce(), gasPrice) if err != nil && !errors.Is(err, ethereum.NotFound) { return false, fmt.Errorf("failed to get cancel transaction receipt: %w", err) } - if errors.Is(err, ethereum.NotFound) || receipt.Status == ethtypes.ReceiptStatusFailed { + if errors.Is(err, ethereum.NotFound) { // this is okay, it means newSwap was included instead, and we can refund it in the calling function log.Infof("failed to cancel swap, attempting to refund") return false, nil diff --git a/rpc/personal.go b/rpc/personal.go index 2e10bdeaf..38852650b 100644 --- a/rpc/personal.go +++ b/rpc/personal.go @@ -167,24 +167,47 @@ func (s *PersonalService) SweepXMR(_ *http.Request, req *SweepXMRRequest, resp * return nil } -// TransferETHRequest ... +// TransferETHRequest is JSON-RPC request object for TransferETH type TransferETHRequest struct { - To ethcommon.Address `json:"to" validate:"required"` - Amount *apd.Decimal `json:"amount" validate:"required"` + To ethcommon.Address `json:"to" validate:"required"` + Amount *apd.Decimal `json:"amount" validate:"required"` + GasLimit *uint64 `json:"gasLimit,omitempty"` } -// TransferETHResponse ... +// TransferETHResponse is JSON-RPC response object for TransferETH type TransferETHResponse struct { - TxHash ethcommon.Hash `json:"txHash"` + TxHash ethcommon.Hash `json:"txHash"` + GasLimit *uint64 `json:"gasLimit,omitempty"` } // TransferETH transfers ETH from the swapd wallet. func (s *PersonalService) TransferETH(_ *http.Request, req *TransferETHRequest, resp *TransferETHResponse) error { - txHash, err := s.pb.TransferETH(req.To, coins.EtherToWei(req.Amount)) + receipt, err := s.pb.TransferETH(req.To, coins.EtherToWei(req.Amount), req.GasLimit) if err != nil { return err } - resp.TxHash = txHash + resp.TxHash = receipt.TxHash + return nil +} + +// SweepETHRequest is JSON-RPC request object for SweepETH +type SweepETHRequest struct { + To ethcommon.Address `json:"to" validate:"required"` +} + +// SweepETHResponse is JSON-RPC response object for SweepETH +type SweepETHResponse struct { + TxHash ethcommon.Hash `json:"txHash"` // Hash of sweep transfer transaction +} + +// SweepETH sweeps all ETH out of the swapd wallet. +func (s *PersonalService) SweepETH(_ *http.Request, req *SweepETHRequest, resp *SweepETHResponse) error { + receipt, err := s.pb.SweepETH(req.To) + if err != nil { + return err + } + + resp.TxHash = receipt.TxHash return nil } diff --git a/rpc/server.go b/rpc/server.go index 0afbab1ee..421e7d6d7 100644 --- a/rpc/server.go +++ b/rpc/server.go @@ -17,6 +17,7 @@ import ( "github.com/MarinX/monerorpc/wallet" "github.com/cockroachdb/apd/v3" ethcommon "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/gorilla/handlers" "github.com/gorilla/mux" "github.com/gorilla/rpc/v2" @@ -239,7 +240,8 @@ type ProtocolBackend interface { ETHClient() extethclient.EthClient TransferXMR(to *mcrypto.Address, amount *coins.PiconeroAmount) (string, error) SweepXMR(to *mcrypto.Address) ([]string, error) - TransferETH(to ethcommon.Address, amount *coins.WeiAmount) (types.Hash, error) + TransferETH(to ethcommon.Address, amount *coins.WeiAmount, gasLimit *uint64) (*ethtypes.Receipt, error) + SweepETH(to ethcommon.Address) (*ethtypes.Receipt, error) } // XMRTaker ... diff --git a/rpcclient/mocks_test.go b/rpcclient/mocks_test.go index 6799fcd84..a94c4a7d6 100644 --- a/rpcclient/mocks_test.go +++ b/rpcclient/mocks_test.go @@ -12,6 +12,7 @@ import ( "github.com/MarinX/monerorpc/wallet" "github.com/cockroachdb/apd/v3" ethcommon "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/libp2p/go-libp2p/core/peer" libp2ptest "github.com/libp2p/go-libp2p/core/test" ma "github.com/multiformats/go-multiaddr" @@ -227,6 +228,10 @@ func (*mockProtocolBackend) SweepXMR(_ *mcrypto.Address) ([]string, error) { panic("not implemented") } -func (*mockProtocolBackend) TransferETH(_ ethcommon.Address, _ *coins.WeiAmount) (ethcommon.Hash, error) { +func (*mockProtocolBackend) TransferETH(_ ethcommon.Address, _ *coins.WeiAmount, _ *uint64) (*ethtypes.Receipt, error) { + panic("not implemented") +} + +func (*mockProtocolBackend) SweepETH(_ ethcommon.Address) (*ethtypes.Receipt, error) { panic("not implemented") } diff --git a/rpcclient/personal.go b/rpcclient/personal.go index b7121ce19..a37f32158 100644 --- a/rpcclient/personal.go +++ b/rpcclient/personal.go @@ -114,3 +114,17 @@ func (c *Client) TransferETH(request *rpc.TransferETHRequest) (*rpc.TransferETHR return resp, nil } + +// SweepETH calls personal_sweepETH +func (c *Client) SweepETH(request *rpc.SweepETHRequest) (*rpc.SweepETHResponse, error) { + const ( + method = "personal_sweepETH" + ) + + resp := new(rpc.SweepETHResponse) + if err := c.post(method, request, resp); err != nil { + return nil, err + } + + return resp, nil +} diff --git a/tests/ganache.go b/tests/ganache.go index 323b7d9b0..04040e9eb 100644 --- a/tests/ganache.go +++ b/tests/ganache.go @@ -41,6 +41,7 @@ var testPackages = []struct { {"daemon", 2}, {"ethereum", 16}, {"ethereum/block", 2}, + {"ethereum/extethclient", 2}, {"net", 2}, {"protocol", 1}, {"protocol/backend", 2},