mirror of
https://github.com/AthanorLabs/atomic-swap.git
synced 2026-01-09 14:18:03 -05:00
505 lines
15 KiB
Go
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)
|
|
}
|
|
}
|