Files
atomic-swap/protocol/xmrtaker/swap_state_test.go
2023-04-30 17:36:31 -05:00

483 lines
14 KiB
Go

// Copyright 2023 The AthanorLabs/atomic-swap Authors
// SPDX-License-Identifier: LGPL-3.0-only
package xmrtaker
import (
"context"
"errors"
"math/big"
"sync"
"testing"
"time"
"github.com/cockroachdb/apd/v3"
"github.com/ethereum/go-ethereum/accounts/abi/bind"
ethcommon "github.com/ethereum/go-ethereum/common"
"github.com/golang/mock/gomock"
logging "github.com/ipfs/go-log"
"github.com/libp2p/go-libp2p/core/peer"
"github.com/stretchr/testify/require"
"github.com/athanorlabs/atomic-swap/coins"
"github.com/athanorlabs/atomic-swap/common"
"github.com/athanorlabs/atomic-swap/common/types"
mcrypto "github.com/athanorlabs/atomic-swap/crypto/monero"
contracts "github.com/athanorlabs/atomic-swap/ethereum"
"github.com/athanorlabs/atomic-swap/ethereum/extethclient"
"github.com/athanorlabs/atomic-swap/monero"
"github.com/athanorlabs/atomic-swap/net/message"
pcommon "github.com/athanorlabs/atomic-swap/protocol"
"github.com/athanorlabs/atomic-swap/protocol/backend"
pswap "github.com/athanorlabs/atomic-swap/protocol/swap"
"github.com/athanorlabs/atomic-swap/tests"
)
var (
testPeerID, _ = peer.Decode("12D3KooWQQRJuKTZ35eiHGNPGDpQqjpJSdaxEMJRxi6NWFrrvQVi")
)
func init() {
logging.SetLogLevel("xmrtaker", "debug")
logging.SetLogLevel("protocol", "debug")
logging.SetLogLevel("txsender", "debug")
}
type mockNet struct {
msgMu sync.Mutex // lock needed, as SendSwapMessage is called async from timeout handlers
msg common.Message // last value passed to SendSwapMessage
}
func (n *mockNet) LastSentMessage() common.Message {
n.msgMu.Lock()
defer n.msgMu.Unlock()
return n.msg
}
func (n *mockNet) SendSwapMessage(msg common.Message, _ types.Hash) error {
n.msgMu.Lock()
defer n.msgMu.Unlock()
n.msg = msg
return nil
}
func (n *mockNet) DiscoverRelayers() ([]peer.ID, error) {
return nil, nil
}
func (n *mockNet) SubmitClaimToRelayer(_ peer.ID, _ *message.RelayClaimRequest) (*message.RelayClaimResponse, error) {
return new(message.RelayClaimResponse), nil
}
func (n *mockNet) CloseProtocolStream(_ types.Hash) {}
func newSwapManager(t *testing.T) pswap.Manager {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
db := pswap.NewMockDatabase(ctrl)
db.EXPECT().GetAllSwaps()
db.EXPECT().PutSwap(gomock.Any()).AnyTimes()
sm, err := pswap.NewManager(db)
require.NoError(t, err)
return sm
}
func newBackendAndNet(t *testing.T) (backend.Backend, *mockNet) {
pk := tests.GetTakerTestKey(t)
ec := extethclient.CreateTestClient(t, pk)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(func() {
cancel()
})
txOpts, err := bind.NewKeyedTransactorWithChainID(pk, ec.ChainID())
require.NoError(t, err)
var forwarderAddr ethcommon.Address
_, tx, _, err := contracts.DeploySwapCreator(txOpts, ec.Raw(), forwarderAddr)
require.NoError(t, err)
addr, err := bind.WaitDeployed(ctx, ec.Raw(), tx)
require.NoError(t, err)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
rdb := backend.NewMockRecoveryDB(ctrl)
rdb.EXPECT().PutContractSwapInfo(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
rdb.EXPECT().PutSwapPrivateKey(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
rdb.EXPECT().PutCounterpartySwapPrivateKey(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
rdb.EXPECT().PutCounterpartySwapKeys(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
rdb.EXPECT().DeleteSwap(gomock.Any()).Return(nil).AnyTimes()
net := new(mockNet)
bcfg := &backend.Config{
Ctx: ctx,
MoneroClient: monero.CreateWalletClient(t),
EthereumClient: ec,
Environment: common.Development,
SwapManager: newSwapManager(t),
SwapCreatorAddr: addr,
Net: net,
RecoveryDB: rdb,
}
b, err := backend.NewBackend(bcfg)
require.NoError(t, err)
return b, net
}
func newBackend(t *testing.T) backend.Backend {
b, _ := newBackendAndNet(t)
return b
}
func newTestSwapStateAndNet(t *testing.T) (*swapState, *mockNet) {
b, net := newBackendAndNet(t)
providedAmt := coins.EtherToWei(coins.StrToDecimal("1"))
exchangeRate := coins.ToExchangeRate(coins.StrToDecimal("1.0")) // 100%
swapState, err := newSwapStateFromStart(b, testPeerID, types.Hash{}, true,
providedAmt, exchangeRate, types.EthAssetETH)
require.NoError(t, err)
return swapState, net
}
func newTestSwapState(t *testing.T) *swapState {
s, _ := newTestSwapStateAndNet(t)
return s
}
func newTestSwapStateWithERC20(t *testing.T, providesAmt *apd.Decimal) (*swapState, *contracts.TestERC20) {
b := newBackend(t)
const numDecimals = 13
// Increase the provided amount to token units, by adding to the exponent
providesAmtTokenUnits := apd.NewWithBigInt(&providesAmt.Coeff, providesAmt.Exponent+numDecimals)
require.Positive(t, providesAmtTokenUnits.Exponent)
// Set the exponent to zero pushing everything into the coefficient
_, err := coins.DecimalCtx().Quantize(providesAmtTokenUnits, providesAmtTokenUnits, 0)
if err != nil {
panic(err)
}
// Now that everything is in the coefficient, we can convert to big.Int
providesAmtTokenUnitsBI := new(big.Int).SetBytes(providesAmtTokenUnits.Coeff.Bytes())
txOpts, err := b.ETHClient().TxOpts(b.Ctx())
require.NoError(t, err)
_, tx, contract, err := contracts.DeployTestERC20(
txOpts,
b.ETHClient().Raw(),
"☢☣☠\a Obnoxious Token \a☠☣☢",
"\a☢\n☣\a☠", // ensure we escape this
13,
b.ETHClient().Address(),
providesAmtTokenUnitsBI,
)
require.NoError(t, err)
addr, err := bind.WaitDeployed(b.Ctx(), b.ETHClient().Raw(), tx)
require.NoError(t, err)
tokenInfo, err := b.ETHClient().ERC20Info(b.Ctx(), addr)
require.NoError(t, err)
providesEthAssetAmt := coins.NewERC20TokenAmountFromDecimals(providesAmt, tokenInfo)
exchangeRate := coins.ToExchangeRate(apd.New(1, 0)) // 100%
swapState, err := newSwapStateFromStart(b, testPeerID, types.Hash{}, false,
providesEthAssetAmt, exchangeRate, types.EthAsset(addr))
require.NoError(t, err)
return swapState, contract
}
func newTestXMRMakerSendKeysMessage(t *testing.T) (*message.SendKeysMessage, *pcommon.KeysAndProof) {
keysAndProof, err := pcommon.GenerateKeysAndProof()
require.NoError(t, err)
msg := &message.SendKeysMessage{
PublicSpendKey: keysAndProof.PublicKeyPair.SpendKey(),
PrivateViewKey: keysAndProof.PrivateKeyPair.ViewKey(),
DLEqProof: keysAndProof.DLEqProof.Proof(),
Secp256k1PublicKey: keysAndProof.Secp256k1PublicKey,
EthAddress: ethcommon.Address{0x1},
ProvidedAmount: apd.New(1, 0),
}
return msg, keysAndProof
}
func TestSwapState_HandleProtocolMessage_SendKeysMessage(t *testing.T) {
s, net := newTestSwapStateAndNet(t)
defer s.cancel()
msg := &message.SendKeysMessage{}
err := s.HandleProtocolMessage(msg)
require.True(t, errors.Is(err, errMissingProvidedAmount))
msg, xmrmakerKeysAndProof := newTestXMRMakerSendKeysMessage(t)
err = s.HandleProtocolMessage(msg)
require.NoError(t, err)
resp := net.LastSentMessage()
require.NotNil(t, resp)
require.Equal(t, s.SwapTimeout(), s.t1.Sub(s.t0))
require.Equal(t, xmrmakerKeysAndProof.PublicKeyPair.SpendKey().String(), s.xmrmakerPublicSpendKey.String())
require.Equal(t, xmrmakerKeysAndProof.PrivateKeyPair.ViewKey().String(), s.xmrmakerPrivateViewKey.String())
}
// test the case where XMRTaker deploys and locks her eth, but XMRMaker never locks his monero.
// XMRTaker should call refund before the timeout t0.
func TestSwapState_HandleProtocolMessage_SendKeysMessage_Refund(t *testing.T) {
s, net := newTestSwapStateAndNet(t)
defer s.cancel()
s.SetSwapTimeout(time.Second * 15)
msg, xmrmakerKeysAndProof := newTestXMRMakerSendKeysMessage(t)
err := s.HandleProtocolMessage(msg)
require.NoError(t, err)
resp := net.LastSentMessage()
require.NotNil(t, resp)
require.Equal(t, message.NotifyETHLockedType, resp.Type())
require.Equal(t, s.SwapTimeout(), s.t1.Sub(s.t0))
require.Equal(t, xmrmakerKeysAndProof.PublicKeyPair.SpendKey().String(), s.xmrmakerPublicSpendKey.String())
require.Equal(t, xmrmakerKeysAndProof.PrivateKeyPair.ViewKey().String(), s.xmrmakerPrivateViewKey.String())
// ensure we refund before t0
for status := range s.info.StatusCh() {
if status == types.CompletedRefund {
// check this is before t0
// TODO: remove the 10-second buffer, this is needed for now
// because the exact refund time isn't stored, and the time
// between the refund happening and this line being called
// causes it to fail
require.Greater(t, s.t0.Add(time.Second*10), time.Now())
break
} else if !status.IsOngoing() {
t.Fatalf("got wrong exit status %s, expected CompletedRefund", status)
}
}
// check swap is marked completed
stage, err := s.SwapCreator().Swaps(nil, s.contractSwapID)
require.NoError(t, err)
require.Equal(t, contracts.StageCompleted, stage)
}
func lockXMRFunds(
t *testing.T,
ctx context.Context, //nolint:revive
wc monero.WalletClient,
destAddr *mcrypto.Address,
amount *coins.PiconeroAmount,
) {
monero.MineMinXMRBalance(t, wc, amount)
_, err := wc.Transfer(ctx, destAddr, 0, amount, monero.MinSpendConfirmations)
require.NoError(t, err)
}
func TestSwapState_NotifyXMRLock(t *testing.T) {
s := newTestSwapState(t)
defer s.cancel()
s.nextExpectedEvent = EventXMRLockedType
xmrmakerKeysAndProof, err := generateKeys()
require.NoError(t, err)
err = s.setXMRMakerKeys(
xmrmakerKeysAndProof.PublicKeyPair.SpendKey(),
xmrmakerKeysAndProof.PrivateKeyPair.ViewKey(),
xmrmakerKeysAndProof.Secp256k1PublicKey,
)
require.NoError(t, err)
s.xmrmakerAddress = fakeAddress
_, err = s.lockAsset()
require.NoError(t, err)
kp := mcrypto.SumSpendAndViewKeys(xmrmakerKeysAndProof.PublicKeyPair, s.pubkeys)
xmrAddr := kp.Address(common.Development)
lockXMRFunds(t, s.ctx, s.XMRClient(), xmrAddr, s.expectedPiconeroAmount())
event := newEventXMRLocked()
s.eventCh <- event
err = <-event.errCh
require.NoError(t, err)
require.Equal(t, EventETHClaimedType, s.nextExpectedEvent)
}
// test the case where the monero is locked, but XMRMaker never claims.
// XMRTaker should call refund after the timeout t1.
func TestSwapState_NotifyXMRLock_Refund(t *testing.T) {
s := newTestSwapState(t)
defer s.cancel()
s.nextExpectedEvent = EventXMRLockedType
s.SetSwapTimeout(time.Second * 3)
xmrmakerKeysAndProof, err := generateKeys()
require.NoError(t, err)
err = s.setXMRMakerKeys(
xmrmakerKeysAndProof.PublicKeyPair.SpendKey(),
xmrmakerKeysAndProof.PrivateKeyPair.ViewKey(),
xmrmakerKeysAndProof.Secp256k1PublicKey,
)
require.NoError(t, err)
s.xmrmakerAddress = fakeAddress
_, err = s.lockAsset()
require.NoError(t, err)
kp := mcrypto.SumSpendAndViewKeys(xmrmakerKeysAndProof.PublicKeyPair, s.pubkeys)
xmrAddr := kp.Address(common.Development)
lockXMRFunds(t, s.ctx, s.XMRClient(), xmrAddr, s.expectedPiconeroAmount())
event := newEventXMRLocked()
s.eventCh <- event
err = <-event.errCh
require.NoError(t, err)
require.Equal(t, EventETHClaimedType, s.nextExpectedEvent)
for status := range s.info.StatusCh() {
if status == types.CompletedRefund {
// check this is after t1
require.Less(t, s.t1, time.Now())
break
} else if !status.IsOngoing() {
t.Fatalf("got wrong exit status %s, expected CompletedRefund", status)
}
}
// check balance of contract is 0
balance, err := s.ETHClient().Raw().BalanceAt(context.Background(), s.SwapCreatorAddr(), nil)
require.NoError(t, err)
require.Equal(t, uint64(0), balance.Uint64())
}
func TestExit_afterSendKeysMessage(t *testing.T) {
s := newTestSwapState(t)
defer s.cancel()
s.nextExpectedEvent = EventKeysReceivedType
err := s.Exit()
require.NoError(t, err)
info, err := s.SwapManager().GetPastSwap(s.info.OfferID)
require.NoError(t, err)
require.Equal(t, types.CompletedAbort, info.Status)
}
func TestExit_afterNotifyXMRLock(t *testing.T) {
s := newTestSwapState(t)
defer s.cancel()
s.nextExpectedEvent = EventXMRLockedType
xmrmakerKeysAndProof, err := generateKeys()
require.NoError(t, err)
err = s.setXMRMakerKeys(
xmrmakerKeysAndProof.PublicKeyPair.SpendKey(),
xmrmakerKeysAndProof.PrivateKeyPair.ViewKey(),
xmrmakerKeysAndProof.Secp256k1PublicKey,
)
require.NoError(t, err)
s.xmrmakerAddress = fakeAddress
_, err = s.lockAsset()
require.NoError(t, err)
err = s.Exit()
require.NoError(t, err)
info, err := s.SwapManager().GetPastSwap(s.info.OfferID)
require.NoError(t, err)
require.Equal(t, types.CompletedRefund, info.Status)
}
func TestExit_afterNotifyClaimed(t *testing.T) {
s := newTestSwapState(t)
defer s.cancel()
s.nextExpectedEvent = EventETHClaimedType
xmrmakerKeysAndProof, err := generateKeys()
require.NoError(t, err)
err = s.setXMRMakerKeys(
xmrmakerKeysAndProof.PublicKeyPair.SpendKey(),
xmrmakerKeysAndProof.PrivateKeyPair.ViewKey(),
xmrmakerKeysAndProof.Secp256k1PublicKey,
)
require.NoError(t, err)
s.xmrmakerAddress = fakeAddress
_, err = s.lockAsset()
require.NoError(t, err)
err = s.Exit()
require.NoError(t, err)
info, err := s.SwapManager().GetPastSwap(s.info.OfferID)
require.NoError(t, err)
require.Equal(t, types.CompletedRefund, info.Status)
}
func TestExit_invalidNextMessageType(t *testing.T) {
// this case shouldn't ever really happen
s := newTestSwapState(t)
defer s.cancel()
s.nextExpectedEvent = EventExitType
xmrmakerKeysAndProof, err := generateKeys()
require.NoError(t, err)
err = s.setXMRMakerKeys(
xmrmakerKeysAndProof.PublicKeyPair.SpendKey(),
xmrmakerKeysAndProof.PrivateKeyPair.ViewKey(),
xmrmakerKeysAndProof.Secp256k1PublicKey,
)
require.NoError(t, err)
s.xmrmakerAddress = fakeAddress
_, err = s.lockAsset()
require.NoError(t, err)
err = s.Exit()
require.True(t, errors.Is(err, errUnexpectedEventType))
info, err := s.SwapManager().GetPastSwap(s.info.OfferID)
require.NoError(t, err)
require.Equal(t, types.CompletedAbort, info.Status)
}
func TestSwapState_ApproveToken(t *testing.T) {
const expectedAmtStr = "5678"
providesAmt := coins.StrToDecimal(expectedAmtStr)
s, contract := newTestSwapStateWithERC20(t, providesAmt)
xmrmakerKeysAndProof, err := generateKeys()
require.NoError(t, err)
err = s.setXMRMakerKeys(
xmrmakerKeysAndProof.PublicKeyPair.SpendKey(),
xmrmakerKeysAndProof.PrivateKeyPair.ViewKey(),
xmrmakerKeysAndProof.Secp256k1PublicKey,
)
require.NoError(t, err)
s.xmrmakerAddress = fakeAddress
// approve is called by NewSwap() in lockAsset()
_, err = s.lockAsset()
require.NoError(t, err)
// Now that the tokens are locked in the contract, validate that
// the contract is no longer approved to transfer additional tokens
// from us.
callOpts := &bind.CallOpts{Context: s.ctx}
allowance, err := contract.Allowance(callOpts, s.ETHClient().Address(), s.SwapCreatorAddr())
require.NoError(t, err)
require.Equal(t, "0", allowance.String())
}