mirror of
https://github.com/AthanorLabs/atomic-swap.git
synced 2026-01-08 21:58:07 -05:00
245 lines
7.1 KiB
Go
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,
|
|
}
|
|
}
|