mirror of
https://github.com/AthanorLabs/atomic-swap.git
synced 2026-04-22 03:00:02 -04:00
490 lines
14 KiB
Go
490 lines
14 KiB
Go
// Copyright 2023 The AthanorLabs/atomic-swap Authors
|
|
// SPDX-License-Identifier: LGPL-3.0-only
|
|
|
|
package xmrtaker
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"sync"
|
|
|
|
"github.com/ChainSafe/chaindb"
|
|
"github.com/ethereum/go-ethereum"
|
|
ethcommon "github.com/ethereum/go-ethereum/common"
|
|
ethtypes "github.com/ethereum/go-ethereum/core/types"
|
|
"github.com/ethereum/go-ethereum/ethclient"
|
|
logging "github.com/ipfs/go-log/v2"
|
|
|
|
"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/block"
|
|
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"
|
|
)
|
|
|
|
var (
|
|
log = logging.Logger("xmrtaker")
|
|
)
|
|
|
|
// Instance implements the functionality that will be used by a user who owns ETH
|
|
// and wishes to swap for XMR.
|
|
type Instance struct {
|
|
backend backend.Backend
|
|
dataDir string
|
|
|
|
noTransferBack bool // leave XMR in per-swap generated wallet
|
|
|
|
// non-nil if a swap is currently happening, nil otherwise
|
|
// map of offer IDs -> ongoing swaps
|
|
swapStates map[types.Hash]*swapState
|
|
swapMu sync.RWMutex // lock for above map
|
|
}
|
|
|
|
// Config contains the configuration values for a new XMRTaker instance.
|
|
type Config struct {
|
|
Backend backend.Backend
|
|
DataDir string
|
|
NoTransferBack bool
|
|
ExternalSender bool
|
|
}
|
|
|
|
// NewInstance returns a new instance of XMRTaker.
|
|
// It accepts an endpoint to a monero-wallet-rpc instance where XMRTaker will generate
|
|
// the account in which the XMR will be deposited.
|
|
func NewInstance(cfg *Config) (*Instance, error) {
|
|
inst := &Instance{
|
|
backend: cfg.Backend,
|
|
dataDir: cfg.DataDir,
|
|
swapStates: make(map[types.Hash]*swapState),
|
|
}
|
|
|
|
err := inst.checkForOngoingSwaps()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return inst, nil
|
|
}
|
|
|
|
func (inst *Instance) checkForOngoingSwaps() error {
|
|
ongoingIDs, err := inst.backend.SwapManager().GetOngoingSwapOfferIDs()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, offerID := range ongoingIDs {
|
|
s, err := inst.backend.SwapManager().GetOngoingSwap(*offerID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if s.Provides != coins.ProvidesETH {
|
|
continue
|
|
}
|
|
|
|
// in this case, we exited either before locking funds, or before the newSwap tx
|
|
// was included in the chain.
|
|
if s.Status == types.ExpectingKeys {
|
|
txHash, err := inst.backend.RecoveryDB().GetNewSwapTxHash(s.OfferID) //nolint:govet
|
|
if err != nil && errors.Is(err, chaindb.ErrKeyNotFound) {
|
|
// since there was no newSwap tx hash, it means there was never an attempt to lock funds,
|
|
// and we can safely abort the swap.
|
|
log.Infof("found ongoing swap %s in DB, aborting since no funds were locked", s.OfferID)
|
|
err = inst.abortOngoingSwap(s)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to abort ongoing swap %s: %s", s.OfferID, err)
|
|
}
|
|
|
|
continue
|
|
} else if err != nil {
|
|
return fmt.Errorf("failed to get newSwap tx hash for ongoing swap %s: %w", s.OfferID, err)
|
|
}
|
|
|
|
// we have a newSwap tx hash, so we need to check if it was included in the chain.
|
|
err = inst.refundOrCancelNewSwap(s, txHash)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to refund or cancel swap %s: %w", s.OfferID, err)
|
|
}
|
|
|
|
continue
|
|
}
|
|
|
|
if s.Status == types.SweepingXMR {
|
|
log.Infof(
|
|
"found ongoing swap %s in DB where XMR was being swept back to the primary account, marking as completed",
|
|
s.OfferID,
|
|
)
|
|
s.Status = types.CompletedSuccess
|
|
err = inst.backend.SwapManager().CompleteOngoingSwap(s)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to mark swap as completed: %w", err)
|
|
}
|
|
|
|
continue
|
|
}
|
|
|
|
err = inst.createOngoingSwap(s)
|
|
if err != nil {
|
|
log.Errorf("%s", err)
|
|
continue
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (inst *Instance) abortOngoingSwap(s *swap.Info) error {
|
|
// set status to aborted, delete info from recovery db
|
|
s.Status = types.CompletedAbort
|
|
err := inst.backend.SwapManager().CompleteOngoingSwap(s)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return inst.backend.RecoveryDB().DeleteSwap(s.OfferID)
|
|
}
|
|
|
|
// refundOrCancelNewSwap checks if the newSwap tx was included in the chain.
|
|
// if it was, it attempts to refund the swap.
|
|
// otherwise, it attempts to cancel the swap by sending a zero-value transfer to our own account
|
|
// with the same nonce.
|
|
func (inst *Instance) refundOrCancelNewSwap(s *swap.Info, txHash ethcommon.Hash) error {
|
|
log.Infof("found ongoing swap %s with status %s in DB, checking to either refund or cancel", s.OfferID, s.Status)
|
|
|
|
cancelled, err := inst.maybeCancelNewSwap(txHash)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if cancelled {
|
|
return nil
|
|
}
|
|
|
|
receipt, err := block.WaitForReceipt(inst.backend.Ctx(), inst.backend.ETHClient().Raw(), txHash)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get newSwap transaction receipt: %w", err)
|
|
}
|
|
|
|
if len(receipt.Logs) == 0 {
|
|
return errSwapInstantiationNoLogs
|
|
}
|
|
|
|
var t1 *big.Int
|
|
var t2 *big.Int
|
|
for _, log := range receipt.Logs {
|
|
t1, t2, err = contracts.GetTimeoutsFromLog(log)
|
|
if err == nil {
|
|
break
|
|
}
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("timeouts not found in transaction receipt's logs: %w", err)
|
|
}
|
|
|
|
// we have a tx hash, so we can assume that the swap is ongoing
|
|
params, err := getNewSwapParametersFromTx(inst.backend.Ctx(), inst.backend.ETHClient().Raw(), txHash)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get newSwap parameters from tx %s: %w", txHash, err)
|
|
}
|
|
|
|
swap := contracts.SwapCreatorSwap{
|
|
Owner: params.owner,
|
|
Claimer: params.claimer,
|
|
ClaimCommitment: params.cmtXMRMaker,
|
|
RefundCommitment: params.cmtXMRTaker,
|
|
Timeout1: t1,
|
|
Timeout2: t2,
|
|
Asset: params.asset,
|
|
Value: params.value,
|
|
Nonce: params.nonce,
|
|
}
|
|
|
|
// our secret value
|
|
secret, err := inst.backend.RecoveryDB().GetSwapPrivateKey(s.OfferID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get private key for ongoing swap from db with offer id %s: %w",
|
|
s.OfferID, err)
|
|
}
|
|
|
|
swapCreator, err := contracts.NewSwapCreator(params.swapCreatorAddr, inst.backend.ETHClient().Raw())
|
|
if err != nil {
|
|
return fmt.Errorf("failed to instantiate SwapCreator contract: %w", err)
|
|
}
|
|
|
|
stage, err := swapCreator.Swaps(nil, swap.SwapID())
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get swap stage: %w", err)
|
|
}
|
|
|
|
// TODO: if this is not the case, then the swap has probably already been claimed.
|
|
// however, this seems very unlikely to happen, as the swap counterparty will exit
|
|
// the swap as soon as the network stream is closed, and they will likely not have
|
|
// locked XMR or claimed.
|
|
if stage != contracts.StagePending {
|
|
return fmt.Errorf("swap %s is not in pending stage, aborting", s.OfferID)
|
|
}
|
|
|
|
// TODO: check for t1/t2? if between t1 and t2, we need to wait for t2
|
|
|
|
txOpts, err := inst.backend.ETHClient().TxOpts(inst.backend.Ctx())
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get tx opts: %w", err)
|
|
}
|
|
|
|
refundTx, err := swapCreator.Refund(txOpts, swap, [32]byte(common.Reverse(secret.Bytes())))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create refund tx: %w", err)
|
|
}
|
|
|
|
log.Infof("submit refund tx %s for swap %s", refundTx.Hash(), s.OfferID)
|
|
receipt, err = block.WaitForReceipt(inst.backend.Ctx(), inst.backend.ETHClient().Raw(), refundTx.Hash())
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get refund transaction receipt: %w", err)
|
|
}
|
|
|
|
log.Infof("refunded swap %s successfully: %s", s.OfferID, common.ReceiptInfo(receipt))
|
|
|
|
// set status to refunded
|
|
s.Status = types.CompletedRefund
|
|
return inst.backend.SwapManager().CompleteOngoingSwap(s)
|
|
}
|
|
|
|
func (inst *Instance) maybeCancelNewSwap(txHash ethcommon.Hash) (bool, error) {
|
|
tx, isPending, err := inst.backend.ETHClient().Raw().TransactionByHash(inst.backend.Ctx(), txHash)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to get newSwap transaction: %w", err)
|
|
}
|
|
|
|
if !isPending {
|
|
// tx is already included, so we can't cancel it
|
|
return false, nil
|
|
}
|
|
|
|
log.Infof("newSwap tx %s is still pending, attempting to cancel", tx.Hash())
|
|
|
|
// just double the gas price for now, this is higher than needed for a replacement tx though
|
|
gasPrice := new(big.Int).Mul(tx.GasPrice(), big.NewInt(2))
|
|
receipt, err := inst.backend.ETHClient().CancelTxWithNonce(inst.backend.Ctx(), tx.Nonce(), gasPrice)
|
|
if err != nil && !errors.Is(err, ethereum.NotFound) {
|
|
return false, fmt.Errorf("failed to get cancel transaction receipt: %w", err)
|
|
}
|
|
|
|
if errors.Is(err, ethereum.NotFound) {
|
|
// this is okay, it means newSwap was included instead, and we can refund it in the calling function
|
|
log.Infof("failed to cancel swap, attempting to refund")
|
|
return false, nil
|
|
}
|
|
|
|
log.Infof("cancelled newSwap tx %s successfully: %s", tx.Hash(), common.ReceiptInfo(receipt))
|
|
return true, nil
|
|
}
|
|
|
|
func (inst *Instance) createOngoingSwap(s *swap.Info) error {
|
|
log.Infof("found ongoing swap %s with status %s in DB, restarting swap", s.OfferID, s.Status)
|
|
|
|
// 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.OfferID)
|
|
if err == nil {
|
|
return inst.completeSwap(s, skB)
|
|
}
|
|
|
|
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 offer id %s: %w", s.OfferID, err)
|
|
}
|
|
|
|
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 offer id %s: %w",
|
|
s.OfferID, err)
|
|
}
|
|
|
|
kp, err := sk.AsPrivateKeyPair()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
inst.swapMu.Lock()
|
|
defer inst.swapMu.Unlock()
|
|
ss, err := newSwapStateFromOngoing(
|
|
inst.backend,
|
|
s,
|
|
inst.noTransferBack,
|
|
ethSwapInfo,
|
|
kp,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create new swap state for ongoing swap, offer id %s: %w", s.OfferID, err)
|
|
}
|
|
|
|
inst.swapStates[s.OfferID] = ss
|
|
|
|
go func() {
|
|
<-ss.done
|
|
inst.swapMu.Lock()
|
|
defer inst.swapMu.Unlock()
|
|
delete(inst.swapStates, s.OfferID)
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
// completeSwap is called in the case where we find an ongoing swap in the db on startup,
|
|
// and the swap already has the counterparty's swap secret stored.
|
|
// In this case, we simply claim the XMR, as we have both secrets required.
|
|
// It's unlikely for this case to ever be hit, unless the daemon was shut down in-between
|
|
// us finding the counterparty's secret and claiming the XMR.
|
|
//
|
|
// Note: this will use the current value of `noTransferBack` (verses whatever value
|
|
// was set when the swap was started). It will also only only recover to the primary
|
|
// 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.OfferID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// fetch our swap private view key
|
|
vkA, err := skA.View()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// fetch counterparty's private view key
|
|
_, vkB, err := inst.backend.RecoveryDB().GetCounterpartySwapKeys(s.OfferID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
kpAB := pcommon.GetClaimKeypair(
|
|
skA, skB,
|
|
vkA, vkB,
|
|
)
|
|
|
|
err = pcommon.ClaimMonero(
|
|
inst.backend.Ctx(),
|
|
inst.backend.Env(),
|
|
s,
|
|
inst.backend.XMRClient(),
|
|
kpAB,
|
|
inst.backend.XMRClient().PrimaryAddress(),
|
|
inst.noTransferBack,
|
|
inst.backend.SwapManager(),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
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.OfferID, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetOngoingSwapState ...
|
|
func (inst *Instance) GetOngoingSwapState(offerID types.Hash) common.SwapState {
|
|
inst.swapMu.RLock()
|
|
defer inst.swapMu.RUnlock()
|
|
return inst.swapStates[offerID]
|
|
}
|
|
|
|
// ExternalSender returns the *txsender.ExternalSender for a swap, if the swap exists and is using
|
|
// and external tx sender
|
|
func (inst *Instance) ExternalSender(offerID types.Hash) (*txsender.ExternalSender, error) {
|
|
inst.swapMu.Lock()
|
|
defer inst.swapMu.Unlock()
|
|
|
|
s, has := inst.swapStates[offerID]
|
|
if !has {
|
|
return nil, errNoOngoingSwap
|
|
}
|
|
|
|
es, ok := s.sender.(*txsender.ExternalSender)
|
|
if !ok {
|
|
return nil, errSenderIsNotExternal
|
|
}
|
|
|
|
return es, nil
|
|
}
|
|
|
|
type newSwapParameters struct {
|
|
swapCreatorAddr ethcommon.Address
|
|
owner ethcommon.Address
|
|
claimer ethcommon.Address
|
|
cmtXMRMaker [32]byte
|
|
cmtXMRTaker [32]byte
|
|
asset ethcommon.Address
|
|
value *big.Int
|
|
nonce *big.Int
|
|
}
|
|
|
|
func getNewSwapParametersFromTx(
|
|
ctx context.Context,
|
|
ec *ethclient.Client,
|
|
txHash ethcommon.Hash,
|
|
) (*newSwapParameters, error) {
|
|
var newSwapTopic = common.GetTopic(contracts.NewSwapFunctionSignature)
|
|
|
|
tx, _, err := ec.TransactionByHash(ctx, txHash)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if tx.To() == nil {
|
|
return nil, fmt.Errorf("invalid transaction: to address is nil")
|
|
}
|
|
|
|
data := tx.Data()
|
|
if len(data) < 4 {
|
|
return nil, fmt.Errorf("invalid transaction data: too short")
|
|
}
|
|
|
|
m, err := contracts.SwapCreatorParsedABI.MethodById(newSwapTopic[:4] /*data[:4]*/)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
newSwapInputs := make(map[string]interface{})
|
|
|
|
err = m.Inputs.UnpackIntoMap(newSwapInputs, data[4:])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
claimer := newSwapInputs["_claimer"].(ethcommon.Address)
|
|
cmtXMRMaker := newSwapInputs["_claimCommitment"].([32]byte)
|
|
cmtXMRTaker := newSwapInputs["_refundCommitment"].([32]byte)
|
|
asset := newSwapInputs["_asset"].(ethcommon.Address)
|
|
value := newSwapInputs["_value"].(*big.Int)
|
|
nonce := newSwapInputs["_nonce"].(*big.Int)
|
|
|
|
signer := ethtypes.LatestSignerForChainID(tx.ChainId())
|
|
from, err := ethtypes.Sender(signer, tx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &newSwapParameters{
|
|
swapCreatorAddr: *tx.To(),
|
|
owner: from,
|
|
claimer: claimer,
|
|
cmtXMRMaker: cmtXMRMaker,
|
|
cmtXMRTaker: cmtXMRTaker,
|
|
asset: asset,
|
|
value: value,
|
|
nonce: nonce,
|
|
}, nil
|
|
}
|