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

704 lines
20 KiB
Go

// Copyright 2023 The AthanorLabs/atomic-swap Authors
// SPDX-License-Identifier: LGPL-3.0-only
// Package xmrtaker manages the swap state of individual swaps where the local swapd
// instance is offering Ethereum assets and accepting Monero in return.
package xmrtaker
import (
"context"
"errors"
"fmt"
"math/big"
"strings"
"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"
"github.com/athanorlabs/atomic-swap/common/types"
mcrypto "github.com/athanorlabs/atomic-swap/crypto/monero"
"github.com/athanorlabs/atomic-swap/crypto/secp256k1"
"github.com/athanorlabs/atomic-swap/db"
"github.com/athanorlabs/atomic-swap/dleq"
contracts "github.com/athanorlabs/atomic-swap/ethereum"
"github.com/athanorlabs/atomic-swap/ethereum/watcher"
"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/protocol/txsender"
ethcommon "github.com/ethereum/go-ethereum/common"
ethtypes "github.com/ethereum/go-ethereum/core/types"
"github.com/fatih/color"
)
const revertSwapCompleted = "swap is already completed"
var claimedTopic = common.GetTopic(common.ClaimedEventSignature)
// swapState is an instance of a swap. it holds the info needed for the swap,
// and its current state.
type swapState struct {
backend.Backend
sender txsender.Sender
ctx context.Context
cancel context.CancelFunc
noTransferBack bool
info *pswap.Info
providedAmount coins.EthAssetAmount
// our keys for this session
dleqProof *dleq.Proof
secp256k1Pub *secp256k1.PublicKey
privkeys *mcrypto.PrivateKeyPair
pubkeys *mcrypto.PublicKeyPair
// XMRMaker's keys for this session
xmrmakerPublicSpendKey *mcrypto.PublicKey
xmrmakerPrivateViewKey *mcrypto.PrivateViewKey
xmrmakerSecp256k1PublicKey *secp256k1.PublicKey
xmrmakerAddress ethcommon.Address
// block height at start of swap used for fast wallet creation
walletScanHeight uint64
// swap contract and timeouts in it; set once contract is deployed
contractSwapID [32]byte
contractSwap *contracts.SwapCreatorSwap
t0, t1 time.Time
// tracks the state of the swap
nextExpectedEvent EventType
// set to true once funds are locked
fundsLocked bool
// channels
// channel for swap events
// the event handler in event.go ensures only one event is being handled at a time
eventCh chan Event
// channel for `Claimed` logs seen on-chain
logClaimedCh chan ethtypes.Log
// signals the t0 expiration handler to return
xmrLockedCh chan struct{}
// signals the t1 expiration handler to return
claimedCh chan struct{}
// signals to the creator xmrmaker instance that it can delete this swap
done chan struct{}
}
func newSwapStateFromStart(
b backend.Backend,
makerPeerID peer.ID,
offerID types.Hash,
noTransferBack bool,
providedAmount coins.EthAssetAmount,
exchangeRate *coins.ExchangeRate,
ethAsset types.EthAsset,
) (*swapState, error) {
stage := types.ExpectingKeys
statusCh := make(chan types.Status, 16)
moneroStartNumber, err := b.XMRClient().GetHeight()
if err != nil {
return nil, err
}
// reduce the scan height a little in case there is a block reorg
if moneroStartNumber >= monero.MinSpendConfirmations {
moneroStartNumber -= monero.MinSpendConfirmations
}
ethHeader, err := b.ETHClient().Raw().HeaderByNumber(b.Ctx(), nil)
if err != nil {
return nil, err
}
expectedAmount, err := exchangeRate.ToXMR(providedAmount.AsStandard())
if err != nil {
return nil, err
}
info := pswap.NewInfo(
makerPeerID,
offerID,
coins.ProvidesETH,
providedAmount.AsStandard(),
expectedAmount,
exchangeRate,
ethAsset,
stage,
moneroStartNumber,
statusCh,
)
if err = b.SwapManager().AddSwap(info); err != nil {
return nil, err
}
s, err := newSwapState(
b,
noTransferBack,
info,
ethHeader.Number,
moneroStartNumber,
)
if err != nil {
return nil, err
}
if err := s.generateAndSetKeys(); err != nil {
return nil, err
}
statusCh <- stage
return s, nil
}
func newSwapStateFromOngoing(
b backend.Backend,
info *pswap.Info,
noTransferBack bool,
ethSwapInfo *db.EthereumSwapInfo,
sk *mcrypto.PrivateKeyPair,
) (*swapState, error) {
if info.Status != types.ETHLocked && info.Status != types.ContractReady {
return nil, errInvalidStageForRecovery
}
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)
}
s, err := newSwapState(
b,
noTransferBack,
info,
ethSwapInfo.StartNumber,
info.MoneroStartHeight,
)
if err != nil {
return nil, err
}
if b.SwapCreatorAddr() != ethSwapInfo.SwapCreatorAddr {
return nil, errContractAddrMismatch(ethSwapInfo.SwapCreatorAddr.String())
}
s.setTimeouts(ethSwapInfo.Swap.Timeout0, ethSwapInfo.Swap.Timeout1)
s.privkeys = sk
s.pubkeys = sk.PublicKeyPair()
s.contractSwapID = ethSwapInfo.SwapID
s.contractSwap = ethSwapInfo.Swap
s.xmrmakerPublicSpendKey = makerSk
s.xmrmakerPrivateViewKey = makerVk
if info.Status == types.ETHLocked {
go s.checkForXMRLock()
}
return s, nil
}
func newSwapState(
b backend.Backend,
noTransferBack bool,
info *pswap.Info,
ethStartNumber *big.Int,
moneroStartNumber uint64,
) (*swapState, error) {
// If the user specified `--external-signer=true` (no private eth key in the
// client) and explicitly set `--no-transfer-back`, we override their
// decision and set it back to `false`, because an external signer (UI) must
// be used, which will prompt the user to set their XMR address for funds to
// be transferred-back to.
if !b.ETHClient().HasPrivateKey() {
noTransferBack = false // front-end must set final deposit address
}
var sender txsender.Sender
if info.EthAsset.IsToken() {
erc20Contract, err := contracts.NewIERC20(info.EthAsset.Address(), b.ETHClient().Raw())
if err != nil {
return nil, err
}
sender, err = b.NewTxSender(info.EthAsset.Address(), erc20Contract)
if err != nil {
return nil, err
}
} else {
var err error
sender, err = b.NewTxSender(info.EthAsset.Address(), nil)
if err != nil {
return nil, err
}
}
// set up ethereum event watchers
const logChSize = 16
logClaimedCh := make(chan ethtypes.Log, logChSize)
ctx, cancel := context.WithCancel(b.Ctx())
claimedWatcher := watcher.NewEventFilter(
ctx,
b.ETHClient().Raw(),
b.SwapCreatorAddr(),
ethStartNumber,
claimedTopic,
logClaimedCh,
)
err := claimedWatcher.Start()
if err != nil {
cancel()
return nil, err
}
var providedAmt coins.EthAssetAmount
if info.EthAsset.IsETH() {
providedAmt = coins.EtherToWei(info.ProvidedAmount)
} else {
tokenInfo, err := b.ETHClient().ERC20Info(b.Ctx(), info.EthAsset.Address())
if err != nil {
cancel()
return nil, err
}
providedAmt = coins.NewERC20TokenAmountFromDecimals(info.ProvidedAmount, tokenInfo)
}
// note: if this is recovering an ongoing swap, this will only
// be invoked if our status is ETHLocked or ContractReady; ie.
// we've locked ETH, but not yet claimed or refunded.
//
// dleqProof and secp256k1Pub are never set, as they are only used
// in the swap step before or where ETH is locked.
//
// similarly, xmrmaker secp256k1 public keys and ETH address are also
// never set, as they're only used in the ETH lock step.
s := &swapState{
ctx: ctx,
cancel: cancel,
Backend: b,
sender: sender,
noTransferBack: noTransferBack,
walletScanHeight: moneroStartNumber,
nextExpectedEvent: nextExpectedEventFromStatus(info.Status),
eventCh: make(chan Event),
logClaimedCh: logClaimedCh,
xmrLockedCh: make(chan struct{}),
claimedCh: make(chan struct{}),
done: make(chan struct{}),
info: info,
providedAmount: providedAmt,
}
go s.runHandleEvents()
go s.runContractEventWatcher()
return s, nil
}
// SendKeysMessage ...
func (s *swapState) SendKeysMessage() common.Message {
return &message.SendKeysMessage{
PublicSpendKey: s.pubkeys.SpendKey(),
PrivateViewKey: s.privkeys.ViewKey(),
DLEqProof: s.dleqProof.Proof(),
Secp256k1PublicKey: s.secp256k1Pub,
}
}
// ExpectedAmount returns the amount received, or expected to be received, at the end of the swap
func (s *swapState) ExpectedAmount() *apd.Decimal {
return s.info.ExpectedAmount
}
func (s *swapState) expectedPiconeroAmount() *coins.PiconeroAmount {
return coins.MoneroToPiconero(s.info.ExpectedAmount)
}
// 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.
// It exists the swap by refunding if necessary. If no locking has been done, it simply aborts the swap.
// If the swap already completed successfully, this function does not do anything regarding the protocol.
func (s *swapState) Exit() error {
event := newEventExit()
s.eventCh <- event
return <-event.errCh
}
// exit is the same as Exit, but assumes the calling code block already holds the swapState lock.
func (s *swapState) exit() error {
defer func() {
s.CloseProtocolStream(s.OfferID())
err := s.SwapManager().CompleteOngoingSwap(s.info)
if err != nil {
log.Warnf("failed to mark swap %s as completed: %s", s.info.OfferID, err)
return
}
err = s.Backend.RecoveryDB().DeleteSwap(s.OfferID())
if err != nil {
log.Warnf("failed to delete temporary swap info %s from db: %s", s.OfferID(), err)
}
// Stop all per-swap goroutines
s.cancel()
close(s.done)
var exitLog string
switch s.info.Status {
case types.CompletedSuccess:
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: offerID=%s**", s.OfferID())
case types.CompletedAbort:
exitLog = color.New(color.Bold).Sprintf("**swap aborted: id=%s**", s.OfferID())
}
log.Info(exitLog)
}()
log.Debugf("attempting to exit swap: nextExpectedEvent=%s", s.nextExpectedEvent)
switch s.nextExpectedEvent {
case EventKeysReceivedType:
// we are fine, as we only just initiated the protocol.
s.clearNextExpectedEvent(types.CompletedAbort)
return nil
case EventXMRLockedType, EventETHClaimedType:
// for EventXMRLocked, we already locked our ETH-asset,
// so we should call Refund().
//
// 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.
receipt, err := s.tryRefund()
if err != nil {
if errors.Is(err, errRefundSwapCompleted) {
s.clearNextExpectedEvent(types.CompletedRefund)
log.Infof("swap was already refunded")
return nil
}
if strings.Contains(err.Error(), revertSwapCompleted) {
// note: this should NOT ever error; it could if the ethclient
// or monero clients crash during the course of the claim,
// but that would be very bad.
err = s.tryClaim()
if err != nil {
return fmt.Errorf("failed to claim even though swap was completed on-chain: %w", err)
}
}
return fmt.Errorf("failed to refund: %w", err)
}
s.clearNextExpectedEvent(types.CompletedRefund)
log.Infof("refunded ether: txID=%s", receipt.TxHash)
return nil
case EventNoneType:
// the swap completed already, do nothing
return nil
default:
log.Errorf("unexpected nextExpectedEvent: %s", s.nextExpectedEvent)
s.clearNextExpectedEvent(types.CompletedAbort)
return errUnexpectedEventType
}
}
func (s *swapState) tryRefund() (*ethtypes.Receipt, error) {
stage, err := s.SwapCreator().Swaps(s.ETHClient().CallOpts(s.ctx), s.contractSwapID)
if err != nil {
return nil, err
}
switch stage {
case contracts.StageInvalid:
return nil, fmt.Errorf("%w: contract swap ID: %s", errRefundInvalid, s.contractSwapID)
case contracts.StageCompleted:
return nil, errRefundSwapCompleted
case contracts.StagePending, contracts.StageReady:
// do nothing
default:
panic("Unhandled stage value")
}
isReady := stage == contracts.StageReady
ts, err := s.ETHClient().LatestBlockTimestamp(s.ctx)
if err != nil {
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 {
receipt, err := s.refund() //nolint:govet
// TODO: Have refund() return errors that we can use errors.Is to check against
if err == nil {
return receipt, nil
}
// There is a small, but non-zero chance that our transaction gets placed in a block that is after T0
// even though the current block is before T0. In this case, the transaction will be reverted, the
// gas fee is lost, but we can wait until T1 and try again.
log.Warnf("first refund attempt failed: err=%s", err)
}
if ts.After(s.t1) {
return s.refund()
}
// the contract is "ready", so we can't do anything until
// the counterparty claims or until t1 passes.
//
// we let the runT1ExpirationHandler() routine continue to run and read
// from s.eventCh for EventShouldRefund or EventETHClaimed.
// (since this function is called from inside the event handler routine,
// it won't handle those events while this function is executing.)
log.Infof("waiting until time %s to refund", s.t1)
waitCtx, waitCtxCancel := context.WithCancel(s.ctx)
defer waitCtxCancel()
waitCh := make(chan error)
go func() {
waitCh <- s.ETHClient().WaitForTimestamp(waitCtx, s.t1)
close(waitCh)
}()
for {
select {
case event := <-s.eventCh:
log.Debugf("got event %s while waiting for T1", event.Type())
switch event.(type) {
case *EventShouldRefund:
return s.refund()
case *EventETHClaimed:
// we should claim; returning this error
// causes the calling function to claim
return nil, fmt.Errorf(revertSwapCompleted)
case *EventExit:
// do nothing, we're already exiting
default:
panic(fmt.Sprintf("got unexpected event while waiting for Claimed/T1: %s", event.Type()))
}
case err = <-waitCh:
if err != nil {
return nil, fmt.Errorf("failed to wait for T1: %w", err)
}
return s.refund()
}
}
}
func (s *swapState) setTimeouts(t0, t1 *big.Int) {
s.t0 = time.Unix(t0.Int64(), 0)
s.t1 = time.Unix(t1.Int64(), 0)
s.info.Timeout0 = &s.t0
s.info.Timeout1 = &s.t1
}
func (s *swapState) generateAndSetKeys() error {
if s.privkeys != nil {
panic("generateAndSetKeys should only be called once")
}
keysAndProof, err := generateKeys()
if err != nil {
return err
}
s.dleqProof = keysAndProof.DLEqProof
s.secp256k1Pub = keysAndProof.Secp256k1PublicKey
s.privkeys = keysAndProof.PrivateKeyPair
s.pubkeys = keysAndProof.PublicKeyPair
return s.Backend.RecoveryDB().PutSwapPrivateKey(s.OfferID(), s.privkeys.SpendKey())
}
// getSecret secrets returns the current secret scalar used to unlock funds from the contract.
func (s *swapState) getSecret() [32]byte {
secret := s.dleqProof.Secret()
var sc [32]byte
copy(sc[:], secret[:])
return sc
}
// setXMRMakerKeys sets XMRMaker's public spend key (to be stored in the contract) and XMRMaker's
// private view key (used to check XMR balance before calling Ready())
func (s *swapState) setXMRMakerKeys(
sk *mcrypto.PublicKey,
vk *mcrypto.PrivateViewKey,
secp256k1Pub *secp256k1.PublicKey,
) error {
s.xmrmakerPublicSpendKey = sk
s.xmrmakerPrivateViewKey = vk
s.xmrmakerSecp256k1PublicKey = secp256k1Pub
return s.Backend.RecoveryDB().PutCounterpartySwapKeys(s.info.OfferID, sk, vk)
}
// lockAsset calls the Swap contract function new_swap and locks `amount` ether in it.
func (s *swapState) lockAsset() (*ethtypes.Receipt, error) {
if s.xmrmakerPublicSpendKey == nil || s.xmrmakerPrivateViewKey == nil {
panic(errCounterpartyKeysNotSet)
}
cmtXMRTaker := s.secp256k1Pub.Keccak256()
cmtXMRMaker := s.xmrmakerSecp256k1PublicKey.Keccak256()
providedAmt := s.providedAmount
log.Debugf("locking %s %s in contract", providedAmt.AsStandard(), providedAmt.StandardSymbol())
nonce := contracts.GenerateNewSwapNonce()
receipt, err := s.sender.NewSwap(
cmtXMRMaker,
cmtXMRTaker,
s.xmrmakerAddress,
big.NewInt(int64(s.SwapTimeout().Seconds())),
nonce,
providedAmt,
)
if err != nil {
return nil, fmt.Errorf("failed to instantiate swap on-chain: %w", err)
}
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 nil, errSwapInstantiationNoLogs
}
for _, rLog := range receipt.Logs {
s.contractSwapID, err = contracts.GetIDFromLog(rLog)
if err == nil {
break
}
}
if err != nil {
return nil, fmt.Errorf("swap ID not found in transaction receipt's logs: %w", err)
}
var t0 *big.Int
var t1 *big.Int
for _, log := range receipt.Logs {
t0, t1, err = contracts.GetTimeoutsFromLog(log)
if err == nil {
break
}
}
if err != nil {
return nil, fmt.Errorf("timeouts not found in transaction receipt's logs: %w", err)
}
s.fundsLocked = true
s.setTimeouts(t0, t1)
s.contractSwap = &contracts.SwapCreatorSwap{
Owner: s.ETHClient().Address(),
Claimer: s.xmrmakerAddress,
PubKeyClaim: cmtXMRMaker,
PubKeyRefund: cmtXMRTaker,
Timeout0: t0,
Timeout1: t1,
Asset: ethcommon.Address(s.info.EthAsset),
Value: s.providedAmount.BigInt(),
Nonce: nonce,
}
ethInfo := &db.EthereumSwapInfo{
StartNumber: receipt.BlockNumber,
SwapID: s.contractSwapID,
Swap: s.contractSwap,
SwapCreatorAddr: s.Backend.SwapCreatorAddr(),
}
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", providedAmt.StandardSymbol())
return receipt, nil
}
// ready calls the Ready() method on the Swap contract, indicating to XMRMaker he has until time t_1 to
// call Claim(). Ready() should only be called once XMRTaker sees XMRMaker lock his XMR.
// If time t_0 has passed, there is no point of calling Ready().
func (s *swapState) ready() error {
stage, err := s.SwapCreator().Swaps(s.ETHClient().CallOpts(s.ctx), s.contractSwapID)
if err != nil {
return err
}
if stage != contracts.StagePending {
if stage == contracts.StageReady {
log.Warnf("contract already set to ready, ignoring call to ready()")
return nil
}
if stage == contracts.StageCompleted {
log.Infof("contract aleady set to completed, ignoring call to ready() and sending EventExit")
go func() {
err = s.Exit()
if err != nil {
log.Errorf("failed to handle EventExit: %s", err)
}
}()
return nil
}
return fmt.Errorf("cannot set contract to ready when swap stage is %s", contracts.StageToString(stage))
}
receipt, err := s.sender.SetReady(s.contractSwap)
if err != nil {
if strings.Contains(err.Error(), revertSwapCompleted) && !s.info.Status.IsOngoing() {
return nil
}
return err
}
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() (*ethtypes.Receipt, error) {
sc := s.getSecret()
log.Infof("attempting to call Refund()...")
receipt, err := s.sender.Refund(s.contractSwap, sc)
if err != nil {
return nil, err
}
log.Infof("refund succeeded %s", common.ReceiptInfo(receipt))
s.clearNextExpectedEvent(types.CompletedRefund)
return receipt, nil
}
// generateKeys generates XMRTaker's monero spend and view keys (S_b, V_b), a secp256k1 public key,
// and a DLEq proof proving that the two keys correspond.
func generateKeys() (*pcommon.KeysAndProof, error) {
return pcommon.GenerateKeysAndProof()
}