Files
atomic-swap/common/types/offer.go
2023-05-30 21:49:22 -05:00

245 lines
7.1 KiB
Go

// Copyright 2023 The AthanorLabs/atomic-swap Authors
// SPDX-License-Identifier: LGPL-3.0-only
package types
import (
"crypto/rand"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"github.com/Masterminds/semver/v3"
"github.com/cockroachdb/apd/v3"
"golang.org/x/crypto/sha3"
"github.com/athanorlabs/atomic-swap/coins"
"github.com/athanorlabs/atomic-swap/common/vjson"
)
var (
// CurOfferVersion is the latest supported version of a serialised Offer struct
CurOfferVersion, _ = semver.NewVersion("1.0.0")
// Don't allow offers over 1000 XMR. Mainly to prevent fat-finger errors, it
// could be raised if users need it.
maxOfferValue = apd.New(1, 3) // 1000 XMR
)
var (
errOfferVersionMissing = errors.New(`required "version" field missing in offer`)
errOfferIDNotSet = errors.New(`"offerID" is not set`)
errExchangeRateNil = errors.New(`"exchangeRate" is not set`)
errMinGreaterThanMax = errors.New(`"minAmount" must be less than or equal to "maxAmount"`)
)
// Offer represents a swap offer
type Offer struct {
Version semver.Version `json:"version"`
ID Hash `json:"offerID" validate:"required"`
Provides coins.ProvidesCoin `json:"provides" validate:"required"`
MinAmount *apd.Decimal `json:"minAmount" validate:"required"` // Min XMR amount
MaxAmount *apd.Decimal `json:"maxAmount" validate:"required"` // Max XMR amount
ExchangeRate *coins.ExchangeRate `json:"exchangeRate" validate:"required"`
EthAsset EthAsset `json:"ethAsset"`
Nonce uint64 `json:"nonce" validate:"required"`
}
// NewOffer creates and returns an Offer with an initialised ID and Version fields
func NewOffer(
coin coins.ProvidesCoin,
minAmount *apd.Decimal,
maxAmount *apd.Decimal,
exRate *coins.ExchangeRate,
ethAsset EthAsset,
) *Offer {
var n [8]byte
if _, err := rand.Read(n[:]); err != nil {
panic(err)
}
// We want the coefficients of apd decimals to be reduced before computing the
// hash at the end. Otherwise an apd value like apd.New(10, -2) will print 0.10
// instead of 0.1. The reduced form is apd.New(1, -1).
_, _ = minAmount.Reduce(minAmount)
_, _ = maxAmount.Reduce(maxAmount)
_, _ = exRate.Decimal().Reduce(exRate.Decimal())
offer := &Offer{
Version: *CurOfferVersion,
Provides: coin,
MinAmount: minAmount,
MaxAmount: maxAmount,
ExchangeRate: exRate,
EthAsset: ethAsset,
Nonce: binary.BigEndian.Uint64(n[:]),
}
offer.setID()
return offer
}
func (o *Offer) setID() {
if !IsHashZero(o.ID) {
panic("offer ID is already set")
}
o.ID = o.hash()
}
func (o *Offer) hash() Hash {
b := append([]byte(o.Version.String()), []byte(o.Provides)...)
b = append(b, []byte(",")...)
b = append(b, []byte(o.MinAmount.Text('f'))...)
b = append(b, []byte(",")...)
b = append(b, []byte(o.MaxAmount.Text('f'))...)
b = append(b, []byte(",")...)
b = append(b, []byte(o.ExchangeRate.String())...)
b = append(b, []byte(",")...)
b = append(b, []byte(o.EthAsset.String())...)
b = append(b, []byte(",")...)
b = append(b, []byte(fmt.Sprintf("%d", o.Nonce))...)
return sha3.Sum256(b)
}
// String ...
func (o *Offer) String() string {
return fmt.Sprintf("OfferID:%s Provides:%s MinAmount:%s MaxAmount:%s ExchangeRate:%s EthAsset:%s Nonce:%d",
o.ID,
o.Provides,
o.MinAmount.String(),
o.MaxAmount.String(),
o.ExchangeRate.String(),
o.EthAsset,
o.Nonce,
)
}
// IsSet returns true if the offer's fields are all set.
func (o *Offer) IsSet() bool {
return !IsHashZero(o.ID) &&
o.Provides != "" &&
o.MinAmount != nil &&
o.MaxAmount != nil &&
o.ExchangeRate != nil
}
func (o *Offer) validate() error {
if IsHashZero(o.ID) {
return errOfferIDNotSet
}
if err := coins.ValidatePositive("minAmount", coins.NumMoneroDecimals, o.MinAmount); err != nil {
return err
}
if err := coins.ValidatePositive("maxAmount", coins.NumMoneroDecimals, o.MaxAmount); err != nil {
return err
}
// It is an error if the min is greater than the max
if o.MinAmount.Cmp(o.MaxAmount) > 0 {
return errMinGreaterThanMax
}
// The JSON decoder for ExchangeRate does validation, but it can't check for nil, as
// it won't get invoked when the value is not present.
if o.ExchangeRate == nil {
return errExchangeRateNil
}
// We want to prevent offers whose claim value is so low that a relayer
// can't be used to complete the swap if the maker does not have sufficient
// ETH to make the claim themselves. While relayers are not used with ERC20
// swaps, we still want a minimum swap amount, so we use the same value.
minAmtAsETH, err := o.ExchangeRate.ToETH(o.MinAmount)
if err != nil {
return err
}
if minAmtAsETH.Cmp(coins.RelayerFeeETH) <= 0 {
return fmt.Errorf(
"min amount must be greater than %s ETH when converted (%s XMR * %s = %s ETH)",
coins.RelayerFeeETH.Text('f'),
o.MaxAmount.Text('f'),
o.ExchangeRate,
minAmtAsETH.Text('f'),
)
}
if o.MaxAmount.Cmp(maxOfferValue) > 0 {
return fmt.Errorf("%s XMR exceeds max offer amount of %s XMR",
o.MaxAmount.Text('f'), maxOfferValue.Text('f'))
}
if o.ID != o.hash() {
return errors.New("hash of offer fields does not match offer ID")
}
return nil
}
// UnmarshalOffer deserializes a JSON offer, checking the version for compatibility before
// attempting to deserialize the whole blob.
func UnmarshalOffer(jsonData []byte) (*Offer, error) {
// First unmarshal into a struct that only has the version. Then, if we ever
// have to support multiple versions, you can use the version to pick which
// offer structure to deserialize the full data into.
ov := struct {
Version *semver.Version `json:"version"`
}{}
if err := json.Unmarshal(jsonData, &ov); err != nil {
return nil, err
}
if ov.Version == nil {
return nil, errOfferVersionMissing
}
if ov.Version.GreaterThan(CurOfferVersion) {
return nil, fmt.Errorf("offer version %q not supported, latest is %q", ov.Version, CurOfferVersion)
}
o := new(Offer)
if err := vjson.UnmarshalStruct(jsonData, o); err != nil {
return nil, err
}
return o, nil
}
// MarshalJSON provides JSON marshalling for the Offer type
func (o *Offer) MarshalJSON() ([]byte, error) {
if err := o.validate(); err != nil {
return nil, err
}
// Do standard JSON marshal without recursion
type _Offer Offer
return vjson.MarshalStruct((*_Offer)(o))
}
// UnmarshalJSON provides JSON unmarshalling the Offer type
func (o *Offer) UnmarshalJSON(data []byte) error {
// Do standard JSON marshal without recursion
type _Offer Offer
if err := vjson.UnmarshalStruct(data, (*_Offer)(o)); err != nil {
return err
}
return o.validate()
}
// OfferExtra represents extra data that is passed when an offer is made.
type OfferExtra struct {
// UseRelayer forces the XMR maker to claim using the relayer even when he
// has enough funds to make the claim himself. Setting it to false will not
// prevent the relayer from being used if there are insufficient ETH funds
// to claim.
UseRelayer bool `json:"useRelayer,omitempty"`
}
// NewOfferExtra creates an OfferExtra instance
func NewOfferExtra(forceUseRelayer bool) *OfferExtra {
return &OfferExtra{
UseRelayer: forceUseRelayer,
}
}