diff --git a/cmd/swapd/main.go b/cmd/swapd/main.go index 61b2a730..05a37b4f 100644 --- a/cmd/swapd/main.go +++ b/cmd/swapd/main.go @@ -253,6 +253,7 @@ func setLogLevels(level string) { _ = logging.SetLogLevel("cmd", level) _ = logging.SetLogLevel("extethclient", level) _ = logging.SetLogLevel("ethereum/watcher", level) + _ = logging.SetLogLevel("ethereum/block", level) _ = logging.SetLogLevel("monero", level) _ = logging.SetLogLevel("net", level) _ = logging.SetLogLevel("offers", level) diff --git a/common/interfaces.go b/common/interfaces.go index c5859787..ab1ff3bf 100644 --- a/common/interfaces.go +++ b/common/interfaces.go @@ -24,13 +24,13 @@ type SwapState interface { // It is implemented by *xmrtaker.swapState and *xmrmaker.swapState type SwapStateNet interface { HandleProtocolMessage(msg Message) error - ID() types.Hash + OfferID() types.Hash Exit() error } // SwapStateRPC contains the methods used by the RPC server into the SwapState. type SwapStateRPC interface { SendKeysMessage() Message - ID() types.Hash + OfferID() types.Hash Exit() error } diff --git a/common/utils.go b/common/utils.go index 14fc8173..4ec95459 100644 --- a/common/utils.go +++ b/common/utils.go @@ -7,12 +7,16 @@ import ( "context" "crypto/ecdsa" "fmt" + "math/big" "net" "os" "time" ethcommon "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" ethcrypto "github.com/ethereum/go-ethereum/crypto" + + "github.com/athanorlabs/atomic-swap/coins" ) // Reverse returns a copy of the slice with the bytes in reverse order @@ -91,3 +95,10 @@ func GetFreeTCPPort() (uint, error) { return uint(ln.Addr().(*net.TCPAddr).Port), nil } + +// ReceiptInfo creates a string for logging from an ethereum transaction receipt +func ReceiptInfo(receipt *ethtypes.Receipt) string { + txCostWei := new(big.Int).Mul(receipt.EffectiveGasPrice, big.NewInt(int64(receipt.GasUsed))) + return fmt.Sprintf("gas-used: %d, gas-price: %s WEI, tx-cost: %s ETH, block: %s, txID: %s", + receipt.GasUsed, receipt.EffectiveGasPrice, coins.FmtWeiAsETH(txCostWei), receipt.BlockNumber, receipt.TxHash) +} diff --git a/common/utils_test.go b/common/utils_test.go index 4197976b..4fb3243d 100644 --- a/common/utils_test.go +++ b/common/utils_test.go @@ -7,12 +7,14 @@ import ( "context" "io/fs" "math" + "math/big" "os" "path" "testing" "time" ethcommon "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" ethcrypto "github.com/ethereum/go-ethereum/crypto" "github.com/stretchr/testify/assert" @@ -106,3 +108,15 @@ func TestGetFreeTCPPort(t *testing.T) { require.GreaterOrEqual(t, port, uint(1024)) require.LessOrEqual(t, port, uint(math.MaxUint16)) } + +func TestReceiptInfo(t *testing.T) { + receipt := ðtypes.Receipt{ + GasUsed: 100000, + EffectiveGasPrice: big.NewInt(1000000), + BlockNumber: big.NewInt(99), + TxHash: ethcommon.Hash{1, 2, 3}, + } + logStr := ReceiptInfo(receipt) + const expectedStr = "gas-used: 100000, gas-price: 1000000 WEI, tx-cost: 0.0000001 ETH, block: 99, txID: 0x0102030000000000000000000000000000000000000000000000000000000000" //nolint:lll + require.Equal(t, expectedStr, logStr) +} diff --git a/daemon/swap_daemon.go b/daemon/swap_daemon.go index ce6ae108..e5f60ebf 100644 --- a/daemon/swap_daemon.go +++ b/daemon/swap_daemon.go @@ -143,7 +143,7 @@ func RunSwapDaemon(ctx context.Context, conf *SwapdConfig) (err error) { } // connect the maker/taker handlers to the p2p network host - host.SetHandlers(xmrMaker, xmrTaker) + host.SetHandlers(xmrMaker, swapBackend) if err = host.Start(); err != nil { return err } diff --git a/daemon/swap_daemon_test.go b/daemon/swap_daemon_test.go index a5695490..2c487b5b 100644 --- a/daemon/swap_daemon_test.go +++ b/daemon/swap_daemon_test.go @@ -7,12 +7,14 @@ import ( "context" "crypto/ecdsa" "fmt" + "math/big" "sync" "testing" "time" "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/ethereum/go-ethereum/ethclient" logging "github.com/ipfs/go-log" @@ -23,6 +25,7 @@ import ( "github.com/athanorlabs/atomic-swap/common" "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/relayer" @@ -31,6 +34,11 @@ import ( "github.com/athanorlabs/atomic-swap/tests" ) +const ( + // transferGas is the amount of gas to perform a standard ETH transfer + transferGas = 21000 +) + func init() { // alphabetically ordered level := "debug" @@ -41,6 +49,7 @@ func init() { _ = logging.SetLogLevel("cmd", level) _ = logging.SetLogLevel("extethclient", level) _ = logging.SetLogLevel("ethereum/watcher", level) + _ = logging.SetLogLevel("ethereum/block", level) _ = logging.SetLogLevel("monero", level) _ = logging.SetLogLevel("net", level) _ = logging.SetLogLevel("offers", level) @@ -73,6 +82,69 @@ func getSwapFactoryAddress(t *testing.T, ec *ethclient.Client) ethcommon.Address return swapFactoryAddr } +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) { + fundingKey := tests.GetTakerTestKey(t) + + // When this comment was written, sample gas costs were: + // newSwap: 53787 + // setReady: 34452 + // refund: 46692 + // relayClaim: 130507 + // + const ( + aliceGasRation = 150000 // roughly 10% more than newSwap+setRead+refund + ) + // We give Alice enough gas money to refund if needed, but not enough to + // relay a claim: + // 150000 - (53787 + 34452) = 61761 + // + suggestedGasPrice, err := ec.Raw().SuggestGasPrice(context.Background()) + 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) + + bal, err := ec.Balance(context.Background()) + require.NoError(t, err) + t.Logf("Alice's start balance is: %s ETH", coins.FmtWeiAsETH(bal)) +} + func createTestConf(t *testing.T, ethKey *ecdsa.PrivateKey) *SwapdConfig { ctx := context.Background() ec, err := extethclient.NewEthClient(ctx, common.Development, common.DefaultEthEndpoint, ethKey) @@ -103,13 +175,45 @@ func createTestConf(t *testing.T, ethKey *ecdsa.PrivateKey) *SwapdConfig { } } -// Tests the scenario, where Bob has no ETH and Alice relays his claim. -func TestRunSwapDaemon_SwapBobHasNoEth(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) +func launchDaemons(t *testing.T, timeout time.Duration, configs ...*SwapdConfig) context.Context { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + + var wg sync.WaitGroup t.Cleanup(func() { cancel() + wg.Wait() }) + var bootNodes []string // First daemon to launch has no bootnodes + + for n, conf := range configs { + + conf.EnvConf.Bootnodes = bootNodes + + wg.Add(1) + go func() { + defer wg.Done() + err := RunSwapDaemon(ctx, conf) + require.ErrorIs(t, err, context.Canceled) + }() + WaitForSwapdStart(t, conf.RPCPort) + + // Configure remaining daemons to use the first one a bootnode + if n == 0 { + c := rpcclient.NewClient(ctx, fmt.Sprintf("http://127.0.0.1:%d", conf.RPCPort)) + addresses, err := c.Addresses() + require.NoError(t, err) + require.Greater(t, len(addresses.Addrs), 1) + bootNodes = []string{addresses.Addrs[0]} + } + } + + return ctx +} + +// Tests the scenario, where Bob has no ETH, there are no advertised relayers in +// the network, and Alice relays Bob's claim. +func TestRunSwapDaemon_SwapBobHasNoEth_AliceRelaysClaim(t *testing.T) { minXMR := coins.StrToDecimal("1") maxXMR := minXMR exRate := coins.StrToExchangeRate("0.1") @@ -121,45 +225,18 @@ func TestRunSwapDaemon_SwapBobHasNoEth(t *testing.T) { bobConf := createTestConf(t, bobEthKey) monero.MineMinXMRBalance(t, bobConf.MoneroClient, coins.MoneroToPiconero(maxXMR)) - var stoppedWG sync.WaitGroup - t.Cleanup(func() { - cancel() - stoppedWG.Wait() // ensure daemons are stopped even if require fails - }) - - stoppedWG.Add(1) - go func() { - defer stoppedWG.Done() - err := RunSwapDaemon(ctx, bobConf) //nolint:govet - require.ErrorIs(t, err, context.Canceled) - }() - WaitForSwapdStart(t, bobConf.RPCPort) - - bc := rpcclient.NewClient(ctx, fmt.Sprintf("http://127.0.0.1:%d", bobConf.RPCPort)) - bws, err := wsclient.NewWsClient(ctx, fmt.Sprintf("ws://127.0.0.1:%d/ws", bobConf.RPCPort)) - require.NoError(t, err) - - // Configure Alice to be a relayer and to use Bob as a bootnode aliceConf := createTestConf(t, tests.GetTakerTestKey(t)) - aliceConf.IsRelayer = true - bobAddrs, err := bc.Addresses() - require.NoError(t, err) - require.Greater(t, len(bobAddrs.Addrs), 1) - aliceConf.EnvConf.Bootnodes = []string{bobAddrs.Addrs[0]} - stoppedWG.Add(1) - go func() { - defer stoppedWG.Done() - err := RunSwapDaemon(ctx, aliceConf) //nolint:govet - require.ErrorIs(t, err, context.Canceled) - }() - WaitForSwapdStart(t, aliceConf.RPCPort) + timeout := 5 * time.Minute + ctx := launchDaemons(t, timeout, bobConf, aliceConf) + + bc, err := wsclient.NewWsClient(ctx, fmt.Sprintf("ws://127.0.0.1:%d/ws", bobConf.RPCPort)) + require.NoError(t, err) + ac, err := wsclient.NewWsClient(ctx, fmt.Sprintf("ws://127.0.0.1:%d/ws", aliceConf.RPCPort)) + require.NoError(t, err) useRelayer := false // Bob will use the relayer regardless, because he has no ETH - makeResp, bobStatusCh, err := bws.MakeOfferAndSubscribe(minXMR, maxXMR, exRate, types.EthAssetETH, useRelayer) - require.NoError(t, err) - - ac, err := wsclient.NewWsClient(ctx, fmt.Sprintf("ws://127.0.0.1:%d/ws", aliceConf.RPCPort)) + makeResp, bobStatusCh, err := bc.MakeOfferAndSubscribe(minXMR, maxXMR, exRate, types.EthAssetETH, useRelayer) require.NoError(t, err) aliceStatusCh, err := ac.TakeOfferAndSubscribe(makeResp.PeerID, makeResp.OfferID, providesAmt) @@ -221,3 +298,284 @@ func TestRunSwapDaemon_SwapBobHasNoEth(t *testing.T) { require.Equal(t, expectedBal.Text('f'), coins.FmtWeiAsETH(bobBalance)) } + +// Tests the scenario where Bob has no ETH, he can't find an advertised relayer, +// and Alice does not have enough ETH to relay his claim. The end result should +// be a refund. Note that this test has a long pause, as the refund cannot +// happen until T1 expires. +func TestRunSwapDaemon_NoRelayersAvailable_Refund(t *testing.T) { + minXMR := coins.StrToDecimal("1") + maxXMR := minXMR + exRate := coins.StrToExchangeRate("0.1") + providesAmt, err := exRate.ToETH(minXMR) + require.NoError(t, err) + + bobEthKey, err := crypto.GenerateKey() // Bob has no ETH (not a ganache key) + require.NoError(t, err) + bobConf := createTestConf(t, bobEthKey) + monero.MineMinXMRBalance(t, bobConf.MoneroClient, coins.MoneroToPiconero(maxXMR)) + + 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) + + timeout := 7 * time.Minute + ctx := launchDaemons(t, timeout, bobConf, aliceConf) + + bc, err := wsclient.NewWsClient(ctx, fmt.Sprintf("ws://127.0.0.1:%d/ws", bobConf.RPCPort)) + require.NoError(t, err) + ac, err := wsclient.NewWsClient(ctx, fmt.Sprintf("ws://127.0.0.1:%d/ws", aliceConf.RPCPort)) + require.NoError(t, err) + + useRelayer := false // Bob will use unsuccessfully use the relayer regardless, because he has no ETH + makeResp, bobStatusCh, err := bc.MakeOfferAndSubscribe(minXMR, maxXMR, exRate, types.EthAssetETH, useRelayer) + require.NoError(t, err) + + aliceStatusCh, err := ac.TakeOfferAndSubscribe(makeResp.PeerID, makeResp.OfferID, providesAmt) + require.NoError(t, err) + + var statusWG sync.WaitGroup + statusWG.Add(2) + + // Ensure Alice completes the swap with a refund + go func() { + defer statusWG.Done() + for { + select { + case status := <-aliceStatusCh: + t.Log("> Alice got status:", status) + if !status.IsOngoing() { + assert.Equal(t, types.CompletedRefund.String(), status.String()) + return + } + case <-ctx.Done(): + t.Errorf("Alice's context cancelled before she completed the swap") + return + } + } + }() + + // Test that Bob completes the swap as a refund + go func() { + defer statusWG.Done() + for { + select { + case status := <-bobStatusCh: + t.Log("> Bob got status:", status) + if !status.IsOngoing() { + assert.Equal(t, types.CompletedRefund.String(), status.String()) + return + } + case <-ctx.Done(): + t.Errorf("Bob's context cancelled before he completed the swap") + return + } + } + }() + + statusWG.Wait() +} + +// Tests the scenario where Bob has no ETH and Charlie, an advertised relayer, +// performs the relay so Bob can get his ETH. To ensure that the test does not +// succeed by Alice relaying the claim, we ensure that Alice does not have +// enough ETH left over after the swap to relay. +func TestRunSwapDaemon_CharlieRelays(t *testing.T) { + minXMR := coins.StrToDecimal("1") + maxXMR := minXMR + exRate := coins.StrToExchangeRate("0.1") + providesAmt, err := exRate.ToETH(minXMR) + require.NoError(t, err) + + bobEthKey, err := crypto.GenerateKey() // Bob has no ETH (not a ganache key) + require.NoError(t, err) + bobConf := createTestConf(t, bobEthKey) + monero.MineMinXMRBalance(t, bobConf.MoneroClient, coins.MoneroToPiconero(maxXMR)) + + // Configure Alice with enough funds to complete the swap, but not to relay Bob's claim + 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) + + // Charlie can safely use the taker key, as Alice is not using it. + charlieConf := createTestConf(t, tests.GetTakerTestKey(t)) + charlieConf.IsRelayer = true + charlieStartBal, err := charlieConf.EthereumClient.Balance(context.Background()) + require.NoError(t, err) + + timeout := 5 * time.Minute + ctx := launchDaemons(t, timeout, bobConf, aliceConf, charlieConf) + + bc, err := wsclient.NewWsClient(ctx, fmt.Sprintf("ws://127.0.0.1:%d/ws", bobConf.RPCPort)) + require.NoError(t, err) + ac, err := wsclient.NewWsClient(ctx, fmt.Sprintf("ws://127.0.0.1:%d/ws", aliceConf.RPCPort)) + require.NoError(t, err) + + useRelayer := false // Bob will use the relayer regardless, because he has no ETH + makeResp, bobStatusCh, err := bc.MakeOfferAndSubscribe(minXMR, maxXMR, exRate, types.EthAssetETH, useRelayer) + require.NoError(t, err) + + aliceStatusCh, err := ac.TakeOfferAndSubscribe(makeResp.PeerID, makeResp.OfferID, providesAmt) + require.NoError(t, err) + + var statusWG sync.WaitGroup + statusWG.Add(2) + + // Ensure Alice completes the swap successfully + go func() { + defer statusWG.Done() + for { + select { + case status := <-aliceStatusCh: + t.Log("> Alice got status:", status) + if !status.IsOngoing() { + assert.Equal(t, types.CompletedSuccess.String(), status.String()) + return + } + case <-ctx.Done(): + t.Errorf("Alice's context cancelled before she completed the swap") + return + } + } + }() + + // Ensure Bob completes the swap successfully + go func() { + defer statusWG.Done() + for { + select { + case status := <-bobStatusCh: + t.Log("> Bob got status:", status) + if !status.IsOngoing() { + assert.Equal(t, types.CompletedSuccess.String(), status.String()) + return + } + case <-ctx.Done(): + t.Errorf("Bob's context cancelled before he completed the swap") + return + } + } + }() + + statusWG.Wait() + if t.Failed() { + return + } + + // + // Bob's ending balance should be Alice's provided amount minus the relayer fee + // + bobExpectedBal := new(apd.Decimal) + _, err = coins.DecimalCtx().Sub(bobExpectedBal, providesAmt, relayer.FeeEth) + require.NoError(t, err) + bobBalance, err := bobConf.EthereumClient.Balance(ctx) + require.NoError(t, err) + require.Equal(t, bobExpectedBal.Text('f'), coins.FmtWeiAsETH(bobBalance)) + + // + // Charlie should be wealthier now than at the start, despite paying the claim + // gas, because he received the relayer fee. + // + charlieEC := charlieConf.EthereumClient + charlieBal, err := charlieEC.Balance(ctx) + require.NoError(t, err) + require.Greater(t, charlieBal.Cmp(charlieStartBal), 0) + charlieProfitWei := new(big.Int).Sub(charlieBal, charlieStartBal) + t.Logf("Charlie earned %s ETH", coins.FmtWeiAsETH(charlieProfitWei)) +} + +// Tests the scenario where Charlie, an advertised relayer, has run out of ETH +// and cannot relay Alice's request. Bob falls back to Alice as the relayer of +// last resort, and she relays his claim. +func TestRunSwapDaemon_CharlieIsBroke_AliceRelays(t *testing.T) { + minXMR := coins.StrToDecimal("1") + maxXMR := minXMR + exRate := coins.StrToExchangeRate("0.1") + providesAmt, err := exRate.ToETH(minXMR) + require.NoError(t, err) + + bobEthKey, err := crypto.GenerateKey() // Bob has no ETH (not a ganache key) + require.NoError(t, err) + bobConf := createTestConf(t, bobEthKey) + monero.MineMinXMRBalance(t, bobConf.MoneroClient, coins.MoneroToPiconero(maxXMR)) + + // Alice is fully funded with the taker key + aliceConf := createTestConf(t, tests.GetTakerTestKey(t)) + + // Charlie is a relayer, but he has no ETH + charlieEthKey, err := crypto.GenerateKey() + require.NoError(t, err) + charlieConf := createTestConf(t, charlieEthKey) + charlieConf.IsRelayer = true + + timeout := 5 * time.Minute + ctx := launchDaemons(t, timeout, bobConf, aliceConf, charlieConf) + + bc, err := wsclient.NewWsClient(ctx, fmt.Sprintf("ws://127.0.0.1:%d/ws", bobConf.RPCPort)) + require.NoError(t, err) + ac, err := wsclient.NewWsClient(ctx, fmt.Sprintf("ws://127.0.0.1:%d/ws", aliceConf.RPCPort)) + require.NoError(t, err) + + useRelayer := false // Bob will use the relayer regardless, because he has no ETH + makeResp, bobStatusCh, err := bc.MakeOfferAndSubscribe(minXMR, maxXMR, exRate, types.EthAssetETH, useRelayer) + require.NoError(t, err) + + aliceStatusCh, err := ac.TakeOfferAndSubscribe(makeResp.PeerID, makeResp.OfferID, providesAmt) + require.NoError(t, err) + + var statusWG sync.WaitGroup + statusWG.Add(2) + + // Ensure Alice completes the swap successfully + go func() { + defer statusWG.Done() + for { + select { + case status := <-aliceStatusCh: + t.Log("> Alice got status:", status) + if !status.IsOngoing() { + assert.Equal(t, types.CompletedSuccess.String(), status.String()) + return + } + case <-ctx.Done(): + t.Errorf("Alice's context cancelled before she completed the swap") + return + } + } + }() + + // Ensure Bob completes the swap successfully + go func() { + defer statusWG.Done() + for { + select { + case status := <-bobStatusCh: + t.Log("> Bob got status:", status) + if !status.IsOngoing() { + assert.Equal(t, types.CompletedSuccess.String(), status.String()) + return + } + case <-ctx.Done(): + t.Errorf("Bob's context cancelled before he completed the swap") + return + } + } + }() + + statusWG.Wait() + if t.Failed() { + return + } + + // + // Bob's ending balance should be Alice's provided amount minus the relayer fee + // + bobExpectedBal := new(apd.Decimal) + _, err = coins.DecimalCtx().Sub(bobExpectedBal, providesAmt, relayer.FeeEth) + require.NoError(t, err) + bobBalance, err := bobConf.EthereumClient.Balance(ctx) + require.NoError(t, err) + require.Equal(t, bobExpectedBal.Text('f'), coins.FmtWeiAsETH(bobBalance)) +} diff --git a/db/database.go b/db/database.go index 6955efec..27e392d8 100644 --- a/db/database.go +++ b/db/database.go @@ -195,7 +195,7 @@ func (db *Database) PutSwap(s *swap.Info) error { return err } - key := s.ID + key := s.OfferID err = db.swapTable.Put(key[:], val) if err != nil { return err diff --git a/db/database_test.go b/db/database_test.go index 0fba7476..65b40fbc 100644 --- a/db/database_test.go +++ b/db/database_test.go @@ -10,6 +10,7 @@ import ( "github.com/ChainSafe/chaindb" logging "github.com/ipfs/go-log" + "github.com/libp2p/go-libp2p/core/peer" "github.com/stretchr/testify/require" "github.com/athanorlabs/atomic-swap/coins" @@ -22,6 +23,8 @@ func init() { _ = logging.SetLogLevel("db", "debug") } +var testPeerID, _ = peer.Decode("12D3KooWQQRJuKTZ35eiHGNPGDpQqjpJSdaxEMJRxi6NWFrrvQVi") + // infoAsJSON converts an Info object to a JSON string. Converting // the struct to JSON is the easiest way to compare 2 structs for // equality, as there are many pointer fields. @@ -41,7 +44,8 @@ func TestDatabase_OfferTable(t *testing.T) { // put swap to ensure iterator over offers is ok infoA := &swap.Info{ Version: swap.CurInfoVersion, - ID: types.Hash{0x1}, + PeerID: testPeerID, + OfferID: types.Hash{0x1}, Provides: coins.ProvidesXMR, ProvidedAmount: coins.StrToDecimal("0.1"), ExpectedAmount: coins.StrToDecimal("1"), @@ -108,7 +112,8 @@ func TestDatabase_GetAllOffers_InvalidEntry(t *testing.T) { // Put a swap entry tied to the bad offer in the database swapEntry := &swap.Info{ Version: swap.CurInfoVersion, - ID: badOfferID, + PeerID: testPeerID, + OfferID: badOfferID, Provides: coins.ProvidesXMR, ProvidedAmount: coins.StrToDecimal("0.1"), ExpectedAmount: coins.StrToDecimal("1"), @@ -180,7 +185,8 @@ func TestDatabase_SwapTable(t *testing.T) { infoA := &swap.Info{ Version: swap.CurInfoVersion, - ID: offerA.ID, + PeerID: testPeerID, + OfferID: offerA.ID, Provides: offerA.Provides, ProvidedAmount: offerA.MinAmount, ExpectedAmount: offerA.MinAmount, @@ -199,7 +205,8 @@ func TestDatabase_SwapTable(t *testing.T) { infoB := &swap.Info{ Version: swap.CurInfoVersion, - ID: types.Hash{0x2}, + PeerID: testPeerID, + OfferID: types.Hash{0x2}, Provides: coins.ProvidesXMR, ProvidedAmount: coins.StrToDecimal("1.5"), ExpectedAmount: coins.StrToDecimal("0.15"), @@ -238,7 +245,8 @@ func TestDatabase_GetAllSwaps_InvalidEntry(t *testing.T) { goodInfo := &swap.Info{ Version: swap.CurInfoVersion, - ID: types.Hash{0x1, 0x2, 0x3}, + PeerID: testPeerID, + OfferID: types.Hash{0x1, 0x2, 0x3}, Provides: coins.ProvidesXMR, ProvidedAmount: coins.StrToDecimal("1.5"), ExpectedAmount: coins.StrToDecimal("0.15"), @@ -261,7 +269,7 @@ func TestDatabase_GetAllSwaps_InvalidEntry(t *testing.T) { require.NoError(t, err) // Establish a baseline that both the good and bad entries exist before calling GetAllSwaps - exists, err := db.swapTable.Has(goodInfo.ID[:]) + exists, err := db.swapTable.Has(goodInfo.OfferID[:]) require.NoError(t, err) require.True(t, exists) @@ -273,10 +281,10 @@ func TestDatabase_GetAllSwaps_InvalidEntry(t *testing.T) { swaps, err := db.GetAllSwaps() require.NoError(t, err) require.Equal(t, 1, len(swaps)) - require.EqualValues(t, goodInfo.ID[:], swaps[0].ID[:]) + require.EqualValues(t, goodInfo.OfferID[:], swaps[0].OfferID[:]) // GetAllSwaps should have pruned the bad swap info entry, but left the good entry - exists, err = db.swapTable.Has(goodInfo.ID[:]) + exists, err = db.swapTable.Has(goodInfo.OfferID[:]) require.NoError(t, err) require.True(t, exists) // entry still exists @@ -301,7 +309,8 @@ func TestDatabase_SwapTable_Update(t *testing.T) { infoA := &swap.Info{ Version: swap.CurInfoVersion, - ID: id, + PeerID: testPeerID, + OfferID: id, Provides: coins.ProvidesXMR, ProvidedAmount: coins.StrToDecimal("0.1"), ExpectedAmount: coins.StrToDecimal("1"), diff --git a/ethereum/block/wait_for_receipt.go b/ethereum/block/wait_for_receipt.go index c458aef0..e2ef45d4 100644 --- a/ethereum/block/wait_for_receipt.go +++ b/ethereum/block/wait_for_receipt.go @@ -45,16 +45,11 @@ func WaitForReceipt(ctx context.Context, ec *ethclient.Client, txHash ethcommon. continue } if receipt.Status != ethtypes.ReceiptStatusSuccessful { - err = fmt.Errorf("transaction failed (gas-lost=%d tx=%s block=%d), %w", - receipt.GasUsed, txHash, receipt.BlockNumber, ErrorFromBlock(ctx, ec, receipt)) + err = fmt.Errorf("failed transaction included in block (%s): %w", + common.ReceiptInfo(receipt), ErrorFromBlock(ctx, ec, receipt)) return nil, err } - log.Infof("transaction %s included in chain, block hash=%s, block number=%d, gas used=%d", - txHash, - receipt.BlockHash, - receipt.BlockNumber, - receipt.CumulativeGasUsed, - ) + log.Debugf("transaction included in chain %s", common.ReceiptInfo(receipt)) return receipt, nil } diff --git a/ethereum/block/wait_for_receipt_test.go b/ethereum/block/wait_for_receipt_test.go index 900a0c84..a4491b0d 100644 --- a/ethereum/block/wait_for_receipt_test.go +++ b/ethereum/block/wait_for_receipt_test.go @@ -108,5 +108,5 @@ func TestWaitForReceipt_failWhenTransactionIsMined(t *testing.T) { // Ensure that we got the expected error require.Contains(t, err.Error(), "revert block.timestamp was not less than stamp") // Ensure that the expected error happened when the transaction was mined and not earlier - require.Contains(t, err.Error(), "gas-lost=") + require.Contains(t, err.Error(), "failed transaction included in block") } diff --git a/ethereum/extethclient/eth_wallet_client.go b/ethereum/extethclient/eth_wallet_client.go index 57fd684c..5635b29f 100644 --- a/ethereum/extethclient/eth_wallet_client.go +++ b/ethereum/extethclient/eth_wallet_client.go @@ -43,6 +43,7 @@ type EthClient interface { SetGasPrice(uint64) SetGasLimit(uint64) + SuggestGasPrice(ctx context.Context) (*big.Int, error) CallOpts(ctx context.Context) *bind.CallOpts TxOpts(ctx context.Context) (*bind.TransactOpts, error) ChainID() *big.Int @@ -137,6 +138,16 @@ func (c *ethClient) Balance(ctx context.Context) (*big.Int, error) { return bal, nil } +// SuggestedGasPrice returns the underlying eth client's suggested gas price +// unless the user specified a fixed gas price to use, in which case the user +// supplied value is returned. +func (c *ethClient) SuggestGasPrice(ctx context.Context) (*big.Int, error) { + if c.gasPrice != nil { + return c.gasPrice, nil + } + return c.Raw().SuggestGasPrice(ctx) +} + func (c *ethClient) ERC20Balance(ctx context.Context, token ethcommon.Address) (*big.Int, error) { tokenContract, err := contracts.NewIERC20(token, c.ec) if err != nil { diff --git a/ethereum/swap_factory_test.go b/ethereum/swap_factory_test.go index bf08d657..5da456aa 100644 --- a/ethereum/swap_factory_test.go +++ b/ethereum/swap_factory_test.go @@ -6,6 +6,7 @@ package contracts import ( "context" "crypto/ecdsa" + "crypto/rand" "encoding/hex" "math/big" "sync" @@ -18,6 +19,7 @@ import ( "github.com/ethereum/go-ethereum/ethclient" "github.com/stretchr/testify/require" + "github.com/athanorlabs/atomic-swap/common" "github.com/athanorlabs/atomic-swap/common/types" "github.com/athanorlabs/atomic-swap/crypto/secp256k1" "github.com/athanorlabs/atomic-swap/dleq" @@ -47,18 +49,77 @@ func testNewSwap(t *testing.T, asset ethcommon.Address) { require.NotEqual(t, ethcommon.Address{}, address) require.NotNil(t, tx) require.NotNil(t, contract) + receipt, err := block.WaitForReceipt(context.Background(), conn, tx.Hash()) require.NoError(t, err) t.Logf("gas cost to deploy SwapFactory.sol: %d", receipt.GasUsed) - nonce := big.NewInt(0) - tx, err = contract.NewSwap(auth, [32]byte{}, [32]byte{}, - ethcommon.Address{}, defaultTimeoutDuration, defaultTimeoutDuration, asset, big.NewInt(0), nonce) + owner := auth.From + claimer := common.EthereumPrivateKeyToAddress(tests.GetMakerTestKey(t)) + + var pubKeyClaim, pubKeyRefund [32]byte + _, err = rand.Read(pubKeyClaim[:]) require.NoError(t, err) - receipt, err = block.WaitForReceipt(context.Background(), conn, tx.Hash()) + _, err = rand.Read(pubKeyRefund[:]) require.NoError(t, err) + nonce, err := rand.Prime(rand.Reader, 256) + require.NoError(t, err) + value, err := rand.Prime(rand.Reader, 53) // (2^54 - 1) / 10^18 ~= .018 ETH + require.NoError(t, err) + + isEthAsset := asset == ethAssetAddress + + if isEthAsset { + auth.Value = value + } else { + value = big.NewInt(0) + } + + tx, err = contract.NewSwap( + auth, + pubKeyClaim, + pubKeyRefund, + claimer, + defaultTimeoutDuration, + defaultTimeoutDuration, + asset, + value, + nonce, + ) + require.NoError(t, err) + + receipt, err = block.WaitForReceipt(context.Background(), conn, tx.Hash()) + require.NoError(t, err) t.Logf("gas cost to call new_swap: %d", receipt.GasUsed) + + newSwapLogIndex := 0 + if !isEthAsset { + newSwapLogIndex = 2 + } + require.Equal(t, newSwapLogIndex+1, len(receipt.Logs)) + + swapID, err := GetIDFromLog(receipt.Logs[newSwapLogIndex]) + require.NoError(t, err) + + t0, t1, err := GetTimeoutsFromLog(receipt.Logs[newSwapLogIndex]) + require.NoError(t, err) + + // validate that off-chain swapID calculation matches the on-chain value + swap := SwapFactorySwap{ + Owner: owner, + Claimer: claimer, + PubKeyClaim: pubKeyClaim, + PubKeyRefund: pubKeyRefund, + Timeout0: t0, + Timeout1: t1, + Asset: asset, + Value: value, + Nonce: nonce, + } + + // validate our off-net calculation of the SwapID + require.Equal(t, types.Hash(swapID).Hex(), swap.SwapID().Hex()) } func TestSwapFactory_NewSwap(t *testing.T) { diff --git a/ethereum/utils.go b/ethereum/utils.go index ead3e664..37060c5d 100644 --- a/ethereum/utils.go +++ b/ethereum/utils.go @@ -11,9 +11,12 @@ import ( "fmt" "math/big" + "github.com/ethereum/go-ethereum/accounts/abi" ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" "github.com/athanorlabs/atomic-swap/common" + "github.com/athanorlabs/atomic-swap/common/types" mcrypto "github.com/athanorlabs/atomic-swap/crypto/monero" ) @@ -52,6 +55,75 @@ func StageToString(stage byte) string { } } +// SwapID calculates and returns the same hashed swap identifier that newSwap +// emits and that is used to track the on-chain stage of a swap. +func (sfs *SwapFactorySwap) SwapID() types.Hash { + uint256Ty, err := abi.NewType("uint256", "", nil) + if err != nil { + panic(fmt.Sprintf("failed to create uint256 type: %s", err)) + } + + bytes32Ty, err := abi.NewType("bytes32", "", nil) + if err != nil { + panic(fmt.Sprintf("failed to create bytes32 type: %s", err)) + } + + addressTy, err := abi.NewType("address", "", nil) + if err != nil { + panic(fmt.Sprintf("failed to create address type: %s", err)) + } + + arguments := abi.Arguments{ + { + Type: addressTy, + }, + { + Type: addressTy, + }, + { + Type: bytes32Ty, + }, + { + Type: bytes32Ty, + }, + { + Type: uint256Ty, + }, + { + Type: uint256Ty, + }, + { + Type: addressTy, + }, + { + Type: uint256Ty, + }, + { + Type: uint256Ty, + }, + } + + args, err := arguments.Pack( + sfs.Owner, + sfs.Claimer, + sfs.PubKeyClaim, + sfs.PubKeyRefund, + sfs.Timeout0, + sfs.Timeout1, + sfs.Asset, + sfs.Value, + sfs.Nonce, + ) + if err != nil { + // As long as none of the *big.Int fields are nil, this cannot fail. + // When receiving SwapFactorySwap objects from the database or peers in + // JSON, all *big.Int values are pre-validated to be non-nil. + panic(fmt.Sprintf("failed to pack arguments: %s", err)) + } + + return crypto.Keccak256Hash(args) +} + // GetSecretFromLog returns the secret from a Claimed or Refunded log func GetSecretFromLog(log *ethtypes.Log, eventTopic [32]byte) (*mcrypto.PrivateSpendKey, error) { if eventTopic != claimedTopic && eventTopic != refundedTopic { diff --git a/net/host.go b/net/host.go index b6d9cd95..57aa5a6c 100644 --- a/net/host.go +++ b/net/host.go @@ -60,10 +60,10 @@ type Host struct { isRelayer bool makerHandler MakerHandler - takerHandler TakerHandler + relayHandler RelayHandler // swap instance info - swapMu sync.Mutex + swapMu sync.RWMutex swaps map[types.Hash]*swap } @@ -108,11 +108,6 @@ func NewHost(cfg *Config) (*Host, error) { return h, nil } -// P2pHost returns the underlying go-p2p-net host. -func (h *Host) P2pHost() P2pHost { - return h.h -} - func (h *Host) advertisedNamespaces() []string { provides := []string{""} @@ -129,20 +124,18 @@ func (h *Host) advertisedNamespaces() []string { // SetHandlers sets the maker and taker instances used by the host, and configures // the stream handlers. -func (h *Host) SetHandlers(makerHandler MakerHandler, takerHandler TakerHandler) { +func (h *Host) SetHandlers(makerHandler MakerHandler, relayHandler RelayHandler) { h.makerHandler = makerHandler - h.takerHandler = takerHandler + h.relayHandler = relayHandler h.h.SetStreamHandler(queryProtocolID, h.handleQueryStream) - if h.isRelayer { - h.h.SetStreamHandler(relayProtocolID, h.handleRelayStream) - } + h.h.SetStreamHandler(relayProtocolID, h.handleRelayStream) h.h.SetStreamHandler(swapID, h.handleProtocolStream) } // Start starts the bootstrap and discovery process. func (h *Host) Start() error { - if h.makerHandler == nil || h.takerHandler == nil { + if h.makerHandler == nil || h.relayHandler == nil { return errNilHandler } @@ -161,8 +154,8 @@ func (h *Host) Stop() error { // SendSwapMessage sends a message to the peer who we're currently doing a swap with. func (h *Host) SendSwapMessage(msg Message, id types.Hash) error { - h.swapMu.Lock() - defer h.swapMu.Unlock() + h.swapMu.RLock() + defer h.swapMu.RUnlock() swap, has := h.swaps[id] if !has { @@ -173,8 +166,10 @@ func (h *Host) SendSwapMessage(msg Message, id types.Hash) error { } // CloseProtocolStream closes the current swap protocol stream. -func (h *Host) CloseProtocolStream(id types.Hash) { - swap, has := h.swaps[id] +func (h *Host) CloseProtocolStream(offerID types.Hash) { + h.swapMu.RLock() + swap, has := h.swaps[offerID] + h.swapMu.RUnlock() if !has { return } diff --git a/net/host_test.go b/net/host_test.go index 17c99260..de04536c 100644 --- a/net/host_test.go +++ b/net/host_test.go @@ -10,6 +10,7 @@ import ( ethcommon "github.com/ethereum/go-ethereum/common" logging "github.com/ipfs/go-log" + "github.com/libp2p/go-libp2p/core/peer" "github.com/stretchr/testify/require" "github.com/athanorlabs/atomic-swap/common/types" @@ -35,30 +36,33 @@ func (h *mockMakerHandler) GetOffers() []*types.Offer { return []*types.Offer{} } -func (h *mockMakerHandler) HandleInitiateMessage(msg *message.SendKeysMessage) (s SwapState, resp Message, err error) { +func (h *mockMakerHandler) HandleInitiateMessage( + _ peer.ID, + msg *message.SendKeysMessage, +) (s SwapState, resp Message, err error) { if (h.id != types.Hash{}) { return &mockSwapState{h.id}, createSendKeysMessage(h.t), nil } return &mockSwapState{}, msg, nil } -type mockTakerHandler struct { +type mockRelayHandler struct { t *testing.T } -func (h *mockTakerHandler) HandleRelayClaimRequest(_ *RelayClaimRequest) (*RelayClaimResponse, error) { +func (h *mockRelayHandler) HandleRelayClaimRequest(_ *RelayClaimRequest) (*RelayClaimResponse, error) { return &RelayClaimResponse{ TxHash: mockEthTXHash, }, nil } type mockSwapState struct { - id types.Hash + offerID types.Hash } -func (s *mockSwapState) ID() types.Hash { - if (s.id != types.Hash{}) { - return s.id +func (s *mockSwapState) OfferID() types.Hash { + if (s.offerID != types.Hash{}) { + return s.offerID } return testID @@ -90,7 +94,7 @@ func basicTestConfig(t *testing.T) *Config { func newHost(t *testing.T, cfg *Config) *Host { h, err := NewHost(cfg) require.NoError(t, err) - h.SetHandlers(&mockMakerHandler{t: t}, &mockTakerHandler{t: t}) + h.SetHandlers(&mockMakerHandler{t: t}, &mockRelayHandler{t: t}) t.Cleanup(func() { err = h.Stop() require.NoError(t, err) diff --git a/net/initiate.go b/net/initiate.go index 5705e4f8..4b1cf714 100644 --- a/net/initiate.go +++ b/net/initiate.go @@ -30,7 +30,7 @@ func (h *Host) Initiate(who peer.AddrInfo, sendKeysMessage common.Message, s com h.swapMu.Lock() defer h.swapMu.Unlock() - id := s.ID() + id := s.OfferID() if h.swaps[id] != nil { return errSwapAlreadyInProgress @@ -63,6 +63,7 @@ func (h *Host) Initiate(who peer.AddrInfo, sendKeysMessage common.Message, s com h.swaps[id] = &swap{ swapState: s, stream: stream, + isTaker: true, } go h.handleProtocolStreamInner(stream, s) @@ -87,12 +88,9 @@ func (h *Host) handleProtocolStream(stream libp2pnetwork.Stream) { return } - log.Debug( - "received message from peer, peer=", - stream.Conn().RemotePeer(), - " type=", - message.TypeToString(msg.Type()), - ) + curPeer := stream.Conn().RemotePeer() + + log.Debugf("received message from peer=%s type=%s", curPeer, message.TypeToString(msg.Type())) im, ok := msg.(*SendKeysMessage) if !ok { @@ -102,7 +100,7 @@ func (h *Host) handleProtocolStream(stream libp2pnetwork.Stream) { } var s SwapState - s, resp, err := h.makerHandler.HandleInitiateMessage(im) + s, resp, err := h.makerHandler.HandleInitiateMessage(curPeer, im) if err != nil { log.Warnf("failed to handle protocol message: err=%s", err) _ = stream.Close() @@ -119,9 +117,10 @@ func (h *Host) handleProtocolStream(stream libp2pnetwork.Stream) { } h.swapMu.Lock() - h.swaps[s.ID()] = &swap{ + h.swaps[s.OfferID()] = &swap{ swapState: s, stream: stream, + isTaker: false, } h.swapMu.Unlock() @@ -139,7 +138,7 @@ func (h *Host) handleProtocolStreamInner(stream libp2pnetwork.Stream, s SwapStat log.Errorf("failed to exit protocol: err=%s", err) } h.swapMu.Lock() - delete(h.swaps, s.ID()) + delete(h.swaps, s.OfferID()) h.swapMu.Unlock() }() diff --git a/net/initiate_test.go b/net/initiate_test.go index daaefcb5..9e0158e6 100644 --- a/net/initiate_test.go +++ b/net/initiate_test.go @@ -48,13 +48,13 @@ func TestHost_Initiate(t *testing.T) { require.NoError(t, err) time.Sleep(time.Millisecond * 500) - ha.swapMu.Lock() + ha.swapMu.RLock() require.NotNil(t, ha.swaps[testID]) - ha.swapMu.Unlock() + ha.swapMu.RUnlock() - hb.swapMu.Lock() + hb.swapMu.RLock() require.NotNil(t, hb.swaps[testID]) - hb.swapMu.Unlock() + hb.swapMu.RUnlock() } func TestHost_ConcurrentSwaps(t *testing.T) { @@ -77,13 +77,13 @@ func TestHost_ConcurrentSwaps(t *testing.T) { require.NoError(t, err) time.Sleep(time.Millisecond * 500) - ha.swapMu.Lock() + ha.swapMu.RLock() require.NotNil(t, ha.swaps[testID]) - ha.swapMu.Unlock() + ha.swapMu.RUnlock() - hb.swapMu.Lock() + hb.swapMu.RLock() require.NotNil(t, hb.swaps[testID]) - hb.swapMu.Unlock() + hb.swapMu.RUnlock() hb.makerHandler.(*mockMakerHandler).id = testID2 @@ -91,11 +91,11 @@ func TestHost_ConcurrentSwaps(t *testing.T) { require.NoError(t, err) time.Sleep(time.Millisecond * 1500) - ha.swapMu.Lock() + ha.swapMu.RLock() require.NotNil(t, ha.swaps[testID]) - ha.swapMu.Unlock() + ha.swapMu.RUnlock() - hb.swapMu.Lock() + hb.swapMu.RLock() require.NotNil(t, hb.swaps[testID]) - hb.swapMu.Unlock() + hb.swapMu.RUnlock() } diff --git a/net/message/relay_message.go b/net/message/relay_message.go index faa0fa96..d980dbb0 100644 --- a/net/message/relay_message.go +++ b/net/message/relay_message.go @@ -8,12 +8,17 @@ import ( ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/athanorlabs/atomic-swap/common/types" "github.com/athanorlabs/atomic-swap/common/vjson" contracts "github.com/athanorlabs/atomic-swap/ethereum" ) -// RelayClaimRequest implements common.Message for our p2p relay claim requests +// RelayClaimRequest implements common.Message for our p2p relay claim requests. type RelayClaimRequest struct { + // OfferID is non-nil, if the request is from a maker to the taker of an + // active swap. It is nil, if the request is being sent to a relay node, + // because it advertised in the DHT. + OfferID *types.Hash `json:"offerID"` SwapFactoryAddress ethcommon.Address `json:"swapFactoryAddress" validate:"required"` Swap *contracts.SwapFactorySwap `json:"swap" validate:"required"` Secret []byte `json:"secret" validate:"required,len=32"` diff --git a/net/relay.go b/net/relay.go index c0da56c3..7e1b2261 100644 --- a/net/relay.go +++ b/net/relay.go @@ -34,27 +34,52 @@ func (h *Host) DiscoverRelayers() ([]peer.ID, error) { func (h *Host) handleRelayStream(stream libp2pnetwork.Stream) { defer func() { _ = stream.Close() }() - // TODO: If the request is from a Maker/OfferID combo that we did a swap with, we - // should always be willing to relay. - if !h.isRelayer { - return - } - msg, err := readStreamMessage(stream, maxRelayMessageSize) if err != nil { log.Debugf("error reading RelayClaimRequest: %s", err) return } + curPeer := stream.Conn().RemotePeer() + req, ok := msg.(*RelayClaimRequest) if !ok { - log.Debugf("ignoring wrong message type=%s sent to relay stream", message.TypeToString(msg.Type())) + log.Debugf("ignoring wrong message type=%s sent to relay stream from %s", + message.TypeToString(msg.Type()), curPeer) return } - resp, err := h.takerHandler.HandleRelayClaimRequest(req) + // Handle case where we are not a relayer, and the request didn't set the offerID + // to indicate that it make from a running swap partner. + + // While HandleRelayClaimRequest(...) will do lower level validation on the + // claim request, there are 2 validations best handled here: + // (1) If the network layer is not advertising that we are a relayer to the + // DHT, we should not be getting claim requests targeted for open + // relayers (i.e. requests that do not have the OfferID set). + // (2) If the request is purportedly from a maker to a taker of a current + // swap, then: + // (a) The swap should exist in our swaps map + // (b) The peerID who sent us the request much match the peerID with + // whom we are performing the swap. + if req.OfferID == nil && !h.isRelayer { + return + } else if req.OfferID != nil { + h.swapMu.RLock() + swap, ok := h.swaps[*req.OfferID] + h.swapMu.RUnlock() + + found := ok && swap.isTaker + if !found || curPeer != swap.stream.Conn().RemotePeer() { + log.Debugf("received invalid taker-specific claim request from peer=%s offerID=%s swap-found=%t", + curPeer, req.OfferID, found) + return + } + } + + resp, err := h.relayHandler.HandleRelayClaimRequest(req) if err != nil { - log.Debugf("Did not handle relay request: %s", err) + log.Debugf("did not handle relay request: %s", err) return } @@ -68,6 +93,12 @@ func (h *Host) handleRelayStream(stream libp2pnetwork.Stream) { // SubmitClaimToRelayer sends a request to relay a swap claim to a peer. func (h *Host) SubmitClaimToRelayer(relayerID peer.ID, request *RelayClaimRequest) (*RelayClaimResponse, error) { + // The timeout should be short enough, that the Maker can try multiple relayers + // before T1 expires even if the receiving node accepts the relay request and + // just sits on it without doing anything. + // TODO: https://github.com/AthanorLabs/atomic-swap/issues/375 + // The context below needs extension to cover the response. Right now + // only covers the Connect(...). ctx, cancel := context.WithTimeout(h.ctx, relayClaimTimeout) defer cancel() diff --git a/net/relay_test.go b/net/relay_test.go index c30ab2ab..c44f40bd 100644 --- a/net/relay_test.go +++ b/net/relay_test.go @@ -9,6 +9,7 @@ import ( "time" ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/libp2p/go-libp2p/core/peer" "github.com/stretchr/testify/require" "github.com/athanorlabs/atomic-swap/common/types" @@ -19,7 +20,7 @@ import ( func twoHostRelayerSetup(t *testing.T) (*Host, *Host) { // ha is not a relayer haCfg := basicTestConfig(t) - haCfg.IsRelayer = true + haCfg.IsRelayer = false ha := newHost(t, haCfg) err := ha.Start() require.NoError(t, err) @@ -44,13 +45,14 @@ func TestHost_DiscoverRelayers(t *testing.T) { peerIDs, err := ha.DiscoverRelayers() require.NoError(t, err) - require.Len(t, peerIDs, 1) + require.True(t, hb.isRelayer) + require.Len(t, peerIDs, 1) // discovers hb require.Equal(t, hb.PeerID(), peerIDs[0]) peerIDs, err = hb.DiscoverRelayers() require.NoError(t, err) - require.Len(t, peerIDs, 1) - require.Equal(t, ha.PeerID(), peerIDs[0]) + require.False(t, ha.isRelayer) + require.Len(t, peerIDs, 0) // ha is not a relayer and not discovered } func createTestClaimRequest() *message.RelayClaimRequest { @@ -77,12 +79,43 @@ func createTestClaimRequest() *message.RelayClaimRequest { return req } -func TestHost_SubmitClaimToRelayer(t *testing.T) { +func TestHost_SubmitClaimToRelayer_dhtRelayer(t *testing.T) { ha, hb := twoHostRelayerSetup(t) + // success path ha->hb, hb is a DHT relayer resp, err := ha.SubmitClaimToRelayer(hb.PeerID(), createTestClaimRequest()) require.NoError(t, err) require.Equal(t, mockEthTXHash.Hex(), resp.TxHash.Hex()) + + // failure path hb->ha, ha is NOT a DHT relayer. Note that the remote end + // does not pass back the exact reason for rejecting a claim to avoid + // possible privacy data leaks, but in this case it is because hb is not + // a DHT advertising relayer. + _, err = hb.SubmitClaimToRelayer(ha.PeerID(), createTestClaimRequest()) + require.ErrorContains(t, err, "failed to read RelayClaimResponse: EOF") +} + +func TestHost_SubmitClaimToRelayer_xmrTakerRelayer(t *testing.T) { + ha, hb := twoHostRelayerSetup(t) + + request := createTestClaimRequest() + offerID := types.Hash{0x1} + request.OfferID = &offerID + + // fail, because there is no ongoing swap between ha and hb + _, err := hb.SubmitClaimToRelayer(ha.PeerID(), request) + require.ErrorContains(t, err, "failed to read RelayClaimResponse: EOF") + + // create an ongoing swap between ha and hb + swapState := &mockSwapState{offerID: offerID} + err = ha.Initiate(peer.AddrInfo{ID: hb.PeerID()}, createSendKeysMessage(t), swapState) + require.NoError(t, err) + defer ha.CloseProtocolStream(offerID) + + // same steps will succeed now, because we started a swap first + response, err := hb.SubmitClaimToRelayer(ha.PeerID(), request) + require.NoError(t, err) + require.Equal(t, mockEthTXHash, response.TxHash) } func TestHost_SubmitClaimToRelayer_fail(t *testing.T) { diff --git a/net/types.go b/net/types.go index 79be70b2..4928c61c 100644 --- a/net/types.go +++ b/net/types.go @@ -4,6 +4,8 @@ package net import ( + "github.com/libp2p/go-libp2p/core/peer" + "github.com/athanorlabs/atomic-swap/common" "github.com/athanorlabs/atomic-swap/common/types" "github.com/athanorlabs/atomic-swap/net/message" @@ -27,16 +29,18 @@ type ( // implemented by *xmrmaker.Instance. type MakerHandler interface { GetOffers() []*types.Offer - HandleInitiateMessage(msg *SendKeysMessage) (SwapState, Message, error) + HandleInitiateMessage(peerID peer.ID, msg *SendKeysMessage) (SwapState, Message, error) } -// TakerHandler handles relay claim requests. It is implemented by -// *xmrtaker.xmrtaker. -type TakerHandler interface { +// RelayHandler handles relay claim requests. It is implemented by +// *backend.backend. +type RelayHandler interface { HandleRelayClaimRequest(msg *RelayClaimRequest) (*RelayClaimResponse, error) } type swap struct { swapState SwapState stream libp2pnetwork.Stream + // isTaker is true if we initiated the swap (created the outbound stream) + isTaker bool } diff --git a/protocol/backend/backend.go b/protocol/backend/backend.go index 98429d8f..0a856474 100644 --- a/protocol/backend/backend.go +++ b/protocol/backend/backend.go @@ -7,6 +7,8 @@ package backend import ( "context" + "errors" + "fmt" "sync" "time" @@ -23,6 +25,7 @@ import ( "github.com/athanorlabs/atomic-swap/net/message" "github.com/athanorlabs/atomic-swap/protocol/swap" "github.com/athanorlabs/atomic-swap/protocol/txsender" + "github.com/athanorlabs/atomic-swap/relayer" ) // NetSender consists of Host methods invoked by the Maker/Taker @@ -62,6 +65,7 @@ type Backend interface { // helpers NewSwapFactory(addr ethcommon.Address) (*contracts.SwapFactory, error) + HandleRelayClaimRequest(request *message.RelayClaimRequest) (*message.RelayClaimResponse, error) // getters Ctx() context.Context @@ -231,3 +235,29 @@ func (b *backend) ClearXMRDepositAddress(offerID types.Hash) { defer b.perSwapXMRDepositAddrRWMu.Unlock() delete(b.perSwapXMRDepositAddr, offerID) } + +// HandleRelayClaimRequest validates and sends the transaction for a relay claim request +func (b *backend) HandleRelayClaimRequest(request *message.RelayClaimRequest) (*message.RelayClaimResponse, error) { + // In the taker relay scenario, the net layer has already validated that we + // have an ongoing swap with the requesting peer that uses the passed + // offerID, but we have not verified that the claim in the swap matches the + // offerID. The backend, with its access to the recovery DB, is in the best + // position to perform this check. The remaining validations will be in the + // relayer library. + if request.OfferID != nil { + swapInfo, err := b.recoveryDB.GetContractSwapInfo(*request.OfferID) + if err != nil { + return nil, fmt.Errorf("swap info for taker claim request not found: %w", err) + } + if swapInfo.SwapID != request.Swap.SwapID() { + return nil, errors.New("counterparty claim request has invalid swap ID") + } + } + + return relayer.ValidateAndSendTransaction( + b.Ctx(), + request, + b.ETHClient(), + b.ContractAddr(), + ) +} diff --git a/protocol/swap/manager.go b/protocol/swap/manager.go index 56af072a..eb75b255 100644 --- a/protocol/swap/manager.go +++ b/protocol/swap/manager.go @@ -57,7 +57,7 @@ func NewManager(db Database) (Manager, error) { continue } - ongoing[s.ID] = s + ongoing[s.OfferID] = s } return &manager{ @@ -74,9 +74,9 @@ func (m *manager) AddSwap(info *Info) error { switch info.Status.IsOngoing() { case true: - m.ongoing[info.ID] = info + m.ongoing[info.OfferID] = info default: - m.past[info.ID] = info + m.past[info.OfferID] = info } return m.db.PutSwap(info) @@ -107,7 +107,7 @@ func (m *manager) GetPastIDs() ([]types.Hash, error) { continue } - ids[s.ID] = struct{}{} + ids[s.OfferID] = struct{}{} } idArr := make([]types.Hash, len(ids)) @@ -135,7 +135,7 @@ func (m *manager) GetPastSwap(id types.Hash) (*Info, error) { } // cache the swap, since it's recently accessed - m.past[s.ID] = s + m.past[s.OfferID] = s return s, nil } @@ -170,7 +170,7 @@ func (m *manager) GetOngoingSwaps() ([]*Info, error) { func (m *manager) CompleteOngoingSwap(info *Info) error { m.Lock() defer m.Unlock() - _, has := m.ongoing[info.ID] + _, has := m.ongoing[info.OfferID] if !has { return errNoSwapWithID } @@ -178,8 +178,8 @@ func (m *manager) CompleteOngoingSwap(info *Info) error { now := time.Now() info.EndTime = &now - m.past[info.ID] = info - delete(m.ongoing, info.ID) + m.past[info.OfferID] = info + delete(m.ongoing, info.OfferID) // re-write to db, as status has changed return m.db.PutSwap(info) diff --git a/protocol/swap/manager_test.go b/protocol/swap/manager_test.go index 3a205ae3..7f3f4670 100644 --- a/protocol/swap/manager_test.go +++ b/protocol/swap/manager_test.go @@ -29,6 +29,7 @@ func TestNewManager(t *testing.T) { hashA := types.Hash{0x1} infoA := NewInfo( + testPeerID, hashA, coins.ProvidesXMR, apd.New(1, 0), @@ -44,6 +45,7 @@ func TestNewManager(t *testing.T) { require.NoError(t, err) infoB := NewInfo( + testPeerID, types.Hash{2}, coins.ProvidesXMR, apd.New(1, 0), @@ -77,6 +79,7 @@ func TestManager_AddSwap_Ongoing(t *testing.T) { m := mgr.(*manager) require.NoError(t, err) info := NewInfo( + testPeerID, types.Hash{}, coins.ProvidesXMR, apd.New(1, 0), @@ -125,34 +128,34 @@ func TestManager_AddSwap_Past(t *testing.T) { require.NoError(t, err) info := &Info{ - ID: types.Hash{1}, - Status: types.CompletedSuccess, + OfferID: types.Hash{1}, + Status: types.CompletedSuccess, } db.EXPECT().PutSwap(info) err = m.AddSwap(info) require.NoError(t, err) - s, err := m.GetPastSwap(info.ID) + s, err := m.GetPastSwap(info.OfferID) require.NoError(t, err) require.NotNil(t, s) info = &Info{ - ID: types.Hash{2}, - Status: types.CompletedSuccess, + OfferID: types.Hash{2}, + Status: types.CompletedSuccess, } db.EXPECT().PutSwap(info) err = m.AddSwap(info) require.NoError(t, err) - s, err = m.GetPastSwap(info.ID) + s, err = m.GetPastSwap(info.OfferID) require.NoError(t, err) require.NotNil(t, s) info = &Info{ - ID: types.Hash{3}, - Status: types.ExpectingKeys, + OfferID: types.Hash{3}, + Status: types.ExpectingKeys, } db.EXPECT().PutSwap(info) diff --git a/protocol/swap/types.go b/protocol/swap/types.go index 2d53f300..548a4aca 100644 --- a/protocol/swap/types.go +++ b/protocol/swap/types.go @@ -11,6 +11,7 @@ import ( "github.com/Masterminds/semver/v3" "github.com/cockroachdb/apd/v3" + "github.com/libp2p/go-libp2p/core/peer" "github.com/athanorlabs/atomic-swap/coins" "github.com/athanorlabs/atomic-swap/common/types" @@ -31,7 +32,8 @@ type ( // Info contains the details of the swap as well as its status. type Info struct { Version *semver.Version `json:"version"` - ID types.Hash `json:"offerID" validate:"required"` // swap offer ID + PeerID peer.ID `json:"peerID" validate:"required"` + OfferID types.Hash `json:"offerID" validate:"required"` Provides coins.ProvidesCoin `json:"provides" validate:"required"` ProvidedAmount *apd.Decimal `json:"providedAmount" validate:"required"` ExpectedAmount *apd.Decimal `json:"expectedAmount" validate:"required"` @@ -66,7 +68,8 @@ type Info struct { // NewInfo creates a new *Info from the given parameters. // Note that the swap ID is the same as the offer ID. func NewInfo( - id types.Hash, + peerID peer.ID, + offerID types.Hash, provides coins.ProvidesCoin, providedAmount, expectedAmount *apd.Decimal, exchangeRate *coins.ExchangeRate, @@ -77,7 +80,8 @@ func NewInfo( ) *Info { info := &Info{ Version: CurInfoVersion, - ID: id, + PeerID: peerID, + OfferID: offerID, Provides: provides, ProvidedAmount: providedAmount, ExpectedAmount: expectedAmount, diff --git a/protocol/swap/types_test.go b/protocol/swap/types_test.go index 705bbf33..7bf80141 100644 --- a/protocol/swap/types_test.go +++ b/protocol/swap/types_test.go @@ -9,6 +9,7 @@ import ( "github.com/cockroachdb/apd/v3" ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/libp2p/go-libp2p/core/peer" "github.com/stretchr/testify/require" "github.com/athanorlabs/atomic-swap/coins" @@ -16,10 +17,13 @@ import ( "github.com/athanorlabs/atomic-swap/common/vjson" ) +var testPeerID, _ = peer.Decode("12D3KooWQQRJuKTZ35eiHGNPGDpQqjpJSdaxEMJRxi6NWFrrvQVi") + func Test_InfoMarshal(t *testing.T) { offerIDStr := "0x0102030405060708091011121314151617181920212223242526272829303132" offerID := ethcommon.HexToHash(offerIDStr) info := NewInfo( + testPeerID, offerID, coins.ProvidesXMR, apd.New(125, -2), // 1.25 @@ -39,6 +43,7 @@ func Test_InfoMarshal(t *testing.T) { expectedJSON := `{ "version": "0.2.0", + "peerID": "12D3KooWQQRJuKTZ35eiHGNPGDpQqjpJSdaxEMJRxi6NWFrrvQVi", "offerID": "0x0102030405060708091011121314151617181920212223242526272829303132", "provides": "XMR", "providedAmount": "1.25", diff --git a/protocol/txsender/external_sender.go b/protocol/txsender/external_sender.go index eb2e0914..316babe9 100644 --- a/protocol/txsender/external_sender.go +++ b/protocol/txsender/external_sender.go @@ -98,10 +98,10 @@ func (s *ExternalSender) IncomingCh(id types.Hash) chan<- ethcommon.Hash { func (s *ExternalSender) Approve( spender ethcommon.Address, amount *big.Int, -) (ethcommon.Hash, *ethtypes.Receipt, error) { +) (*ethtypes.Receipt, error) { input, err := s.abi.Pack("approve", spender, amount) if err != nil { - return ethcommon.Hash{}, nil, err + return nil, err } return s.sendAndReceive(input, s.erc20Addr) @@ -116,11 +116,11 @@ func (s *ExternalSender) NewSwap( nonce *big.Int, ethAsset types.EthAsset, value *big.Int, -) (ethcommon.Hash, *ethtypes.Receipt, error) { +) (*ethtypes.Receipt, error) { input, err := s.abi.Pack("new_swap", pubKeyClaim, pubKeyRefund, claimer, timeoutDuration, ethAsset, value, nonce) if err != nil { - return ethcommon.Hash{}, nil, err + return nil, err } valueWei := coins.NewWeiAmount(value) @@ -137,23 +137,23 @@ func (s *ExternalSender) NewSwap( var txHash ethcommon.Hash select { case <-time.After(transactionTimeout): - return ethcommon.Hash{}, nil, errTransactionTimeout + return nil, errTransactionTimeout case txHash = <-s.in: } receipt, err := block.WaitForReceipt(s.ctx, s.ec, txHash) if err != nil { - return ethcommon.Hash{}, nil, err + return nil, err } - return txHash, receipt, nil + return receipt, nil } // SetReady prompts the external sender to sign a set_ready transaction -func (s *ExternalSender) SetReady(swap *contracts.SwapFactorySwap) (ethcommon.Hash, *ethtypes.Receipt, error) { +func (s *ExternalSender) SetReady(swap *contracts.SwapFactorySwap) (*ethtypes.Receipt, error) { input, err := s.abi.Pack("set_ready", swap) if err != nil { - return ethcommon.Hash{}, nil, err + return nil, err } return s.sendAndReceive(input, s.contractAddr) @@ -163,10 +163,10 @@ func (s *ExternalSender) SetReady(swap *contracts.SwapFactorySwap) (ethcommon.Ha func (s *ExternalSender) Claim( swap *contracts.SwapFactorySwap, secret [32]byte, -) (ethcommon.Hash, *ethtypes.Receipt, error) { +) (*ethtypes.Receipt, error) { input, err := s.abi.Pack("claim", swap, secret) if err != nil { - return ethcommon.Hash{}, nil, err + return nil, err } return s.sendAndReceive(input, s.contractAddr) @@ -176,16 +176,16 @@ func (s *ExternalSender) Claim( func (s *ExternalSender) Refund( swap *contracts.SwapFactorySwap, secret [32]byte, -) (ethcommon.Hash, *ethtypes.Receipt, error) { +) (*ethtypes.Receipt, error) { input, err := s.abi.Pack("refund", swap, secret) if err != nil { - return ethcommon.Hash{}, nil, err + return nil, err } return s.sendAndReceive(input, s.contractAddr) } -func (s *ExternalSender) sendAndReceive(input []byte, to ethcommon.Address) (ethcommon.Hash, *ethtypes.Receipt, error) { +func (s *ExternalSender) sendAndReceive(input []byte, to ethcommon.Address) (*ethtypes.Receipt, error) { tx := &Transaction{To: to, Data: input} s.Lock() @@ -195,14 +195,14 @@ func (s *ExternalSender) sendAndReceive(input []byte, to ethcommon.Address) (eth var txHash ethcommon.Hash select { case <-time.After(transactionTimeout): - return ethcommon.Hash{}, nil, errTransactionTimeout + return nil, errTransactionTimeout case txHash = <-s.in: } receipt, err := block.WaitForReceipt(s.ctx, s.ec, txHash) if err != nil { - return ethcommon.Hash{}, nil, err + return nil, err } - return txHash, receipt, nil + return receipt, nil } diff --git a/protocol/txsender/sender.go b/protocol/txsender/sender.go index c90fcbc6..0a4a1507 100644 --- a/protocol/txsender/sender.go +++ b/protocol/txsender/sender.go @@ -26,7 +26,7 @@ import ( type Sender interface { SetContract(*contracts.SwapFactory) SetContractAddress(ethcommon.Address) - Approve(spender ethcommon.Address, amount *big.Int) (ethcommon.Hash, *ethtypes.Receipt, error) // for ERC20 swaps + Approve(spender ethcommon.Address, amount *big.Int) (*ethtypes.Receipt, error) // for ERC20 swaps NewSwap( pubKeyClaim [32]byte, pubKeyRefund [32]byte, @@ -35,10 +35,10 @@ type Sender interface { nonce *big.Int, ethAsset types.EthAsset, amount *big.Int, - ) (ethcommon.Hash, *ethtypes.Receipt, error) - SetReady(swap *contracts.SwapFactorySwap) (ethcommon.Hash, *ethtypes.Receipt, error) - Claim(swap *contracts.SwapFactorySwap, secret [32]byte) (ethcommon.Hash, *ethtypes.Receipt, error) - Refund(swap *contracts.SwapFactorySwap, secret [32]byte) (ethcommon.Hash, *ethtypes.Receipt, error) + ) (*ethtypes.Receipt, error) + SetReady(swap *contracts.SwapFactorySwap) (*ethtypes.Receipt, error) + Claim(swap *contracts.SwapFactorySwap, secret [32]byte) (*ethtypes.Receipt, error) + Refund(swap *contracts.SwapFactorySwap, secret [32]byte) (*ethtypes.Receipt, error) } type privateKeySender struct { @@ -72,27 +72,27 @@ func (s *privateKeySender) SetContractAddress(_ ethcommon.Address) {} func (s *privateKeySender) Approve( spender ethcommon.Address, amount *big.Int, -) (ethcommon.Hash, *ethtypes.Receipt, error) { +) (*ethtypes.Receipt, error) { s.ethClient.Lock() defer s.ethClient.Unlock() txOpts, err := s.ethClient.TxOpts(s.ctx) if err != nil { - return ethcommon.Hash{}, nil, err + return nil, err } tx, err := s.erc20Contract.Approve(txOpts, spender, amount) if err != nil { err = fmt.Errorf("set_ready tx creation failed, %w", err) - return ethcommon.Hash{}, nil, err + return nil, err } receipt, err := block.WaitForReceipt(s.ctx, s.ethClient.Raw(), tx.Hash()) if err != nil { err = fmt.Errorf("set_ready failed, %w", err) - return ethcommon.Hash{}, nil, err + return nil, err } - return tx.Hash(), receipt, nil + return receipt, nil } func (s *privateKeySender) NewSwap( @@ -103,12 +103,12 @@ func (s *privateKeySender) NewSwap( nonce *big.Int, ethAsset types.EthAsset, value *big.Int, -) (ethcommon.Hash, *ethtypes.Receipt, error) { +) (*ethtypes.Receipt, error) { s.ethClient.Lock() defer s.ethClient.Unlock() txOpts, err := s.ethClient.TxOpts(s.ctx) if err != nil { - return ethcommon.Hash{}, nil, err + return nil, err } // transfer ETH if we're not doing an ERC20 swap @@ -120,89 +120,89 @@ func (s *privateKeySender) NewSwap( ethcommon.Address(ethAsset), value, nonce) if err != nil { err = fmt.Errorf("new_swap tx creation failed, %w", err) - return ethcommon.Hash{}, nil, err + return nil, err } receipt, err := block.WaitForReceipt(s.ctx, s.ethClient.Raw(), tx.Hash()) if err != nil { err = fmt.Errorf("new_swap failed, %w", err) - return ethcommon.Hash{}, nil, err + return nil, err } - return tx.Hash(), receipt, nil + return receipt, nil } -func (s *privateKeySender) SetReady(swap *contracts.SwapFactorySwap) (ethcommon.Hash, *ethtypes.Receipt, error) { +func (s *privateKeySender) SetReady(swap *contracts.SwapFactorySwap) (*ethtypes.Receipt, error) { s.ethClient.Lock() defer s.ethClient.Unlock() txOpts, err := s.ethClient.TxOpts(s.ctx) if err != nil { - return ethcommon.Hash{}, nil, err + return nil, err } tx, err := s.swapContract.SetReady(txOpts, *swap) if err != nil { err = fmt.Errorf("set_ready tx creation failed, %w", err) - return ethcommon.Hash{}, nil, err + return nil, err } receipt, err := block.WaitForReceipt(s.ctx, s.ethClient.Raw(), tx.Hash()) if err != nil { err = fmt.Errorf("set_ready failed, %w", err) - return ethcommon.Hash{}, nil, err + return nil, err } - return tx.Hash(), receipt, nil + return receipt, nil } func (s *privateKeySender) Claim( swap *contracts.SwapFactorySwap, secret [32]byte, -) (ethcommon.Hash, *ethtypes.Receipt, error) { +) (*ethtypes.Receipt, error) { s.ethClient.Lock() defer s.ethClient.Unlock() txOpts, err := s.ethClient.TxOpts(s.ctx) if err != nil { - return ethcommon.Hash{}, nil, err + return nil, err } tx, err := s.swapContract.Claim(txOpts, *swap, secret) if err != nil { err = fmt.Errorf("claim tx creation failed, %w", err) - return ethcommon.Hash{}, nil, err + return nil, err } receipt, err := block.WaitForReceipt(s.ctx, s.ethClient.Raw(), tx.Hash()) if err != nil { err = fmt.Errorf("claim failed, %w", err) - return ethcommon.Hash{}, nil, err + return nil, err } - return tx.Hash(), receipt, nil + return receipt, nil } func (s *privateKeySender) Refund( swap *contracts.SwapFactorySwap, secret [32]byte, -) (ethcommon.Hash, *ethtypes.Receipt, error) { +) (*ethtypes.Receipt, error) { s.ethClient.Lock() defer s.ethClient.Unlock() txOpts, err := s.ethClient.TxOpts(s.ctx) if err != nil { - return ethcommon.Hash{}, nil, err + return nil, err } tx, err := s.swapContract.Refund(txOpts, *swap, secret) if err != nil { err = fmt.Errorf("refund tx creation failed, %w", err) - return ethcommon.Hash{}, nil, err + return nil, err } receipt, err := block.WaitForReceipt(s.ctx, s.ethClient.Raw(), tx.Hash()) if err != nil { err = fmt.Errorf("refund failed, %w", err) - return ethcommon.Hash{}, nil, err + return nil, err } - return tx.Hash(), receipt, nil + return receipt, nil } diff --git a/protocol/xmrmaker/checks.go b/protocol/xmrmaker/checks.go index 546ef99a..1d0c0b2d 100644 --- a/protocol/xmrmaker/checks.go +++ b/protocol/xmrmaker/checks.go @@ -9,88 +9,14 @@ import ( "math/big" "time" - "github.com/ethereum/go-ethereum/accounts/abi" ethcommon "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/crypto" "github.com/athanorlabs/atomic-swap/common" "github.com/athanorlabs/atomic-swap/common/types" contracts "github.com/athanorlabs/atomic-swap/ethereum" - "github.com/athanorlabs/atomic-swap/net/message" pcommon "github.com/athanorlabs/atomic-swap/protocol" ) -// checkContractSwapID checks that the `Swap` type sent matches the swap ID when hashed -func checkContractSwapID(msg *message.NotifyETHLocked) error { - uint256Ty, err := abi.NewType("uint256", "", nil) - if err != nil { - return fmt.Errorf("failed to create uint256 type: %w", err) - } - - bytes32Ty, err := abi.NewType("bytes32", "", nil) - if err != nil { - return fmt.Errorf("failed to create bytes32 type: %w", err) - } - - addressTy, err := abi.NewType("address", "", nil) - if err != nil { - return fmt.Errorf("failed to create address type: %w", err) - } - - arguments := abi.Arguments{ - { - Type: addressTy, - }, - { - Type: addressTy, - }, - { - Type: bytes32Ty, - }, - { - Type: bytes32Ty, - }, - { - Type: uint256Ty, - }, - { - Type: uint256Ty, - }, - { - Type: addressTy, - }, - { - Type: uint256Ty, - }, - { - Type: uint256Ty, - }, - } - - args, err := arguments.Pack( - msg.ContractSwap.Owner, - msg.ContractSwap.Claimer, - msg.ContractSwap.PubKeyClaim, - msg.ContractSwap.PubKeyRefund, - msg.ContractSwap.Timeout0, - msg.ContractSwap.Timeout1, - msg.ContractSwap.Asset, - msg.ContractSwap.Value, - msg.ContractSwap.Nonce, - ) - if err != nil { - return fmt.Errorf("failed to pack arguments: %w", err) - } - - hash := crypto.Keccak256Hash(args) - if !bytes.Equal(hash[:], msg.ContractSwapID[:]) { - log.Debugf("swap hash mismatch, expected args=%v\n", args) - return errSwapIDMismatch - } - - return nil -} - // checkContract checks the contract's balance and Claim/Refund keys. // if the balance doesn't match what we're expecting to receive, or the public keys in the contract // aren't what we expect, we error and abort the swap. diff --git a/protocol/xmrmaker/claim.go b/protocol/xmrmaker/claim.go index e3459da0..683f1dbe 100644 --- a/protocol/xmrmaker/claim.go +++ b/protocol/xmrmaker/claim.go @@ -20,11 +20,12 @@ import ( "github.com/athanorlabs/atomic-swap/common" "github.com/athanorlabs/atomic-swap/common/types" "github.com/athanorlabs/atomic-swap/ethereum/block" + "github.com/athanorlabs/atomic-swap/net/message" "github.com/athanorlabs/atomic-swap/relayer" ) // claimFunds redeems XMRMaker's ETH funds by calling Claim() on the contract -func (s *swapState) claimFunds() (ethcommon.Hash, error) { +func (s *swapState) claimFunds() (*ethtypes.Receipt, error) { var ( symbol string decimals uint8 @@ -33,13 +34,13 @@ func (s *swapState) claimFunds() (ethcommon.Hash, error) { if types.EthAsset(s.contractSwap.Asset) != types.EthAssetETH { _, symbol, decimals, err = s.ETHClient().ERC20Info(s.ctx, s.contractSwap.Asset) if err != nil { - return ethcommon.Hash{}, fmt.Errorf("failed to get ERC20 info: %w", err) + return nil, fmt.Errorf("failed to get ERC20 info: %w", err) } } weiBalance, err := s.ETHClient().Balance(s.ctx) if err != nil { - return ethcommon.Hash{}, err + return nil, err } if types.EthAsset(s.contractSwap.Asset) == types.EthAssetETH { @@ -47,7 +48,7 @@ func (s *swapState) claimFunds() (ethcommon.Hash, error) { } else { balance, err := s.ETHClient().ERC20Balance(s.ctx, s.contractSwap.Asset) //nolint:govet if err != nil { - return ethcommon.Hash{}, err + return nil, err } log.Infof("balance before claim: %v %s", coins.NewERC20TokenAmountFromBigInt(balance, decimals).AsStandard().Text('f'), @@ -55,37 +56,40 @@ func (s *swapState) claimFunds() (ethcommon.Hash, error) { ) } - var txHash ethcommon.Hash + var receipt *ethtypes.Receipt // call swap.Swap.Claim() w/ b.privkeys.sk, revealing XMRMaker's secret spend key if s.offerExtra.UseRelayer || weiBalance.Cmp(big.NewInt(0)) == 0 { // relayer fee was set or we had insufficient funds to claim without a relayer // TODO: Sufficient funds check above should be more specific - txHash, err = s.discoverRelayersAndClaim() + receipt, err = s.claimWithRelay() if err != nil { - log.Warnf("failed to claim using relayers: %s", err) + return nil, fmt.Errorf("failed to claim using relayers: %w", err) } + log.Infof("claim transaction was relayed: %s", common.ReceiptInfo(receipt)) } else { // claim and wait for tx to be included sc := s.getSecret() - txHash, _, err = s.sender.Claim(s.contractSwap, sc) + receipt, err = s.sender.Claim(s.contractSwap, sc) + if err != nil { + return nil, err + } + log.Infof("claim transaction %s", common.ReceiptInfo(receipt)) } if err != nil { - return ethcommon.Hash{}, err + return nil, err } - log.Infof("sent claim transaction, tx hash=%s", txHash) - if types.EthAsset(s.contractSwap.Asset) == types.EthAssetETH { balance, err := s.ETHClient().Balance(s.ctx) if err != nil { - return ethcommon.Hash{}, err + return nil, err } log.Infof("balance after claim: %s ETH", coins.FmtWeiAsETH(balance)) } else { balance, err := s.ETHClient().ERC20Balance(s.ctx, s.contractSwap.Asset) if err != nil { - return ethcommon.Hash{}, err + return nil, err } log.Infof("balance after claim: %s %s", @@ -94,50 +98,64 @@ func (s *swapState) claimFunds() (ethcommon.Hash, error) { ) } - return txHash, nil + return receipt, nil } -// discoverRelayersAndClaim discovers available relayers on the network, -func (s *swapState) discoverRelayersAndClaim() (ethcommon.Hash, error) { +// relayClaimWithXMRTaker relays the claim to the swap's XMR taker, who should +// process the claim even if they are not relaying claims for everyone. +func (s *swapState) relayClaimWithXMRTaker(request *message.RelayClaimRequest) (*ethtypes.Receipt, error) { + // only requests to the XMR taker set the offerID field + request.OfferID = &s.offer.ID + defer func() { request.OfferID = nil }() + + response, err := s.Backend.SubmitClaimToRelayer(s.info.PeerID, request) + if err != nil { + return nil, err + } + + receipt, err := waitForClaimReceipt( + s.ctx, + s.ETHClient().Raw(), + response.TxHash, + s.contractAddr, + s.contractSwapID, + s.getSecret(), + ) + if err != nil { + return nil, fmt.Errorf("failed to get receipt of relayer's tx: %s", err) + } + + log.Infof("relayer's claim via counterparty included and validated %s", common.ReceiptInfo(receipt)) + + return receipt, nil +} + +// claimWithAdvertisedRelayers relays the claim to nodes that advertise +// themselves as relayers in the DHT until the claim succeeds, all relayers have +// been tried, or the context is cancelled. +func (s *swapState) claimWithAdvertisedRelayers(request *message.RelayClaimRequest) (*ethtypes.Receipt, error) { relayers, err := s.Backend.DiscoverRelayers() if err != nil { - return ethcommon.Hash{}, err + return nil, err } if len(relayers) == 0 { - return ethcommon.Hash{}, errors.New("no relayers found to submit claim to") + return nil, errors.New("no relayers found to submit claim to") } log.Debugf("Found %d relayers to submit claim to", len(relayers)) - - forwarderAddress, err := s.Contract().TrustedForwarder(&bind.CallOpts{Context: s.ctx}) - if err != nil { - return ethcommon.Hash{}, err - } - - secret := s.getSecret() - - req, err := relayer.CreateRelayClaimRequest( - s.ctx, - s.ETHClient().PrivateKey(), - s.ETHClient().Raw(), - s.contractAddr, - forwarderAddress, - s.contractSwap, - &secret, - ) - if err != nil { - return ethcommon.Hash{}, err - } - - for _, relayer := range relayers { - log.Debugf("submitting claim to relayer with peer ID %s", relayer) - resp, err := s.Backend.SubmitClaimToRelayer(relayer, req) + for _, relayerPeerID := range relayers { + if relayerPeerID == s.info.PeerID { + log.Debugf("skipping DHT-advertised relayer that is our swap counterparty") + continue + } + log.Debugf("submitting claim to relayer with peer ID %s", relayerPeerID) + resp, err := s.Backend.SubmitClaimToRelayer(relayerPeerID, request) if err != nil { log.Warnf("failed to submit tx to relayer: %s", err) continue } - err = waitForClaimReceipt( + receipt, err := waitForClaimReceipt( s.ctx, s.ETHClient().Raw(), resp.TxHash, @@ -150,10 +168,48 @@ func (s *swapState) discoverRelayersAndClaim() (ethcommon.Hash, error) { continue } - return resp.TxHash, nil + log.Infof("DHT relayer's claim included and validated %s", common.ReceiptInfo(receipt)) + + return receipt, nil } - return ethcommon.Hash{}, errors.New("failed to submit transaction to any relayer") + return nil, errors.New("failed to relay claim with any non-counterparty relayer") +} + +// claimWithRelay first tries to relay sequentially with all relayers +// advertising in the DHT that are not the XMR taker and, if that fails, falls +// back to the XMR taker who, if using our software, will act as a relayer of +// last resort for their own swap, even if they are not performing relay +// operations more generally. Note that the receipt returned is for a +// transaction created by the remote relayer, not by us. +func (s *swapState) claimWithRelay() (*ethtypes.Receipt, error) { + forwarderAddress, err := s.Contract().TrustedForwarder(&bind.CallOpts{Context: s.ctx}) + if err != nil { + return nil, err + } + + secret := s.getSecret() + + request, err := relayer.CreateRelayClaimRequest( + s.ctx, + s.ETHClient().PrivateKey(), + s.ETHClient().Raw(), + s.contractAddr, + forwarderAddress, + s.contractSwap, + &secret, + ) + if err != nil { + return nil, err + } + + receipt, err := s.claimWithAdvertisedRelayers(request) + if err != nil { + log.Warnf("failed to relay with DHT-advertised relayers: %s", err) + log.Infof("falling back to swap counterparty as relayer") + return s.relayClaimWithXMRTaker(request) + } + return receipt, nil } func waitForClaimReceipt( @@ -161,8 +217,9 @@ func waitForClaimReceipt( ec *ethclient.Client, txHash ethcommon.Hash, contractAddr ethcommon.Address, - contractSwapID, secret [32]byte, -) error { + contractSwapID [32]byte, + secret [32]byte, +) (*ethtypes.Receipt, error) { const ( checkInterval = time.Second // time between transaction polls maxWait = time.Minute // max wait for the tx to be included in a block @@ -178,7 +235,7 @@ func waitForClaimReceipt( // into the node we're using err := common.SleepWithContext(ctx, checkInterval) if err != nil { - return err + return nil, err } _, isPending, err := ec.TransactionByHash(ctx, txHash) @@ -189,12 +246,12 @@ func waitForClaimReceipt( continue } - return err + return nil, err } if time.Since(start) > maxWait { // the tx is taking too long, return an error so we try with another relayer - return errRelayedTransactionTimeout + return nil, errRelayedTransactionTimeout } if !isPending { @@ -204,28 +261,26 @@ func waitForClaimReceipt( receipt, err := ec.TransactionReceipt(ctx, txHash) if err != nil { - return err + return nil, err } if receipt.Status != ethtypes.ReceiptStatusSuccessful { err = fmt.Errorf("relayer's claim transaction failed (gas-lost=%d tx=%s block=%d), %w", receipt.GasUsed, txHash, receipt.BlockNumber, block.ErrorFromBlock(ctx, ec, receipt)) - return err + return nil, err } if len(receipt.Logs) == 0 { - return fmt.Errorf("relayer's claim transaction had no logs (tx=%s block=%d)", + return nil, fmt.Errorf("relayer's claim transaction had no logs (tx=%s block=%d)", txHash, receipt.BlockNumber) } if err = checkClaimedLog(receipt.Logs[0], contractAddr, contractSwapID, secret); err != nil { - return fmt.Errorf("relayer's claim had logs error (tx=%s block=%d): %w", + return nil, fmt.Errorf("relayer's claim had logs error (tx=%s block=%d): %w", txHash, receipt.BlockNumber, err) } - log.Infof("relayer's claim tx=%s in block=%d validated, gas used: %d", - receipt.TxHash, receipt.BlockNumber, receipt.GasUsed) - return nil + return receipt, nil } func checkClaimedLog(log *ethtypes.Log, contractAddr ethcommon.Address, contractSwapID, secret [32]byte) error { diff --git a/protocol/xmrmaker/event.go b/protocol/xmrmaker/event.go index dd838719..bc2279e8 100644 --- a/protocol/xmrmaker/event.go +++ b/protocol/xmrmaker/event.go @@ -259,7 +259,7 @@ func (s *swapState) handleEventContractReady() error { s.readyWatcher.Stop() // contract ready, let's claim our ether - txHash, err := s.claimFunds() + receipt, err := s.claimFunds() if err != nil { log.Warnf("failed to claim funds from contract, attempting to safely exit: %s", err) @@ -271,7 +271,7 @@ func (s *swapState) handleEventContractReady() error { return fmt.Errorf("failed to claim: %w", err) } - log.Debugf("funds claimed, tx: %s", txHash) + log.Debugf("funds claimed, tx: %s", receipt.TxHash) s.clearNextExpectedEvent(types.CompletedSuccess) return nil } @@ -284,6 +284,6 @@ func (s *swapState) handleEventETHRefunded(e *EventETHRefunded) error { } s.clearNextExpectedEvent(types.CompletedRefund) - s.CloseProtocolStream(s.ID()) + s.CloseProtocolStream(s.OfferID()) return nil } diff --git a/protocol/xmrmaker/instance.go b/protocol/xmrmaker/instance.go index c5e25516..7b0ff64d 100644 --- a/protocol/xmrmaker/instance.go +++ b/protocol/xmrmaker/instance.go @@ -95,7 +95,7 @@ func (inst *Instance) checkForOngoingSwaps() error { } if s.Status == types.KeysExchanged || s.Status == types.ExpectingKeys { - log.Infof("found ongoing swap %s in DB, aborting since no funds were locked", s.ID) + log.Infof("found ongoing swap %s in DB, aborting since no funds were locked", s.OfferID) // for these two cases, no funds have been locked, so we can safely // abort the swap. @@ -125,32 +125,34 @@ func (inst *Instance) abortOngoingSwap(s *swap.Info) error { return err } - return inst.backend.RecoveryDB().DeleteSwap(s.ID) + return inst.backend.RecoveryDB().DeleteSwap(s.OfferID) } func (inst *Instance) createOngoingSwap(s *swap.Info) error { - log.Infof("found ongoing swap %s in DB, restarting swap", s.ID) + log.Infof("found ongoing swap %s in DB, restarting swap", s.OfferID) // check if we have shared secret key in db; if so, recover XMR from that // otherwise, create new swap state from recovery info - skA, err := inst.backend.RecoveryDB().GetCounterpartySwapPrivateKey(s.ID) + skA, err := inst.backend.RecoveryDB().GetCounterpartySwapPrivateKey(s.OfferID) if err == nil { return inst.completeSwap(s, skA) } - offer, _, err := inst.offerManager.GetOffer(s.ID) + offer, _, err := inst.offerManager.GetOffer(s.OfferID) if err != nil { - return fmt.Errorf("failed to get offer for ongoing swap, id %s: %s", s.ID, err) + return fmt.Errorf("failed to get offer for ongoing swap, offer ID %s: %s", s.OfferID, err) } - ethSwapInfo, err := inst.backend.RecoveryDB().GetContractSwapInfo(s.ID) + ethSwapInfo, err := inst.backend.RecoveryDB().GetContractSwapInfo(s.OfferID) if err != nil { - return fmt.Errorf("failed to get contract info for ongoing swap from db with swap id %s: %w", s.ID, err) + return fmt.Errorf("failed to get contract info for ongoing swap from db with offer ID %s: %s", + s.OfferID, err) } - sk, err := inst.backend.RecoveryDB().GetSwapPrivateKey(s.ID) + sk, err := inst.backend.RecoveryDB().GetSwapPrivateKey(s.OfferID) if err != nil { - return fmt.Errorf("failed to get private key for ongoing swap from db with swap id %s: %w", s.ID, err) + return fmt.Errorf("failed to get private key for ongoing swap from db with offer ID %s: %s", + s.OfferID, err) } kp, err := sk.AsPrivateKeyPair() @@ -158,7 +160,7 @@ func (inst *Instance) createOngoingSwap(s *swap.Info) error { return err } - relayerInfo, err := inst.backend.RecoveryDB().GetSwapRelayerInfo(s.ID) + relayerInfo, err := inst.backend.RecoveryDB().GetSwapRelayerInfo(s.OfferID) if err != nil { // we can ignore the error; if the key doesn't exist, // then no relayer was set for this swap. @@ -175,11 +177,11 @@ func (inst *Instance) createOngoingSwap(s *swap.Info) error { kp, ) if err != nil { - return fmt.Errorf("failed to create new swap state for ongoing swap, id %s: %w", s.ID, err) + return fmt.Errorf("failed to create new swap state for ongoing swap, offer id %s: %w", s.OfferID, err) } inst.swapMu.Lock() - inst.swapStates[s.ID] = ss + inst.swapStates[s.OfferID] = ss inst.swapMu.Unlock() go func() { @@ -203,7 +205,7 @@ func (inst *Instance) createOngoingSwap(s *swap.Info) error { // address, not whatever address was used when the swap was started. func (inst *Instance) completeSwap(s *swap.Info, skA *mcrypto.PrivateSpendKey) error { // fetch our swap private spend key - skB, err := inst.backend.RecoveryDB().GetSwapPrivateKey(s.ID) + skB, err := inst.backend.RecoveryDB().GetSwapPrivateKey(s.OfferID) if err != nil { return err } @@ -216,7 +218,7 @@ func (inst *Instance) completeSwap(s *swap.Info, skA *mcrypto.PrivateSpendKey) e // we save the counterparty's public keys in case they send public keys derived // in a non-standard way. - _, vkA, err := inst.backend.RecoveryDB().GetCounterpartySwapKeys(s.ID) + _, vkA, err := inst.backend.RecoveryDB().GetCounterpartySwapKeys(s.OfferID) if err != nil { return err } @@ -229,7 +231,7 @@ func (inst *Instance) completeSwap(s *swap.Info, skA *mcrypto.PrivateSpendKey) e err = pcommon.ClaimMonero( inst.backend.Ctx(), inst.backend.Env(), - s.ID, + s.OfferID, inst.backend.XMRClient(), s.MoneroStartHeight, kpAB, @@ -243,7 +245,7 @@ func (inst *Instance) completeSwap(s *swap.Info, skA *mcrypto.PrivateSpendKey) e s.Status = types.CompletedRefund err = inst.backend.SwapManager().CompleteOngoingSwap(s) if err != nil { - return fmt.Errorf("failed to mark swap %s as completed: %w", s.ID, err) + return fmt.Errorf("failed to mark swap %s as completed: %w", s.OfferID, err) } return nil diff --git a/protocol/xmrmaker/instance_test.go b/protocol/xmrmaker/instance_test.go index 9d8e22a8..72556406 100644 --- a/protocol/xmrmaker/instance_test.go +++ b/protocol/xmrmaker/instance_test.go @@ -188,7 +188,7 @@ func TestInstance_createOngoingSwap(t *testing.T) { require.NoError(t, err) s := &pswap.Info{ - ID: offer.ID, + OfferID: offer.ID, Provides: coins.ProvidesXMR, ProvidedAmount: one, ExpectedAmount: one, @@ -200,9 +200,9 @@ func TestInstance_createOngoingSwap(t *testing.T) { sk, err := mcrypto.GenerateKeys() require.NoError(t, err) - rdb.EXPECT().GetSwapRelayerInfo(s.ID).Return(nil, errors.New("some error")) - rdb.EXPECT().GetCounterpartySwapPrivateKey(s.ID).Return(nil, errors.New("some error")) - rdb.EXPECT().GetContractSwapInfo(s.ID).Return(&db.EthereumSwapInfo{ + rdb.EXPECT().GetSwapRelayerInfo(s.OfferID).Return(nil, errors.New("some error")) + rdb.EXPECT().GetCounterpartySwapPrivateKey(s.OfferID).Return(nil, errors.New("some error")) + rdb.EXPECT().GetContractSwapInfo(s.OfferID).Return(&db.EthereumSwapInfo{ StartNumber: big.NewInt(1), ContractAddress: inst.backend.ContractAddr(), SwapID: contractSwapID, @@ -211,17 +211,17 @@ func TestInstance_createOngoingSwap(t *testing.T) { Timeout1: big.NewInt(2), }, }, nil) - rdb.EXPECT().GetSwapPrivateKey(s.ID).Return( + rdb.EXPECT().GetSwapPrivateKey(s.OfferID).Return( sk.SpendKey(), nil, ) - offerDB.EXPECT().GetOffer(s.ID).Return(offer, nil) + offerDB.EXPECT().GetOffer(s.OfferID).Return(offer, nil) err = inst.createOngoingSwap(s) require.NoError(t, err) inst.swapMu.Lock() defer inst.swapMu.Unlock() - close(inst.swapStates[s.ID].done) + close(inst.swapStates[s.OfferID].done) } func TestInstance_CompleteSwap(t *testing.T) { @@ -245,7 +245,7 @@ func TestInstance_CompleteSwap(t *testing.T) { height, err := inst.backend.XMRClient().GetHeight() require.NoError(t, err) sinfo := &pswap.Info{ - ID: id, + OfferID: id, MoneroStartHeight: height, Status: types.XMRLocked, } diff --git a/protocol/xmrmaker/message_handler.go b/protocol/xmrmaker/message_handler.go index bc781532..97918b55 100644 --- a/protocol/xmrmaker/message_handler.go +++ b/protocol/xmrmaker/message_handler.go @@ -101,8 +101,8 @@ func (s *swapState) handleNotifyETHLocked(msg *message.NotifyETHLocked) error { log.Infof("got NotifyETHLocked; address=%s contract swap ID=%s", msg.Address, msg.ContractSwapID) // validate that swap ID == keccak256(swap struct) - if err := checkContractSwapID(msg); err != nil { - return err + if msg.ContractSwap.SwapID() != msg.ContractSwapID { + return errSwapIDMismatch } s.contractSwapID = msg.ContractSwapID @@ -133,11 +133,11 @@ func (s *swapState) handleNotifyETHLocked(msg *message.NotifyETHLocked) error { ContractAddress: contractAddr, } - if err = s.Backend.RecoveryDB().PutContractSwapInfo(s.ID(), ethInfo); err != nil { + if err = s.Backend.RecoveryDB().PutContractSwapInfo(s.OfferID(), ethInfo); err != nil { return err } - log.Infof("stored ContractSwapInfo: id=%s", s.ID()) + log.Infof("stored ContractSwapInfo: id=%s", s.OfferID()) if err = s.checkContract(msg.TxHash); err != nil { return err diff --git a/protocol/xmrmaker/net.go b/protocol/xmrmaker/net.go index 253ea3c4..54f7d0cf 100644 --- a/protocol/xmrmaker/net.go +++ b/protocol/xmrmaker/net.go @@ -7,6 +7,7 @@ import ( "math/big" "github.com/cockroachdb/apd/v3" + "github.com/libp2p/go-libp2p/core/peer" "github.com/athanorlabs/atomic-swap/coins" "github.com/athanorlabs/atomic-swap/common" @@ -30,6 +31,7 @@ func (inst *Instance) Provides() coins.ProvidesCoin { } func (inst *Instance) initiate( + takerPeerID peer.ID, offer *types.Offer, offerExtra *types.OfferExtra, providesAmount *coins.PiconeroAmount, @@ -62,6 +64,7 @@ func (inst *Instance) initiate( s, err := newSwapStateFromStart( inst.backend, + takerPeerID, offer, offerExtra, inst.offerManager, @@ -84,7 +87,7 @@ func (inst *Instance) initiate( return nil, err } - log.Info(color.New(color.Bold).Sprintf("**initiated swap with offer ID=%s**", s.info.ID)) + log.Info(color.New(color.Bold).Sprintf("**initiated swap with offer ID=%s**", s.info.OfferID)) log.Info(color.New(color.Bold).Sprint("DO NOT EXIT THIS PROCESS OR THE SWAP MAY BE CANCELLED!")) log.Infof(color.New(color.Bold).Sprintf("receiving %v %s for %v XMR", s.info.ExpectedAmount, @@ -96,7 +99,10 @@ func (inst *Instance) initiate( } // HandleInitiateMessage is called when we receive a network message from a peer that they wish to initiate a swap. -func (inst *Instance) HandleInitiateMessage(msg *message.SendKeysMessage) (net.SwapState, common.Message, error) { +func (inst *Instance) HandleInitiateMessage( + takerPeerID peer.ID, + msg *message.SendKeysMessage, +) (net.SwapState, common.Message, error) { inst.swapMu.Lock() defer inst.swapMu.Unlock() @@ -150,7 +156,7 @@ func (inst *Instance) HandleInitiateMessage(msg *message.SendKeysMessage) (net.S return nil, nil, err } - state, err := inst.initiate(offer, offerExtra, providedPiconero, expectedAmount) + state, err := inst.initiate(takerPeerID, offer, offerExtra, providedPiconero, expectedAmount) if err != nil { return nil, nil, err } diff --git a/protocol/xmrmaker/net_test.go b/protocol/xmrmaker/net_test.go index 131094d5..a088ee13 100644 --- a/protocol/xmrmaker/net_test.go +++ b/protocol/xmrmaker/net_test.go @@ -32,7 +32,7 @@ func TestXMRMaker_HandleInitiateMessage(t *testing.T) { msg.ProvidedAmount, err = offer.ExchangeRate.ToETH(offer.MinAmount) require.NoError(t, err) - _, resp, err := b.HandleInitiateMessage(msg) + _, resp, err := b.HandleInitiateMessage("", msg) require.NoError(t, err) require.Equal(t, message.SendKeysType, resp.Type()) require.NotNil(t, b.swapStates[offer.ID]) diff --git a/protocol/xmrmaker/swap_state.go b/protocol/xmrmaker/swap_state.go index 58e8112f..4d4135b6 100644 --- a/protocol/xmrmaker/swap_state.go +++ b/protocol/xmrmaker/swap_state.go @@ -13,10 +13,11 @@ import ( "time" "github.com/cockroachdb/apd/v3" - 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/fatih/color" + "github.com/libp2p/go-libp2p/core/peer" "github.com/athanorlabs/atomic-swap/coins" "github.com/athanorlabs/atomic-swap/common" @@ -99,6 +100,7 @@ type swapState struct { // newSwapStateFromStart returns a new *swapState for a fresh swap. func newSwapStateFromStart( b backend.Backend, + takerPeerID peer.ID, offer *types.Offer, offerExtra *types.OfferExtra, om *offers.Manager, @@ -134,6 +136,7 @@ func newSwapStateFromStart( } info := pswap.NewInfo( + takerPeerID, offer.ID, coins.ProvidesXMR, providesAmount.AsMonero(), @@ -247,20 +250,20 @@ func completeSwap(info *swap.Info, b backend.Backend, om *offers.Manager) error info.SetStatus(types.CompletedSuccess) err := b.SwapManager().CompleteOngoingSwap(info) if err != nil { - return fmt.Errorf("failed to mark swap %s as completed: %s", info.ID, err) + return fmt.Errorf("failed to mark swap %s as completed: %s", info.OfferID, err) } - err = om.DeleteOffer(info.ID) + err = om.DeleteOffer(info.OfferID) if err != nil { - return fmt.Errorf("failed to delete offer %s from db: %s", info.ID, err) + return fmt.Errorf("failed to delete offer %s from db: %s", info.OfferID, err) } - err = b.RecoveryDB().DeleteSwap(info.ID) + err = b.RecoveryDB().DeleteSwap(info.OfferID) if err != nil { - return fmt.Errorf("failed to delete temporary swap info %s from db: %s", info.ID, err) + return fmt.Errorf("failed to delete temporary swap info %s from db: %s", info.OfferID, err) } - exitLog := color.New(color.Bold).Sprintf("**swap completed successfully: id=%s**", info.ID) + exitLog := color.New(color.Bold).Sprintf("**swap completed successfully: id=%s**", info.OfferID) log.Info(exitLog) return nil } @@ -436,9 +439,9 @@ func (s *swapState) ExpectedAmount() *apd.Decimal { return s.info.ExpectedAmount } -// ID returns the ID of the swap -func (s *swapState) ID() types.Hash { - return s.info.ID +// OfferID returns the ID of the swap +func (s *swapState) OfferID() types.Hash { + return s.info.OfferID } // Exit is called by the network when the protocol stream closes, or if the swap_refund RPC endpoint is called. @@ -490,11 +493,11 @@ func (s *swapState) exit() error { var exitLog string switch s.info.Status { case types.CompletedSuccess: - exitLog = color.New(color.Bold).Sprintf("**swap completed successfully: id=%s**", s.ID()) + exitLog = color.New(color.Bold).Sprintf("**swap completed successfully: id=%s**", s.OfferID()) case types.CompletedRefund: - exitLog = color.New(color.Bold).Sprintf("**swap refunded successfully: id=%s**", s.ID()) + exitLog = color.New(color.Bold).Sprintf("**swap refunded successfully: id=%s**", s.OfferID()) case types.CompletedAbort: - exitLog = color.New(color.Bold).Sprintf("**swap aborted: id=%s**", s.ID()) + exitLog = color.New(color.Bold).Sprintf("**swap aborted: id=%s**", s.OfferID()) } log.Info(exitLog) @@ -542,13 +545,13 @@ func (s *swapState) exit() error { func (s *swapState) reclaimMonero(skA *mcrypto.PrivateSpendKey) error { // write counterparty swap privkey to disk in case something goes wrong - err := s.Backend.RecoveryDB().PutCounterpartySwapPrivateKey(s.ID(), skA) + err := s.Backend.RecoveryDB().PutCounterpartySwapPrivateKey(s.OfferID(), skA) if err != nil { return err } if s.xmrtakerPublicSpendKey == nil || s.xmrtakerPrivateViewKey == nil { - s.xmrtakerPublicSpendKey, s.xmrtakerPrivateViewKey, err = s.RecoveryDB().GetCounterpartySwapKeys(s.ID()) + s.xmrtakerPublicSpendKey, s.xmrtakerPrivateViewKey, err = s.RecoveryDB().GetCounterpartySwapKeys(s.OfferID()) if err != nil { return fmt.Errorf("failed to get counterparty public keypair: %w", err) } @@ -562,7 +565,7 @@ func (s *swapState) reclaimMonero(skA *mcrypto.PrivateSpendKey) error { return pcommon.ClaimMonero( s.ctx, s.Env(), - s.ID(), + s.OfferID(), s.XMRClient(), s.moneroStartHeight, kpAB, @@ -589,7 +592,7 @@ func (s *swapState) generateAndSetKeys() error { s.privkeys = keysAndProof.PrivateKeyPair s.pubkeys = keysAndProof.PublicKeyPair - return s.Backend.RecoveryDB().PutSwapPrivateKey(s.ID(), s.privkeys.SpendKey()) + return s.Backend.RecoveryDB().PutSwapPrivateKey(s.OfferID(), s.privkeys.SpendKey()) } func generateKeys() (*pcommon.KeysAndProof, error) { @@ -616,7 +619,7 @@ func (s *swapState) setXMRTakerKeys( s.xmrtakerPublicSpendKey = sk s.xmrtakerPrivateViewKey = vk s.xmrtakerSecp256K1PublicKey = secp256k1Pub - return s.RecoveryDB().PutCounterpartySwapKeys(s.ID(), sk, vk) + return s.RecoveryDB().PutCounterpartySwapKeys(s.OfferID(), sk, vk) } // setContract sets the contract in which XMRTaker has locked her ETH. diff --git a/protocol/xmrmaker/swap_state_ongoing_test.go b/protocol/xmrmaker/swap_state_ongoing_test.go index 2bf6be45..9441665a 100644 --- a/protocol/xmrmaker/swap_state_ongoing_test.go +++ b/protocol/xmrmaker/swap_state_ongoing_test.go @@ -116,7 +116,7 @@ func TestSwapStateOngoing_Refund(t *testing.T) { s.info.Status = types.XMRLocked rdb := inst.backend.RecoveryDB().(*backend.MockRecoveryDB) - rdb.EXPECT().GetCounterpartySwapKeys(s.ID()).Return( + rdb.EXPECT().GetCounterpartySwapKeys(s.OfferID()).Return( xmrtakerKeysAndProof.PublicKeyPair.SpendKey(), xmrtakerKeysAndProof.PrivateKeyPair.ViewKey(), nil, diff --git a/protocol/xmrmaker/swap_state_test.go b/protocol/xmrmaker/swap_state_test.go index 6b500766..c766ae6b 100644 --- a/protocol/xmrmaker/swap_state_test.go +++ b/protocol/xmrmaker/swap_state_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/cockroachdb/apd/v3" + "github.com/libp2p/go-libp2p/core/peer" "github.com/athanorlabs/atomic-swap/coins" "github.com/athanorlabs/atomic-swap/common" @@ -33,6 +34,7 @@ var ( _ = logging.SetLogLevel("xmrmaker", "debug") desiredAmount = coins.EtherToWei(apd.New(33, -2)) // "0.33" defaultTimeoutDuration, _ = time.ParseDuration("86400s") // 1 day = 60s * 60min * 24hr + testPeerID, _ = peer.Decode("12D3KooWQQRJuKTZ35eiHGNPGDpQqjpJSdaxEMJRxi6NWFrrvQVi") ) func newTestSwapStateAndDB(t *testing.T) (*Instance, *swapState, *offers.MockDatabase) { @@ -40,6 +42,7 @@ func newTestSwapStateAndDB(t *testing.T) (*Instance, *swapState, *offers.MockDat swapState, err := newSwapStateFromStart( xmrmaker.backend, + testPeerID, types.NewOffer("", new(apd.Decimal), new(apd.Decimal), new(coins.ExchangeRate), types.EthAssetETH), &types.OfferExtra{}, xmrmaker.offerManager, @@ -154,9 +157,9 @@ func TestSwapState_ClaimFunds(t *testing.T) { require.NoError(t, err) tests.MineTransaction(t, swapState.ETHClient().Raw(), tx) - txHash, err := swapState.claimFunds() + receipt, err := swapState.claimFunds() require.NoError(t, err) - require.NotEqual(t, "", txHash) + require.NotNil(t, receipt) require.True(t, swapState.info.Status.IsOngoing()) } diff --git a/protocol/xmrtaker/claim.go b/protocol/xmrtaker/claim.go index de97f753..67f06e85 100644 --- a/protocol/xmrtaker/claim.go +++ b/protocol/xmrtaker/claim.go @@ -85,12 +85,12 @@ func (s *swapState) claimMonero(skB *mcrypto.PrivateSpendKey) (*mcrypto.Address, } // write counterparty swap privkey to disk in case something goes wrong - err := s.Backend.RecoveryDB().PutCounterpartySwapPrivateKey(s.ID(), skB) + err := s.Backend.RecoveryDB().PutCounterpartySwapPrivateKey(s.OfferID(), skB) if err != nil { return nil, err } - id := s.ID() + id := s.OfferID() depositAddr := s.XMRDepositAddress(&id) if s.noTransferBack { depositAddr = nil @@ -104,7 +104,7 @@ func (s *swapState) claimMonero(skB *mcrypto.PrivateSpendKey) (*mcrypto.Address, err = pcommon.ClaimMonero( s.ctx, s.Env(), - s.info.ID, + s.info.OfferID, s.XMRClient(), s.walletScanHeight, kpAB, diff --git a/protocol/xmrtaker/event.go b/protocol/xmrtaker/event.go index 1ada6af8..bebfc1e9 100644 --- a/protocol/xmrtaker/event.go +++ b/protocol/xmrtaker/event.go @@ -314,7 +314,7 @@ func (s *swapState) handleEventKeysReceived(event *EventKeysReceived) error { return err } - return s.SendSwapMessage(resp, s.ID()) + return s.SendSwapMessage(resp, s.OfferID()) } func (s *swapState) handleEventETHClaimed(event *EventETHClaimed) error { @@ -324,7 +324,7 @@ func (s *swapState) handleEventETHClaimed(event *EventETHClaimed) error { } s.clearNextExpectedEvent(types.CompletedSuccess) - s.CloseProtocolStream(s.ID()) + s.CloseProtocolStream(s.OfferID()) return nil } @@ -333,7 +333,7 @@ func (s *swapState) handleEventShouldRefund(event *EventShouldRefund) error { return nil } - txHash, err := s.refund() + receipt, err := s.refund() if err != nil { // TODO: could this ever happen anymore? if !strings.Contains(err.Error(), revertSwapCompleted) { @@ -344,7 +344,7 @@ func (s *swapState) handleEventShouldRefund(event *EventShouldRefund) error { return nil } - log.Infof("got our ETH back: tx hash=%s", txHash) - event.txHashCh <- txHash + log.Infof("got our ETH back: tx hash=%s", receipt.TxHash) + event.txHashCh <- receipt.TxHash return nil } diff --git a/protocol/xmrtaker/instance.go b/protocol/xmrtaker/instance.go index 905a9d38..5ce3ecfc 100644 --- a/protocol/xmrtaker/instance.go +++ b/protocol/xmrtaker/instance.go @@ -13,12 +13,10 @@ import ( "github.com/athanorlabs/atomic-swap/common" "github.com/athanorlabs/atomic-swap/common/types" mcrypto "github.com/athanorlabs/atomic-swap/crypto/monero" - "github.com/athanorlabs/atomic-swap/net/message" pcommon "github.com/athanorlabs/atomic-swap/protocol" "github.com/athanorlabs/atomic-swap/protocol/backend" "github.com/athanorlabs/atomic-swap/protocol/swap" "github.com/athanorlabs/atomic-swap/protocol/txsender" - "github.com/athanorlabs/atomic-swap/relayer" ) var ( @@ -78,10 +76,10 @@ func (inst *Instance) checkForOngoingSwaps() error { if s.Status == types.KeysExchanged || s.Status == types.ExpectingKeys { // set status to aborted, delete info from recovery db - log.Infof("found ongoing swap %s in DB, aborting since no funds were locked", s.ID) + log.Infof("found ongoing swap %s in DB, aborting since no funds were locked", s.OfferID) err = inst.abortOngoingSwap(s) if err != nil { - log.Warnf("failed to abort ongoing swap %s: %s", s.ID, err) + log.Warnf("failed to abort ongoing swap %s: %s", s.OfferID, err) } continue } @@ -104,27 +102,28 @@ func (inst *Instance) abortOngoingSwap(s *swap.Info) error { return err } - return inst.backend.RecoveryDB().DeleteSwap(s.ID) + return inst.backend.RecoveryDB().DeleteSwap(s.OfferID) } func (inst *Instance) createOngoingSwap(s *swap.Info) error { - log.Infof("found ongoing swap %s in DB, restarting swap", s.ID) + log.Infof("found ongoing swap %s in DB, restarting swap", s.OfferID) // check if we have shared secret key in db; if so, claim XMR from that // otherwise, create new swap state from recovery info - skB, err := inst.backend.RecoveryDB().GetCounterpartySwapPrivateKey(s.ID) + skB, err := inst.backend.RecoveryDB().GetCounterpartySwapPrivateKey(s.OfferID) if err == nil { return inst.completeSwap(s, skB) } - ethSwapInfo, err := inst.backend.RecoveryDB().GetContractSwapInfo(s.ID) + ethSwapInfo, err := inst.backend.RecoveryDB().GetContractSwapInfo(s.OfferID) if err != nil { - return fmt.Errorf("failed to get contract info for ongoing swap from db with swap id %s: %w", s.ID, err) + return fmt.Errorf("failed to get contract info for ongoing swap from db with offer id %s: %w", s.OfferID, err) } - sk, err := inst.backend.RecoveryDB().GetSwapPrivateKey(s.ID) + sk, err := inst.backend.RecoveryDB().GetSwapPrivateKey(s.OfferID) if err != nil { - return fmt.Errorf("failed to get private key for ongoing swap from db with swap id %s: %w", s.ID, err) + return fmt.Errorf("failed to get private key for ongoing swap from db with offer id %s: %w", + s.OfferID, err) } kp, err := sk.AsPrivateKeyPair() @@ -142,16 +141,16 @@ func (inst *Instance) createOngoingSwap(s *swap.Info) error { kp, ) if err != nil { - return fmt.Errorf("failed to create new swap state for ongoing swap, id %s: %w", s.ID, err) + return fmt.Errorf("failed to create new swap state for ongoing swap, offer id %s: %w", s.OfferID, err) } - inst.swapStates[s.ID] = ss + inst.swapStates[s.OfferID] = ss go func() { <-ss.done inst.swapMu.Lock() defer inst.swapMu.Unlock() - delete(inst.swapStates, s.ID) + delete(inst.swapStates, s.OfferID) }() return nil @@ -168,7 +167,7 @@ func (inst *Instance) createOngoingSwap(s *swap.Info) error { // wallet address, not whatever address was used when the swap was started. func (inst *Instance) completeSwap(s *swap.Info, skB *mcrypto.PrivateSpendKey) error { // fetch our swap private spend key - skA, err := inst.backend.RecoveryDB().GetSwapPrivateKey(s.ID) + skA, err := inst.backend.RecoveryDB().GetSwapPrivateKey(s.OfferID) if err != nil { return err } @@ -180,7 +179,7 @@ func (inst *Instance) completeSwap(s *swap.Info, skB *mcrypto.PrivateSpendKey) e } // fetch counterparty's private view key - _, vkB, err := inst.backend.RecoveryDB().GetCounterpartySwapKeys(s.ID) + _, vkB, err := inst.backend.RecoveryDB().GetCounterpartySwapKeys(s.OfferID) if err != nil { return err } @@ -193,7 +192,7 @@ func (inst *Instance) completeSwap(s *swap.Info, skB *mcrypto.PrivateSpendKey) e err = pcommon.ClaimMonero( inst.backend.Ctx(), inst.backend.Env(), - s.ID, + s.OfferID, inst.backend.XMRClient(), s.MoneroStartHeight, kpAB, @@ -207,7 +206,7 @@ func (inst *Instance) completeSwap(s *swap.Info, skB *mcrypto.PrivateSpendKey) e s.Status = types.CompletedSuccess err = inst.backend.SwapManager().CompleteOngoingSwap(s) if err != nil { - return fmt.Errorf("failed to mark swap %s as completed: %w", s.ID, err) + return fmt.Errorf("failed to mark swap %s as completed: %w", s.OfferID, err) } return nil @@ -238,13 +237,3 @@ func (inst *Instance) ExternalSender(offerID types.Hash) (*txsender.ExternalSend return es, nil } - -// HandleRelayClaimRequest validates and sends the transaction for a relay claim request -func (inst *Instance) HandleRelayClaimRequest(request *message.RelayClaimRequest) (*message.RelayClaimResponse, error) { - return relayer.ValidateAndSendTransaction( - inst.backend.Ctx(), - request, - inst.backend.ETHClient(), - inst.backend.ContractAddr(), - ) -} diff --git a/protocol/xmrtaker/instance_test.go b/protocol/xmrtaker/instance_test.go index 5c0ba66c..0fe12a9f 100644 --- a/protocol/xmrtaker/instance_test.go +++ b/protocol/xmrtaker/instance_test.go @@ -48,7 +48,7 @@ func TestInstance_createOngoingSwap(t *testing.T) { offer := types.NewOffer(coins.ProvidesXMR, one, one, coins.ToExchangeRate(one), types.EthAssetETH) s := &pswap.Info{ - ID: offer.ID, + OfferID: offer.ID, Provides: coins.ProvidesXMR, ProvidedAmount: one, ExpectedAmount: one, @@ -63,8 +63,8 @@ func TestInstance_createOngoingSwap(t *testing.T) { makerKeys, err := mcrypto.GenerateKeys() require.NoError(t, err) - rdb.EXPECT().GetCounterpartySwapPrivateKey(s.ID).Return(nil, errors.New("some error")) - rdb.EXPECT().GetContractSwapInfo(s.ID).Return(&db.EthereumSwapInfo{ + rdb.EXPECT().GetCounterpartySwapPrivateKey(s.OfferID).Return(nil, errors.New("some error")) + rdb.EXPECT().GetContractSwapInfo(s.OfferID).Return(&db.EthereumSwapInfo{ StartNumber: big.NewInt(1), ContractAddress: inst.backend.ContractAddr(), Swap: &contracts.SwapFactorySwap{ @@ -72,10 +72,10 @@ func TestInstance_createOngoingSwap(t *testing.T) { Timeout1: big.NewInt(2), }, }, nil) - rdb.EXPECT().GetSwapPrivateKey(s.ID).Return( + rdb.EXPECT().GetSwapPrivateKey(s.OfferID).Return( sk.SpendKey(), nil, ) - rdb.EXPECT().GetCounterpartySwapKeys(s.ID).Return( + rdb.EXPECT().GetCounterpartySwapKeys(s.OfferID).Return( makerKeys.SpendKey().Public(), makerKeys.ViewKey(), nil, ) @@ -84,5 +84,5 @@ func TestInstance_createOngoingSwap(t *testing.T) { inst.swapMu.Lock() defer inst.swapMu.Unlock() - close(inst.swapStates[s.ID].done) + close(inst.swapStates[s.OfferID].done) } diff --git a/protocol/xmrtaker/message_handler.go b/protocol/xmrtaker/message_handler.go index cac26eb0..5b3a2c17 100644 --- a/protocol/xmrtaker/message_handler.go +++ b/protocol/xmrtaker/message_handler.go @@ -8,15 +8,15 @@ import ( "fmt" "time" + ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/fatih/color" + "github.com/athanorlabs/atomic-swap/common" "github.com/athanorlabs/atomic-swap/common/types" mcrypto "github.com/athanorlabs/atomic-swap/crypto/monero" "github.com/athanorlabs/atomic-swap/monero" "github.com/athanorlabs/atomic-swap/net/message" pcommon "github.com/athanorlabs/atomic-swap/protocol" - - ethcommon "github.com/ethereum/go-ethereum/common" - "github.com/fatih/color" ) // HandleProtocolMessage is called by the network to handle an incoming message. @@ -122,10 +122,9 @@ func (s *swapState) handleSendKeysMessage(msg *message.SendKeysMessage) (common. if err != nil { return nil, fmt.Errorf("failed to set xmrmaker keys: %w", err) } - log.Debugf("stored XMR maker's keys, going to lock ETH") - txHash, err := s.lockAsset() + receipt, err := s.lockAsset() if err != nil { return nil, fmt.Errorf("failed to lock ethereum asset in contract: %w", err) } @@ -138,7 +137,7 @@ func (s *swapState) handleSendKeysMessage(msg *message.SendKeysMessage) (common. out := &message.NotifyETHLocked{ Address: s.ContractAddr(), - TxHash: txHash, + TxHash: receipt.TxHash, ContractSwapID: s.contractSwapID, ContractSwap: s.contractSwap, } diff --git a/protocol/xmrtaker/net.go b/protocol/xmrtaker/net.go index 59abd87a..ab4d17d6 100644 --- a/protocol/xmrtaker/net.go +++ b/protocol/xmrtaker/net.go @@ -7,6 +7,7 @@ import ( "math/big" "github.com/cockroachdb/apd/v3" + "github.com/libp2p/go-libp2p/core/peer" "github.com/athanorlabs/atomic-swap/coins" "github.com/athanorlabs/atomic-swap/common" @@ -30,7 +31,11 @@ func (inst *Instance) Provides() coins.ProvidesCoin { // InitiateProtocol is called when an RPC call is made from the user to initiate a swap. // The input units are ether that we will provide. -func (inst *Instance) InitiateProtocol(providesAmount *apd.Decimal, offer *types.Offer) (common.SwapState, error) { +func (inst *Instance) InitiateProtocol( + makerPeerID peer.ID, + providesAmount *apd.Decimal, + offer *types.Offer, +) (common.SwapState, error) { expectedAmount, err := offer.ExchangeRate.ToXMR(providesAmount) if err != nil { return nil, err @@ -45,7 +50,7 @@ func (inst *Instance) InitiateProtocol(providesAmount *apd.Decimal, offer *types return nil, err } - state, err := inst.initiate(providedAmount, coins.MoneroToPiconero(expectedAmount), + state, err := inst.initiate(makerPeerID, providedAmount, coins.MoneroToPiconero(expectedAmount), offer.ExchangeRate, offer.EthAsset, offer.ID) if err != nil { return nil, err @@ -54,8 +59,14 @@ func (inst *Instance) InitiateProtocol(providesAmount *apd.Decimal, offer *types return state, nil } -func (inst *Instance) initiate(providesAmount EthereumAssetAmount, expectedAmount *coins.PiconeroAmount, - exchangeRate *coins.ExchangeRate, ethAsset types.EthAsset, offerID types.Hash) (*swapState, error) { +func (inst *Instance) initiate( + makerPeerID peer.ID, + providesAmount EthereumAssetAmount, + expectedAmount *coins.PiconeroAmount, + exchangeRate *coins.ExchangeRate, + ethAsset types.EthAsset, + offerID types.Hash, +) (*swapState, error) { inst.swapMu.Lock() defer inst.swapMu.Unlock() @@ -70,7 +81,8 @@ func (inst *Instance) initiate(providesAmount EthereumAssetAmount, expectedAmoun // Ensure the user's balance is strictly greater than the amount they will provide if ethAsset == types.EthAssetETH && balance.Cmp(providesAmount.BigInt()) <= 0 { - log.Warnf("Account %s needs additional funds for this transaction", inst.backend.ETHClient().Address()) + log.Warnf("Account %s needs additional funds for swap balance=%s ETH providesAmount=%s ETH", + inst.backend.ETHClient().Address(), coins.FmtWeiAsETH(balance), providesAmount.AsStandard()) return nil, errBalanceTooLow } @@ -92,6 +104,7 @@ func (inst *Instance) initiate(providesAmount EthereumAssetAmount, expectedAmoun s, err := newSwapStateFromStart( inst.backend, + makerPeerID, offerID, inst.noTransferBack, providesAmount, @@ -110,7 +123,7 @@ func (inst *Instance) initiate(providesAmount EthereumAssetAmount, expectedAmoun delete(inst.swapStates, offerID) }() - log.Info(color.New(color.Bold).Sprintf("**initiated swap with ID=%s**", s.info.ID)) + log.Info(color.New(color.Bold).Sprintf("**initiated swap with offer ID=%s**", s.info.OfferID)) log.Info(color.New(color.Bold).Sprint("DO NOT EXIT THIS PROCESS OR THE SWAP MAY BE CANCELLED!")) inst.swapStates[offerID] = s return s, nil diff --git a/protocol/xmrtaker/net_test.go b/protocol/xmrtaker/net_test.go index 4ba20eef..f1c70238 100644 --- a/protocol/xmrtaker/net_test.go +++ b/protocol/xmrtaker/net_test.go @@ -32,7 +32,7 @@ func TestXMRTaker_InitiateProtocol(t *testing.T) { one := apd.New(1, 0) offer := types.NewOffer(coins.ProvidesETH, zero, zero, coins.ToExchangeRate(one), types.EthAssetETH) providesAmount := apd.New(333, -2) // 3.33 - s, err := a.InitiateProtocol(providesAmount, offer) + s, err := a.InitiateProtocol(testPeerID, providesAmount, offer) require.NoError(t, err) require.Equal(t, a.swapStates[offer.ID], s) } diff --git a/protocol/xmrtaker/swap_state.go b/protocol/xmrtaker/swap_state.go index 3ca39653..91942edc 100644 --- a/protocol/xmrtaker/swap_state.go +++ b/protocol/xmrtaker/swap_state.go @@ -15,6 +15,7 @@ import ( "time" "github.com/cockroachdb/apd/v3" + "github.com/libp2p/go-libp2p/core/peer" "github.com/athanorlabs/atomic-swap/coins" "github.com/athanorlabs/atomic-swap/common" @@ -97,6 +98,7 @@ type swapState struct { func newSwapStateFromStart( b backend.Backend, + makerPeerID peer.ID, offerID types.Hash, noTransferBack bool, providedAmount EthereumAssetAmount, @@ -123,6 +125,7 @@ func newSwapStateFromStart( } info := pswap.NewInfo( + makerPeerID, offerID, coins.ProvidesETH, providedAmount.AsStandard(), @@ -163,7 +166,7 @@ func newSwapStateFromOngoing( return nil, errInvalidStageForRecovery } - makerSk, makerVk, err := b.RecoveryDB().GetCounterpartySwapKeys(info.ID) + makerSk, makerVk, err := b.RecoveryDB().GetCounterpartySwapKeys(info.OfferID) if err != nil { return nil, fmt.Errorf("failed to get xmrmaker swap keys from db: %w", err) } @@ -329,9 +332,9 @@ func (s *swapState) expectedPiconeroAmount() *coins.PiconeroAmount { return coins.MoneroToPiconero(s.info.ExpectedAmount) } -// ID returns the ID of the swap -func (s *swapState) ID() types.Hash { - return s.info.ID +// OfferID returns the Offer ID of the swap +func (s *swapState) OfferID() types.Hash { + return s.info.OfferID } // Exit is called by the network when the protocol stream closes, or if the swap_refund RPC endpoint is called. @@ -348,13 +351,13 @@ func (s *swapState) exit() error { defer func() { err := s.SwapManager().CompleteOngoingSwap(s.info) if err != nil { - log.Warnf("failed to mark swap %s as completed: %s", s.info.ID, err) + log.Warnf("failed to mark swap %s as completed: %s", s.info.OfferID, err) return } - err = s.Backend.RecoveryDB().DeleteSwap(s.ID()) + err = s.Backend.RecoveryDB().DeleteSwap(s.OfferID()) if err != nil { - log.Warnf("failed to delete temporary swap info %s from db: %s", s.ID(), err) + log.Warnf("failed to delete temporary swap info %s from db: %s", s.OfferID(), err) } // Stop all per-swap goroutines @@ -364,11 +367,11 @@ func (s *swapState) exit() error { var exitLog string switch s.info.Status { case types.CompletedSuccess: - exitLog = color.New(color.Bold).Sprintf("**swap completed successfully: id=%s**", s.ID()) + exitLog = color.New(color.Bold).Sprintf("**swap completed successfully: offerID=%s**", s.OfferID()) case types.CompletedRefund: - exitLog = color.New(color.Bold).Sprintf("**swap refunded successfully: id=%s**", s.ID()) + exitLog = color.New(color.Bold).Sprintf("**swap refunded successfully: offerID=%s**", s.OfferID()) case types.CompletedAbort: - exitLog = color.New(color.Bold).Sprintf("**swap aborted: id=%s**", s.ID()) + exitLog = color.New(color.Bold).Sprintf("**swap aborted: id=%s**", s.OfferID()) } log.Info(exitLog) @@ -388,7 +391,7 @@ func (s *swapState) exit() error { // for EventETHClaimed, the XMR has been locked, but the // ETH hasn't been claimed, but the contract has been set to ready. // we should also refund in this case, since we might be past t1. - txHash, err := s.tryRefund() + receipt, err := s.tryRefund() if err != nil { if strings.Contains(err.Error(), revertSwapCompleted) { // note: this should NOT ever error; it could if the ethclient @@ -404,7 +407,7 @@ func (s *swapState) exit() error { } s.clearNextExpectedEvent(types.CompletedRefund) - log.Infof("refunded ether: transaction hash=%s", txHash) + log.Infof("refunded ether: txID=%s", receipt.TxHash) return nil case EventNoneType: // the swap completed already, do nothing @@ -416,17 +419,17 @@ func (s *swapState) exit() error { } } -func (s *swapState) tryRefund() (ethcommon.Hash, error) { +func (s *swapState) tryRefund() (*ethtypes.Receipt, error) { stage, err := s.Contract().Swaps(s.ETHClient().CallOpts(s.ctx), s.contractSwapID) if err != nil { - return ethcommon.Hash{}, err + return nil, err } switch stage { case contracts.StageInvalid: - return ethcommon.Hash{}, fmt.Errorf("%w: contract swap ID: %s", errRefundInvalid, s.contractSwapID) + return nil, fmt.Errorf("%w: contract swap ID: %s", errRefundInvalid, s.contractSwapID) case contracts.StageCompleted: - return ethcommon.Hash{}, errRefundSwapCompleted + return nil, errRefundSwapCompleted case contracts.StagePending, contracts.StageReady: // do nothing default: @@ -437,17 +440,17 @@ func (s *swapState) tryRefund() (ethcommon.Hash, error) { ts, err := s.ETHClient().LatestBlockTimestamp(s.ctx) if err != nil { - return ethcommon.Hash{}, err + return nil, err } log.Debugf("tryRefund isReady=%v untilT0=%vs untilT1=%vs", isReady, s.t0.Sub(ts).Seconds(), s.t1.Sub(ts).Seconds()) if ts.Before(s.t0) && !isReady { - txHash, err := s.refund() //nolint:govet + receipt, err := s.refund() //nolint:govet // TODO: Have refund() return errors that we can use errors.Is to check against if err == nil { - return txHash, nil + return receipt, nil } // There is a small, but non-zero chance that our transaction gets placed in a block that is after T0 @@ -487,13 +490,13 @@ func (s *swapState) tryRefund() (ethcommon.Hash, error) { case *EventETHClaimed: // we should claim; returning this error // causes the calling function to claim - return ethcommon.Hash{}, fmt.Errorf(revertSwapCompleted) + return nil, fmt.Errorf(revertSwapCompleted) default: panic(fmt.Sprintf("got unexpected event while waiting for Claimed/T1: %s", event)) } case err = <-waitCh: if err != nil { - return ethcommon.Hash{}, fmt.Errorf("failed to wait for T1: %w", err) + return nil, fmt.Errorf("failed to wait for T1: %w", err) } return s.refund() @@ -522,7 +525,7 @@ func (s *swapState) generateAndSetKeys() error { s.privkeys = keysAndProof.PrivateKeyPair s.pubkeys = keysAndProof.PublicKeyPair - return s.Backend.RecoveryDB().PutSwapPrivateKey(s.ID(), s.privkeys.SpendKey()) + return s.Backend.RecoveryDB().PutSwapPrivateKey(s.OfferID(), s.privkeys.SpendKey()) } // getSecret secrets returns the current secret scalar used to unlock funds from the contract. @@ -543,7 +546,7 @@ func (s *swapState) setXMRMakerKeys( s.xmrmakerPublicSpendKey = sk s.xmrmakerPrivateViewKey = vk s.xmrmakerSecp256k1PublicKey = secp256k1Pub - return s.Backend.RecoveryDB().PutCounterpartySwapKeys(s.info.ID, sk, vk) + return s.Backend.RecoveryDB().PutCounterpartySwapKeys(s.info.OfferID, sk, vk) } func (s *swapState) approveToken() error { @@ -558,7 +561,7 @@ func (s *swapState) approveToken() error { } log.Info("approving token for use by the swap contract...") - _, _, err = s.sender.Approve(s.ContractAddr(), balance) + _, err = s.sender.Approve(s.ContractAddr(), balance) if err != nil { return fmt.Errorf("failed to approve token: %w", err) } @@ -568,20 +571,20 @@ func (s *swapState) approveToken() error { } // lockAsset calls the Swap contract function new_swap and locks `amount` ether in it. -func (s *swapState) lockAsset() (ethcommon.Hash, error) { +func (s *swapState) lockAsset() (*ethtypes.Receipt, error) { if s.xmrmakerPublicSpendKey == nil || s.xmrmakerPrivateViewKey == nil { panic(errCounterpartyKeysNotSet) } symbol, err := pcommon.AssetSymbol(s.Backend, s.info.EthAsset) if err != nil { - return ethcommon.Hash{}, err + return nil, err } if s.info.EthAsset != types.EthAssetETH { err = s.approveToken() if err != nil { - return ethcommon.Hash{}, err + return nil, err } } @@ -591,7 +594,7 @@ func (s *swapState) lockAsset() (ethcommon.Hash, error) { log.Debugf("locking %s in contract", symbol) nonce := generateNonce() - txHash, receipt, err := s.sender.NewSwap( + receipt, err := s.sender.NewSwap( cmtXMRMaker, cmtXMRTaker, s.xmrmakerAddress, @@ -601,13 +604,14 @@ func (s *swapState) lockAsset() (ethcommon.Hash, error) { s.providedAmount.BigInt(), ) if err != nil { - return ethcommon.Hash{}, fmt.Errorf("failed to instantiate swap on-chain: %w", err) + return nil, fmt.Errorf("failed to instantiate swap on-chain: %w", err) } - log.Debugf("instantiated swap on-chain: amount=%s asset=%s txHash=%s", s.providedAmount, s.info.EthAsset, txHash) + log.Infof("instantiated swap on-chain: amount=%s asset=%s %s", + s.providedAmount, s.info.EthAsset, common.ReceiptInfo(receipt)) if len(receipt.Logs) == 0 { - return ethcommon.Hash{}, errSwapInstantiationNoLogs + return nil, errSwapInstantiationNoLogs } for _, rLog := range receipt.Logs { @@ -617,7 +621,7 @@ func (s *swapState) lockAsset() (ethcommon.Hash, error) { } } if err != nil { - return ethcommon.Hash{}, fmt.Errorf("swap ID not found in transaction receipt's logs: %w", err) + return nil, fmt.Errorf("swap ID not found in transaction receipt's logs: %w", err) } var t0 *big.Int @@ -629,7 +633,7 @@ func (s *swapState) lockAsset() (ethcommon.Hash, error) { } } if err != nil { - return ethcommon.Hash{}, fmt.Errorf("timeouts not found in transaction receipt's logs: %w", err) + return nil, fmt.Errorf("timeouts not found in transaction receipt's logs: %w", err) } s.fundsLocked = true @@ -654,12 +658,12 @@ func (s *swapState) lockAsset() (ethcommon.Hash, error) { ContractAddress: s.Backend.ContractAddr(), } - if err := s.Backend.RecoveryDB().PutContractSwapInfo(s.ID(), ethInfo); err != nil { - return ethcommon.Hash{}, err + if err := s.Backend.RecoveryDB().PutContractSwapInfo(s.OfferID(), ethInfo); err != nil { + return nil, err } log.Infof("locked %s in swap contract, waiting for XMR to be locked", symbol) - return txHash, nil + return receipt, nil } // ready calls the Ready() method on the Swap contract, indicating to XMRMaker he has until time t_1 to @@ -675,7 +679,7 @@ func (s *swapState) ready() error { return fmt.Errorf("cannot set contract to ready when swap stage is %s", contracts.StageToString(stage)) } - txHash, receipt, err := s.sender.SetReady(s.contractSwap) + receipt, err := s.sender.SetReady(s.contractSwap) if err != nil { if strings.Contains(err.Error(), revertSwapCompleted) && !s.info.Status.IsOngoing() { return nil @@ -683,24 +687,26 @@ func (s *swapState) ready() error { return err } - log.Debugf("contract set to ready in block %d, tx %s", receipt.BlockNumber, txHash) + log.Infof("contract set to ready %s", common.ReceiptInfo(receipt)) + return nil } // refund calls the Refund() method in the Swap contract, revealing XMRTaker's secret // and returns to her the ether in the contract. // If time t_1 passes and Claim() has not been called, XMRTaker should call Refund(). -func (s *swapState) refund() (ethcommon.Hash, error) { +func (s *swapState) refund() (*ethtypes.Receipt, error) { sc := s.getSecret() log.Infof("attempting to call Refund()...") - txHash, _, err := s.sender.Refund(s.contractSwap, sc) + receipt, err := s.sender.Refund(s.contractSwap, sc) if err != nil { - return ethcommon.Hash{}, err + return nil, err } + log.Infof("refund succeeded %s", common.ReceiptInfo(receipt)) s.clearNextExpectedEvent(types.CompletedRefund) - return txHash, nil + return receipt, nil } // generateKeys generates XMRTaker's monero spend and view keys (S_b, V_b), a secp256k1 public key, diff --git a/protocol/xmrtaker/swap_state_ongoing_test.go b/protocol/xmrtaker/swap_state_ongoing_test.go index 6227953d..22e8faf0 100644 --- a/protocol/xmrtaker/swap_state_ongoing_test.go +++ b/protocol/xmrtaker/swap_state_ongoing_test.go @@ -29,11 +29,12 @@ func setupSwapStateUntilETHLocked(t *testing.T) (*swapState, uint64) { makerKeys, err := pcommon.GenerateKeysAndProof() require.NoError(t, err) - s.setXMRMakerKeys( + err = s.setXMRMakerKeys( makerKeys.PublicKeyPair.SpendKey(), makerKeys.PrivateKeyPair.ViewKey(), makerKeys.Secp256k1PublicKey, ) + require.NoError(t, err) _, err = s.lockAsset() require.NoError(t, err) @@ -43,7 +44,7 @@ func setupSwapStateUntilETHLocked(t *testing.T) (*swapState, uint64) { // shutdown swap state, re-create from ongoing s.cancel() - rdb.EXPECT().GetCounterpartySwapKeys(s.info.ID).Return( + rdb.EXPECT().GetCounterpartySwapKeys(s.info.OfferID).Return( makerKeys.PublicKeyPair.SpendKey(), makerKeys.PrivateKeyPair.ViewKey(), nil, diff --git a/protocol/xmrtaker/swap_state_test.go b/protocol/xmrtaker/swap_state_test.go index 71f764f1..7f73f66a 100644 --- a/protocol/xmrtaker/swap_state_test.go +++ b/protocol/xmrtaker/swap_state_test.go @@ -33,8 +33,11 @@ import ( "github.com/athanorlabs/atomic-swap/tests" ) -var _ = logging.SetLogLevel("protocol", "debug") -var _ = logging.SetLogLevel("xmrtaker", "debug") +var ( + _ = logging.SetLogLevel("protocol", "debug") + _ = logging.SetLogLevel("xmrtaker", "debug") + testPeerID, _ = peer.Decode("12D3KooWQQRJuKTZ35eiHGNPGDpQqjpJSdaxEMJRxi6NWFrrvQVi") +) type mockNet struct { msgMu sync.Mutex // lock needed, as SendSwapMessage is called async from timeout handlers @@ -79,7 +82,10 @@ func newSwapManager(t *testing.T) pswap.Manager { func newBackendAndNet(t *testing.T) (backend.Backend, *mockNet) { pk := tests.GetTakerTestKey(t) ec := extethclient.CreateTestClient(t, pk) - ctx := context.Background() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(func() { + cancel() + }) txOpts, err := bind.NewKeyedTransactorWithChainID(pk, ec.ChainID()) require.NoError(t, err) @@ -127,7 +133,7 @@ func newTestSwapStateAndNet(t *testing.T) (*swapState, *mockNet) { providedAmt := coins.EtherToWei(coins.StrToDecimal("1")) expectedAmt := coins.MoneroToPiconero(coins.StrToDecimal("1")) exchangeRate := coins.ToExchangeRate(coins.StrToDecimal("1.0")) // 100% - swapState, err := newSwapStateFromStart(b, types.Hash{}, true, + swapState, err := newSwapStateFromStart(b, testPeerID, types.Hash{}, true, providedAmt, expectedAmt, exchangeRate, types.EthAssetETH) require.NoError(t, err) return swapState, net @@ -158,7 +164,7 @@ func newTestSwapStateWithERC20(t *testing.T, initialBalance *big.Int) (*swapStat exchangeRate := coins.ToExchangeRate(apd.New(1, 0)) // 100% zeroPiconeros := coins.NewPiconeroAmount(0) - swapState, err := newSwapStateFromStart(b, types.Hash{}, false, + swapState, err := newSwapStateFromStart(b, testPeerID, types.Hash{}, false, coins.IntToWei(1), zeroPiconeros, exchangeRate, types.EthAsset(addr)) require.NoError(t, err) return swapState, contract @@ -334,7 +340,7 @@ func TestExit_afterSendKeysMessage(t *testing.T) { s.nextExpectedEvent = EventKeysReceivedType err := s.Exit() require.NoError(t, err) - info, err := s.SwapManager().GetPastSwap(s.info.ID) + info, err := s.SwapManager().GetPastSwap(s.info.OfferID) require.NoError(t, err) require.Equal(t, types.CompletedAbort, info.Status) } @@ -360,7 +366,7 @@ func TestExit_afterNotifyXMRLock(t *testing.T) { err = s.Exit() require.NoError(t, err) - info, err := s.SwapManager().GetPastSwap(s.info.ID) + info, err := s.SwapManager().GetPastSwap(s.info.OfferID) require.NoError(t, err) require.Equal(t, types.CompletedRefund, info.Status) } @@ -386,7 +392,7 @@ func TestExit_afterNotifyClaimed(t *testing.T) { err = s.Exit() require.NoError(t, err) - info, err := s.SwapManager().GetPastSwap(s.info.ID) + info, err := s.SwapManager().GetPastSwap(s.info.OfferID) require.NoError(t, err) require.Equal(t, types.CompletedRefund, info.Status) } @@ -413,7 +419,7 @@ func TestExit_invalidNextMessageType(t *testing.T) { err = s.Exit() require.True(t, errors.Is(err, errUnexpectedEventType)) - info, err := s.SwapManager().GetPastSwap(s.info.ID) + info, err := s.SwapManager().GetPastSwap(s.info.OfferID) require.NoError(t, err) require.Equal(t, types.CompletedAbort, info.Status) } diff --git a/relayer/claim_request.go b/relayer/claim_request.go index 67efbbd3..188b0a65 100644 --- a/relayer/claim_request.go +++ b/relayer/claim_request.go @@ -11,6 +11,7 @@ import ( ethcommon "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" + logging "github.com/ipfs/go-log" "github.com/athanorlabs/atomic-swap/coins" contracts "github.com/athanorlabs/atomic-swap/ethereum" @@ -18,7 +19,8 @@ import ( ) const ( - relayedClaimGas = 70000 + relayedClaimGas = 70000 // worst case gas usage for the claimRelayer swapFactory call + forwarderClaimGas = 156000 // worst case gas usage when using forwarder to claim ) // FeeWei and FeeEth are the fixed 0.009 ETH fee for using a swap relayer to claim. @@ -27,6 +29,8 @@ var ( FeeEth = coins.NewWeiAmount(FeeWei).AsEther() ) +var log = logging.Logger("relayer") + // CreateRelayClaimRequest fills and returns a RelayClaimRequest ready for // submission to a relayer. func CreateRelayClaimRequest( @@ -53,6 +57,7 @@ func CreateRelayClaimRequest( } return &message.RelayClaimRequest{ + OfferID: nil, // set elsewhere if sending to counterparty SwapFactoryAddress: swapFactoryAddress, Swap: swap, Secret: secret[:], diff --git a/relayer/claim_request_test.go b/relayer/claim_request_test.go index e0f9e13f..3f6f7676 100644 --- a/relayer/claim_request_test.go +++ b/relayer/claim_request_test.go @@ -20,17 +20,26 @@ import ( "github.com/athanorlabs/atomic-swap/tests" ) +// Speed up tests a little by giving deployContracts(...) a package-level cache. +// These variables should not be accessed by other functions. +var _forwarderAddress *ethcommon.Address +var _swapFactoryAddress *ethcommon.Address + // deployContracts deploys and returns the swapFactory and forwarder addresses. func deployContracts(t *testing.T, ec *ethclient.Client, key *ecdsa.PrivateKey) (ethcommon.Address, ethcommon.Address) { ctx := context.Background() - forwarderAddr, err := contracts.DeployGSNForwarderWithKey(ctx, ec, key) - require.NoError(t, err) + if _forwarderAddress == nil || _swapFactoryAddress == nil { + forwarderAddr, err := contracts.DeployGSNForwarderWithKey(ctx, ec, key) + require.NoError(t, err) + _forwarderAddress = &forwarderAddr - swapFactoryAddr, _, err := contracts.DeploySwapFactoryWithKey(ctx, ec, key, forwarderAddr) - require.NoError(t, err) + swapFactoryAddr, _, err := contracts.DeploySwapFactoryWithKey(ctx, ec, key, forwarderAddr) + require.NoError(t, err) + _swapFactoryAddress = &swapFactoryAddr + } - return swapFactoryAddr, forwarderAddr + return *_swapFactoryAddress, *_forwarderAddress } func createTestSwap(claimer ethcommon.Address) *contracts.SwapFactorySwap { diff --git a/relayer/submit_transaction.go b/relayer/submit_transaction.go index e900eaca..b7128b30 100644 --- a/relayer/submit_transaction.go +++ b/relayer/submit_transaction.go @@ -5,11 +5,15 @@ package relayer import ( "context" + "fmt" + "math/big" "github.com/athanorlabs/go-relayer/impls/gsnforwarder" "github.com/ethereum/go-ethereum/accounts/abi/bind" ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/athanorlabs/atomic-swap/coins" + "github.com/athanorlabs/atomic-swap/common" contracts "github.com/athanorlabs/atomic-swap/ethereum" "github.com/athanorlabs/atomic-swap/ethereum/block" "github.com/athanorlabs/atomic-swap/ethereum/extethclient" @@ -57,6 +61,11 @@ func ValidateAndSendTransaction( return nil, err } + gasPrice, err := checkForMinClaimBalance(ctx, ec) + if err != nil { + return nil, err + } + // Lock the wallet's nonce until we get a receipt ec.Lock() defer ec.Unlock() @@ -65,6 +74,7 @@ func ValidateAndSendTransaction( if err != nil { return nil, err } + txOpts.GasPrice = gasPrice tx, err := reqForwarder.Execute( txOpts, @@ -78,10 +88,34 @@ func ValidateAndSendTransaction( return nil, err } - _, err = block.WaitForReceipt(ctx, ec.Raw(), tx.Hash()) + receipt, err := block.WaitForReceipt(ctx, ec.Raw(), tx.Hash()) if err != nil { return nil, err } + log.Infof("relayed claim %s", common.ReceiptInfo(receipt)) + return &message.RelayClaimResponse{TxHash: tx.Hash()}, nil } + +// checkForMinClaimBalance verifies that we have enough gas to relay a claim and +// returns the gas price that was used for the calculation. +func checkForMinClaimBalance(ctx context.Context, ec extethclient.EthClient) (*big.Int, error) { + balance, err := ec.Balance(ctx) + if err != nil { + return nil, err + } + + gasPrice, err := ec.SuggestGasPrice(ctx) + if err != nil { + return nil, err + } + + txCost := new(big.Int).Mul(gasPrice, big.NewInt(forwarderClaimGas)) + if balance.Cmp(txCost) < 0 { + return nil, fmt.Errorf("balance %s ETH is under the minimum %s ETH to relay claim", + coins.FmtWeiAsETH(balance), coins.FmtWeiAsETH(txCost)) + } + + return gasPrice, nil +} diff --git a/relayer/validate.go b/relayer/validate.go index 372400a2..61dcc8d7 100644 --- a/relayer/validate.go +++ b/relayer/validate.go @@ -39,29 +39,35 @@ func validateClaimRequest( // 4. TODO: Validate that the swap exists and is in a claimable state? func validateClaimValues( ctx context.Context, - req *message.RelayClaimRequest, + request *message.RelayClaimRequest, ec *ethclient.Client, ourSwapFactoryAddr ethcommon.Address, ) error { + isTakerRelay := request.OfferID != nil + // Validate the deployed SwapFactory contract, if it is not at the same address // as our own. The CheckSwapFactoryContractCode method validates both the // SwapFactory bytecode and the Forwarder bytecode. - if req.SwapFactoryAddress != ourSwapFactoryAddr { - _, err := contracts.CheckSwapFactoryContractCode(ctx, ec, req.SwapFactoryAddress) + if request.SwapFactoryAddress != ourSwapFactoryAddr { + if isTakerRelay { + return fmt.Errorf("taker claim swap factory mismatch found=%s expected=%s", + request.SwapFactoryAddress, ourSwapFactoryAddr) + } + _, err := contracts.CheckSwapFactoryContractCode(ctx, ec, request.SwapFactoryAddress) if err != nil { return err } } - asset := types.EthAsset(req.Swap.Asset) + asset := types.EthAsset(request.Swap.Asset) if asset != types.EthAssetETH { return fmt.Errorf("relaying for ETH Asset %s is not supported", asset) } // The relayer fee must be strictly less than the swap value - if FeeWei.Cmp(req.Swap.Value) >= 0 { + if FeeWei.Cmp(request.Swap.Value) >= 0 { return fmt.Errorf("swap value of %s ETH is too low to support %s ETH relayer fee", - coins.FmtWeiAsETH(req.Swap.Value), coins.FmtWeiAsETH(FeeWei)) + coins.FmtWeiAsETH(request.Swap.Value), coins.FmtWeiAsETH(FeeWei)) } return nil @@ -72,14 +78,14 @@ func validateClaimValues( func validateClaimSignature( ctx context.Context, ec *ethclient.Client, - req *message.RelayClaimRequest, + request *message.RelayClaimRequest, ) error { callOpts := &bind.CallOpts{ Context: ctx, From: ethcommon.Address{0xFF}, // can be any value but zero, which will validate all signatures } - swapFactory, err := contracts.NewSwapFactory(req.SwapFactoryAddress, ec) + swapFactory, err := contracts.NewSwapFactory(request.SwapFactoryAddress, ec) if err != nil { return err } @@ -94,17 +100,17 @@ func validateClaimSignature( return err } - nonce, err := forwarder.GetNonce(callOpts, req.Swap.Claimer) + nonce, err := forwarder.GetNonce(callOpts, request.Swap.Claimer) if err != nil { return err } - secret := (*[32]byte)(req.Secret) + secret := (*[32]byte)(request.Secret) forwarderRequest, err := createForwarderRequest( nonce, - req.SwapFactoryAddress, - req.Swap, + request.SwapFactoryAddress, + request.Swap, secret, ) if err != nil { @@ -117,7 +123,7 @@ func validateClaimSignature( *domainSeparator, gsnforwarder.ForwardRequestTypehash, nil, - req.Signature, + request.Signature, ) if err != nil { return fmt.Errorf("failed to verify signature: %w", err) diff --git a/relayer/validate_test.go b/relayer/validate_test.go index 123c791c..08ab633e 100644 --- a/relayer/validate_test.go +++ b/relayer/validate_test.go @@ -24,7 +24,7 @@ func TestValidateRelayerFee(t *testing.T) { ctx := context.Background() ec, _ := tests.NewEthClient(t) key := tests.GetTakerTestKey(t) - swapFactoryAddr, forwarderAddr := deployContracts(t, ec, key) + swapFactoryAddr, _ := deployContracts(t, ec, key) type testCase struct { description string @@ -62,15 +62,13 @@ func TestValidateRelayerFee(t *testing.T) { Nonce: new(big.Int), } - var secret [32]byte - request := &message.RelayClaimRequest{ SwapFactoryAddress: swapFactoryAddr, Swap: swap, - Secret: secret[:], + Secret: make([]byte, 32), } - err := validateClaimValues(ctx, request, ec, forwarderAddr) + err := validateClaimValues(ctx, request, ec, swapFactoryAddr) if tc.expectErr != "" { require.ErrorContains(t, err, tc.expectErr, tc.description) } else { @@ -79,6 +77,44 @@ func TestValidateRelayerFee(t *testing.T) { } } +// In the taker claim scenario, we need to fail if the contract address is not +// identical. If the claim has a different address, the swap was not created by +// the taker who is being asked to claim. +func Test_validateClaimValues_takerClaim_contractAddressNotEqualFail(t *testing.T) { + offerID := types.Hash{0x1} // non-nil offer ID passed to indicate taker claim + swapFactoryAddrInClaim := ethcommon.Address{0x1} // address in claim + swapFactoryAddrOurs := ethcommon.Address{0x2} // passed to validateClaimValues + + request := &message.RelayClaimRequest{ + OfferID: &offerID, + SwapFactoryAddress: swapFactoryAddrInClaim, + Secret: make([]byte, 32), + Swap: new(contracts.SwapFactorySwap), // test fails before we validate this + } + + err := validateClaimValues(context.Background(), request, nil, swapFactoryAddrOurs) + require.ErrorContains(t, err, "taker claim swap factory mismatch") +} + +// When validating a claim made to a DHT advertised relayer, the contacts can have +// different addresses, but the claim's contract must be byte-code compatible. This +// tests for failure when it is not byte-code compatible. +func Test_validateClaimValues_dhtClaim_contractAddressNotEqual(t *testing.T) { + ec, _ := tests.NewEthClient(t) + key := tests.GetTakerTestKey(t) + swapFactoryAddr, forwarderAddr := deployContracts(t, ec, key) + + request := &message.RelayClaimRequest{ + OfferID: nil, // DHT relayer claim + SwapFactoryAddress: forwarderAddr, // not a valid swap factory contract + Secret: make([]byte, 32), + Swap: new(contracts.SwapFactorySwap), // test fails before we validate this + } + + err := validateClaimValues(context.Background(), request, ec, swapFactoryAddr) + require.ErrorContains(t, err, "contract address does not contain correct SwapFactory code") +} + func Test_validateSignature(t *testing.T) { ctx := context.Background() ethKey := tests.GetMakerTestKey(t) @@ -120,6 +156,6 @@ func Test_validateClaimRequest(t *testing.T) { // test failure path by passing a non-eth asset asset := ethcommon.Address{0x1} req.Swap.Asset = asset - err = validateClaimRequest(ctx, req, ec, forwarderAddr) + err = validateClaimRequest(ctx, req, ec, swapFactoryAddr) require.ErrorContains(t, err, fmt.Sprintf("relaying for ETH Asset %s is not supported", types.EthAsset(asset))) } diff --git a/rpc/mocks_test.go b/rpc/mocks_test.go index 6c3894d1..392aab26 100644 --- a/rpc/mocks_test.go +++ b/rpc/mocks_test.go @@ -90,6 +90,7 @@ func (*mockSwapManager) GetOngoingSwap(id types.Hash) (swap.Info, error) { one := apd.New(1, 0) return *swap.NewInfo( + testPeerID, id, coins.ProvidesETH, one, @@ -120,7 +121,7 @@ func (*mockXMRTaker) GetOngoingSwapState(_ types.Hash) common.SwapState { return new(mockSwapState) } -func (*mockXMRTaker) InitiateProtocol(_ *apd.Decimal, _ *types.Offer) (common.SwapState, error) { +func (*mockXMRTaker) InitiateProtocol(_ peer.ID, _ *apd.Decimal, _ *types.Offer) (common.SwapState, error) { return new(mockSwapState), nil } @@ -180,7 +181,7 @@ func (*mockSwapState) SendKeysMessage() common.Message { return &message.SendKeysMessage{} } -func (*mockSwapState) ID() types.Hash { +func (*mockSwapState) OfferID() types.Hash { return testSwapID } diff --git a/rpc/net.go b/rpc/net.go index 71eda275..9c882a33 100644 --- a/rpc/net.go +++ b/rpc/net.go @@ -152,11 +152,11 @@ func (s *NetService) TakeOffer( return nil } -func (s *NetService) takeOffer(who peer.ID, offerID types.Hash, providesAmount *apd.Decimal) ( +func (s *NetService) takeOffer(makerPeerID peer.ID, offerID types.Hash, providesAmount *apd.Decimal) ( <-chan types.Status, error, ) { - queryResp, err := s.net.Query(who) + queryResp, err := s.net.Query(makerPeerID) if err != nil { return nil, err } @@ -172,7 +172,7 @@ func (s *NetService) takeOffer(who peer.ID, offerID types.Hash, providesAmount * return nil, errNoOfferWithID } - swapState, err := s.xmrtaker.InitiateProtocol(providesAmount, offer) + swapState, err := s.xmrtaker.InitiateProtocol(makerPeerID, providesAmount, offer) if err != nil { return nil, fmt.Errorf("failed to initiate protocol: %w", err) } @@ -181,7 +181,7 @@ func (s *NetService) takeOffer(who peer.ID, offerID types.Hash, providesAmount * skm.OfferID = offerID skm.ProvidedAmount = providesAmount - if err = s.net.Initiate(peer.AddrInfo{ID: who}, skm, swapState); err != nil { + if err = s.net.Initiate(peer.AddrInfo{ID: makerPeerID}, skm, swapState); err != nil { if err = swapState.Exit(); err != nil { log.Warnf("Swap exit failure: %s", err) } diff --git a/rpc/server.go b/rpc/server.go index cdbe5c8c..0ab5938e 100644 --- a/rpc/server.go +++ b/rpc/server.go @@ -20,6 +20,7 @@ import ( "github.com/gorilla/mux" "github.com/gorilla/rpc/v2" logging "github.com/ipfs/go-log" + "github.com/libp2p/go-libp2p/core/peer" "github.com/athanorlabs/atomic-swap/coins" "github.com/athanorlabs/atomic-swap/common" @@ -175,7 +176,7 @@ type ProtocolBackend interface { // XMRTaker ... type XMRTaker interface { Protocol - InitiateProtocol(providesAmount *apd.Decimal, offer *types.Offer) (common.SwapState, error) + InitiateProtocol(peerID peer.ID, providesAmount *apd.Decimal, offer *types.Offer) (common.SwapState, error) ExternalSender(offerID types.Hash) (*txsender.ExternalSender, error) } diff --git a/rpc/swap.go b/rpc/swap.go index 3425a4f7..06167ef4 100644 --- a/rpc/swap.go +++ b/rpc/swap.go @@ -103,7 +103,7 @@ func (s *SwapService) GetPast(_ *http.Request, req *GetPastRequest, resp *GetPas resp.Swaps = make([]*PastSwap, len(swaps)) for i, info := range swaps { resp.Swaps[i] = &PastSwap{ - ID: info.ID, + ID: info.OfferID, Provided: info.Provides, ProvidedAmount: info.ProvidedAmount, ExpectedAmount: info.ExpectedAmount, @@ -172,7 +172,7 @@ func (s *SwapService) GetOngoing(_ *http.Request, req *GetOngoingRequest, resp * resp.Swaps = make([]*OngoingSwap, len(swaps)) for i, info := range swaps { swap := new(OngoingSwap) - swap.ID = info.ID + swap.ID = info.OfferID swap.Provided = info.Provides swap.ProvidedAmount = info.ProvidedAmount swap.ExpectedAmount = info.ExpectedAmount @@ -184,7 +184,7 @@ func (s *SwapService) GetOngoing(_ *http.Request, req *GetOngoingRequest, resp * swap.Timeout1 = info.Timeout1 swap.EstimatedTimeToCompletion, err = estimatedTimeToCompletion(env, info.Status, info.LastStatusUpdateTime) if err != nil { - return fmt.Errorf("failed to estimate time to completion for swap %s: %w", info.ID, err) + return fmt.Errorf("failed to estimate time to completion for swap %s: %w", info.OfferID, err) } resp.Swaps[i] = swap @@ -287,7 +287,7 @@ func (s *SwapService) Cancel(_ *http.Request, req *CancelRequest, resp *CancelRe s.net.CloseProtocolStream(req.OfferID) - past, err := s.sm.GetPastSwap(info.ID) + past, err := s.sm.GetPastSwap(info.OfferID) if err != nil { return err } diff --git a/scripts/run-unit-tests.sh b/scripts/run-unit-tests.sh index c2058f12..bd72f295 100755 --- a/scripts/run-unit-tests.sh +++ b/scripts/run-unit-tests.sh @@ -11,7 +11,7 @@ start-ganache # run unit tests echo "running unit tests..." rm -f coverage.txt -go test ./... -v -short -timeout=30m -count=1 -covermode=atomic -coverprofile=coverage.txt +go test -coverpkg=./... -v -short -timeout=30m -count=1 -covermode=atomic -coverprofile=coverage.txt ./... OK=$? if [[ -e coverage.txt ]]; then