This repository has been archived by the owner on Jul 1, 2024. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 42
/
service_convertor.go
354 lines (293 loc) · 9.76 KB
/
service_convertor.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
package blockchain
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/antihax/optional"
"github.com/jellydator/ttlcache/v3"
"github.com/oxygenpay/oxygen/internal/money"
client "github.com/oxygenpay/tatum-sdk/tatum"
"github.com/pkg/errors"
)
type Convertor interface {
GetExchangeRate(ctx context.Context, from, to string) (ExchangeRate, error)
Convert(ctx context.Context, from, to, amount string) (Conversion, error)
FiatToFiat(ctx context.Context, from money.Money, to money.FiatCurrency) (Conversion, error)
FiatToCrypto(ctx context.Context, from money.Money, to money.CryptoCurrency) (Conversion, error)
CryptoToFiat(ctx context.Context, from money.Money, to money.FiatCurrency) (Conversion, error)
}
type ExchangeRate struct {
From string
To string
Rate float64
CalculatedAt time.Time
}
type ConversionType string
const (
ConversionTypeFiatToFiat ConversionType = "fiatToFiat"
ConversionTypeFiatToCrypto ConversionType = "fiatToCrypto"
ConversionTypeCryptoToFiat ConversionType = "cryptoToFiat"
)
type Conversion struct {
Type ConversionType
Rate float64
From money.Money
To money.Money
}
func (s *Service) GetExchangeRate(ctx context.Context, from, to string) (ExchangeRate, error) {
if from == "" || to == "" {
return ExchangeRate{}, ErrValidation
}
// noop
if from == to {
return ExchangeRate{
Rate: 1,
From: from,
To: to,
CalculatedAt: time.Now(),
}, nil
}
convType, err := determineConversionType(from, to)
if err != nil {
return ExchangeRate{}, err
}
var (
rate float64
at time.Time
)
switch convType {
case ConversionTypeFiatToFiat, ConversionTypeCryptoToFiat:
rate, at, err = s.getExchangeRate(ctx, NormalizeTicker(to), NormalizeTicker(from))
case ConversionTypeFiatToCrypto:
// Tatum does not support USD to ETH, that's why we need to calculate ETH to USD and reverse it
rate, at, err = s.getExchangeRate(ctx, NormalizeTicker(from), NormalizeTicker(to))
if err == nil {
rate = 1 / rate
}
default:
return ExchangeRate{}, errors.Errorf("unsupported conversion type %q", convType)
}
if err != nil {
return ExchangeRate{}, err
}
return ExchangeRate{
From: from,
To: to,
Rate: rate,
CalculatedAt: at,
}, nil
}
// Convert Converts currencies according to automatically resolved ConversionType. This method parses amount as float64,
// please don't use it internally as output would contain huge error rate when dealing with 18 eth decimals.
// Suitable for API responses.
//
//nolint:gocyclo
func (s *Service) Convert(ctx context.Context, from, to, amount string) (Conversion, error) {
switch {
case from == "":
return Conversion{}, errors.Wrap(ErrValidation, "from is required")
case to == "":
return Conversion{}, errors.Wrap(ErrValidation, "to is required")
case amount == "":
return Conversion{}, errors.Wrap(ErrValidation, "amount is required")
}
from, to = strings.ToUpper(from), strings.ToUpper(to)
amountFloat, err := strconv.ParseFloat(amount, 64)
if err != nil || amountFloat <= 0 {
return Conversion{}, errors.Wrap(ErrValidation, "invalid amount")
}
convType, err := determineConversionType(from, to)
if err != nil {
return Conversion{}, errors.Wrap(ErrValidation, err.Error())
}
switch convType {
case ConversionTypeFiatToFiat:
fromMoney, err := money.FiatFromFloat64(money.FiatCurrency(from), amountFloat)
if err != nil {
return Conversion{}, errors.Wrap(ErrValidation, "unable to make selected fiat money")
}
toCurrency, err := money.MakeFiatCurrency(to)
if err != nil {
return Conversion{}, errors.Wrap(err, "unable to resolve desired fiat currency")
}
return s.FiatToFiat(ctx, fromMoney, toCurrency)
case ConversionTypeFiatToCrypto:
fromMoney, err := money.FiatFromFloat64(money.FiatCurrency(from), amountFloat)
if err != nil {
return Conversion{}, errors.Wrap(ErrValidation, "unable to make selected fiat money")
}
toCurrency, err := s.GetCurrencyByTicker(to)
if err != nil {
return Conversion{}, errors.Wrap(ErrValidation, "unable to resolve desired currency")
}
return s.FiatToCrypto(ctx, fromMoney, toCurrency)
case ConversionTypeCryptoToFiat:
fromCurrency, err := s.GetCurrencyByTicker(from)
if err != nil {
return Conversion{}, errors.Wrap(ErrValidation, "unable to resolve selected crypto currency")
}
fromMoney, err := money.CryptoFromFloat64(fromCurrency.Ticker, amountFloat, fromCurrency.Decimals)
if err != nil {
return Conversion{}, errors.Wrap(ErrValidation, "unable to resolve selected crypto currency")
}
toCurrency, err := money.MakeFiatCurrency(to)
if err != nil {
return Conversion{}, errors.Wrap(ErrValidation, "unable to resolve desired fiat currency")
}
return s.CryptoToFiat(ctx, fromMoney, toCurrency)
}
return Conversion{}, errors.Wrap(ErrValidation, "unsupported conversion")
}
func (s *Service) FiatToFiat(ctx context.Context, from money.Money, to money.FiatCurrency) (Conversion, error) {
if from.Type() != money.Fiat {
return Conversion{}, errors.Wrapf(ErrValidation, "%s is not fiat", from.Ticker())
}
rate, err := s.GetExchangeRate(ctx, from.Ticker(), to.String())
if err != nil {
return Conversion{}, err
}
toValue, err := from.MultiplyFloat64(rate.Rate)
if err != nil {
return Conversion{}, err
}
toMoney, err := money.New(money.Fiat, to.String(), toValue.StringRaw(), money.FiatDecimals)
if err != nil {
return Conversion{}, err
}
return Conversion{
Type: ConversionTypeFiatToFiat,
From: from,
To: toMoney,
Rate: rate.Rate,
}, nil
}
func (s *Service) FiatToCrypto(ctx context.Context, from money.Money, to money.CryptoCurrency) (Conversion, error) {
if from.Type() != money.Fiat {
return Conversion{}, errors.Wrapf(ErrValidation, "%s is not fiat", from.Ticker())
}
if from.IsZero() {
return Conversion{}, errors.Wrapf(ErrValidation, "%s is zero", from.Ticker())
}
rate, err := s.GetExchangeRate(ctx, from.Ticker(), to.Ticker)
if err != nil {
return Conversion{}, err
}
// Example: "$123 to ETH". How it works:
// - Create "1 ETH"
// - Multiply it by 123 -> "123 ETH"
// - Multiply by exchange rate of 1/1800
// - 1 * 123 * 1/1800 = 123/1800 = 0.0683 ETH
//
// This approach is taken because cryptocurrencies have more decimals that USD/EUR, so if we'd multiply USD by
// exchange rate (that can be <1), we would get a huge error rate due to rounding.
cryptoMoney, err := money.CryptoFromFloat64(to.Ticker, 1, to.Decimals)
if err != nil {
return Conversion{}, err
}
fiat, err := from.FiatToFloat64()
if err != nil {
return Conversion{}, err
}
cryptoMoney, err = cryptoMoney.MultiplyFloat64(fiat)
if err != nil {
return Conversion{}, err
}
cryptoMoney, err = cryptoMoney.MultiplyFloat64(rate.Rate)
if err != nil {
return Conversion{}, err
}
return Conversion{
Type: ConversionTypeFiatToCrypto,
Rate: rate.Rate,
From: from,
To: cryptoMoney,
}, nil
}
func (s *Service) CryptoToFiat(ctx context.Context, from money.Money, to money.FiatCurrency) (Conversion, error) {
if from.Type() != money.Crypto {
return Conversion{}, errors.Wrapf(ErrValidation, "%s is not crypto", from.Ticker())
}
rate, err := s.GetExchangeRate(ctx, from.Ticker(), to.String())
if err != nil {
return Conversion{}, err
}
fiatMoney, err := money.CryptoToFiat(from, to, rate.Rate)
if err != nil {
return Conversion{}, err
}
return Conversion{
Type: ConversionTypeCryptoToFiat,
Rate: rate.Rate,
From: from,
To: fiatMoney,
}, nil
}
// getExchangeRate. Example: is 1 ETH = $1500, then semantics are following:
// getExchangeRate(ctx, "USD", "ETH") (1500, time.Time, nil)
func (s *Service) getExchangeRate(ctx context.Context, desired, selected string) (float64, time.Time, error) {
res, err := s.getTatumExchangeRate(ctx, desired, selected)
if err != nil {
return 0, time.Time{}, errors.Wrapf(err, "unable to get exchange rate of %q / %q", desired, selected)
}
rate, err := strconv.ParseFloat(res.Value, 64)
if err != nil {
return 0, time.Time{}, err
}
return rate, time.UnixMilli(int64(res.Timestamp)), nil
}
func tatumRateCacheKey(desired, selected string) string {
return fmt.Sprintf("%s/%s", selected, desired)
}
func (s *Service) getTatumExchangeRate(ctx context.Context, desired, selected string) (client.ExchangeRate, error) {
key := tatumRateCacheKey(desired, selected)
if s.ratesCache != nil {
if hit := s.ratesCache.Get(key); hit != nil {
return hit.Value(), nil
}
}
opts := &client.ExchangeRateApiGetExchangeRateOpts{BasePair: optional.NewString(desired)}
res, _, err := s.providers.Tatum.Main().ExchangeRateApi.GetExchangeRate(ctx, selected, opts)
if err != nil {
return client.ExchangeRate{}, errors.Wrapf(err, "unable to get exchange rate of %q / %q", desired, selected)
}
if s.ratesCache != nil {
s.ratesCache.Set(key, res, ttlcache.DefaultTTL)
}
return res, err
}
func determineConversionType(from, to string) (ConversionType, error) {
var fromIsFiat, toIsFiat bool
if _, err := money.MakeFiatCurrency(from); err == nil {
fromIsFiat = true
}
if _, err := money.MakeFiatCurrency(to); err == nil {
toIsFiat = true
}
switch {
case fromIsFiat && toIsFiat:
return ConversionTypeFiatToFiat, nil
case fromIsFiat && !toIsFiat:
return ConversionTypeFiatToCrypto, nil
case !fromIsFiat && toIsFiat:
return ConversionTypeCryptoToFiat, nil
}
return "", errors.Errorf("unsupported conversion type: %q to %q", from, to)
}
// e.g. ETH_USDT -> USDT
var normalizations = map[string]string{
"_USDT": "USDT",
"_USDC": "USDC",
"_BUSD": "BUSD",
}
// NormalizeTicker normalizes fiat / crypto ticker for further usage in external services (e.g. Tatum).
func NormalizeTicker(ticker string) string {
ticker = strings.ToUpper(ticker)
for substr, replaced := range normalizations {
if strings.Contains(ticker, substr) {
return replaced
}
}
return ticker
}