additonal erc20 and taker decimal checks (#478)

This commit is contained in:
Dmitry Holodov
2023-05-30 21:49:22 -05:00
committed by GitHub
parent 65f75c295a
commit 0f1865c33f
39 changed files with 805 additions and 339 deletions

View File

@@ -550,7 +550,8 @@ func runBalances(ctx *cli.Context) error {
fmt.Printf("Token: %s\n", tokenBalance.TokenInfo.Address) fmt.Printf("Token: %s\n", tokenBalance.TokenInfo.Address)
fmt.Printf("Name: %q\n", tokenBalance.TokenInfo.Name) fmt.Printf("Name: %q\n", tokenBalance.TokenInfo.Name)
fmt.Printf("Symbol: %q\n", tokenBalance.TokenInfo.Symbol) fmt.Printf("Symbol: %q\n", tokenBalance.TokenInfo.Symbol)
fmt.Printf("Balance: %s\n", tokenBalance.AsStandard().Text('f')) fmt.Printf("Decimals: %d\n", tokenBalance.TokenInfo.NumDecimals)
fmt.Printf("Balance: %s\n", tokenBalance.AsStdString())
fmt.Println() fmt.Println()
} }

View File

@@ -9,7 +9,9 @@ import (
ethcommon "github.com/ethereum/go-ethereum/common" ethcommon "github.com/ethereum/go-ethereum/common"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"github.com/athanorlabs/atomic-swap/coins"
"github.com/athanorlabs/atomic-swap/daemon" "github.com/athanorlabs/atomic-swap/daemon"
contracts "github.com/athanorlabs/atomic-swap/ethereum"
"github.com/athanorlabs/atomic-swap/rpcclient" "github.com/athanorlabs/atomic-swap/rpcclient"
"github.com/athanorlabs/atomic-swap/tests" "github.com/athanorlabs/atomic-swap/tests"
) )
@@ -19,7 +21,8 @@ import (
type swapCLITestSuite struct { type swapCLITestSuite struct {
suite.Suite suite.Suite
conf *daemon.SwapdConfig conf *daemon.SwapdConfig
mockTokens map[string]ethcommon.Address mockTether *coins.ERC20TokenInfo
mockDAI *coins.ERC20TokenInfo
} }
func TestRunSwapcliWithDaemonTests(t *testing.T) { func TestRunSwapcliWithDaemonTests(t *testing.T) {
@@ -28,7 +31,10 @@ func TestRunSwapcliWithDaemonTests(t *testing.T) {
s.conf = daemon.CreateTestConf(t, tests.GetMakerTestKey(t)) s.conf = daemon.CreateTestConf(t, tests.GetMakerTestKey(t))
t.Setenv("SWAPD_PORT", strconv.Itoa(int(s.conf.RPCPort))) t.Setenv("SWAPD_PORT", strconv.Itoa(int(s.conf.RPCPort)))
daemon.LaunchDaemons(t, 10*time.Minute, s.conf) daemon.LaunchDaemons(t, 10*time.Minute, s.conf)
s.mockTokens = daemon.GetMockTokens(t, s.conf.EthereumClient) ec := s.conf.EthereumClient.Raw()
pk := s.conf.EthereumClient.PrivateKey()
s.mockTether = contracts.GetMockTether(t, ec, pk)
s.mockDAI = contracts.GetMockDAI(t, ec, pk)
suite.Run(t, s) suite.Run(t, s)
} }
@@ -37,9 +43,9 @@ func (s *swapCLITestSuite) rpcEndpoint() *rpcclient.Client {
} }
func (s *swapCLITestSuite) mockDaiAddr() ethcommon.Address { func (s *swapCLITestSuite) mockDaiAddr() ethcommon.Address {
return s.mockTokens[daemon.MockDAI] return s.mockDAI.Address
} }
func (s *swapCLITestSuite) mockTetherAddr() ethcommon.Address { func (s *swapCLITestSuite) mockTetherAddr() ethcommon.Address {
return s.mockTokens[daemon.MockTether] return s.mockTether.Address
} }

View File

@@ -70,7 +70,8 @@ func MoneroToPiconero(xmrAmt *apd.Decimal) *PiconeroAmount {
// We do input validation and reject XMR values with more than 12 decimal // We do input validation and reject XMR values with more than 12 decimal
// places from external sources, so no rounding will happen with those // places from external sources, so no rounding will happen with those
// values below. // values below.
if err := roundToDecimalPlace(pnAmt, pnAmt, 0); err != nil { pnAmt, err := roundToDecimalPlace(pnAmt, 0)
if err != nil {
panic(err) // shouldn't be possible panic(err) // shouldn't be possible
} }
return (*PiconeroAmount)(pnAmt) return (*PiconeroAmount)(pnAmt)
@@ -123,12 +124,24 @@ func FmtPiconeroAsXMR(piconeros uint64) string {
// EthAssetAmount represents an amount of an Ethereum asset (ie. ether or an ERC20) // EthAssetAmount represents an amount of an Ethereum asset (ie. ether or an ERC20)
type EthAssetAmount interface { type EthAssetAmount interface {
BigInt() *big.Int BigInt() *big.Int
AsStandard() *apd.Decimal AsStd() *apd.Decimal
StandardSymbol() string AsStdString() string
StdSymbol() string
IsToken() bool IsToken() bool
NumStdDecimals() uint8
TokenAddress() ethcommon.Address TokenAddress() ethcommon.Address
} }
// NewEthAssetAmount accepts an amount, in standard units, for ETH or a token and
// returns a type implementing EthAssetAmount. If the token is nil, we assume
// the asset is ETH.
func NewEthAssetAmount(amount *apd.Decimal, token *ERC20TokenInfo) EthAssetAmount {
if token == nil {
return EtherToWei(amount)
}
return NewTokenAmountFromDecimals(amount, token)
}
// WeiAmount represents some amount of ETH in the smallest denomination (Wei) // WeiAmount represents some amount of ETH in the smallest denomination (Wei)
type WeiAmount apd.Decimal type WeiAmount apd.Decimal
@@ -183,7 +196,8 @@ func EtherToWei(ethAmt *apd.Decimal) *WeiAmount {
// We do input validation on provided amounts and prevent values with // We do input validation on provided amounts and prevent values with
// more than 18 decimal places, so no rounding happens with such values // more than 18 decimal places, so no rounding happens with such values
// below. // below.
if err := roundToDecimalPlace(weiAmt, weiAmt, 0); err != nil { weiAmt, err := roundToDecimalPlace(weiAmt, 0)
if err != nil {
panic(err) // shouldn't be possible panic(err) // shouldn't be possible
} }
return ToWeiAmount(weiAmt) return ToWeiAmount(weiAmt)
@@ -220,19 +234,24 @@ func (a *WeiAmount) AsEtherString() string {
return a.AsEther().Text('f') return a.AsEther().Text('f')
} }
// AsStandard is an alias for AsEther, returning the Wei amount as ETH // AsStd is an alias for AsEther, returning the Wei amount as ETH
func (a *WeiAmount) AsStandard() *apd.Decimal { func (a *WeiAmount) AsStd() *apd.Decimal {
return a.AsEther() return a.AsEther()
} }
// AsStandardString is an alias for AsEtherString, returning the Wei amount as // AsStdString is an alias for AsEtherString, returning the Wei amount as
// an ETH string // an ETH string
func (a *WeiAmount) AsStandardString() *apd.Decimal { func (a *WeiAmount) AsStdString() string {
return a.AsEther() return a.AsEther().Text('f')
} }
// StandardSymbol returns the string "ETH" // NumStdDecimals returns 18
func (a *WeiAmount) StandardSymbol() string { func (a *WeiAmount) NumStdDecimals() uint8 {
return NumEtherDecimals
}
// StdSymbol returns the string "ETH"
func (a *WeiAmount) StdSymbol() string {
return "ETH" return "ETH"
} }
@@ -283,50 +302,49 @@ func (t *ERC20TokenInfo) SanitizedSymbol() string {
// ERC20TokenAmount represents some amount of an ERC20 token in the smallest denomination // ERC20TokenAmount represents some amount of an ERC20 token in the smallest denomination
type ERC20TokenAmount struct { type ERC20TokenAmount struct {
Amount *apd.Decimal `json:"amount" validate:"required"` // in smallest non-divisible units of token Amount *apd.Decimal `json:"amount" validate:"required"` // in standard units
TokenInfo *ERC20TokenInfo `json:"tokenInfo" validate:"required"` TokenInfo *ERC20TokenInfo `json:"tokenInfo" validate:"required"`
} }
// NewERC20TokenAmountFromBigInt converts some amount in the smallest token denomination into an ERC20TokenAmount. // NewERC20TokenAmountFromBigInt converts some amount in the smallest token denomination
func NewERC20TokenAmountFromBigInt(amount *big.Int, tokenInfo *ERC20TokenInfo) *ERC20TokenAmount { // into an ERC20TokenAmount.
func NewERC20TokenAmountFromBigInt(amount *big.Int, token *ERC20TokenInfo) *ERC20TokenAmount {
asDecimal := new(apd.Decimal) asDecimal := new(apd.Decimal)
asDecimal.Coeff.SetBytes(amount.Bytes()) asDecimal.Coeff.SetBytes(amount.Bytes())
decreaseExponent(asDecimal, token.NumDecimals)
_, _ = asDecimal.Reduce(asDecimal)
return &ERC20TokenAmount{ return &ERC20TokenAmount{
Amount: asDecimal, Amount: asDecimal,
TokenInfo: tokenInfo, TokenInfo: token,
} }
} }
// NewERC20TokenAmount converts some amount in the smallest token denomination into an ERC20TokenAmount. // NewTokenAmountFromDecimals converts an amount in standard units from
func NewERC20TokenAmount(amount int64, tokenInfo *ERC20TokenInfo) *ERC20TokenAmount { // apd.Decimal into the ERC20TokenAmount type. During the conversion, rounding
return &ERC20TokenAmount{ // may occur if the input value is too precise for the token's decimals.
Amount: apd.New(amount, 0), func NewTokenAmountFromDecimals(amount *apd.Decimal, token *ERC20TokenInfo) *ERC20TokenAmount {
TokenInfo: tokenInfo, if ExceedsDecimals(amount, token.NumDecimals) {
log.Warnf("Converting amount=%s (digits=%d) to token amount required rounding",
amount.Text('f'), token.NumDecimals)
roundedAmt, err := roundToDecimalPlace(amount, token.NumDecimals)
if err != nil {
panic(err) // shouldn't be possible
}
amount = roundedAmt
} }
}
// NewERC20TokenAmountFromDecimals converts some amount for a token in its
// standard form into the smallest denomination that the token supports. For
// example, if amount is 1.99 and decimals is 4, the resulting value stored is
// 19900
func NewERC20TokenAmountFromDecimals(amount *apd.Decimal, tokenInfo *ERC20TokenInfo) *ERC20TokenAmount {
adjusted := new(apd.Decimal).Set(amount)
increaseExponent(adjusted, tokenInfo.NumDecimals)
// If we are rejecting token amounts that have too many decimal places on input, rounding
// below will never occur.
if err := roundToDecimalPlace(adjusted, adjusted, 0); err != nil {
panic(err) // this shouldn't be possible
}
return &ERC20TokenAmount{ return &ERC20TokenAmount{
Amount: adjusted, Amount: amount,
TokenInfo: tokenInfo, TokenInfo: token,
} }
} }
// BigInt returns the ERC20TokenAmount as a *big.Int // BigInt returns the ERC20TokenAmount as a *big.Int
func (a *ERC20TokenAmount) BigInt() *big.Int { func (a *ERC20TokenAmount) BigInt() *big.Int {
wholeTokenUnits := new(apd.Decimal) wholeTokenUnits := new(apd.Decimal).Set(a.Amount)
cond, err := decimalCtx.Quantize(wholeTokenUnits, a.Amount, 0) increaseExponent(wholeTokenUnits, a.TokenInfo.NumDecimals)
cond, err := decimalCtx.Quantize(wholeTokenUnits, wholeTokenUnits, 0)
if err != nil { if err != nil {
panic(err) panic(err)
} }
@@ -334,24 +352,28 @@ func (a *ERC20TokenAmount) BigInt() *big.Int {
log.Warn("Converting ERC20TokenAmount=%s (digits=%d) to big.Int required rounding", log.Warn("Converting ERC20TokenAmount=%s (digits=%d) to big.Int required rounding",
a.Amount, a.TokenInfo.NumDecimals) a.Amount, a.TokenInfo.NumDecimals)
} }
return new(big.Int).SetBytes(wholeTokenUnits.Coeff.Bytes()) return new(big.Int).SetBytes(wholeTokenUnits.Coeff.Bytes())
} }
// AsStandard returns the amount in standard form // AsStd returns the amount in standard units
func (a *ERC20TokenAmount) AsStandard() *apd.Decimal { func (a *ERC20TokenAmount) AsStd() *apd.Decimal {
tokenAmt := new(apd.Decimal).Set(a.Amount) return a.Amount
decreaseExponent(tokenAmt, a.TokenInfo.NumDecimals)
_, _ = tokenAmt.Reduce(tokenAmt)
return tokenAmt
} }
// AsStandardString returns the amount as a standard (decimal adjusted) string // AsStdString returns the ERC20TokenAmount as a base10 string in standard units.
func (a *ERC20TokenAmount) AsStandardString() string { func (a *ERC20TokenAmount) AsStdString() string {
return a.AsStandard().Text('f') return a.String()
} }
// StandardSymbol returns the token's symbol in a format that is safe to log and display // NumStdDecimals returns the max decimal precision of the token's standard
func (a *ERC20TokenAmount) StandardSymbol() string { // representation
func (a *ERC20TokenAmount) NumStdDecimals() uint8 {
return a.TokenInfo.NumDecimals
}
// StdSymbol returns the token's symbol in a format that is safe to log and display
func (a *ERC20TokenAmount) StdSymbol() string {
return a.TokenInfo.SanitizedSymbol() return a.TokenInfo.SanitizedSymbol()
} }
@@ -365,8 +387,7 @@ func (a *ERC20TokenAmount) TokenAddress() ethcommon.Address {
return a.TokenInfo.Address return a.TokenInfo.Address
} }
// String returns the ERC20TokenAmount as a base10 string of the token's smallest, // String returns the ERC20TokenAmount as a base10 string in standard units.
// non-divisible units.
func (a *ERC20TokenAmount) String() string { func (a *ERC20TokenAmount) String() string {
return a.Amount.Text('f') return a.Amount.Text('f')
} }

View File

@@ -84,7 +84,7 @@ func TestWeiAmount(t *testing.T) {
wei := EtherToWei(amount) wei := EtherToWei(amount)
assert.Equal(t, "33300000000000000000", wei.String()) assert.Equal(t, "33300000000000000000", wei.String())
assert.Equal(t, "33.3", wei.AsEther().String()) assert.Equal(t, "33.3", wei.AsEther().String())
assert.Equal(t, "33.3", wei.AsStandard().String()) // alias for AsEther assert.Equal(t, "33.3", wei.AsStdString())
amountUint := int64(8181) amountUint := int64(8181)
WeiAmount := IntToWei(amountUint) WeiAmount := IntToWei(amountUint)
@@ -114,42 +114,37 @@ func TestERC20TokenAmount(t *testing.T) {
tokenInfo := NewERC20TokenInfo(ethcommon.Address{}, numDecimals, "", "") tokenInfo := NewERC20TokenInfo(ethcommon.Address{}, numDecimals, "", "")
amount := StrToDecimal("33.999999999") amount := StrToDecimal("33.999999999")
wei := NewERC20TokenAmountFromDecimals(amount, tokenInfo) tokenAmt := NewTokenAmountFromDecimals(amount, tokenInfo)
assert.Equal(t, amount.String(), wei.AsStandard().String()) assert.Equal(t, amount.String(), tokenAmt.AsStdString())
amount = StrToDecimal("33.000000005") amount = StrToDecimal("33.000000005")
wei = NewERC20TokenAmountFromDecimals(amount, tokenInfo) tokenAmt = NewTokenAmountFromDecimals(amount, tokenInfo)
assert.Equal(t, "33.000000005", wei.AsStandard().String()) assert.Equal(t, "33.000000005", tokenAmt.AsStdString())
amount = StrToDecimal("33.0000000005") amount = StrToDecimal("33.0000000005")
wei = NewERC20TokenAmountFromDecimals(amount, tokenInfo) tokenAmt = NewTokenAmountFromDecimals(amount, tokenInfo)
assert.Equal(t, "33.000000001", wei.AsStandard().String()) assert.Equal(t, "33.000000001", tokenAmt.AsStdString())
amount = StrToDecimal("999999999999999999.0000000005") amount = StrToDecimal("999999999999999999.0000000005")
wei = NewERC20TokenAmountFromDecimals(amount, tokenInfo) tokenAmt = NewTokenAmountFromDecimals(amount, tokenInfo)
assert.Equal(t, "999999999999999999.000000001", wei.AsStandard().String()) assert.Equal(t, "999999999999999999.000000001", tokenAmt.AsStdString())
amountUint := int64(8181)
tokenAmt := NewERC20TokenAmount(amountUint, tokenInfo)
assert.Equal(t, amountUint, tokenAmt.BigInt().Int64())
} }
func TestNewERC20TokenAmountFromBigInt(t *testing.T) { func TestNewERC20TokenAmountFromBigInt(t *testing.T) {
bi := big.NewInt(4321) bi := big.NewInt(4321)
token := NewERC20TokenAmountFromBigInt(bi, &ERC20TokenInfo{NumDecimals: 2}) tokenAmt := NewERC20TokenAmountFromBigInt(bi, &ERC20TokenInfo{NumDecimals: 2})
assert.Equal(t, "4321", token.String()) assert.Equal(t, "43.21", tokenAmt.String())
assert.Equal(t, "43.21", token.AsStandard().String()) assert.Equal(t, "43.21", tokenAmt.AsStdString())
assert.Equal(t, "4321", tokenAmt.BigInt().String())
} }
func TestNewERC20TokenAmountFromDecimals(t *testing.T) { func TestNewERC20TokenAmountFromDecimals(t *testing.T) {
stdAmount := StrToDecimal("0.19") stdAmount := StrToDecimal("0.19")
token := NewERC20TokenAmountFromDecimals(stdAmount, &ERC20TokenInfo{NumDecimals: 1}) token := NewTokenAmountFromDecimals(stdAmount, &ERC20TokenInfo{NumDecimals: 1})
// There's only one decimal place, so this is getting rounded to 2 // There's only one decimal place, so this is getting rounded to 2
// under the current implementation. It's not entirely clear what // under the current implementation
// the ideal behavior is. assert.Equal(t, "0.2", token.String())
assert.Equal(t, "2", token.String())
assert.Equal(t, "0.2", token.AsStandard().String())
} }
func TestJSONMarshal(t *testing.T) { func TestJSONMarshal(t *testing.T) {

View File

@@ -4,6 +4,9 @@
package coins package coins
import ( import (
"errors"
"fmt"
"github.com/cockroachdb/apd/v3" "github.com/cockroachdb/apd/v3"
) )
@@ -21,7 +24,7 @@ func CalcExchangeRate(xmrPrice *apd.Decimal, ethPrice *apd.Decimal) (*ExchangeRa
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err = roundToDecimalPlace(rate, rate, MaxExchangeRateDecimals); err != nil { if rate, err = roundToDecimalPlace(rate, MaxExchangeRateDecimals); err != nil {
return nil, err return nil, err
} }
return ToExchangeRate(rate), nil return ToExchangeRate(rate), nil
@@ -51,48 +54,72 @@ func (r *ExchangeRate) MarshalText() ([]byte, error) {
return r.Decimal().MarshalText() return r.Decimal().MarshalText()
} }
// ToXMR converts an ETH amount to an XMR amount with the given exchange rate // ToXMR converts an ETH amount to an XMR amount with the given exchange rate.
func (r *ExchangeRate) ToXMR(ethAmount *apd.Decimal) (*apd.Decimal, error) { // If the calculated value would have fractional piconeros, an error is
// returned.
func (r *ExchangeRate) ToXMR(ethAssetAmt EthAssetAmount) (*apd.Decimal, error) {
xmrAmt := new(apd.Decimal) xmrAmt := new(apd.Decimal)
_, err := decimalCtx.Quo(xmrAmt, ethAmount, r.Decimal()) _, err := decimalCtx.Quo(xmrAmt, ethAssetAmt.AsStd(), r.Decimal())
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err = roundToDecimalPlace(xmrAmt, xmrAmt, NumMoneroDecimals); err != nil {
return nil, err if ExceedsDecimals(xmrAmt, NumMoneroDecimals) {
errMsg := fmt.Sprintf(
"%s %s / %s exceeds XMR's %d decimal precision",
ethAssetAmt.AsStdString(), ethAssetAmt.StdSymbol(), r, NumMoneroDecimals,
)
suggestedAltAmt := calcAltNumeratorAmount(ethAssetAmt.NumStdDecimals(), NumMoneroDecimals, r.Decimal(), xmrAmt)
if suggestedAltAmt != nil {
errMsg = fmt.Sprintf("%s, try %s", errMsg, suggestedAltAmt.Text('f'))
}
return nil, errors.New(errMsg)
} }
return xmrAmt, nil return xmrAmt, nil
} }
// ToETH converts an XMR amount to an ETH amount with the given exchange rate // ToETH converts an XMR amount to an ETH amount with the given exchange rate.
// If the calculated result would have fractional wei, an error is returned.
func (r *ExchangeRate) ToETH(xmrAmount *apd.Decimal) (*apd.Decimal, error) { func (r *ExchangeRate) ToETH(xmrAmount *apd.Decimal) (*apd.Decimal, error) {
ethAmt := new(apd.Decimal) ethAmt := new(apd.Decimal)
_, err := decimalCtx.Mul(ethAmt, r.Decimal(), xmrAmount) _, err := decimalCtx.Mul(ethAmt, xmrAmount, r.Decimal())
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Assuming the xmrAmount was capped at 12 decimal places and the exchange // Assuming the xmrAmount was capped at 12 decimal places and the exchange
// rate was capped at 6 decimal places, you can't generate more than 18 // rate was capped at 6 decimal places, you can't generate more than 18
// decimal places below, so no rounding occurs. // decimal places below, so the error below can't happen.
if err = roundToDecimalPlace(ethAmt, ethAmt, NumEtherDecimals); err != nil { if ExceedsDecimals(ethAmt, NumEtherDecimals) {
err := fmt.Errorf("%s XMR * %s exceeds ETH's %d decimal precision",
xmrAmount.Text('f'), r, NumEtherDecimals)
return nil, err return nil, err
} }
return ethAmt, nil return ethAmt, nil
} }
// ToERC20Amount converts an XMR amount to a token amount in standard units with // ToERC20Amount converts an XMR amount to a token amount in standard units with
// the given exchange rate // the given exchange rate. If the result requires more decimal places than the
// token allows, an error is returned.
func (r *ExchangeRate) ToERC20Amount(xmrAmount *apd.Decimal, token *ERC20TokenInfo) (*apd.Decimal, error) { func (r *ExchangeRate) ToERC20Amount(xmrAmount *apd.Decimal, token *ERC20TokenInfo) (*apd.Decimal, error) {
erc20Amount := new(apd.Decimal) erc20Amount := new(apd.Decimal)
_, err := decimalCtx.Mul(erc20Amount, r.Decimal(), xmrAmount) _, err := decimalCtx.Mul(erc20Amount, xmrAmount, r.Decimal())
if err != nil { if err != nil {
return nil, err return nil, err
} }
// The token, if required, will get rounded to whole token units in if ExceedsDecimals(erc20Amount, token.NumDecimals) {
// NewERC20TokenAmountFromDecimals. // We could have a suggested value to try, like we have in ToXMR(...),
return NewERC20TokenAmountFromDecimals(erc20Amount, token).AsStandard(), nil // but since this is multiplication and not division, the end user
// probably doesn't need the hint.
err := fmt.Errorf("%s XMR * %s exceeds token's %d decimal precision",
xmrAmount.Text('f'), r, token.NumDecimals)
return nil, err
}
return NewTokenAmountFromDecimals(erc20Amount, token).AsStd(), nil
} }
func (r *ExchangeRate) String() string { func (r *ExchangeRate) String() string {

View File

@@ -13,41 +13,34 @@ import (
func TestExchangeRate_ToXMR(t *testing.T) { func TestExchangeRate_ToXMR(t *testing.T) {
rate := StrToExchangeRate("0.25") // 4 XMR * 0.25 = 1 ETH rate := StrToExchangeRate("0.25") // 4 XMR * 0.25 = 1 ETH
ethAmount := StrToDecimal("1") ethAssetAmt := StrToETHAsset("1", nil)
const expectedXMRAmount = "4" const expectedXMRAmount = "4"
xmrAmount, err := rate.ToXMR(ethAmount) xmrAmount, err := rate.ToXMR(ethAssetAmt)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, expectedXMRAmount, xmrAmount.String()) assert.Equal(t, expectedXMRAmount, xmrAmount.String())
} }
func TestExchangeRate_ToXMR_roundDown(t *testing.T) { func TestExchangeRate_ToXMR_exceedsXMRPrecision(t *testing.T) {
rate := StrToExchangeRate("0.333333")
ethAmount := StrToDecimal("3.1")
// 3.1/0.333333 calculated to 13 decimals is 9.3000093000093 (300009 repeats indefinitely) // 3.1/0.333333 calculated to 13 decimals is 9.3000093000093 (300009 repeats indefinitely)
// This calculator goes to 200 decimals: https://www.mathsisfun.com/calculator-precision.html rate := StrToExchangeRate("0.333333")
// XMR rounds at 12 decimal places to: ethAssetAmt := StrToETHAsset("3.1", nil)
const expectedXMRAmount = "9.300009300009"
xmrAmount, err := rate.ToXMR(ethAmount) _, err := rate.ToXMR(ethAssetAmt)
require.NoError(t, err) expectedErr := "3.1 ETH / 0.333333 exceeds XMR's 12 decimal precision, try 3.099999999999899997"
assert.Equal(t, expectedXMRAmount, xmrAmount.String()) require.ErrorContains(t, err, expectedErr)
}
func TestExchangeRate_ToXMR_roundUp(t *testing.T) {
rate := StrToExchangeRate("0.666666")
ethAmount := StrToDecimal("6.6")
// 6.6/0.666666 to 13 decimal places is 9.9000099000099 (900009 repeats indefinitely) // 6.6/0.666666 to 13 decimal places is 9.9000099000099 (900009 repeats indefinitely)
// The 9 in the 12th position goes to zero changing 11th position to 1: rate = StrToExchangeRate("0.666666")
const expectedXMRAmount = "9.90000990001" // only 11 decimal places shown as 12th is 0 ethAssetAmt = StrToETHAsset("6.6", nil)
xmrAmount, err := rate.ToXMR(ethAmount)
require.NoError(t, err) _, err = rate.ToXMR(ethAssetAmt)
assert.Equal(t, expectedXMRAmount, xmrAmount.String()) expectedErr = "6.6 ETH / 0.666666 exceeds XMR's 12 decimal precision, try 6.60000000000006666"
require.ErrorContains(t, err, expectedErr)
} }
func TestExchangeRate_ToXMR_fail(t *testing.T) { func TestExchangeRate_ToXMR_fail(t *testing.T) {
rateZero := ToExchangeRate(new(apd.Decimal)) // zero exchange rate rateZero := ToExchangeRate(new(apd.Decimal)) // zero exchange rate
_, err := rateZero.ToXMR(StrToDecimal("0.1")) _, err := rateZero.ToXMR(StrToETHAsset("0.1", nil))
require.ErrorContains(t, err, "division by zero") require.ErrorContains(t, err, "division by zero")
} }
@@ -72,35 +65,15 @@ func TestExchangeRate_ToERC20Amount(t *testing.T) {
assert.Equal(t, expectedTokenStandardAmount, erc20Amt.Text('f')) assert.Equal(t, expectedTokenStandardAmount, erc20Amt.Text('f'))
} }
func TestExchangeRate_ToERC20Amount_roundDown(t *testing.T) { func TestExchangeRate_ToERC20Amount_exceedsTokenPrecision(t *testing.T) {
// 0.333333 * 1.0000015 = 0.333333499... const tokenDecimals = 6
// = 0.333333 (token only supports 6 decimals) token := &ERC20TokenInfo{NumDecimals: tokenDecimals}
rate := StrToExchangeRate("0.333333")
// 1.0000015 * 0.333333 = 0.3333334999995
xmrAmount := StrToDecimal("1.0000015") xmrAmount := StrToDecimal("1.0000015")
const tokenDecimals = 6
const expectedTokenStandardAmount = "0.333333"
erc20Info := &ERC20TokenInfo{NumDecimals: tokenDecimals}
erc20Amt, err := rate.ToERC20Amount(xmrAmount, erc20Info)
require.NoError(t, err)
assert.Equal(t, expectedTokenStandardAmount, erc20Amt.Text('f'))
}
func TestExchangeRate_ToERC20Amount_roundUp(t *testing.T) {
// 0.333333 * 1.000001501 = 0.333333500..
// = 0.333334 (token only supports 6 decimals)
rate := StrToExchangeRate("0.333333") rate := StrToExchangeRate("0.333333")
xmrAmount := StrToDecimal("1.000001501") _, err := rate.ToERC20Amount(xmrAmount, token)
require.ErrorContains(t, err, "1.0000015 XMR * 0.333333 exceeds token's 6 decimal precision")
const tokenDecimals = 6
const expectedTokenStandardAmount = "0.333334"
erc20Info := &ERC20TokenInfo{NumDecimals: tokenDecimals}
erc20Amt, err := rate.ToERC20Amount(xmrAmount, erc20Info)
require.NoError(t, err)
assert.Equal(t, expectedTokenStandardAmount, erc20Amt.Text('f'))
} }
func TestExchangeRate_String(t *testing.T) { func TestExchangeRate_String(t *testing.T) {

View File

@@ -5,18 +5,107 @@ package coins
import ( import (
"github.com/cockroachdb/apd/v3" "github.com/cockroachdb/apd/v3"
"github.com/ethereum/go-ethereum/common/math"
) )
func roundToDecimalPlace(result *apd.Decimal, n *apd.Decimal, decimalPlace uint8) error { func roundToDecimalPlace(n *apd.Decimal, decimalPlace uint8) (*apd.Decimal, error) {
result.Set(n) // already optimizes result == n result := new(apd.Decimal).Set(n)
// Adjust the exponent to the rounding place, round, then adjust the exponent back // Adjust the exponent to the rounding place, round, then adjust the exponent back
increaseExponent(result, decimalPlace) increaseExponent(result, decimalPlace)
_, err := decimalCtx.RoundToIntegralValue(result, result) _, err := decimalCtx.RoundToIntegralValue(result, result)
if err != nil { if err != nil {
return err return nil, err
} }
decreaseExponent(result, decimalPlace) decreaseExponent(result, decimalPlace)
_, _ = result.Reduce(result) _, _ = result.Reduce(result)
return nil
return result, nil
}
// ExceedsDecimals returns `true` if the the number, written without an
// exponent, would require more digits after the decimal place than the passed
// value `decimals`. Otherwise, `false` is returned.
func ExceedsDecimals(val *apd.Decimal, maxDecimals uint8) bool {
return NumDecimals(val) > int32(maxDecimals)
}
// NumDecimals returns the minimum number digits needed to represent the passed
// value after the decimal point.
func NumDecimals(value *apd.Decimal) int32 {
// Transfer any rightmost digits in the coefficient to the exponent
_, _ = value.Reduce(value)
if value.Exponent >= 0 {
return 0
}
// The number of base-10 digits that we need to shift left by, is the number
// of digits needed to represent the value after the decimal point.
return -value.Exponent
}
// calcAltNumeratorAmount attempts to find an alternative, close numerator
// amount that will not exceed the result's decimal precision when divided by
// the denominator and will also not exceed the numerator's precision. If
// no close approximation is found, nil is returned.
//
// Take this example, where the calculation below has already failed due to the
// computed XMR amount exceeding XMR's 12 decimal places.
//
// TOKEN_AMOUNT / EXCHANGE_RATE = XMR_AMOUNT_TOO_PRECISE
//
// In the above example, we want to calculate the closest possible numerator
// (TOKEN_AMOUNT) by rounding the right-hand-side and multiplying by the
// denominator (EXCHANGE_RATE).
//
// ROUND(XMR_AMOUNT_TOO_PRECISE, PRECISION) * EXCHANGE_RATE = SUGGESTED_TOKEN_AMOUNT
// or rewritten:
// ROUND(unroundedResult, PRECISION) * denominator = SUGGESTED_ALTERNATE_NUMERATOR
//
// The trick is in computing that maximum value for PRECISION that will yield a
// valid result. Rounding too early increases the chance that we will compute a
// value that is outside of the offer's allowed range (which we don't have
// access to at this point in the code).
func calcAltNumeratorAmount(
numeratorDecimals uint8,
resultDecimals uint8,
denominator *apd.Decimal,
unroundedResult *apd.Decimal,
) *apd.Decimal {
// Start with the max precision that the numerator will allow. Note that the
// denominator's exponent can be positive (300 is 3E2) or negative (0.003 is
// 3E-3). When it is positive, we can round the result at a larger precision
// than the numerator's precision and still get a result that fits in the
// numerator's precision.
_, _ = denominator.Reduce(denominator)
roundingPrecision := denominator.Exponent + int32(numeratorDecimals)
if roundingPrecision < 0 || roundingPrecision > math.MaxUint8 {
return nil
}
// Pick the smaller precision value (precision of result or the precision
// needed to calculate a valid numerator).
if roundingPrecision > int32(resultDecimals) {
roundingPrecision = int32(resultDecimals)
}
roundedResult, err := roundToDecimalPlace(unroundedResult, uint8(roundingPrecision))
if err != nil {
return nil // not reachable
}
closestAltResult := new(apd.Decimal)
_, err = decimalCtx.Mul(closestAltResult, roundedResult, denominator)
if err != nil {
return nil
}
_, _ = closestAltResult.Reduce(closestAltResult)
if closestAltResult.IsZero() {
return nil
}
return closestAltResult
} }

View File

@@ -14,21 +14,147 @@ import (
func Test_roundToDecimalPlace(t *testing.T) { func Test_roundToDecimalPlace(t *testing.T) {
// Round half down // Round half down
amt := StrToDecimal("33.4999999999999999999999999999999999") amt := StrToDecimal("33.4999999999999999999999999999999999")
err := roundToDecimalPlace(amt, amt, 0) amt, err := roundToDecimalPlace(amt, 0)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "33", amt.String()) assert.Equal(t, "33", amt.String())
// Round half up // Round half up
amt = StrToDecimal("33.5") amt = StrToDecimal("33.5")
err = roundToDecimalPlace(amt, amt, 0) amt, err = roundToDecimalPlace(amt, 0)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "34", amt.String()) assert.Equal(t, "34", amt.String())
// Round at Decimal position // Round at Decimal position
amt = StrToDecimal("0.00009") amt = StrToDecimal("0.00009")
res := new(apd.Decimal) // use a separate result variable this time res, err := roundToDecimalPlace(amt, 4) // use a separate result variable this time
err = roundToDecimalPlace(res, amt, 4)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "0.0001", res.String()) assert.Equal(t, "0.0001", res.String())
assert.Equal(t, "0.00009", amt.String()) // input value unchanged assert.Equal(t, "0.00009", amt.String()) // input value unchanged
} }
func Test_exceedsMaxDigitsAfterDecimal(t *testing.T) {
type testCase struct {
val *apd.Decimal
decimals uint8
exceeds bool
}
testCases := []testCase{
{
val: StrToDecimal("1234567890"),
decimals: 0,
exceeds: false,
},
{
val: StrToDecimal("0.0000000000001"), // 13 decimal places
decimals: 12,
exceeds: true,
},
{
val: StrToDecimal("123456789.999999"), // 6 decimal places
decimals: 6,
exceeds: false,
},
{
val: StrToDecimal("123456789.999999"), // 6 decimal places
decimals: 5,
exceeds: true,
},
{
val: StrToDecimal("123.123400000000000000"), // only 4 non-zero decimal places
decimals: 4,
exceeds: false,
},
}
for _, test := range testCases {
r := ExceedsDecimals(test.val, test.decimals)
require.Equal(t, test.exceeds, r)
}
}
func TestExchangeRate_calcAltNumeratorAmount(t *testing.T) {
type testCase struct {
numeratorDecimals uint8
resultDecimals uint8
numerator *apd.Decimal
denominator *apd.Decimal
expectedAltValue *apd.Decimal
}
testCases := []*testCase{
{
//
// 30/20.5 = 1.46341[46341...] (repeats forever, so won't fit in 12 decimals)
//
// In order to back calculate the closest numerator that will work
// (fit within 6 decimals), we need to round the result at 5
// decimals (6 + -1), where the -1 was contributed by the
// denominator.
//
// 1.46341 * 20.5 = 29.999905
//
numeratorDecimals: 6,
resultDecimals: 12,
numerator: StrToDecimal("30"),
denominator: StrToDecimal("20.5"),
expectedAltValue: StrToDecimal("29.999905"),
},
{
//
// Same as the previous example, but now the precision of the
// numerator is 18 decimals, so we can round at the full precision
// (12) of the result.
//
// 1.463414634146 * 20.5 = 29.999999999993
numeratorDecimals: 18,
resultDecimals: 12,
numerator: StrToDecimal("30"),
denominator: StrToDecimal("20.5"),
expectedAltValue: StrToDecimal("29.999999999993"),
},
{
//
// 200/300 = 0.666666666666... (repeats 6 forever)
//
// This example is interesting, because the denominator, when reduced,
// is 3E2, allowing us to round the divided result at 8 decimal (2
// more decimal places than the precision of the numerator).
//
// 0.666667 * 300 = 200.0001 (naive answer, rounding at the numerator's precision)
// 0.66666667 * 300 = 200.000001 (true closest value)
//
numeratorDecimals: 6,
resultDecimals: 12,
numerator: StrToDecimal("200"),
denominator: StrToDecimal("300"),
expectedAltValue: StrToDecimal("200.000001"),
},
{
//
// This is a case were we just return nil. The denominator has 6
// decimal places (what we cap exchange rates at), but the numerator
// is capped at 4 decimals. We can't just multiply any rounded
// result by the denominator and get an alternate numerator that
// fits in 4 decimals.
//
numeratorDecimals: 4,
resultDecimals: 12,
numerator: StrToDecimal("0.1"),
denominator: StrToDecimal("0.333333"),
expectedAltValue: nil,
},
}
for _, tc := range testCases {
result := new(apd.Decimal)
_, err := decimalCtx.Quo(result, tc.numerator, tc.denominator)
require.NoError(t, err)
altVal := calcAltNumeratorAmount(tc.numeratorDecimals, tc.resultDecimals, tc.denominator, result)
if tc.expectedAltValue == nil {
require.Nil(t, altVal)
} else {
require.Equal(t, tc.expectedAltValue.Text('f'), altVal.Text('f'))
}
}
}

View File

@@ -27,6 +27,12 @@ func StrToDecimal(amount string) *apd.Decimal {
return a return a
} }
// StrToETHAsset converts a string into the EthAssetAmount. Pass nil for the
// token if the asset is ETH.
func StrToETHAsset(amount string, optionalToken *ERC20TokenInfo) EthAssetAmount {
return NewEthAssetAmount(StrToDecimal(amount), nil)
}
// StrToExchangeRate converts strings to ExchangeRate for tests, panicking on error. // StrToExchangeRate converts strings to ExchangeRate for tests, panicking on error.
func StrToExchangeRate(rate string) *ExchangeRate { func StrToExchangeRate(rate string) *ExchangeRate {
r := new(ExchangeRate) r := new(ExchangeRate)

View File

@@ -39,5 +39,8 @@ type Error struct {
// Error ... // Error ...
func (e *Error) Error() string { func (e *Error) Error() string {
return fmt.Sprintf("message=%s; code=%d; data=%v", e.Message, e.ErrorCode, e.Data) if e.ErrorCode != 0 {
return fmt.Sprintf("message=%s; code=%d; data=%v", e.Message, e.ErrorCode, e.Data)
}
return e.Message
} }

View File

@@ -152,14 +152,18 @@ func (o *Offer) validate() error {
// can't be used to complete the swap if the maker does not have sufficient // 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 // 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. // swaps, we still want a minimum swap amount, so we use the same value.
relayerFeeAsXMR, err := o.ExchangeRate.ToXMR(coins.RelayerFeeETH) minAmtAsETH, err := o.ExchangeRate.ToETH(o.MinAmount)
if err != nil { if err != nil {
return err return err
} }
if o.MinAmount.Cmp(relayerFeeAsXMR) <= 0 { if minAmtAsETH.Cmp(coins.RelayerFeeETH) <= 0 {
return fmt.Errorf( return fmt.Errorf(
"min amount must be greater than %s ETH when converted (%s XMR)", "min amount must be greater than %s ETH when converted (%s XMR * %s = %s ETH)",
coins.RelayerFeeETH.Text('f'), relayerFeeAsXMR.Text('f')) coins.RelayerFeeETH.Text('f'),
o.MaxAmount.Text('f'),
o.ExchangeRate,
minAmtAsETH.Text('f'),
)
} }
if o.MaxAmount.Cmp(maxOfferValue) > 0 { if o.MaxAmount.Cmp(maxOfferValue) > 0 {

View File

@@ -179,7 +179,7 @@ func TestOffer_UnmarshalJSON_BadAmountsOrRate(t *testing.T) {
{ {
// 0.01 relayer fee is 0.1 XMR with exchange rate of 0.1 // 0.01 relayer fee is 0.1 XMR with exchange rate of 0.1
jsonData: fmt.Sprintf(offerJSON, `"0.01"`, `"10"`, `"0.1"`), jsonData: fmt.Sprintf(offerJSON, `"0.01"`, `"10"`, `"0.1"`),
errContains: `min amount must be greater than 0.01 ETH when converted (0.1 XMR)`, errContains: `min amount must be greater than 0.01 ETH when converted (10 XMR * 0.1 = 0.001 ETH)`,
}, },
// Max Amount checks // Max Amount checks
{ {

View File

@@ -0,0 +1,58 @@
package daemon
import (
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/athanorlabs/atomic-swap/coins"
"github.com/athanorlabs/atomic-swap/monero"
"github.com/athanorlabs/atomic-swap/rpcclient"
"github.com/athanorlabs/atomic-swap/tests"
)
// Tests end-to-end (client->swapd->client) make/take failures with currency
// precision issues.
func TestBadMakeTakeValues(t *testing.T) {
bobConf := CreateTestConf(t, tests.GetMakerTestKey(t))
monero.MineMinXMRBalance(t, bobConf.MoneroClient, coins.MoneroToPiconero(coins.StrToDecimal("10")))
aliceConf := CreateTestConf(t, tests.GetTakerTestKey(t))
timeout := 3 * time.Minute
ctx, _ := LaunchDaemons(t, timeout, bobConf, aliceConf)
bc := rpcclient.NewClient(ctx, bobConf.RPCPort)
ac := rpcclient.NewClient(ctx, aliceConf.RPCPort)
// Trigger a TakeOffer failure because the precision of the min/or max
// value combined with the exchange rate would exceed the token's precision.
// 14.979329 * 13.3 = 199.2250757 (7 digits of precision)
minMaxXMRAmt := coins.StrToDecimal("14.979329")
exRate := coins.StrToExchangeRate("13.3")
mockTether := getMockTetherAsset(t, aliceConf.EthereumClient)
expectedErr := `"net_makeOffer" failed: 14.979329 XMR * 13.3 exceeds token's 6 decimal precision`
_, err := bc.MakeOffer(minMaxXMRAmt, minMaxXMRAmt, exRate, mockTether, false)
require.ErrorContains(t, err, expectedErr)
t.Log(err)
// Now configure the MakeOffer to succeed, so we can fail some TakeOffer calls
minXMRAmt := coins.StrToDecimal("1")
maxXMRAmt := coins.StrToDecimal("10")
providesAmt := coins.StrToDecimal("5.1234567") // 7 digits, max is 6
makeResp, err := bc.MakeOffer(minXMRAmt, maxXMRAmt, exRate, mockTether, false)
require.NoError(t, err)
// Fail because providesAmount has too much precision in the token's standard units
err = ac.TakeOffer(makeResp.PeerID, makeResp.OfferID, providesAmt)
require.ErrorContains(t, err, `"net_takeOffer" failed: "providesAmount" has too many decimal points; found=7 max=6`)
// Fail because the providesAmount has too much precision when converted into XMR
// 20.123456/13.3 = 1.51304[180451127819548872] (bracketed sequence repeats forever)
providesAmt = coins.StrToDecimal("20.123456")
err = ac.TakeOffer(makeResp.PeerID, makeResp.OfferID, providesAmt)
expectedErr = `"net_takeOffer" failed: 20.123456 "USDT" / 13.3 exceeds XMR's 12 decimal precision, try 20.123432`
require.ErrorContains(t, err, expectedErr)
t.Log(err)
}

View File

@@ -28,6 +28,7 @@ import (
// so she refunds the swap. // so she refunds the swap.
// Bob should have aborted the swap in all cases. // Bob should have aborted the swap in all cases.
func TestXMRTakerCancelOrRefundAfterKeyExchange(t *testing.T) { func TestXMRTakerCancelOrRefundAfterKeyExchange(t *testing.T) {
t.Skip("Test disabled until https://github.com/AthanorLabs/atomic-swap/issues/479 is fixed")
minXMR := coins.StrToDecimal("1") minXMR := coins.StrToDecimal("1")
maxXMR := minXMR maxXMR := minXMR
exRate := coins.StrToExchangeRate("300") exRate := coins.StrToExchangeRate("300")

View File

@@ -43,8 +43,7 @@ func TestAliceDoubleRestartAfterXMRLock(t *testing.T) {
bc := rpcclient.NewClient(context.Background(), bobConf.RPCPort) bc := rpcclient.NewClient(context.Background(), bobConf.RPCPort)
ac := rpcclient.NewClient(context.Background(), aliceConf.RPCPort) ac := rpcclient.NewClient(context.Background(), aliceConf.RPCPort)
tokenAddr := GetMockTokens(t, aliceConf.EthereumClient)[MockTether] tokenAsset := getMockTetherAsset(t, aliceConf.EthereumClient)
tokenAsset := types.EthAsset(tokenAddr)
makeResp, bobStatusCh, err := bc.MakeOfferAndSubscribe(minXMR, maxXMR, exRate, tokenAsset, false) makeResp, bobStatusCh, err := bc.MakeOfferAndSubscribe(minXMR, maxXMR, exRate, tokenAsset, false)
require.NoError(t, err) require.NoError(t, err)

View File

@@ -5,6 +5,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/cockroachdb/apd/v3"
ethcommon "github.com/ethereum/go-ethereum/common" ethcommon "github.com/ethereum/go-ethereum/common"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -29,8 +30,7 @@ func TestRunSwapDaemon_ExchangesXMRForERC20Tokens(t *testing.T) {
aliceConf := CreateTestConf(t, tests.GetTakerTestKey(t)) aliceConf := CreateTestConf(t, tests.GetTakerTestKey(t))
tokenAddr := GetMockTokens(t, aliceConf.EthereumClient)[MockTether] tokenAsset := getMockTetherAsset(t, aliceConf.EthereumClient)
tokenAsset := types.EthAsset(tokenAddr)
timeout := 7 * time.Minute timeout := 7 * time.Minute
ctx, _ := LaunchDaemons(t, timeout, aliceConf, bobConf) ctx, _ := LaunchDaemons(t, timeout, aliceConf, bobConf)
@@ -38,6 +38,9 @@ func TestRunSwapDaemon_ExchangesXMRForERC20Tokens(t *testing.T) {
bc := rpcclient.NewClient(ctx, bobConf.RPCPort) bc := rpcclient.NewClient(ctx, bobConf.RPCPort)
ac := rpcclient.NewClient(ctx, aliceConf.RPCPort) ac := rpcclient.NewClient(ctx, aliceConf.RPCPort)
bobStartTokenBal, err := bobConf.EthereumClient.ERC20Balance(ctx, tokenAsset.Address())
require.NoError(t, err)
_, bobStatusCh, err := bc.MakeOfferAndSubscribe(minXMR, maxXMR, exRate, tokenAsset, false) _, bobStatusCh, err := bc.MakeOfferAndSubscribe(minXMR, maxXMR, exRate, tokenAsset, false)
require.NoError(t, err) require.NoError(t, err)
time.Sleep(250 * time.Millisecond) // offer propagation time time.Sleep(250 * time.Millisecond) // offer propagation time
@@ -104,10 +107,12 @@ func TestRunSwapDaemon_ExchangesXMRForERC20Tokens(t *testing.T) {
// //
// Check Bob's token balance via RPC method instead of doing it directly // Check Bob's token balance via RPC method instead of doing it directly
// //
balances, err := bc.Balances(&rpctypes.BalancesRequest{TokenAddrs: []ethcommon.Address{tokenAddr}}) endBalances, err := bc.Balances(&rpctypes.BalancesRequest{TokenAddrs: []ethcommon.Address{tokenAsset.Address()}})
require.NoError(t, err) require.NoError(t, err)
t.Logf("Balances: %#v", balances) require.NotEmpty(t, endBalances.TokenBalances)
require.NotEmpty(t, balances.TokenBalances) delta := new(apd.Decimal)
require.Equal(t, providesAmt.Text('f'), balances.TokenBalances[0].AsStandardString()) _, err = coins.DecimalCtx().Sub(delta, endBalances.TokenBalances[0].Amount, bobStartTokenBal.Amount)
require.NoError(t, err)
require.Equal(t, providesAmt.Text('f'), delta.Text('f'))
} }

View File

@@ -40,8 +40,7 @@ func TestAliceStoppedAndRestartedDuringXMRSweep(t *testing.T) {
bc := rpcclient.NewClient(context.Background(), bobConf.RPCPort) bc := rpcclient.NewClient(context.Background(), bobConf.RPCPort)
ac := rpcclient.NewClient(context.Background(), aliceConf.RPCPort) ac := rpcclient.NewClient(context.Background(), aliceConf.RPCPort)
tokenAddr := GetMockTokens(t, aliceConf.EthereumClient)[MockTether] tokenAsset := getMockTetherAsset(t, aliceConf.EthereumClient)
tokenAsset := types.EthAsset(tokenAddr)
makeResp, bobStatusCh, err := bc.MakeOfferAndSubscribe(minXMR, maxXMR, exRate, tokenAsset, false) makeResp, bobStatusCh, err := bc.MakeOfferAndSubscribe(minXMR, maxXMR, exRate, tokenAsset, false)
require.NoError(t, err) require.NoError(t, err)

View File

@@ -9,7 +9,6 @@ import (
"context" "context"
"crypto/ecdsa" "crypto/ecdsa"
"fmt" "fmt"
"math/big"
"net" "net"
"path" "path"
"sync" "sync"
@@ -17,11 +16,11 @@ import (
"testing" "testing"
"time" "time"
ethcommon "github.com/ethereum/go-ethereum/common"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/athanorlabs/atomic-swap/bootnode" "github.com/athanorlabs/atomic-swap/bootnode"
"github.com/athanorlabs/atomic-swap/common" "github.com/athanorlabs/atomic-swap/common"
"github.com/athanorlabs/atomic-swap/common/types"
contracts "github.com/athanorlabs/atomic-swap/ethereum" contracts "github.com/athanorlabs/atomic-swap/ethereum"
"github.com/athanorlabs/atomic-swap/ethereum/extethclient" "github.com/athanorlabs/atomic-swap/ethereum/extethclient"
"github.com/athanorlabs/atomic-swap/monero" "github.com/athanorlabs/atomic-swap/monero"
@@ -29,12 +28,6 @@ import (
"github.com/athanorlabs/atomic-swap/tests" "github.com/athanorlabs/atomic-swap/tests"
) )
// map indexes for our mock tokens
const (
MockDAI = "DAI"
MockTether = "USDT"
)
// This file is only for test support. Use the build tag "prod" to prevent // This file is only for test support. Use the build tag "prod" to prevent
// symbols in this file from consuming space in production binaries. // symbols in this file from consuming space in production binaries.
@@ -183,57 +176,7 @@ func WaitForSwapdStart(t *testing.T, rpcPort uint16) {
t.Fatalf("giving up, swapd RPC port %d is not listening after %d seconds", rpcPort, maxSeconds) t.Fatalf("giving up, swapd RPC port %d is not listening after %d seconds", rpcPort, maxSeconds)
} }
// these variables are only for use by GetMockTokens func getMockTetherAsset(t *testing.T, ec extethclient.EthClient) types.EthAsset {
var _mockTokens map[string]ethcommon.Address token := contracts.GetMockTether(t, ec.Raw(), ec.PrivateKey())
var _mockTokensMu sync.Mutex return types.EthAsset(token.Address)
// GetMockTokens returns a symbol=>address map of our mock ERC20 tokens,
// deploying them if they haven't already been deployed. Use the constants
// defined earlier to access the map elements.
func GetMockTokens(t *testing.T, ec extethclient.EthClient) map[string]ethcommon.Address {
_mockTokensMu.Lock()
defer _mockTokensMu.Unlock()
if _mockTokens == nil {
_mockTokens = make(map[string]ethcommon.Address)
}
calcSupply := func(numStdUnits int64, decimals int64) *big.Int {
return new(big.Int).Mul(big.NewInt(numStdUnits), new(big.Int).Exp(big.NewInt(10), big.NewInt(decimals), nil))
}
ctx := context.Background()
txOpts, err := ec.TxOpts(ctx)
require.NoError(t, err)
mockDaiAddr, mockDaiTx, _, err := contracts.DeployTestERC20(
txOpts,
ec.Raw(),
"Dai Stablecoin",
"DAI",
18,
ec.Address(),
calcSupply(1000, 18),
)
require.NoError(t, err)
tests.MineTransaction(t, ec.Raw(), mockDaiTx)
_mockTokens[MockDAI] = mockDaiAddr
txOpts, err = ec.TxOpts(ctx)
require.NoError(t, err)
mockTetherAddr, mockTetherTx, _, err := contracts.DeployTestERC20(
txOpts,
ec.Raw(),
"Tether USD",
"USDT",
6,
ec.Address(),
calcSupply(1000, 6),
)
require.NoError(t, err)
tests.MineTransaction(t, ec.Raw(), mockTetherTx)
_mockTokens[MockTether] = mockTetherAddr
return _mockTokens
} }

View File

@@ -1,8 +1,9 @@
# Joining the Stagenet/Sepolia network # Joining the Stagenet/Sepolia network
Currently, an initial version of the swap is deployed onto the Sepolia (Ethereum testnet) and Monero Stagenet networks. To join the network and try out the swap, either as a maker or a taker, please see the following. Swaps can be performed on the Sepolia (Ethereum testnet) together with the
Monero Stagenet network. This document describes how to do stagenet swaps.
> Note: a swap on stagenet currently takes around 10-20 minutes due to block times. > Note: stagenet and mainnet swaps currently take around 20 minutes due to the 2-minute block time and required 10 confirmations before funds can be spent.
> Note: the `swapd` process directly interacts with an unlocked Monero wallet and Ethereum private key. This is to allow for a smoother swap process that doesn't require any interaction from you once initiated. However, this effectively gives `swapd` access to all your (testnet) funds. In the future, there will be a mode that does not access your keys/wallet, but will require user interaction during a swap. > Note: the `swapd` process directly interacts with an unlocked Monero wallet and Ethereum private key. This is to allow for a smoother swap process that doesn't require any interaction from you once initiated. However, this effectively gives `swapd` access to all your (testnet) funds. In the future, there will be a mode that does not access your keys/wallet, but will require user interaction during a swap.
@@ -38,11 +39,19 @@ For Linux 64-bit, you can do:
named `{DATA_DIR}/eth.key`. If you skip this step, a new wallet will be created for you named `{DATA_DIR}/eth.key`. If you skip this step, a new wallet will be created for you
that you can transfer Sepolia ether to or fund directly in the next step. that you can transfer Sepolia ether to or fund directly in the next step.
6. Fund your Sepolia account using a faucet: 6a. Fund your Sepolia account using a faucet:
- https://sepolia-faucet.pk910.de/ - https://sepolia-faucet.pk910.de/
- https://sepoliafaucet.com/ - https://sepoliafaucet.com/
- https://sepolia.dev/ - https://sepolia.dev/
6b. Optional: Obtain some Sepolia ERC20 tokens
This [unaffiliated project](https://github.com/bokkypoobah/WeenusTokenFaucet/blob/master/README.md)
has deployed some Sepolia tokens of different decimal sizes that can be useful
for testing. You can use MetaMask to send the contract address zero Sepolia ETH
and the contract will grant you 1000 of its ERC20 tokens. You will pay gas fees
that you should validate are sane before sending.
7. Obtain a Sepolia JSON-RPC endpoint. If you don't want to sync your own node, you can find public ones here: https://sepolia.dev/ 7. Obtain a Sepolia JSON-RPC endpoint. If you don't want to sync your own node, you can find public ones here: https://sepolia.dev/
8. Install go 1.20+. See [build instructions](./build.md) for more details. 8. Install go 1.20+. See [build instructions](./build.md) for more details.

View File

@@ -8,13 +8,17 @@ package contracts
import ( import (
"context" "context"
"crypto/ecdsa" "crypto/ecdsa"
"math/big"
"sync" "sync"
"testing" "testing"
"github.com/ethereum/go-ethereum/accounts/abi/bind"
ethcommon "github.com/ethereum/go-ethereum/common" ethcommon "github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/ethclient"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/athanorlabs/atomic-swap/coins"
"github.com/athanorlabs/atomic-swap/common"
"github.com/athanorlabs/atomic-swap/ethereum/block" "github.com/athanorlabs/atomic-swap/ethereum/block"
) )
@@ -54,3 +58,128 @@ func DevDeploySwapCreator(t *testing.T, ec *ethclient.Client, pk *ecdsa.PrivateK
return *_swapCreatorAddr, _swapCreator return *_swapCreatorAddr, _swapCreator
} }
// variables should only be accessed by GetMockTether
var _mockTether *coins.ERC20TokenInfo
var _mockTetherMu sync.Mutex
// GetMockTether returns the ERC20TokenInfo of a dev token configured with
// similar parameters to Tether.
func GetMockTether(t *testing.T, ec *ethclient.Client, pk *ecdsa.PrivateKey) *coins.ERC20TokenInfo {
const (
name = "Tether USD"
symbol = "USDT"
numDecimals = 6
)
_mockTetherMu.Lock()
defer _mockTetherMu.Unlock()
if _mockTether != nil {
mintTokens(t, ec, pk, _mockTether)
return _mockTether
}
ownerAddress := common.EthereumPrivateKeyToAddress(pk)
ctx := context.Background()
txOpts, err := newTXOpts(ctx, ec, pk)
require.NoError(t, err)
supply := calcTokenUnits(1000, numDecimals)
addr, tx, _, err := DeployTestERC20(txOpts, ec, name, symbol, numDecimals, ownerAddress, supply)
require.NoError(t, err)
_, err = block.WaitForReceipt(context.Background(), ec, tx.Hash())
require.NoError(t, err)
_mockTether = &coins.ERC20TokenInfo{
Address: addr,
NumDecimals: numDecimals,
Name: name,
Symbol: symbol,
}
return _mockTether
}
// variables should only be accessed by GetMockDAI
var _mockDAI *coins.ERC20TokenInfo
var _mockDAIMu sync.Mutex
// GetMockDAI returns the ERC20TokenInfo of a dev token configured with
// similar parameters to the DAI stablecoin.
func GetMockDAI(t *testing.T, ec *ethclient.Client, pk *ecdsa.PrivateKey) *coins.ERC20TokenInfo {
const (
name = "Dai Stablecoin"
symbol = "DAI"
numDecimals = 18
)
_mockDAIMu.Lock()
defer _mockDAIMu.Unlock()
if _mockDAI != nil {
mintTokens(t, ec, pk, _mockDAI)
return _mockDAI
}
ownerAddress := common.EthereumPrivateKeyToAddress(pk)
ctx := context.Background()
txOpts, err := newTXOpts(ctx, ec, pk)
require.NoError(t, err)
supply := calcTokenUnits(1000, numDecimals)
addr, tx, _, err := DeployTestERC20(txOpts, ec, name, symbol, numDecimals, ownerAddress, supply)
require.NoError(t, err)
_, err = block.WaitForReceipt(context.Background(), ec, tx.Hash())
require.NoError(t, err)
_mockDAI = &coins.ERC20TokenInfo{
Address: addr,
NumDecimals: numDecimals,
Name: name,
Symbol: symbol,
}
return _mockDAI
}
// calcTokenUnits converts the token's standard units into its internal,
// smallest non-divisible units.
func calcTokenUnits(numStdUnits int64, decimals uint8) *big.Int {
powerOf10 := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimals)), nil)
return new(big.Int).Mul(big.NewInt(numStdUnits), powerOf10)
}
// mintTokens ensures that the account associated with `pk` has at least 1000 tokens
func mintTokens(t *testing.T, ec *ethclient.Client, pk *ecdsa.PrivateKey, token *coins.ERC20TokenInfo) {
ctx := context.Background()
bindOpts := &bind.CallOpts{Context: ctx}
tokenContract, err := NewTestERC20(token.Address, ec)
require.NoError(t, err)
decimals, err := tokenContract.Decimals(bindOpts)
require.NoError(t, err)
symbol, err := tokenContract.Symbol(bindOpts)
require.NoError(t, err)
require.Equal(t, token.Symbol, symbol)
ownerAddress := common.EthereumPrivateKeyToAddress(pk)
desiredAmt := calcTokenUnits(1000, decimals)
currentAmt, err := tokenContract.BalanceOf(bindOpts, ownerAddress)
require.NoError(t, err)
if currentAmt.Cmp(desiredAmt) < 0 {
txOpts, err := newTXOpts(context.Background(), ec, pk)
require.NoError(t, err)
mintAmt := new(big.Int).Sub(desiredAmt, currentAmt)
tx, err := tokenContract.Mint(txOpts, ownerAddress, mintAmt)
require.NoError(t, err)
_, err = block.WaitForReceipt(ctx, ec, tx.Hash())
require.NoError(t, err)
}
}

View File

@@ -132,7 +132,7 @@ func (s *ExternalSender) NewSwap(
tx := &Transaction{ tx := &Transaction{
To: s.contractAddr, To: s.contractAddr,
Data: input, Data: input,
Value: amount.AsStandard(), Value: amount.AsStd(),
} }
s.Lock() s.Lock()

View File

@@ -110,7 +110,7 @@ func (s *privateKeySender) NewSwap(
log.Debugf("approve transaction included %s", common.ReceiptInfo(receipt)) log.Debugf("approve transaction included %s", common.ReceiptInfo(receipt))
log.Infof("%s %s approved for use by SwapCreator's new_swap", log.Infof("%s %s approved for use by SwapCreator's new_swap",
amount.AsStandard().Text('f'), amount.StandardSymbol()) amount.AsStdString(), amount.StdSymbol())
} }
txOpts, err := s.ethClient.TxOpts(s.ctx) txOpts, err := s.ethClient.TxOpts(s.ctx)

View File

@@ -23,8 +23,33 @@ func (inst *Instance) MakeOffer(
return nil, err return nil, err
} }
if useRelayer && o.EthAsset.IsToken() { if o.EthAsset.IsToken() {
return nil, errRelayingWithNonEthAsset if useRelayer {
return nil, errRelayingWithNonEthAsset
}
token, err := inst.backend.ETHClient().ERC20Info(inst.backend.Ctx(), o.EthAsset.Address()) //nolint:govet
if err != nil {
return nil, err
}
// We limit exchange rates to 6 decimals and the min/max XMR amounts to
// 12 decimals when marshalling the offer. This means we can never
// exceed ETH's 18 decimals when multiplying min/max values by the
// exchange rate. Tokens can have far fewer decimals though, so we need
// additional checks. Calculating the exchange rate will give a good
// error message if the combined precision of the exchange rate and
// min/max values would exceed the token's precision.
_, err = o.ExchangeRate.ToERC20Amount(o.MinAmount, token)
if err != nil {
return nil, err
}
_, err = o.ExchangeRate.ToERC20Amount(o.MaxAmount, token)
if err != nil {
return nil, err
}
} }
extra, err := inst.offerManager.AddOffer(o, useRelayer) extra, err := inst.offerManager.AddOffer(o, useRelayer)

View File

@@ -14,12 +14,11 @@ import (
"github.com/athanorlabs/atomic-swap/common" "github.com/athanorlabs/atomic-swap/common"
"github.com/athanorlabs/atomic-swap/common/types" "github.com/athanorlabs/atomic-swap/common/types"
contracts "github.com/athanorlabs/atomic-swap/ethereum" contracts "github.com/athanorlabs/atomic-swap/ethereum"
pcommon "github.com/athanorlabs/atomic-swap/protocol"
) )
// checkContract checks the contract's balance and Claim/Refund keys. // checkContract checks the contract's balance and Claim/Refund keys. If the
// if the balance doesn't match what we're expecting to receive, or the public keys in the contract // balance doesn't match what we're expecting to receive, or the public keys in
// aren't what we expect, we error and abort the swap. // the contract aren't what we expect, we error and abort the swap.
func (s *swapState) checkContract(txHash ethcommon.Hash) error { func (s *swapState) checkContract(txHash ethcommon.Hash) error {
tx, _, err := s.ETHClient().Raw().TransactionByHash(s.ctx, txHash) tx, _, err := s.ETHClient().Raw().TransactionByHash(s.ctx, txHash)
if err != nil { if err != nil {
@@ -82,7 +81,7 @@ func (s *swapState) checkContract(txHash ethcommon.Hash) error {
return fmt.Errorf("swap value and event value don't match: got %v, expected %v", event.Value, s.contractSwap.Value) return fmt.Errorf("swap value and event value don't match: got %v, expected %v", event.Value, s.contractSwap.Value)
} }
expectedAmount, err := pcommon.GetEthAssetAmount( expectedAmount, err := getEthAssetAmount(
s.ctx, s.ctx,
s.ETHClient(), s.ETHClient(),
s.info.ExpectedAmount, s.info.ExpectedAmount,

View File

@@ -38,7 +38,7 @@ func (s *swapState) claimFunds() (*ethtypes.Receipt, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
log.Infof("balance before claim: %s %s", balance.AsStandardString(), balance.StandardSymbol()) log.Infof("balance before claim: %s %s", balance.AsStdString(), balance.StdSymbol())
} }
hasBalanceToClaim, err := checkForMinClaimBalance(s.ctx, s.ETHClient()) hasBalanceToClaim, err := checkForMinClaimBalance(s.ctx, s.ETHClient())
@@ -91,7 +91,7 @@ func (s *swapState) claimFunds() (*ethtypes.Receipt, error) {
return nil, err return nil, err
} }
log.Infof("balance after claim: %s %s", balance.AsStandardString(), balance.StandardSymbol()) log.Infof("balance after claim: %s %s", balance.AsStdString(), balance.StdSymbol())
} }
return receipt, nil return receipt, nil

View File

@@ -1,7 +1,7 @@
// Copyright 2023 The AthanorLabs/atomic-swap Authors // Copyright 2023 The AthanorLabs/atomic-swap Authors
// SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
package protocol package xmrmaker
import ( import (
"context" "context"
@@ -14,21 +14,29 @@ import (
"github.com/athanorlabs/atomic-swap/ethereum/extethclient" "github.com/athanorlabs/atomic-swap/ethereum/extethclient"
) )
// GetEthAssetAmount converts the passed asset amt (in standard units) to // getEthAssetAmount converts the passed asset amt (in standard units) to
// EthAssetAmount (ie WeiAmount or ERC20TokenAmount) // EthAssetAmount (ie WeiAmount or ERC20TokenAmount)
func GetEthAssetAmount( func getEthAssetAmount(
ctx context.Context, ctx context.Context,
ec extethclient.EthClient, ec extethclient.EthClient,
amt *apd.Decimal, // in standard units amt *apd.Decimal, // in standard units
asset types.EthAsset, asset types.EthAsset,
) (coins.EthAssetAmount, error) { ) (coins.EthAssetAmount, error) {
if asset != types.EthAssetETH { if asset.IsToken() {
tokenInfo, err := ec.ERC20Info(ctx, asset.Address()) token, err := ec.ERC20Info(ctx, asset.Address())
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get ERC20 info: %w", err) return nil, fmt.Errorf("failed to get ERC20 info: %w", err)
} }
return coins.NewERC20TokenAmountFromDecimals(amt, tokenInfo), nil if coins.ExceedsDecimals(amt, token.NumDecimals) {
return nil, fmt.Errorf("value can not be represented in the token's %d decimals", token.NumDecimals)
}
return coins.NewTokenAmountFromDecimals(amt, token), nil
}
if coins.ExceedsDecimals(amt, coins.NumEtherDecimals) {
return nil, fmt.Errorf("value can not be represented in ETH's %d decimals", coins.NumEtherDecimals)
} }
return coins.EtherToWei(amt), nil return coins.EtherToWei(amt), nil

View File

@@ -110,44 +110,48 @@ func (inst *Instance) HandleInitiateMessage(
return nil, errOfferIDNotSet return nil, errOfferIDNotSet
} }
// TODO: If this is not ETH, we need quick/easy access to the number
// of token decimal places. Should it be in the OfferExtra struct?
err := coins.ValidatePositive("providedAmount", coins.NumEtherDecimals, msg.ProvidedAmount)
if err != nil {
return nil, err
}
offer, offerExtra, err := inst.offerManager.GetOffer(msg.OfferID) offer, offerExtra, err := inst.offerManager.GetOffer(msg.OfferID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
providedAmount, err := offer.ExchangeRate.ToXMR(msg.ProvidedAmount) maxDecimals := uint8(coins.NumEtherDecimals)
var token *coins.ERC20TokenInfo
if offer.EthAsset.IsToken() {
token, err = inst.backend.ETHClient().ERC20Info(inst.backend.Ctx(), offer.EthAsset.Address())
if err != nil {
return nil, err
}
maxDecimals = token.NumDecimals
}
err = coins.ValidatePositive("providedAmount", maxDecimals, msg.ProvidedAmount)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if providedAmount.Cmp(offer.MinAmount) < 0 { expectedAmount := coins.NewEthAssetAmount(msg.ProvidedAmount, token)
// The calculation below will return an error if the provided amount, when
// represented in XMR, would require fractional piconeros. This can happen
// more easily than one might expect, as ToXMR is doing a division by the
// exchange rate. The taker also verifies that their provided amount will
// not result in fractional piconeros, so the issue will normally be caught
// before the taker ever contacts us.
providedAmtAsXMR, err := offer.ExchangeRate.ToXMR(expectedAmount)
if err != nil {
return nil, err
}
if providedAmtAsXMR.Cmp(offer.MinAmount) < 0 {
return nil, errAmountProvidedTooLow{msg.ProvidedAmount, offer.MinAmount} return nil, errAmountProvidedTooLow{msg.ProvidedAmount, offer.MinAmount}
} }
if providedAmount.Cmp(offer.MaxAmount) > 0 { if providedAmtAsXMR.Cmp(offer.MaxAmount) > 0 {
return nil, errAmountProvidedTooHigh{msg.ProvidedAmount, offer.MaxAmount} return nil, errAmountProvidedTooHigh{msg.ProvidedAmount, offer.MaxAmount}
} }
providedPiconero := coins.MoneroToPiconero(providedAmount) providedPiconero := coins.MoneroToPiconero(providedAmtAsXMR)
// check decimals if ERC20
// note: this is our counterparty's provided amount, ie. how much we're receiving
expectedAmount, err := pcommon.GetEthAssetAmount(
inst.backend.Ctx(),
inst.backend.ETHClient(),
msg.ProvidedAmount,
offer.EthAsset,
)
if err != nil {
return nil, err
}
state, err := inst.initiate(takerPeerID, offer, offerExtra, providedPiconero, expectedAmount) state, err := inst.initiate(takerPeerID, offer, offerExtra, providedPiconero, expectedAmount)
if err != nil { if err != nil {

View File

@@ -138,7 +138,7 @@ func newSwapStateFromStart(
offer.ID, offer.ID,
coins.ProvidesXMR, coins.ProvidesXMR,
providesAmount.AsMonero(), providesAmount.AsMonero(),
desiredAmount.AsStandard(), desiredAmount.AsStd(),
offer.ExchangeRate, offer.ExchangeRate,
offer.EthAsset, offer.EthAsset,
stage, stage,

View File

@@ -8,6 +8,8 @@ import (
"fmt" "fmt"
"github.com/cockroachdb/apd/v3" "github.com/cockroachdb/apd/v3"
"github.com/athanorlabs/atomic-swap/coins"
) )
var ( var (
@@ -50,26 +52,34 @@ func errContractAddrMismatch(addr string) error {
} }
type errAmountProvidedTooLow struct { type errAmountProvidedTooLow struct {
providedAmtETH *apd.Decimal providedAmtETH *apd.Decimal
offerMinAmtETH *apd.Decimal providedAmtAsXMR *apd.Decimal
offerMinAmtXMR *apd.Decimal
exchangeRate *coins.ExchangeRate
} }
func (e errAmountProvidedTooLow) Error() string { func (e errAmountProvidedTooLow) Error() string {
return fmt.Sprintf("%s ETH provided is under offer minimum of %s ETH", return fmt.Sprintf("provided ETH converted to XMR is under offer min of %s XMR (%s ETH / %s = %s)",
e.offerMinAmtXMR.Text('f'),
e.providedAmtETH.Text('f'), e.providedAmtETH.Text('f'),
e.offerMinAmtETH.Text('f'), e.exchangeRate,
e.providedAmtAsXMR.Text('f'),
) )
} }
type errAmountProvidedTooHigh struct { type errAmountProvidedTooHigh struct {
providedAmtETH *apd.Decimal providedAmtETH *apd.Decimal
offerMaxETH *apd.Decimal providedAmtAsXMR *apd.Decimal
offerMaxAmtXMR *apd.Decimal
exchangeRate *coins.ExchangeRate
} }
func (e errAmountProvidedTooHigh) Error() string { func (e errAmountProvidedTooHigh) Error() string {
return fmt.Sprintf("%s ETH provided is over offer maximum of %s ETH", return fmt.Sprintf("provided ETH converted to XMR is over offer max of %s XMR (%s ETH / %s = %s XMR)",
e.offerMaxAmtXMR.Text('f'),
e.providedAmtETH.Text('f'), e.providedAmtETH.Text('f'),
e.offerMaxETH.Text('f'), e.exchangeRate,
e.providedAmtAsXMR.Text('f'),
) )
} }

View File

@@ -77,7 +77,7 @@ func TestSwapState_handleEvent_EventETHClaimed(t *testing.T) {
msg = s.SendKeysMessage().(*message.SendKeysMessage) msg = s.SendKeysMessage().(*message.SendKeysMessage)
msg.PrivateViewKey = s.privkeys.ViewKey() msg.PrivateViewKey = s.privkeys.ViewKey()
msg.EthAddress = s.ETHClient().Address() msg.EthAddress = s.ETHClient().Address()
msg.ProvidedAmount = s.providedAmount.AsStandard() msg.ProvidedAmount = s.providedAmount.AsStd()
err = s.HandleProtocolMessage(msg) err = s.HandleProtocolMessage(msg)
require.NoError(t, err) require.NoError(t, err)

View File

@@ -71,11 +71,11 @@ func validateMinBalForTokenSwap(
providesAmt *apd.Decimal, // standard units providesAmt *apd.Decimal, // standard units
gasPriceWei *big.Int, gasPriceWei *big.Int,
) error { ) error {
if tokenBalance.AsStandard().Cmp(providesAmt) < 0 { if tokenBalance.AsStd().Cmp(providesAmt) < 0 {
return errTokenBalanceTooLow{ return errTokenBalanceTooLow{
providedAmount: providesAmt, providedAmount: providesAmt,
tokenBalance: tokenBalance.AsStandard(), tokenBalance: tokenBalance.AsStd(),
symbol: tokenBalance.StandardSymbol(), symbol: tokenBalance.StdSymbol(),
} }
} }

View File

@@ -70,7 +70,7 @@ func Test_validateMinBalForTokenSwap(t *testing.T) {
Symbol: "TK", Symbol: "TK",
} }
balanceWei := coins.EtherToWei(coins.StrToDecimal("0.5")) balanceWei := coins.EtherToWei(coins.StrToDecimal("0.5"))
tokenBalance := coins.NewERC20TokenAmountFromDecimals(coins.StrToDecimal("10"), tokenInfo) tokenBalance := coins.NewTokenAmountFromDecimals(coins.StrToDecimal("10"), tokenInfo)
providesAmt := coins.StrToDecimal("5") providesAmt := coins.StrToDecimal("5")
gasPriceWei := big.NewInt(35e9) // 35 GWei gasPriceWei := big.NewInt(35e9) // 35 GWei
err := validateMinBalForTokenSwap(balanceWei, tokenBalance, providesAmt, gasPriceWei) err := validateMinBalForTokenSwap(balanceWei, tokenBalance, providesAmt, gasPriceWei)
@@ -85,7 +85,7 @@ func Test_validateMinBalForTokenSwap_InsufficientTokenBalance(t *testing.T) {
Symbol: "TK", Symbol: "TK",
} }
balanceWei := coins.EtherToWei(coins.StrToDecimal("0.5")) balanceWei := coins.EtherToWei(coins.StrToDecimal("0.5"))
tokenBalance := coins.NewERC20TokenAmountFromDecimals(coins.StrToDecimal("10"), tokenInfo) tokenBalance := coins.NewTokenAmountFromDecimals(coins.StrToDecimal("10"), tokenInfo)
providesAmt := coins.StrToDecimal("20") providesAmt := coins.StrToDecimal("20")
gasPriceWei := big.NewInt(35e9) // 35 GWei gasPriceWei := big.NewInt(35e9) // 35 GWei
err := validateMinBalForTokenSwap(balanceWei, tokenBalance, providesAmt, gasPriceWei) err := validateMinBalForTokenSwap(balanceWei, tokenBalance, providesAmt, gasPriceWei)
@@ -100,7 +100,7 @@ func Test_validateMinBalForTokenSwap_InsufficientETHBalance(t *testing.T) {
Symbol: "TK", Symbol: "TK",
} }
balanceWei := coins.EtherToWei(coins.StrToDecimal("0.007")) balanceWei := coins.EtherToWei(coins.StrToDecimal("0.007"))
tokenBalance := coins.NewERC20TokenAmountFromDecimals(coins.StrToDecimal("10"), tokenInfo) tokenBalance := coins.NewTokenAmountFromDecimals(coins.StrToDecimal("10"), tokenInfo)
providesAmt := coins.StrToDecimal("1") providesAmt := coins.StrToDecimal("1")
gasPriceWei := big.NewInt(35e9) // 35 GWei gasPriceWei := big.NewInt(35e9) // 35 GWei
err := validateMinBalForTokenSwap(balanceWei, tokenBalance, providesAmt, gasPriceWei) err := validateMinBalForTokenSwap(balanceWei, tokenBalance, providesAmt, gasPriceWei)

View File

@@ -7,12 +7,11 @@ import (
"github.com/cockroachdb/apd/v3" "github.com/cockroachdb/apd/v3"
"github.com/libp2p/go-libp2p/core/peer" "github.com/libp2p/go-libp2p/core/peer"
"github.com/fatih/color"
"github.com/athanorlabs/atomic-swap/coins" "github.com/athanorlabs/atomic-swap/coins"
"github.com/athanorlabs/atomic-swap/common" "github.com/athanorlabs/atomic-swap/common"
"github.com/athanorlabs/atomic-swap/common/types" "github.com/athanorlabs/atomic-swap/common/types"
pcommon "github.com/athanorlabs/atomic-swap/protocol"
"github.com/fatih/color"
) )
// Provides returns types.ProvidesETH // Provides returns types.ProvidesETH
@@ -20,34 +19,52 @@ func (inst *Instance) Provides() coins.ProvidesCoin {
return coins.ProvidesETH return coins.ProvidesETH
} }
// InitiateProtocol is called when an RPC call is made from the user to initiate a swap. // InitiateProtocol is called when an RPC call is made from the user to take a swap.
// The input units are ether that we will provide. // The input units are ether that we will provide.
func (inst *Instance) InitiateProtocol( func (inst *Instance) InitiateProtocol(
makerPeerID peer.ID, makerPeerID peer.ID,
providesAmount *apd.Decimal, providesAmount *apd.Decimal,
offer *types.Offer, offer *types.Offer,
) (common.SwapState, error) { ) (common.SwapState, error) {
err := coins.ValidatePositive("providesAmount", coins.NumEtherDecimals, providesAmount) maxDecimals := uint8(coins.NumEtherDecimals)
var token *coins.ERC20TokenInfo
if offer.EthAsset.IsToken() {
var err error
token, err = inst.backend.ETHClient().ERC20Info(inst.backend.Ctx(), offer.EthAsset.Address())
if err != nil {
return nil, err
}
maxDecimals = token.NumDecimals
}
err := coins.ValidatePositive("providesAmount", maxDecimals, providesAmount)
if err != nil { if err != nil {
return nil, err return nil, err
} }
offerMinETH, err := offer.ExchangeRate.ToETH(offer.MinAmount) providedAssetAmount := coins.NewEthAssetAmount(providesAmount, token)
providesAmtAsXMR, err := offer.ExchangeRate.ToXMR(providedAssetAmount)
if err != nil { if err != nil {
return nil, err return nil, err
} }
offerMaxETH, err := offer.ExchangeRate.ToETH(offer.MaxAmount) if providesAmtAsXMR.Cmp(offer.MinAmount) < 0 {
if err != nil { return nil, &errAmountProvidedTooLow{
return nil, err providedAmtETH: providesAmount,
providedAmtAsXMR: providesAmtAsXMR,
offerMinAmtXMR: offer.MinAmount,
exchangeRate: offer.ExchangeRate,
}
} }
if offerMinETH.Cmp(providesAmount) > 0 { if providesAmtAsXMR.Cmp(offer.MaxAmount) > 0 {
return nil, errAmountProvidedTooLow{providesAmount, offerMinETH} return nil, &errAmountProvidedTooHigh{
} providedAmtETH: providesAmount,
providedAmtAsXMR: providesAmtAsXMR,
if offerMaxETH.Cmp(providesAmount) < 0 { offerMaxAmtXMR: offer.MaxAmount,
return nil, errAmountProvidedTooHigh{providesAmount, offerMaxETH} exchangeRate: offer.ExchangeRate,
}
} }
err = validateMinBalance( err = validateMinBalance(
@@ -60,17 +77,7 @@ func (inst *Instance) InitiateProtocol(
return nil, err return nil, err
} }
providedAmount, err := pcommon.GetEthAssetAmount( state, err := inst.initiate(makerPeerID, providedAssetAmount, offer.ExchangeRate, offer.EthAsset, offer.ID)
inst.backend.Ctx(),
inst.backend.ETHClient(),
providesAmount,
offer.EthAsset,
)
if err != nil {
return nil, err
}
state, err := inst.initiate(makerPeerID, providedAmount, offer.ExchangeRate, offer.EthAsset, offer.ID)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -15,6 +15,10 @@ import (
"github.com/athanorlabs/atomic-swap/common/types" "github.com/athanorlabs/atomic-swap/common/types"
) )
var (
testExchangeRate = coins.StrToExchangeRate("0.08")
)
func newTestXMRTaker(t *testing.T) *Instance { func newTestXMRTaker(t *testing.T) *Instance {
b := newBackend(t) b := newBackend(t)
cfg := &Config{ cfg := &Config{
@@ -37,7 +41,7 @@ func initiate(
coins.ProvidesETH, coins.ProvidesETH,
minAmount, minAmount,
maxAmount, maxAmount,
coins.ToExchangeRate(apd.New(1, 0)), testExchangeRate,
types.EthAssetETH, types.EthAssetETH,
) )
s, err := xmrtaker.InitiateProtocol(testPeerID, providesAmount, offer) s, err := xmrtaker.InitiateProtocol(testPeerID, providesAmount, offer)
@@ -46,31 +50,43 @@ func initiate(
func TestXMRTaker_InitiateProtocol(t *testing.T) { func TestXMRTaker_InitiateProtocol(t *testing.T) {
a := newTestXMRTaker(t) a := newTestXMRTaker(t)
zero := new(apd.Decimal) min := coins.StrToDecimal("0.1")
one := apd.New(1, 0) max := coins.StrToDecimal("1")
// Provided between minAmount and maxAmount // Provided between minAmount and maxAmount (0.05 ETH / 0.08 = 0.625 XMR)
offer, s, err := initiate(a, apd.New(1, -1), zero, one) // 0.1 offer, s, err := initiate(a, coins.StrToDecimal("0.05"), min, max)
require.NoError(t, err)
require.Equal(t, a.swapStates[offer.ID], s)
// Exact max is in range (0.08 ETH / 0.08 = 1 XMR)
offer, s, err = initiate(a, coins.StrToDecimal("0.08"), min, max)
require.NoError(t, err)
require.Equal(t, a.swapStates[offer.ID], s)
// Exact min is in range (0.008 ETH / 0.08 = 0.1 XMR)
offer, s, err = initiate(a, coins.StrToDecimal("0.008"), min, max)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, a.swapStates[offer.ID], s) require.Equal(t, a.swapStates[offer.ID], s)
// Provided with too many decimals // Provided with too many decimals
_, s, err = initiate(a, apd.New(1, -50), zero, one) // 10^-50 _, s, err = initiate(a, apd.New(1, -50), min, max) // 10^-50
require.Error(t, err) require.ErrorContains(t, err, `"providesAmount" has too many decimal points; found=50 max=18`)
require.Equal(t, nil, s) require.Equal(t, nil, s)
// Provided with a negative number // Provided with a negative number
_, s, err = initiate(a, apd.New(-1, 0), zero, one) // -1 _, s, err = initiate(a, coins.StrToDecimal("-1"), min, max)
require.Error(t, err) require.ErrorContains(t, err, `"providesAmount" cannot be negative`)
require.Equal(t, nil, s) require.Equal(t, nil, s)
// Provided over maxAmount // Provided over maxAmount (0.09 ETH / 0.08 = 1.125 XMR)
_, s, err = initiate(a, apd.New(2, 0), one, one) // 2 _, s, err = initiate(a, coins.StrToDecimal("0.09"), min, max)
require.Error(t, err) expected := `provided ETH converted to XMR is over offer max of 1 XMR (0.09 ETH / 0.08 = 1.125 XMR)`
require.ErrorContains(t, err, expected)
require.Equal(t, nil, s) require.Equal(t, nil, s)
// Provided under minAmount // Provided under minAmount (0.00079 ETH / 0.08 = 0.009875 XMR)
_, s, err = initiate(a, apd.New(1, -1), one, one) // 0.1 _, s, err = initiate(a, coins.StrToDecimal("0.00079"), min, max)
require.Error(t, err) expected = `provided ETH converted to XMR is under offer min of 0.1 XMR (0.00079 ETH / 0.08 = 0.009875)`
require.ErrorContains(t, err, expected)
require.Equal(t, nil, s) require.Equal(t, nil, s)
} }

View File

@@ -121,7 +121,7 @@ func newSwapStateFromStart(
return nil, err return nil, err
} }
expectedAmount, err := exchangeRate.ToXMR(providedAmount.AsStandard()) expectedAmount, err := exchangeRate.ToXMR(providedAmount)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -130,7 +130,7 @@ func newSwapStateFromStart(
makerPeerID, makerPeerID,
offerID, offerID,
coins.ProvidesETH, coins.ProvidesETH,
providedAmount.AsStandard(), providedAmount.AsStd(),
expectedAmount, expectedAmount,
exchangeRate, exchangeRate,
ethAsset, ethAsset,
@@ -274,7 +274,7 @@ func newSwapState(
cancel() cancel()
return nil, err return nil, err
} }
providedAmt = coins.NewERC20TokenAmountFromDecimals(info.ProvidedAmount, tokenInfo) providedAmt = coins.NewTokenAmountFromDecimals(info.ProvidedAmount, tokenInfo)
} }
// note: if this is recovering an ongoing swap, this will only // note: if this is recovering an ongoing swap, this will only
@@ -602,7 +602,7 @@ func (s *swapState) lockAsset() (*ethtypes.Receipt, error) {
cmtXMRTaker := s.secp256k1Pub.Keccak256() cmtXMRTaker := s.secp256k1Pub.Keccak256()
cmtXMRMaker := s.xmrmakerSecp256k1PublicKey.Keccak256() cmtXMRMaker := s.xmrmakerSecp256k1PublicKey.Keccak256()
log.Debugf("locking %s %s in contract", s.providedAmount.AsStandard(), s.providedAmount.StandardSymbol()) log.Debugf("locking %s %s in contract", s.providedAmount.AsStd(), s.providedAmount.StdSymbol())
nonce := contracts.GenerateNewSwapNonce() nonce := contracts.GenerateNewSwapNonce()
receipt, err := s.lockAndWaitForReceipt(cmtXMRMaker, cmtXMRTaker, nonce) receipt, err := s.lockAndWaitForReceipt(cmtXMRMaker, cmtXMRTaker, nonce)
@@ -665,7 +665,7 @@ func (s *swapState) lockAsset() (*ethtypes.Receipt, error) {
return nil, err return nil, err
} }
log.Infof("locked %s in swap contract, waiting for XMR to be locked", s.providedAmount.StandardSymbol()) log.Infof("locked %s in swap contract, waiting for XMR to be locked", s.providedAmount.StdSymbol())
return receipt, nil return receipt, nil
} }

View File

@@ -188,7 +188,7 @@ func newTestSwapStateWithERC20(t *testing.T, providesAmt *apd.Decimal) (*swapSta
tokenInfo, err := b.ETHClient().ERC20Info(b.Ctx(), addr) tokenInfo, err := b.ETHClient().ERC20Info(b.Ctx(), addr)
require.NoError(t, err) require.NoError(t, err)
providesEthAssetAmt := coins.NewERC20TokenAmountFromDecimals(providesAmt, tokenInfo) providesEthAssetAmt := coins.NewTokenAmountFromDecimals(providesAmt, tokenInfo)
exchangeRate := coins.ToExchangeRate(apd.New(1, 0)) // 100% exchangeRate := coins.ToExchangeRate(apd.New(1, 0)) // 100%
swapState, err := newSwapStateFromStart(b, testPeerID, types.Hash{}, false, swapState, err := newSwapStateFromStart(b, testPeerID, types.Hash{}, false,

View File

@@ -190,7 +190,7 @@ func (s *NetService) takeOffer(makerPeerID peer.ID, offerID types.Hash, provides
swapState, err := s.xmrtaker.InitiateProtocol(makerPeerID, providesAmount, offer) swapState, err := s.xmrtaker.InitiateProtocol(makerPeerID, providesAmount, offer)
if err != nil { if err != nil {
return fmt.Errorf("failed to initiate protocol: %w", err) return err
} }
skm := swapState.SendKeysMessage().(*message.SendKeysMessage) skm := swapState.SendKeysMessage().(*message.SendKeysMessage)

View File

@@ -8,6 +8,7 @@ package rpcclient
import ( import (
"bytes" "bytes"
"context" "context"
"errors"
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
@@ -80,12 +81,14 @@ func (c *Client) post(method string, request any, response any) error {
defer func() { _ = httpResp.Body.Close() }() defer func() { _ = httpResp.Body.Close() }()
if response == nil { // Even if the response is nil, we still need to parse the outer JSON-RPC
return nil // shell to get any error that the server may have returned.
err = json2.DecodeClientResponse(httpResp.Body, response)
if response == nil && errors.Is(err, json2.ErrNullResult) {
err = nil
} }
if err != nil {
if err = json2.DecodeClientResponse(httpResp.Body, response); err != nil { return fmt.Errorf("%q failed: %w", method, err)
return fmt.Errorf("failed to read %q response: %w", method, err)
} }
return nil return nil

View File

@@ -70,13 +70,13 @@ func deployTestERC20(t *testing.T) ethcommon.Address {
aliceCli := rpcclient.NewClient(ctx, defaultXMRTakerSwapdPort) aliceCli := rpcclient.NewClient(ctx, defaultXMRTakerSwapdPort)
balResp, err = aliceCli.Balances(tokenBalReq) balResp, err = aliceCli.Balances(tokenBalReq)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "1000", balResp.TokenBalances[0].AsStandardString()) require.Equal(t, "1000", balResp.TokenBalances[0].AsStdString())
// verify that Charlie also has exactly 1000 tokens // verify that Charlie also has exactly 1000 tokens
balResp, err = charlieCli.Balances(tokenBalReq) balResp, err = charlieCli.Balances(tokenBalReq)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "1000", balResp.TokenBalances[0].AsStandardString()) require.Equal(t, "1000", balResp.TokenBalances[0].AsStdString())
return erc20Addr return erc20Addr
} }