From 0f1865c33fd997b12ff33995db81edd03f452ab3 Mon Sep 17 00:00:00 2001 From: Dmitry Holodov Date: Tue, 30 May 2023 21:49:22 -0500 Subject: [PATCH] additonal erc20 and taker decimal checks (#478) --- cmd/swapcli/main.go | 3 +- cmd/swapcli/suite_test.go | 14 +- coins/coins.go | 121 +++++++++------- coins/coins_test.go | 37 +++-- coins/exchange_rate.go | 57 ++++++-- coins/exchange_rate_test.go | 71 +++------- coins/round.go | 97 ++++++++++++- coins/round_test.go | 134 +++++++++++++++++- coins/test_support.go | 6 + common/rpctypes/jsonrpc.go | 5 +- common/types/offer.go | 12 +- common/types/offer_test.go | 2 +- daemon/bad_make_take_values_test.go | 58 ++++++++ daemon/cancel_or_refund_test.go | 1 + daemon/double_restart_test.go | 3 +- daemon/swap_daemon_erc20_test.go | 17 ++- daemon/sweep_test.go | 3 +- daemon/test_support.go | 65 +-------- docs/stagenet.md | 15 +- ethereum/test_support.go | 129 +++++++++++++++++ protocol/txsender/external_sender.go | 2 +- protocol/txsender/sender.go | 2 +- protocol/xmrmaker/backend_offers.go | 29 +++- protocol/xmrmaker/checks.go | 9 +- protocol/xmrmaker/claim.go | 4 +- .../{ => xmrmaker}/ethereum_asset_amount.go | 20 ++- protocol/xmrmaker/net.go | 50 ++++--- protocol/xmrmaker/swap_state.go | 2 +- protocol/xmrtaker/errors.go | 26 ++-- protocol/xmrtaker/event_test.go | 2 +- protocol/xmrtaker/min_balance.go | 6 +- protocol/xmrtaker/min_balance_test.go | 6 +- protocol/xmrtaker/net.go | 59 ++++---- protocol/xmrtaker/net_test.go | 46 ++++-- protocol/xmrtaker/swap_state.go | 10 +- protocol/xmrtaker/swap_state_test.go | 2 +- rpc/net.go | 2 +- rpcclient/client.go | 13 +- tests/erc20_integration_test.go | 4 +- 39 files changed, 805 insertions(+), 339 deletions(-) create mode 100644 daemon/bad_make_take_values_test.go rename protocol/{ => xmrmaker}/ethereum_asset_amount.go (53%) diff --git a/cmd/swapcli/main.go b/cmd/swapcli/main.go index a4aeeb4a..828b5a96 100644 --- a/cmd/swapcli/main.go +++ b/cmd/swapcli/main.go @@ -550,7 +550,8 @@ func runBalances(ctx *cli.Context) error { fmt.Printf("Token: %s\n", tokenBalance.TokenInfo.Address) fmt.Printf("Name: %q\n", tokenBalance.TokenInfo.Name) 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() } diff --git a/cmd/swapcli/suite_test.go b/cmd/swapcli/suite_test.go index bff5e62f..4fae42a1 100644 --- a/cmd/swapcli/suite_test.go +++ b/cmd/swapcli/suite_test.go @@ -9,7 +9,9 @@ import ( ethcommon "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/suite" + "github.com/athanorlabs/atomic-swap/coins" "github.com/athanorlabs/atomic-swap/daemon" + contracts "github.com/athanorlabs/atomic-swap/ethereum" "github.com/athanorlabs/atomic-swap/rpcclient" "github.com/athanorlabs/atomic-swap/tests" ) @@ -19,7 +21,8 @@ import ( type swapCLITestSuite struct { suite.Suite conf *daemon.SwapdConfig - mockTokens map[string]ethcommon.Address + mockTether *coins.ERC20TokenInfo + mockDAI *coins.ERC20TokenInfo } func TestRunSwapcliWithDaemonTests(t *testing.T) { @@ -28,7 +31,10 @@ func TestRunSwapcliWithDaemonTests(t *testing.T) { s.conf = daemon.CreateTestConf(t, tests.GetMakerTestKey(t)) t.Setenv("SWAPD_PORT", strconv.Itoa(int(s.conf.RPCPort))) 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) } @@ -37,9 +43,9 @@ func (s *swapCLITestSuite) rpcEndpoint() *rpcclient.Client { } func (s *swapCLITestSuite) mockDaiAddr() ethcommon.Address { - return s.mockTokens[daemon.MockDAI] + return s.mockDAI.Address } func (s *swapCLITestSuite) mockTetherAddr() ethcommon.Address { - return s.mockTokens[daemon.MockTether] + return s.mockTether.Address } diff --git a/coins/coins.go b/coins/coins.go index 9884e8d8..ff521136 100644 --- a/coins/coins.go +++ b/coins/coins.go @@ -70,7 +70,8 @@ func MoneroToPiconero(xmrAmt *apd.Decimal) *PiconeroAmount { // We do input validation and reject XMR values with more than 12 decimal // places from external sources, so no rounding will happen with those // values below. - if err := roundToDecimalPlace(pnAmt, pnAmt, 0); err != nil { + pnAmt, err := roundToDecimalPlace(pnAmt, 0) + if err != nil { panic(err) // shouldn't be possible } 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) type EthAssetAmount interface { BigInt() *big.Int - AsStandard() *apd.Decimal - StandardSymbol() string + AsStd() *apd.Decimal + AsStdString() string + StdSymbol() string IsToken() bool + NumStdDecimals() uint8 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) 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 // more than 18 decimal places, so no rounding happens with such values // below. - if err := roundToDecimalPlace(weiAmt, weiAmt, 0); err != nil { + weiAmt, err := roundToDecimalPlace(weiAmt, 0) + if err != nil { panic(err) // shouldn't be possible } return ToWeiAmount(weiAmt) @@ -220,19 +234,24 @@ func (a *WeiAmount) AsEtherString() string { return a.AsEther().Text('f') } -// AsStandard is an alias for AsEther, returning the Wei amount as ETH -func (a *WeiAmount) AsStandard() *apd.Decimal { +// AsStd is an alias for AsEther, returning the Wei amount as ETH +func (a *WeiAmount) AsStd() *apd.Decimal { 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 -func (a *WeiAmount) AsStandardString() *apd.Decimal { - return a.AsEther() +func (a *WeiAmount) AsStdString() string { + return a.AsEther().Text('f') } -// StandardSymbol returns the string "ETH" -func (a *WeiAmount) StandardSymbol() string { +// NumStdDecimals returns 18 +func (a *WeiAmount) NumStdDecimals() uint8 { + return NumEtherDecimals +} + +// StdSymbol returns the string "ETH" +func (a *WeiAmount) StdSymbol() string { return "ETH" } @@ -283,50 +302,49 @@ func (t *ERC20TokenInfo) SanitizedSymbol() string { // ERC20TokenAmount represents some amount of an ERC20 token in the smallest denomination 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"` } -// NewERC20TokenAmountFromBigInt converts some amount in the smallest token denomination into an ERC20TokenAmount. -func NewERC20TokenAmountFromBigInt(amount *big.Int, tokenInfo *ERC20TokenInfo) *ERC20TokenAmount { +// NewERC20TokenAmountFromBigInt converts some amount in the smallest token denomination +// into an ERC20TokenAmount. +func NewERC20TokenAmountFromBigInt(amount *big.Int, token *ERC20TokenInfo) *ERC20TokenAmount { asDecimal := new(apd.Decimal) asDecimal.Coeff.SetBytes(amount.Bytes()) + decreaseExponent(asDecimal, token.NumDecimals) + _, _ = asDecimal.Reduce(asDecimal) + return &ERC20TokenAmount{ Amount: asDecimal, - TokenInfo: tokenInfo, + TokenInfo: token, } } -// NewERC20TokenAmount converts some amount in the smallest token denomination into an ERC20TokenAmount. -func NewERC20TokenAmount(amount int64, tokenInfo *ERC20TokenInfo) *ERC20TokenAmount { - return &ERC20TokenAmount{ - Amount: apd.New(amount, 0), - TokenInfo: tokenInfo, +// NewTokenAmountFromDecimals converts an amount in standard units from +// apd.Decimal into the ERC20TokenAmount type. During the conversion, rounding +// may occur if the input value is too precise for the token's decimals. +func NewTokenAmountFromDecimals(amount *apd.Decimal, token *ERC20TokenInfo) *ERC20TokenAmount { + 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{ - Amount: adjusted, - TokenInfo: tokenInfo, + Amount: amount, + TokenInfo: token, } } // BigInt returns the ERC20TokenAmount as a *big.Int func (a *ERC20TokenAmount) BigInt() *big.Int { - wholeTokenUnits := new(apd.Decimal) - cond, err := decimalCtx.Quantize(wholeTokenUnits, a.Amount, 0) + wholeTokenUnits := new(apd.Decimal).Set(a.Amount) + increaseExponent(wholeTokenUnits, a.TokenInfo.NumDecimals) + cond, err := decimalCtx.Quantize(wholeTokenUnits, wholeTokenUnits, 0) if err != nil { panic(err) } @@ -334,24 +352,28 @@ func (a *ERC20TokenAmount) BigInt() *big.Int { log.Warn("Converting ERC20TokenAmount=%s (digits=%d) to big.Int required rounding", a.Amount, a.TokenInfo.NumDecimals) } + return new(big.Int).SetBytes(wholeTokenUnits.Coeff.Bytes()) } -// AsStandard returns the amount in standard form -func (a *ERC20TokenAmount) AsStandard() *apd.Decimal { - tokenAmt := new(apd.Decimal).Set(a.Amount) - decreaseExponent(tokenAmt, a.TokenInfo.NumDecimals) - _, _ = tokenAmt.Reduce(tokenAmt) - return tokenAmt +// AsStd returns the amount in standard units +func (a *ERC20TokenAmount) AsStd() *apd.Decimal { + return a.Amount } -// AsStandardString returns the amount as a standard (decimal adjusted) string -func (a *ERC20TokenAmount) AsStandardString() string { - return a.AsStandard().Text('f') +// AsStdString returns the ERC20TokenAmount as a base10 string in standard units. +func (a *ERC20TokenAmount) AsStdString() string { + return a.String() } -// StandardSymbol returns the token's symbol in a format that is safe to log and display -func (a *ERC20TokenAmount) StandardSymbol() string { +// NumStdDecimals returns the max decimal precision of the token's standard +// 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() } @@ -365,8 +387,7 @@ func (a *ERC20TokenAmount) TokenAddress() ethcommon.Address { return a.TokenInfo.Address } -// String returns the ERC20TokenAmount as a base10 string of the token's smallest, -// non-divisible units. +// String returns the ERC20TokenAmount as a base10 string in standard units. func (a *ERC20TokenAmount) String() string { return a.Amount.Text('f') } diff --git a/coins/coins_test.go b/coins/coins_test.go index 0e1d1a63..b7008898 100644 --- a/coins/coins_test.go +++ b/coins/coins_test.go @@ -84,7 +84,7 @@ func TestWeiAmount(t *testing.T) { wei := EtherToWei(amount) assert.Equal(t, "33300000000000000000", wei.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) WeiAmount := IntToWei(amountUint) @@ -114,42 +114,37 @@ func TestERC20TokenAmount(t *testing.T) { tokenInfo := NewERC20TokenInfo(ethcommon.Address{}, numDecimals, "", "") amount := StrToDecimal("33.999999999") - wei := NewERC20TokenAmountFromDecimals(amount, tokenInfo) - assert.Equal(t, amount.String(), wei.AsStandard().String()) + tokenAmt := NewTokenAmountFromDecimals(amount, tokenInfo) + assert.Equal(t, amount.String(), tokenAmt.AsStdString()) amount = StrToDecimal("33.000000005") - wei = NewERC20TokenAmountFromDecimals(amount, tokenInfo) - assert.Equal(t, "33.000000005", wei.AsStandard().String()) + tokenAmt = NewTokenAmountFromDecimals(amount, tokenInfo) + assert.Equal(t, "33.000000005", tokenAmt.AsStdString()) amount = StrToDecimal("33.0000000005") - wei = NewERC20TokenAmountFromDecimals(amount, tokenInfo) - assert.Equal(t, "33.000000001", wei.AsStandard().String()) + tokenAmt = NewTokenAmountFromDecimals(amount, tokenInfo) + assert.Equal(t, "33.000000001", tokenAmt.AsStdString()) amount = StrToDecimal("999999999999999999.0000000005") - wei = NewERC20TokenAmountFromDecimals(amount, tokenInfo) - assert.Equal(t, "999999999999999999.000000001", wei.AsStandard().String()) - - amountUint := int64(8181) - tokenAmt := NewERC20TokenAmount(amountUint, tokenInfo) - assert.Equal(t, amountUint, tokenAmt.BigInt().Int64()) + tokenAmt = NewTokenAmountFromDecimals(amount, tokenInfo) + assert.Equal(t, "999999999999999999.000000001", tokenAmt.AsStdString()) } func TestNewERC20TokenAmountFromBigInt(t *testing.T) { bi := big.NewInt(4321) - token := NewERC20TokenAmountFromBigInt(bi, &ERC20TokenInfo{NumDecimals: 2}) - assert.Equal(t, "4321", token.String()) - assert.Equal(t, "43.21", token.AsStandard().String()) + tokenAmt := NewERC20TokenAmountFromBigInt(bi, &ERC20TokenInfo{NumDecimals: 2}) + assert.Equal(t, "43.21", tokenAmt.String()) + assert.Equal(t, "43.21", tokenAmt.AsStdString()) + assert.Equal(t, "4321", tokenAmt.BigInt().String()) } func TestNewERC20TokenAmountFromDecimals(t *testing.T) { 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 - // under the current implementation. It's not entirely clear what - // the ideal behavior is. - assert.Equal(t, "2", token.String()) - assert.Equal(t, "0.2", token.AsStandard().String()) + // under the current implementation + assert.Equal(t, "0.2", token.String()) } func TestJSONMarshal(t *testing.T) { diff --git a/coins/exchange_rate.go b/coins/exchange_rate.go index 8a66687c..deed3f25 100644 --- a/coins/exchange_rate.go +++ b/coins/exchange_rate.go @@ -4,6 +4,9 @@ package coins import ( + "errors" + "fmt" + "github.com/cockroachdb/apd/v3" ) @@ -21,7 +24,7 @@ func CalcExchangeRate(xmrPrice *apd.Decimal, ethPrice *apd.Decimal) (*ExchangeRa if err != nil { return nil, err } - if err = roundToDecimalPlace(rate, rate, MaxExchangeRateDecimals); err != nil { + if rate, err = roundToDecimalPlace(rate, MaxExchangeRateDecimals); err != nil { return nil, err } return ToExchangeRate(rate), nil @@ -51,48 +54,72 @@ func (r *ExchangeRate) MarshalText() ([]byte, error) { return r.Decimal().MarshalText() } -// ToXMR converts an ETH amount to an XMR amount with the given exchange rate -func (r *ExchangeRate) ToXMR(ethAmount *apd.Decimal) (*apd.Decimal, error) { +// ToXMR converts an ETH amount to an XMR amount with the given exchange rate. +// 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) - _, err := decimalCtx.Quo(xmrAmt, ethAmount, r.Decimal()) + _, err := decimalCtx.Quo(xmrAmt, ethAssetAmt.AsStd(), r.Decimal()) if err != nil { 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 } -// 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) { ethAmt := new(apd.Decimal) - _, err := decimalCtx.Mul(ethAmt, r.Decimal(), xmrAmount) + _, err := decimalCtx.Mul(ethAmt, xmrAmount, r.Decimal()) if err != nil { return nil, err } // 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 - // decimal places below, so no rounding occurs. - if err = roundToDecimalPlace(ethAmt, ethAmt, NumEtherDecimals); err != nil { + // decimal places below, so the error below can't happen. + 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 ethAmt, nil } // 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) { erc20Amount := new(apd.Decimal) - _, err := decimalCtx.Mul(erc20Amount, r.Decimal(), xmrAmount) + _, err := decimalCtx.Mul(erc20Amount, xmrAmount, r.Decimal()) if err != nil { return nil, err } - // The token, if required, will get rounded to whole token units in - // NewERC20TokenAmountFromDecimals. - return NewERC20TokenAmountFromDecimals(erc20Amount, token).AsStandard(), nil + if ExceedsDecimals(erc20Amount, token.NumDecimals) { + // We could have a suggested value to try, like we have in ToXMR(...), + // 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 { diff --git a/coins/exchange_rate_test.go b/coins/exchange_rate_test.go index 8f2fa09d..515cfb1f 100644 --- a/coins/exchange_rate_test.go +++ b/coins/exchange_rate_test.go @@ -13,41 +13,34 @@ import ( func TestExchangeRate_ToXMR(t *testing.T) { rate := StrToExchangeRate("0.25") // 4 XMR * 0.25 = 1 ETH - ethAmount := StrToDecimal("1") + ethAssetAmt := StrToETHAsset("1", nil) const expectedXMRAmount = "4" - xmrAmount, err := rate.ToXMR(ethAmount) + xmrAmount, err := rate.ToXMR(ethAssetAmt) require.NoError(t, err) assert.Equal(t, expectedXMRAmount, xmrAmount.String()) } -func TestExchangeRate_ToXMR_roundDown(t *testing.T) { - rate := StrToExchangeRate("0.333333") - ethAmount := StrToDecimal("3.1") - +func TestExchangeRate_ToXMR_exceedsXMRPrecision(t *testing.T) { // 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 - // XMR rounds at 12 decimal places to: - const expectedXMRAmount = "9.300009300009" + rate := StrToExchangeRate("0.333333") + ethAssetAmt := StrToETHAsset("3.1", nil) - xmrAmount, err := rate.ToXMR(ethAmount) - require.NoError(t, err) - assert.Equal(t, expectedXMRAmount, xmrAmount.String()) -} + _, err := rate.ToXMR(ethAssetAmt) + expectedErr := "3.1 ETH / 0.333333 exceeds XMR's 12 decimal precision, try 3.099999999999899997" + 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) - // The 9 in the 12th position goes to zero changing 11th position to 1: - const expectedXMRAmount = "9.90000990001" // only 11 decimal places shown as 12th is 0 - xmrAmount, err := rate.ToXMR(ethAmount) - require.NoError(t, err) - assert.Equal(t, expectedXMRAmount, xmrAmount.String()) + rate = StrToExchangeRate("0.666666") + ethAssetAmt = StrToETHAsset("6.6", nil) + + _, err = rate.ToXMR(ethAssetAmt) + 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) { 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") } @@ -72,35 +65,15 @@ func TestExchangeRate_ToERC20Amount(t *testing.T) { assert.Equal(t, expectedTokenStandardAmount, erc20Amt.Text('f')) } -func TestExchangeRate_ToERC20Amount_roundDown(t *testing.T) { - // 0.333333 * 1.0000015 = 0.333333499... - // = 0.333333 (token only supports 6 decimals) - rate := StrToExchangeRate("0.333333") +func TestExchangeRate_ToERC20Amount_exceedsTokenPrecision(t *testing.T) { + const tokenDecimals = 6 + token := &ERC20TokenInfo{NumDecimals: tokenDecimals} + + // 1.0000015 * 0.333333 = 0.3333334999995 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") - xmrAmount := StrToDecimal("1.000001501") - - 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')) + _, err := rate.ToERC20Amount(xmrAmount, token) + require.ErrorContains(t, err, "1.0000015 XMR * 0.333333 exceeds token's 6 decimal precision") } func TestExchangeRate_String(t *testing.T) { diff --git a/coins/round.go b/coins/round.go index 6f6cedf4..cee5e363 100644 --- a/coins/round.go +++ b/coins/round.go @@ -5,18 +5,107 @@ package coins import ( "github.com/cockroachdb/apd/v3" + "github.com/ethereum/go-ethereum/common/math" ) -func roundToDecimalPlace(result *apd.Decimal, n *apd.Decimal, decimalPlace uint8) error { - result.Set(n) // already optimizes result == n +func roundToDecimalPlace(n *apd.Decimal, decimalPlace uint8) (*apd.Decimal, error) { + result := new(apd.Decimal).Set(n) // Adjust the exponent to the rounding place, round, then adjust the exponent back increaseExponent(result, decimalPlace) _, err := decimalCtx.RoundToIntegralValue(result, result) if err != nil { - return err + return nil, err } decreaseExponent(result, decimalPlace) _, _ = 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 } diff --git a/coins/round_test.go b/coins/round_test.go index bf60f8cb..d5a81303 100644 --- a/coins/round_test.go +++ b/coins/round_test.go @@ -14,21 +14,147 @@ import ( func Test_roundToDecimalPlace(t *testing.T) { // Round half down amt := StrToDecimal("33.4999999999999999999999999999999999") - err := roundToDecimalPlace(amt, amt, 0) + amt, err := roundToDecimalPlace(amt, 0) require.NoError(t, err) assert.Equal(t, "33", amt.String()) // Round half up amt = StrToDecimal("33.5") - err = roundToDecimalPlace(amt, amt, 0) + amt, err = roundToDecimalPlace(amt, 0) require.NoError(t, err) assert.Equal(t, "34", amt.String()) // Round at Decimal position amt = StrToDecimal("0.00009") - res := new(apd.Decimal) // use a separate result variable this time - err = roundToDecimalPlace(res, amt, 4) + res, err := roundToDecimalPlace(amt, 4) // use a separate result variable this time require.NoError(t, err) assert.Equal(t, "0.0001", res.String()) 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')) + } + } +} diff --git a/coins/test_support.go b/coins/test_support.go index 3de2c5f9..9e8dc6b0 100644 --- a/coins/test_support.go +++ b/coins/test_support.go @@ -27,6 +27,12 @@ func StrToDecimal(amount string) *apd.Decimal { 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. func StrToExchangeRate(rate string) *ExchangeRate { r := new(ExchangeRate) diff --git a/common/rpctypes/jsonrpc.go b/common/rpctypes/jsonrpc.go index c98268ac..1c479d36 100644 --- a/common/rpctypes/jsonrpc.go +++ b/common/rpctypes/jsonrpc.go @@ -39,5 +39,8 @@ type Error struct { // Error ... 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 } diff --git a/common/types/offer.go b/common/types/offer.go index 15fc4c88..b9e50b12 100644 --- a/common/types/offer.go +++ b/common/types/offer.go @@ -152,14 +152,18 @@ func (o *Offer) validate() error { // 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. - relayerFeeAsXMR, err := o.ExchangeRate.ToXMR(coins.RelayerFeeETH) + minAmtAsETH, err := o.ExchangeRate.ToETH(o.MinAmount) if err != nil { return err } - if o.MinAmount.Cmp(relayerFeeAsXMR) <= 0 { + if minAmtAsETH.Cmp(coins.RelayerFeeETH) <= 0 { return fmt.Errorf( - "min amount must be greater than %s ETH when converted (%s XMR)", - coins.RelayerFeeETH.Text('f'), relayerFeeAsXMR.Text('f')) + "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 { diff --git a/common/types/offer_test.go b/common/types/offer_test.go index b859ea1f..8e4489a7 100644 --- a/common/types/offer_test.go +++ b/common/types/offer_test.go @@ -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 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 { diff --git a/daemon/bad_make_take_values_test.go b/daemon/bad_make_take_values_test.go new file mode 100644 index 00000000..f2e6d212 --- /dev/null +++ b/daemon/bad_make_take_values_test.go @@ -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) +} diff --git a/daemon/cancel_or_refund_test.go b/daemon/cancel_or_refund_test.go index e1fef799..af81a57d 100644 --- a/daemon/cancel_or_refund_test.go +++ b/daemon/cancel_or_refund_test.go @@ -28,6 +28,7 @@ import ( // so she refunds the swap. // Bob should have aborted the swap in all cases. func TestXMRTakerCancelOrRefundAfterKeyExchange(t *testing.T) { + t.Skip("Test disabled until https://github.com/AthanorLabs/atomic-swap/issues/479 is fixed") minXMR := coins.StrToDecimal("1") maxXMR := minXMR exRate := coins.StrToExchangeRate("300") diff --git a/daemon/double_restart_test.go b/daemon/double_restart_test.go index 10fc81a7..e3daaba3 100644 --- a/daemon/double_restart_test.go +++ b/daemon/double_restart_test.go @@ -43,8 +43,7 @@ func TestAliceDoubleRestartAfterXMRLock(t *testing.T) { bc := rpcclient.NewClient(context.Background(), bobConf.RPCPort) ac := rpcclient.NewClient(context.Background(), aliceConf.RPCPort) - tokenAddr := GetMockTokens(t, aliceConf.EthereumClient)[MockTether] - tokenAsset := types.EthAsset(tokenAddr) + tokenAsset := getMockTetherAsset(t, aliceConf.EthereumClient) makeResp, bobStatusCh, err := bc.MakeOfferAndSubscribe(minXMR, maxXMR, exRate, tokenAsset, false) require.NoError(t, err) diff --git a/daemon/swap_daemon_erc20_test.go b/daemon/swap_daemon_erc20_test.go index b30d804a..bdd5f96a 100644 --- a/daemon/swap_daemon_erc20_test.go +++ b/daemon/swap_daemon_erc20_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/cockroachdb/apd/v3" ethcommon "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -29,8 +30,7 @@ func TestRunSwapDaemon_ExchangesXMRForERC20Tokens(t *testing.T) { aliceConf := CreateTestConf(t, tests.GetTakerTestKey(t)) - tokenAddr := GetMockTokens(t, aliceConf.EthereumClient)[MockTether] - tokenAsset := types.EthAsset(tokenAddr) + tokenAsset := getMockTetherAsset(t, aliceConf.EthereumClient) timeout := 7 * time.Minute ctx, _ := LaunchDaemons(t, timeout, aliceConf, bobConf) @@ -38,6 +38,9 @@ func TestRunSwapDaemon_ExchangesXMRForERC20Tokens(t *testing.T) { bc := rpcclient.NewClient(ctx, bobConf.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) require.NoError(t, err) 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 // - 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) - t.Logf("Balances: %#v", balances) + require.NotEmpty(t, endBalances.TokenBalances) - require.NotEmpty(t, balances.TokenBalances) - require.Equal(t, providesAmt.Text('f'), balances.TokenBalances[0].AsStandardString()) + delta := new(apd.Decimal) + _, err = coins.DecimalCtx().Sub(delta, endBalances.TokenBalances[0].Amount, bobStartTokenBal.Amount) + require.NoError(t, err) + require.Equal(t, providesAmt.Text('f'), delta.Text('f')) } diff --git a/daemon/sweep_test.go b/daemon/sweep_test.go index 418e1044..9e6b536f 100644 --- a/daemon/sweep_test.go +++ b/daemon/sweep_test.go @@ -40,8 +40,7 @@ func TestAliceStoppedAndRestartedDuringXMRSweep(t *testing.T) { bc := rpcclient.NewClient(context.Background(), bobConf.RPCPort) ac := rpcclient.NewClient(context.Background(), aliceConf.RPCPort) - tokenAddr := GetMockTokens(t, aliceConf.EthereumClient)[MockTether] - tokenAsset := types.EthAsset(tokenAddr) + tokenAsset := getMockTetherAsset(t, aliceConf.EthereumClient) makeResp, bobStatusCh, err := bc.MakeOfferAndSubscribe(minXMR, maxXMR, exRate, tokenAsset, false) require.NoError(t, err) diff --git a/daemon/test_support.go b/daemon/test_support.go index d2f4979e..05e51238 100644 --- a/daemon/test_support.go +++ b/daemon/test_support.go @@ -9,7 +9,6 @@ import ( "context" "crypto/ecdsa" "fmt" - "math/big" "net" "path" "sync" @@ -17,11 +16,11 @@ import ( "testing" "time" - ethcommon "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/require" "github.com/athanorlabs/atomic-swap/bootnode" "github.com/athanorlabs/atomic-swap/common" + "github.com/athanorlabs/atomic-swap/common/types" contracts "github.com/athanorlabs/atomic-swap/ethereum" "github.com/athanorlabs/atomic-swap/ethereum/extethclient" "github.com/athanorlabs/atomic-swap/monero" @@ -29,12 +28,6 @@ import ( "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 // 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) } -// these variables are only for use by GetMockTokens -var _mockTokens map[string]ethcommon.Address -var _mockTokensMu sync.Mutex - -// 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 +func getMockTetherAsset(t *testing.T, ec extethclient.EthClient) types.EthAsset { + token := contracts.GetMockTether(t, ec.Raw(), ec.PrivateKey()) + return types.EthAsset(token.Address) } diff --git a/docs/stagenet.md b/docs/stagenet.md index dd5300a1..7601c439 100644 --- a/docs/stagenet.md +++ b/docs/stagenet.md @@ -1,8 +1,9 @@ # 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. @@ -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 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://sepoliafaucet.com/ - 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/ 8. Install go 1.20+. See [build instructions](./build.md) for more details. diff --git a/ethereum/test_support.go b/ethereum/test_support.go index eb3d6b74..03e43f2c 100644 --- a/ethereum/test_support.go +++ b/ethereum/test_support.go @@ -8,13 +8,17 @@ package contracts import ( "context" "crypto/ecdsa" + "math/big" "sync" "testing" + "github.com/ethereum/go-ethereum/accounts/abi/bind" ethcommon "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" "github.com/stretchr/testify/require" + "github.com/athanorlabs/atomic-swap/coins" + "github.com/athanorlabs/atomic-swap/common" "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 } + +// 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) + } +} diff --git a/protocol/txsender/external_sender.go b/protocol/txsender/external_sender.go index 56808220..a2cc21dd 100644 --- a/protocol/txsender/external_sender.go +++ b/protocol/txsender/external_sender.go @@ -132,7 +132,7 @@ func (s *ExternalSender) NewSwap( tx := &Transaction{ To: s.contractAddr, Data: input, - Value: amount.AsStandard(), + Value: amount.AsStd(), } s.Lock() diff --git a/protocol/txsender/sender.go b/protocol/txsender/sender.go index 09b1e5c4..5f379ce5 100644 --- a/protocol/txsender/sender.go +++ b/protocol/txsender/sender.go @@ -110,7 +110,7 @@ func (s *privateKeySender) NewSwap( log.Debugf("approve transaction included %s", common.ReceiptInfo(receipt)) 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) diff --git a/protocol/xmrmaker/backend_offers.go b/protocol/xmrmaker/backend_offers.go index f87f0d9d..93e28b74 100644 --- a/protocol/xmrmaker/backend_offers.go +++ b/protocol/xmrmaker/backend_offers.go @@ -23,8 +23,33 @@ func (inst *Instance) MakeOffer( return nil, err } - if useRelayer && o.EthAsset.IsToken() { - return nil, errRelayingWithNonEthAsset + if o.EthAsset.IsToken() { + 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) diff --git a/protocol/xmrmaker/checks.go b/protocol/xmrmaker/checks.go index f86ff2a0..fb090e12 100644 --- a/protocol/xmrmaker/checks.go +++ b/protocol/xmrmaker/checks.go @@ -14,12 +14,11 @@ import ( "github.com/athanorlabs/atomic-swap/common" "github.com/athanorlabs/atomic-swap/common/types" contracts "github.com/athanorlabs/atomic-swap/ethereum" - pcommon "github.com/athanorlabs/atomic-swap/protocol" ) -// checkContract checks the contract's balance and Claim/Refund keys. -// if the balance doesn't match what we're expecting to receive, or the public keys in the contract -// aren't what we expect, we error and abort the swap. +// checkContract checks the contract's balance and Claim/Refund keys. If the +// balance doesn't match what we're expecting to receive, or the public keys in +// the contract aren't what we expect, we error and abort the swap. func (s *swapState) checkContract(txHash ethcommon.Hash) error { tx, _, err := s.ETHClient().Raw().TransactionByHash(s.ctx, txHash) 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) } - expectedAmount, err := pcommon.GetEthAssetAmount( + expectedAmount, err := getEthAssetAmount( s.ctx, s.ETHClient(), s.info.ExpectedAmount, diff --git a/protocol/xmrmaker/claim.go b/protocol/xmrmaker/claim.go index a66d920d..a2e6f28b 100644 --- a/protocol/xmrmaker/claim.go +++ b/protocol/xmrmaker/claim.go @@ -38,7 +38,7 @@ func (s *swapState) claimFunds() (*ethtypes.Receipt, error) { if err != nil { 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()) @@ -91,7 +91,7 @@ func (s *swapState) claimFunds() (*ethtypes.Receipt, error) { 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 diff --git a/protocol/ethereum_asset_amount.go b/protocol/xmrmaker/ethereum_asset_amount.go similarity index 53% rename from protocol/ethereum_asset_amount.go rename to protocol/xmrmaker/ethereum_asset_amount.go index 8832645e..694e1e99 100644 --- a/protocol/ethereum_asset_amount.go +++ b/protocol/xmrmaker/ethereum_asset_amount.go @@ -1,7 +1,7 @@ // Copyright 2023 The AthanorLabs/atomic-swap Authors // SPDX-License-Identifier: LGPL-3.0-only -package protocol +package xmrmaker import ( "context" @@ -14,21 +14,29 @@ import ( "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) -func GetEthAssetAmount( +func getEthAssetAmount( ctx context.Context, ec extethclient.EthClient, amt *apd.Decimal, // in standard units asset types.EthAsset, ) (coins.EthAssetAmount, error) { - if asset != types.EthAssetETH { - tokenInfo, err := ec.ERC20Info(ctx, asset.Address()) + if asset.IsToken() { + token, err := ec.ERC20Info(ctx, asset.Address()) if err != nil { 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 diff --git a/protocol/xmrmaker/net.go b/protocol/xmrmaker/net.go index 5b9af5d6..1ffdef5f 100644 --- a/protocol/xmrmaker/net.go +++ b/protocol/xmrmaker/net.go @@ -110,44 +110,48 @@ func (inst *Instance) HandleInitiateMessage( 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) if err != nil { 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 { 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} } - if providedAmount.Cmp(offer.MaxAmount) > 0 { + if providedAmtAsXMR.Cmp(offer.MaxAmount) > 0 { return nil, errAmountProvidedTooHigh{msg.ProvidedAmount, offer.MaxAmount} } - providedPiconero := coins.MoneroToPiconero(providedAmount) - - // 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 - } + providedPiconero := coins.MoneroToPiconero(providedAmtAsXMR) state, err := inst.initiate(takerPeerID, offer, offerExtra, providedPiconero, expectedAmount) if err != nil { diff --git a/protocol/xmrmaker/swap_state.go b/protocol/xmrmaker/swap_state.go index 37a4e9dd..f8aaf2f3 100644 --- a/protocol/xmrmaker/swap_state.go +++ b/protocol/xmrmaker/swap_state.go @@ -138,7 +138,7 @@ func newSwapStateFromStart( offer.ID, coins.ProvidesXMR, providesAmount.AsMonero(), - desiredAmount.AsStandard(), + desiredAmount.AsStd(), offer.ExchangeRate, offer.EthAsset, stage, diff --git a/protocol/xmrtaker/errors.go b/protocol/xmrtaker/errors.go index d5ff8aa5..5c5d82a1 100644 --- a/protocol/xmrtaker/errors.go +++ b/protocol/xmrtaker/errors.go @@ -8,6 +8,8 @@ import ( "fmt" "github.com/cockroachdb/apd/v3" + + "github.com/athanorlabs/atomic-swap/coins" ) var ( @@ -50,26 +52,34 @@ func errContractAddrMismatch(addr string) error { } type errAmountProvidedTooLow struct { - providedAmtETH *apd.Decimal - offerMinAmtETH *apd.Decimal + providedAmtETH *apd.Decimal + providedAmtAsXMR *apd.Decimal + offerMinAmtXMR *apd.Decimal + exchangeRate *coins.ExchangeRate } 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.offerMinAmtETH.Text('f'), + e.exchangeRate, + e.providedAmtAsXMR.Text('f'), ) } type errAmountProvidedTooHigh struct { - providedAmtETH *apd.Decimal - offerMaxETH *apd.Decimal + providedAmtETH *apd.Decimal + providedAmtAsXMR *apd.Decimal + offerMaxAmtXMR *apd.Decimal + exchangeRate *coins.ExchangeRate } 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.offerMaxETH.Text('f'), + e.exchangeRate, + e.providedAmtAsXMR.Text('f'), ) } diff --git a/protocol/xmrtaker/event_test.go b/protocol/xmrtaker/event_test.go index 03732056..51adee33 100644 --- a/protocol/xmrtaker/event_test.go +++ b/protocol/xmrtaker/event_test.go @@ -77,7 +77,7 @@ func TestSwapState_handleEvent_EventETHClaimed(t *testing.T) { msg = s.SendKeysMessage().(*message.SendKeysMessage) msg.PrivateViewKey = s.privkeys.ViewKey() msg.EthAddress = s.ETHClient().Address() - msg.ProvidedAmount = s.providedAmount.AsStandard() + msg.ProvidedAmount = s.providedAmount.AsStd() err = s.HandleProtocolMessage(msg) require.NoError(t, err) diff --git a/protocol/xmrtaker/min_balance.go b/protocol/xmrtaker/min_balance.go index 0e40b985..fd255cc1 100644 --- a/protocol/xmrtaker/min_balance.go +++ b/protocol/xmrtaker/min_balance.go @@ -71,11 +71,11 @@ func validateMinBalForTokenSwap( providesAmt *apd.Decimal, // standard units gasPriceWei *big.Int, ) error { - if tokenBalance.AsStandard().Cmp(providesAmt) < 0 { + if tokenBalance.AsStd().Cmp(providesAmt) < 0 { return errTokenBalanceTooLow{ providedAmount: providesAmt, - tokenBalance: tokenBalance.AsStandard(), - symbol: tokenBalance.StandardSymbol(), + tokenBalance: tokenBalance.AsStd(), + symbol: tokenBalance.StdSymbol(), } } diff --git a/protocol/xmrtaker/min_balance_test.go b/protocol/xmrtaker/min_balance_test.go index 095c9e43..991f5f9d 100644 --- a/protocol/xmrtaker/min_balance_test.go +++ b/protocol/xmrtaker/min_balance_test.go @@ -70,7 +70,7 @@ func Test_validateMinBalForTokenSwap(t *testing.T) { Symbol: "TK", } 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") gasPriceWei := big.NewInt(35e9) // 35 GWei err := validateMinBalForTokenSwap(balanceWei, tokenBalance, providesAmt, gasPriceWei) @@ -85,7 +85,7 @@ func Test_validateMinBalForTokenSwap_InsufficientTokenBalance(t *testing.T) { Symbol: "TK", } 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") gasPriceWei := big.NewInt(35e9) // 35 GWei err := validateMinBalForTokenSwap(balanceWei, tokenBalance, providesAmt, gasPriceWei) @@ -100,7 +100,7 @@ func Test_validateMinBalForTokenSwap_InsufficientETHBalance(t *testing.T) { Symbol: "TK", } 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") gasPriceWei := big.NewInt(35e9) // 35 GWei err := validateMinBalForTokenSwap(balanceWei, tokenBalance, providesAmt, gasPriceWei) diff --git a/protocol/xmrtaker/net.go b/protocol/xmrtaker/net.go index d1877985..230f99cf 100644 --- a/protocol/xmrtaker/net.go +++ b/protocol/xmrtaker/net.go @@ -7,12 +7,11 @@ import ( "github.com/cockroachdb/apd/v3" "github.com/libp2p/go-libp2p/core/peer" + "github.com/fatih/color" + "github.com/athanorlabs/atomic-swap/coins" "github.com/athanorlabs/atomic-swap/common" "github.com/athanorlabs/atomic-swap/common/types" - pcommon "github.com/athanorlabs/atomic-swap/protocol" - - "github.com/fatih/color" ) // Provides returns types.ProvidesETH @@ -20,34 +19,52 @@ func (inst *Instance) Provides() coins.ProvidesCoin { 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. func (inst *Instance) InitiateProtocol( makerPeerID peer.ID, providesAmount *apd.Decimal, offer *types.Offer, ) (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 { return nil, err } - offerMinETH, err := offer.ExchangeRate.ToETH(offer.MinAmount) + providedAssetAmount := coins.NewEthAssetAmount(providesAmount, token) + + providesAmtAsXMR, err := offer.ExchangeRate.ToXMR(providedAssetAmount) if err != nil { return nil, err } - offerMaxETH, err := offer.ExchangeRate.ToETH(offer.MaxAmount) - if err != nil { - return nil, err + if providesAmtAsXMR.Cmp(offer.MinAmount) < 0 { + return nil, &errAmountProvidedTooLow{ + providedAmtETH: providesAmount, + providedAmtAsXMR: providesAmtAsXMR, + offerMinAmtXMR: offer.MinAmount, + exchangeRate: offer.ExchangeRate, + } } - if offerMinETH.Cmp(providesAmount) > 0 { - return nil, errAmountProvidedTooLow{providesAmount, offerMinETH} - } - - if offerMaxETH.Cmp(providesAmount) < 0 { - return nil, errAmountProvidedTooHigh{providesAmount, offerMaxETH} + if providesAmtAsXMR.Cmp(offer.MaxAmount) > 0 { + return nil, &errAmountProvidedTooHigh{ + providedAmtETH: providesAmount, + providedAmtAsXMR: providesAmtAsXMR, + offerMaxAmtXMR: offer.MaxAmount, + exchangeRate: offer.ExchangeRate, + } } err = validateMinBalance( @@ -60,17 +77,7 @@ func (inst *Instance) InitiateProtocol( return nil, err } - providedAmount, err := pcommon.GetEthAssetAmount( - 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) + state, err := inst.initiate(makerPeerID, providedAssetAmount, offer.ExchangeRate, offer.EthAsset, offer.ID) if err != nil { return nil, err } diff --git a/protocol/xmrtaker/net_test.go b/protocol/xmrtaker/net_test.go index 458189b5..f7d9a005 100644 --- a/protocol/xmrtaker/net_test.go +++ b/protocol/xmrtaker/net_test.go @@ -15,6 +15,10 @@ import ( "github.com/athanorlabs/atomic-swap/common/types" ) +var ( + testExchangeRate = coins.StrToExchangeRate("0.08") +) + func newTestXMRTaker(t *testing.T) *Instance { b := newBackend(t) cfg := &Config{ @@ -37,7 +41,7 @@ func initiate( coins.ProvidesETH, minAmount, maxAmount, - coins.ToExchangeRate(apd.New(1, 0)), + testExchangeRate, types.EthAssetETH, ) s, err := xmrtaker.InitiateProtocol(testPeerID, providesAmount, offer) @@ -46,31 +50,43 @@ func initiate( func TestXMRTaker_InitiateProtocol(t *testing.T) { a := newTestXMRTaker(t) - zero := new(apd.Decimal) - one := apd.New(1, 0) + min := coins.StrToDecimal("0.1") + max := coins.StrToDecimal("1") - // Provided between minAmount and maxAmount - offer, s, err := initiate(a, apd.New(1, -1), zero, one) // 0.1 + // Provided between minAmount and maxAmount (0.05 ETH / 0.08 = 0.625 XMR) + 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.Equal(t, a.swapStates[offer.ID], s) // Provided with too many decimals - _, s, err = initiate(a, apd.New(1, -50), zero, one) // 10^-50 - require.Error(t, err) + _, s, err = initiate(a, apd.New(1, -50), min, max) // 10^-50 + require.ErrorContains(t, err, `"providesAmount" has too many decimal points; found=50 max=18`) require.Equal(t, nil, s) // Provided with a negative number - _, s, err = initiate(a, apd.New(-1, 0), zero, one) // -1 - require.Error(t, err) + _, s, err = initiate(a, coins.StrToDecimal("-1"), min, max) + require.ErrorContains(t, err, `"providesAmount" cannot be negative`) require.Equal(t, nil, s) - // Provided over maxAmount - _, s, err = initiate(a, apd.New(2, 0), one, one) // 2 - require.Error(t, err) + // Provided over maxAmount (0.09 ETH / 0.08 = 1.125 XMR) + _, s, err = initiate(a, coins.StrToDecimal("0.09"), min, max) + 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) - // Provided under minAmount - _, s, err = initiate(a, apd.New(1, -1), one, one) // 0.1 - require.Error(t, err) + // Provided under minAmount (0.00079 ETH / 0.08 = 0.009875 XMR) + _, s, err = initiate(a, coins.StrToDecimal("0.00079"), min, max) + 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) } diff --git a/protocol/xmrtaker/swap_state.go b/protocol/xmrtaker/swap_state.go index 3ba728b8..87368efb 100644 --- a/protocol/xmrtaker/swap_state.go +++ b/protocol/xmrtaker/swap_state.go @@ -121,7 +121,7 @@ func newSwapStateFromStart( return nil, err } - expectedAmount, err := exchangeRate.ToXMR(providedAmount.AsStandard()) + expectedAmount, err := exchangeRate.ToXMR(providedAmount) if err != nil { return nil, err } @@ -130,7 +130,7 @@ func newSwapStateFromStart( makerPeerID, offerID, coins.ProvidesETH, - providedAmount.AsStandard(), + providedAmount.AsStd(), expectedAmount, exchangeRate, ethAsset, @@ -274,7 +274,7 @@ func newSwapState( cancel() 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 @@ -602,7 +602,7 @@ func (s *swapState) lockAsset() (*ethtypes.Receipt, error) { cmtXMRTaker := s.secp256k1Pub.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() receipt, err := s.lockAndWaitForReceipt(cmtXMRMaker, cmtXMRTaker, nonce) @@ -665,7 +665,7 @@ func (s *swapState) lockAsset() (*ethtypes.Receipt, error) { 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 } diff --git a/protocol/xmrtaker/swap_state_test.go b/protocol/xmrtaker/swap_state_test.go index fb38a161..4f466eab 100644 --- a/protocol/xmrtaker/swap_state_test.go +++ b/protocol/xmrtaker/swap_state_test.go @@ -188,7 +188,7 @@ func newTestSwapStateWithERC20(t *testing.T, providesAmt *apd.Decimal) (*swapSta tokenInfo, err := b.ETHClient().ERC20Info(b.Ctx(), addr) require.NoError(t, err) - providesEthAssetAmt := coins.NewERC20TokenAmountFromDecimals(providesAmt, tokenInfo) + providesEthAssetAmt := coins.NewTokenAmountFromDecimals(providesAmt, tokenInfo) exchangeRate := coins.ToExchangeRate(apd.New(1, 0)) // 100% swapState, err := newSwapStateFromStart(b, testPeerID, types.Hash{}, false, diff --git a/rpc/net.go b/rpc/net.go index 0249989b..942643f9 100644 --- a/rpc/net.go +++ b/rpc/net.go @@ -190,7 +190,7 @@ func (s *NetService) takeOffer(makerPeerID peer.ID, offerID types.Hash, provides swapState, err := s.xmrtaker.InitiateProtocol(makerPeerID, providesAmount, offer) if err != nil { - return fmt.Errorf("failed to initiate protocol: %w", err) + return err } skm := swapState.SendKeysMessage().(*message.SendKeysMessage) diff --git a/rpcclient/client.go b/rpcclient/client.go index e2c56c4b..08d47de4 100644 --- a/rpcclient/client.go +++ b/rpcclient/client.go @@ -8,6 +8,7 @@ package rpcclient import ( "bytes" "context" + "errors" "fmt" "net" "net/http" @@ -80,12 +81,14 @@ func (c *Client) post(method string, request any, response any) error { defer func() { _ = httpResp.Body.Close() }() - if response == nil { - return nil + // Even if the response is nil, we still need to parse the outer JSON-RPC + // 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 = json2.DecodeClientResponse(httpResp.Body, response); err != nil { - return fmt.Errorf("failed to read %q response: %w", method, err) + if err != nil { + return fmt.Errorf("%q failed: %w", method, err) } return nil diff --git a/tests/erc20_integration_test.go b/tests/erc20_integration_test.go index 32340fd5..794bc975 100644 --- a/tests/erc20_integration_test.go +++ b/tests/erc20_integration_test.go @@ -70,13 +70,13 @@ func deployTestERC20(t *testing.T) ethcommon.Address { aliceCli := rpcclient.NewClient(ctx, defaultXMRTakerSwapdPort) balResp, err = aliceCli.Balances(tokenBalReq) 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 balResp, err = charlieCli.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()) return erc20Addr }