Files
atomic-swap/rpc/swap.go
2023-05-22 23:00:46 -05:00

505 lines
15 KiB
Go

// Copyright 2023 The AthanorLabs/atomic-swap Authors
// SPDX-License-Identifier: LGPL-3.0-only
package rpc
import (
"context"
"fmt"
"net/http"
"sort"
"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"
contracts "github.com/athanorlabs/atomic-swap/ethereum"
"github.com/athanorlabs/atomic-swap/pricefeed"
"github.com/athanorlabs/atomic-swap/protocol/swap"
)
// SwapService handles information about ongoing or past swaps.
type SwapService struct {
ctx context.Context
sm swap.Manager
xmrtaker XMRTaker
xmrmaker XMRMaker
net Net
backend ProtocolBackend
rdb RecoveryDB
}
// NewSwapService ...
func NewSwapService(
ctx context.Context,
sm swap.Manager,
xmrtaker XMRTaker,
xmrmaker XMRMaker,
net Net,
b ProtocolBackend,
rdb RecoveryDB,
) *SwapService {
return &SwapService{
ctx: ctx,
sm: sm,
xmrtaker: xmrtaker,
xmrmaker: xmrmaker,
net: net,
backend: b,
rdb: rdb,
}
}
// PastSwap represents a past swap returned by swap_getPast.
type PastSwap struct {
ID types.Hash `json:"id" validate:"required"`
Provided coins.ProvidesCoin `json:"provided" validate:"required"`
EthAsset types.EthAsset `json:"ethAsset"`
ProvidedAmount *apd.Decimal `json:"providedAmount" validate:"required"`
ExpectedAmount *apd.Decimal `json:"expectedAmount" validate:"required"`
RelayerFee *apd.Decimal `json:"relayerFee"`
ExchangeRate *coins.ExchangeRate `json:"exchangeRate" validate:"required"`
Status types.Status `json:"status" validate:"required"`
StartTime time.Time `json:"startTime" validate:"required"`
EndTime *time.Time `json:"endTime"`
}
// GetPastRequest ...
type GetPastRequest struct {
OfferID *types.Hash `json:"offerID,omitempty"`
}
// GetPastResponse ...
type GetPastResponse struct {
Swaps []*PastSwap `json:"swaps" validate:"dive,required"`
}
// GetPast returns information about a past swap given its ID.
// If no ID is provided, all past swaps are returned.
// It sorts them in order from newest to oldest.
func (s *SwapService) GetPast(_ *http.Request, req *GetPastRequest, resp *GetPastResponse) error {
var swaps []*swap.Info
if req.OfferID == nil {
ids, err := s.sm.GetPastIDs()
if err != nil {
return err
}
for _, id := range ids {
info, err := s.sm.GetPastSwap(id)
if err != nil {
return fmt.Errorf("failed to get past swap %s: %w", id, err)
}
swaps = append(swaps, info)
}
} else {
info, err := s.sm.GetPastSwap(*req.OfferID)
if err != nil {
return err
}
swaps = append(swaps, info)
}
resp.Swaps = make([]*PastSwap, len(swaps))
for i, info := range swaps {
resp.Swaps[i] = &PastSwap{
ID: info.OfferID,
Provided: info.Provides,
EthAsset: info.EthAsset,
ProvidedAmount: info.ProvidedAmount,
ExpectedAmount: info.ExpectedAmount,
RelayerFee: info.RelayerFee,
ExchangeRate: info.ExchangeRate,
Status: info.Status,
StartTime: info.StartTime,
EndTime: info.EndTime,
}
}
sort.Slice(resp.Swaps, func(i, j int) bool {
return resp.Swaps[j].StartTime.Before(resp.Swaps[i].StartTime)
})
return nil
}
// OngoingSwap represents an ongoing swap returned by swap_getOngoing.
type OngoingSwap struct {
ID types.Hash `json:"id" validate:"required"`
Provided coins.ProvidesCoin `json:"provided" validate:"required"`
EthAsset types.EthAsset `json:"ethAsset"`
ProvidedAmount *apd.Decimal `json:"providedAmount" validate:"required"`
ExpectedAmount *apd.Decimal `json:"expectedAmount" validate:"required"`
ExchangeRate *coins.ExchangeRate `json:"exchangeRate" validate:"required"`
Status types.Status `json:"status" validate:"required"`
LastStatusUpdateTime time.Time `json:"lastStatusUpdateTime" validate:"required"`
StartTime time.Time `json:"startTime" validate:"required"`
Timeout1 *time.Time `json:"timeout1"`
Timeout2 *time.Time `json:"timeout2"`
EstimatedTimeToCompletion time.Duration `json:"estimatedTimeToCompletion" validate:"required"`
}
// GetOngoingRequest ...
type GetOngoingRequest struct {
OfferID *types.Hash `json:"offerID,omitempty"`
}
// GetOngoingResponse ...
type GetOngoingResponse struct {
Swaps []*OngoingSwap `json:"swaps" validate:"dive,required"`
}
// GetOngoing returns information about an ongoing swap given its ID.
// If no ID is provided, all ongoing swaps are returned.
// It sorts them in order from newest to oldest.
func (s *SwapService) GetOngoing(_ *http.Request, req *GetOngoingRequest, resp *GetOngoingResponse) error {
env := s.backend.Env()
var (
swaps []*swap.Info
err error
)
if req.OfferID == nil {
swaps, err = s.sm.GetOngoingSwapsSnapshot()
if err != nil {
return err
}
} else {
info, err := s.sm.GetOngoingSwapSnapshot(*req.OfferID) //nolint:govet
if err != nil {
return err
}
swaps = []*swap.Info{info}
}
resp.Swaps = make([]*OngoingSwap, len(swaps))
for i, info := range swaps {
swap := new(OngoingSwap)
swap.ID = info.OfferID
swap.Provided = info.Provides
swap.EthAsset = info.EthAsset
swap.ProvidedAmount = info.ProvidedAmount
swap.ExpectedAmount = info.ExpectedAmount
swap.ExchangeRate = info.ExchangeRate
swap.Status = info.Status
swap.LastStatusUpdateTime = info.LastStatusUpdateTime
swap.StartTime = info.StartTime
swap.Timeout1 = info.Timeout1
swap.Timeout2 = info.Timeout2
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.OfferID, err)
}
resp.Swaps[i] = swap
}
sort.Slice(resp.Swaps, func(i, j int) bool {
return resp.Swaps[j].StartTime.Before(resp.Swaps[i].StartTime)
})
return nil
}
// GetStatusRequest ...
type GetStatusRequest struct {
ID types.Hash `json:"id" validate:"required"`
}
// GetStatusResponse ...
type GetStatusResponse struct {
Status types.Status `json:"status" validate:"required"`
Description string `json:"info" validate:"required"`
StartTime time.Time `json:"startTime" validate:"required"`
}
// GetStatus returns the status of the ongoing swap, if there is one.
func (s *SwapService) GetStatus(_ *http.Request, req *GetStatusRequest, resp *GetStatusResponse) error {
info, err := s.sm.GetOngoingSwapSnapshot(req.ID)
if err != nil {
return err
}
resp.Status = info.Status
resp.Description = info.Status.Description()
resp.StartTime = info.StartTime
return nil
}
// GetOffersResponse ...
type GetOffersResponse struct {
PeerID peer.ID `json:"peerID" validate:"required"`
Offers []*types.Offer `json:"offers" validate:"dive,required"`
}
// GetOffers returns our currently available offers.
func (s *SwapService) GetOffers(_ *http.Request, _ *interface{}, resp *GetOffersResponse) error {
resp.PeerID = s.net.PeerID()
resp.Offers = s.xmrmaker.GetOffers()
return nil
}
// ClearOffersRequest ...
type ClearOffersRequest struct {
OfferIDs []types.Hash `json:"offerIDs" validate:"dive,required"`
}
// ClearOffers clears our provided offers. If there are no offers provided, it clears all offers.
func (s *SwapService) ClearOffers(_ *http.Request, req *ClearOffersRequest, _ *interface{}) error {
err := s.xmrmaker.ClearOffers(req.OfferIDs)
if err != nil {
return err
}
return nil
}
// CancelRequest ...
type CancelRequest struct {
OfferID types.Hash `json:"offerID" validate:"required"`
}
// CancelResponse ...
type CancelResponse struct {
Status types.Status `json:"status" validate:"required"`
}
// Cancel attempts to cancel the currently ongoing swap, if there is one.
func (s *SwapService) Cancel(_ *http.Request, req *CancelRequest, resp *CancelResponse) error {
info, err := s.sm.GetOngoingSwapSnapshot(req.OfferID)
if err != nil {
return fmt.Errorf("failed to get ongoing swap: %w", err)
}
var ss common.SwapState
switch info.Provides {
case coins.ProvidesETH:
ss = s.xmrtaker.GetOngoingSwapState(req.OfferID)
case coins.ProvidesXMR:
ss = s.xmrmaker.GetOngoingSwapState(req.OfferID)
}
if ss == nil {
return fmt.Errorf("failed to find swap state with ID %s", req.OfferID)
}
// Exit() is safe to be called concurrently, as it puts an exit event
// into the swap state's eventCh, and events are handled sequentially.
if err = ss.Exit(); err != nil {
return err
}
s.net.CloseProtocolStream(req.OfferID)
past, err := s.sm.GetPastSwap(info.OfferID)
if err != nil {
return err
}
resp.Status = past.Status
return nil
}
// ManualTransactionRequest is used to call swap_claim or swap_refund.
type ManualTransactionRequest struct {
OfferID types.Hash `json:"offerID" validate:"required"`
}
// ManualTransactionResponse is returned from swap_claim or swap_refund and contains
// the transaction hash of the claim or refund transaction.
type ManualTransactionResponse struct {
TxHash types.Hash `json:"txHash" validate:"required"`
}
// Claim calls the `claim` method on the swap contract given swap's offer ID.
// It uses the swap recovery info stored in the database to do so.
// It does not require the swap to be ongoing.
// This is meant as a fail-safe in case of some unknown swap error.
// It returns the transaction hash of the claim transaction.
func (s *SwapService) Claim(_ *http.Request, req *ManualTransactionRequest, resp *ManualTransactionResponse) error {
contractSwapInfo, err := s.rdb.GetContractSwapInfo(req.OfferID)
if err != nil {
return err
}
secret, err := s.rdb.GetSwapPrivateKey(req.OfferID)
if err != nil {
return err
}
ec := s.backend.ETHClient()
swapCreator, err := contracts.NewSwapCreator(contractSwapInfo.SwapCreatorAddr, ec.Raw())
if err != nil {
return err
}
ec.Lock()
defer ec.Unlock()
txOpts, err := ec.TxOpts(s.backend.Ctx())
if err != nil {
return err
}
tx, err := swapCreator.Claim(txOpts, *contractSwapInfo.Swap, [32]byte(common.Reverse(secret.Bytes())))
if err != nil {
return err
}
resp.TxHash = tx.Hash()
return nil
}
// Refund calls the `refund` method on the swap contract given swap's offer ID.
// It uses the swap recovery info stored in the database to do so.
// It does not require the swap to be ongoing.
// This is meant as a fail-safe in case of some unknown swap error.
// It returns the transaction hash of the refund transaction.
func (s *SwapService) Refund(_ *http.Request, req *ManualTransactionRequest, resp *ManualTransactionResponse) error {
contractSwapInfo, err := s.rdb.GetContractSwapInfo(req.OfferID)
if err != nil {
return err
}
secret, err := s.rdb.GetSwapPrivateKey(req.OfferID)
if err != nil {
return err
}
ec := s.backend.ETHClient()
swapCreator, err := contracts.NewSwapCreator(contractSwapInfo.SwapCreatorAddr, ec.Raw())
if err != nil {
return err
}
ec.Lock()
defer ec.Unlock()
txOpts, err := ec.TxOpts(s.backend.Ctx())
if err != nil {
return err
}
tx, err := swapCreator.Refund(txOpts, *contractSwapInfo.Swap, [32]byte(common.Reverse(secret.Bytes())))
if err != nil {
return err
}
resp.TxHash = tx.Hash()
return nil
}
// SuggestedExchangeRateResponse ...
type SuggestedExchangeRateResponse struct {
ETHUpdatedAt time.Time `json:"ethUpdatedAt" validate:"required"`
ETHPrice *apd.Decimal `json:"ethPrice" validate:"required"`
XMRUpdatedAt time.Time `json:"xmrUpdatedAt" validate:"required"`
XMRPrice *apd.Decimal `json:"xmrPrice" validate:"required"`
ExchangeRate *coins.ExchangeRate `json:"exchangeRate" validate:"required"`
}
// SuggestedExchangeRate returns the current mainnet exchange rate, expressed as the XMR/ETH price.
func (s *SwapService) SuggestedExchangeRate(_ *http.Request, _ *interface{}, resp *SuggestedExchangeRateResponse) error { //nolint:lll
ec := s.backend.ETHClient().Raw()
xmrFeed, err := pricefeed.GetXMRUSDPrice(s.ctx, ec)
if err != nil {
return err
}
ethFeed, err := pricefeed.GetETHUSDPrice(s.ctx, ec)
if err != nil {
return err
}
exchangeRate, err := coins.CalcExchangeRate(xmrFeed.Price, ethFeed.Price)
if err != nil {
return err
}
resp.XMRUpdatedAt = xmrFeed.UpdatedAt
resp.XMRPrice = xmrFeed.Price
resp.ETHUpdatedAt = ethFeed.UpdatedAt
resp.ETHPrice = ethFeed.Price
resp.ExchangeRate = exchangeRate
return nil
}
// estimatedTimeToCompletion returns the estimated time for the swap to complete
// in the optimistic case based on the given status and the time the status was updated.
func estimatedTimeToCompletion(
env common.Environment,
status types.Status,
lastStatusUpdateTime time.Time,
) (time.Duration, error) {
if time.Until(lastStatusUpdateTime) > 0 {
return 0, fmt.Errorf("last status update time must be less than now")
}
timeForStatus, err := estimatedTimeToCompletionForStatus(env, status)
if err != nil {
return 0, err
}
estimatedTime := timeForStatus - time.Since(lastStatusUpdateTime)
if estimatedTime < 0 {
// TODO: add explanation as to why time to completion can't be estimated,
// probably because we need to wait for the countparty to refund, or
// monero block times were longer than expected.
return 0, nil
}
return estimatedTime.Round(time.Second), nil
}
// estimatedTimeToCompletionForStatus returns the estimated time for the swap to complete
// in the optimistic case based on the given status, assuming the status was updated just now.
func estimatedTimeToCompletionForStatus(env common.Environment, status types.Status) (time.Duration, error) {
var (
moneroBlockTime time.Duration
ethBlockTime time.Duration
)
switch env {
case common.Development:
moneroBlockTime = time.Second
ethBlockTime = time.Second
default:
moneroBlockTime = time.Minute * 2
ethBlockTime = time.Second * 12
}
// we assume the Monero lock step will take 10 blocks, and for the taker,
// there is the additional 2 blocks to transfer the funds from the swap wallet
// to the original wallet.
//
// we also assume all Ethereum txs will take at maximum 2 blocks
// to be included.
switch status {
case types.ExpectingKeys:
return (moneroBlockTime * 12) + (ethBlockTime * 6), nil
case types.KeysExchanged:
return (moneroBlockTime * 10) + (ethBlockTime * 6), nil
case types.ETHLocked:
return (moneroBlockTime * 12) + (ethBlockTime * 4), nil
case types.XMRLocked:
return (moneroBlockTime * 10) + (ethBlockTime * 4), nil
case types.ContractReady:
return (moneroBlockTime * 2) + (ethBlockTime * 2), nil
case types.SweepingXMR:
return (moneroBlockTime * 2), nil
default:
return 0, fmt.Errorf("invalid status %s; must be ongoing status type", status)
}
}