satellite/payments: specialized type for monetary amounts

Why: big.Float is not an ideal type for dealing with monetary amounts,
because no matter how high the precision, some non-integer decimal
values can not be represented exactly in base-2 floating point. Also,
storing gob-encoded big.Float values in the database makes it very hard
to use those values in meaningful queries, making it difficult to do
any sort of analysis on billing.

For better accuracy, then, we can just represent monetary values as
integers (in whatever base units are appropriate for the currency). For
example, STORJ tokens or Bitcoins can not be split into pieces smaller
than 10^-8, so we can store amounts of STORJ or BTC with precision
simply by moving the decimal point 8 digits to the right. For USD values
(assuming we don't want to deal with fractional cents), we can move the
decimal point 2 digits to the right.

To make it easier and less error-prone to deal with the math involved, I
introduce here a new type, monetary.Amount, instances of which have an
associated value _and_ a currency.

Change-Id: I03395d52f0e2473cf301361f6033722b54640265
This commit is contained in:
paul cannon 2021-08-10 17:29:50 -05:00 committed by Yingrong Zhao
parent c911360eb5
commit a16aecfa96
17 changed files with 503 additions and 238 deletions

View File

@ -297,8 +297,8 @@ func (p *Payments) TokenDeposit(w http.ResponseWriter, r *http.Request) {
responseData.Address = tx.Address
responseData.Amount = float64(requestData.Amount) / 100
responseData.TokenAmount = tx.Amount.String()
responseData.Rate = tx.Rate.Text('f', 8)
responseData.TokenAmount = tx.Amount.AsDecimal().String()
responseData.Rate = tx.Rate.StringFixed(8)
responseData.Status = tx.Status.String()
responseData.Link = tx.Link
responseData.ExpiresAt = tx.CreatedAt.Add(tx.Timeout)

View File

@ -1,18 +0,0 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
package coinpayments
import (
"math/big"
)
// Precision is precision amount used to parse currency amount.
// Set enough precision to to able to handle up to 8 digits after point.
const Precision = 32
// parseAmount parses amount string into big.Float with package wide defined precision.
func parseAmount(s string) (*big.Float, error) {
amount, _, err := big.ParseFloat(s, 10, Precision, big.ToNearestEven)
return amount, err
}

View File

@ -6,10 +6,13 @@ package coinpayments
import (
"context"
"encoding/json"
"math/big"
"net/url"
"strconv"
"time"
"github.com/shopspring/decimal"
"storj.io/storj/satellite/payments/monetary"
)
// cmdRates is API command for retrieving currency rate infos.
@ -28,8 +31,8 @@ const (
// CurrencyRateInfo holds currency conversion info.
type CurrencyRateInfo struct {
IsFiat bool
RateBTC big.Float
TXFee big.Float
RateBTC decimal.Decimal
TXFee decimal.Decimal
Status ExchangeStatus
LastUpdate time.Time
}
@ -48,16 +51,11 @@ func (rateInfo *CurrencyRateInfo) UnmarshalJSON(b []byte) error {
return err
}
parseBigFloat := func(s string) (*big.Float, error) {
f, _, err := big.ParseFloat(s, 10, 256, big.ToNearestEven)
return f, err
}
rateBTC, err := parseBigFloat(rateRaw.RateBTC)
rateBTC, err := decimal.NewFromString(rateRaw.RateBTC)
if err != nil {
return err
}
txFee, err := parseBigFloat(rateRaw.TXFee)
txFee, err := decimal.NewFromString(rateRaw.TXFee)
if err != nil {
return err
}
@ -69,8 +67,8 @@ func (rateInfo *CurrencyRateInfo) UnmarshalJSON(b []byte) error {
*rateInfo = CurrencyRateInfo{
IsFiat: rateRaw.IsFiat > 0,
RateBTC: *rateBTC,
TXFee: *txFee,
RateBTC: rateBTC,
TXFee: txFee,
Status: ExchangeStatus(rateRaw.Status),
LastUpdate: time.Unix(lastUpdate, 0),
}
@ -79,7 +77,7 @@ func (rateInfo *CurrencyRateInfo) UnmarshalJSON(b []byte) error {
}
// CurrencyRateInfos maps currency to currency rate info.
type CurrencyRateInfos map[Currency]CurrencyRateInfo
type CurrencyRateInfos map[*monetary.Currency]CurrencyRateInfo
// ConversionRates collection of API methods for retrieving currency
// conversion rates.

View File

@ -3,19 +3,26 @@
package coinpayments
// Currency is a type wrapper for defined currencies.
type Currency string
const (
// CurrencyUSD defines USD.
CurrencyUSD Currency = "USD"
// CurrencyLTCT defines LTCT, coins used for testing purpose.
CurrencyLTCT Currency = "LTCT"
// CurrencySTORJ defines STORJ tokens.
CurrencySTORJ Currency = "STORJ"
import (
"storj.io/storj/satellite/payments/monetary"
)
// String returns Currency string representation.
func (c Currency) String() string {
return string(c)
}
// CurrencySymbol is a symbol for a currency as recognized by coinpayments.net.
type CurrencySymbol string
var (
// CurrencyLTCT defines LTCT, coins used for testing purpose.
CurrencyLTCT = monetary.NewCurrency("LTCT test coins", "LTCT", 8)
// currencySymbols maps known currency objects to the currency symbols
// as recognized on coinpayments.net. In many cases, the currency's own
// idea of its symbol (currency.Symbol()) will be the same as this
// CurrencySymbol, but we should probably not count on that always being
// the case.
currencySymbols = map[*monetary.Currency]CurrencySymbol{
monetary.USDollars: "USD",
monetary.StorjToken: "STORJ",
monetary.Bitcoin: "BTC",
CurrencyLTCT: "LTCT",
}
)

View File

@ -6,13 +6,15 @@ package coinpayments
import (
"context"
"encoding/json"
"math/big"
"net/url"
"strconv"
"strings"
"time"
"github.com/shopspring/decimal"
"github.com/zeebo/errs"
"storj.io/storj/satellite/payments/monetary"
)
const (
@ -90,7 +92,7 @@ func (list TransactionIDList) Encode() string {
type Transaction struct {
ID TransactionID
Address string
Amount big.Float
Amount monetary.Amount
DestTag string
ConfirmsNeeded int
Timeout time.Duration
@ -117,7 +119,7 @@ func (tx *Transaction) UnmarshalJSON(b []byte) error {
return err
}
amount, err := parseAmount(txRaw.Amount)
amount, err := monetary.AmountFromString(txRaw.Amount, monetary.StorjToken)
if err != nil {
return err
}
@ -130,7 +132,7 @@ func (tx *Transaction) UnmarshalJSON(b []byte) error {
*tx = Transaction{
ID: TransactionID(txRaw.TxID),
Address: txRaw.Address,
Amount: *amount,
Amount: amount,
DestTag: txRaw.DestTag,
ConfirmsNeeded: int(confirms),
Timeout: time.Second * time.Duration(txRaw.Timeout),
@ -145,9 +147,9 @@ func (tx *Transaction) UnmarshalJSON(b []byte) error {
// TransactionInfo holds transaction information.
type TransactionInfo struct {
Address string
Coin Currency
Amount big.Float
Received big.Float
Coin CurrencySymbol
Amount decimal.Decimal
Received decimal.Decimal
ConfirmsReceived int
Status Status
ExpiresAt time.Time
@ -157,34 +159,25 @@ type TransactionInfo struct {
// UnmarshalJSON handles json unmarshaling for transaction info.
func (info *TransactionInfo) UnmarshalJSON(b []byte) error {
var txInfoRaw struct {
Address string `json:"payment_address"`
Coin string `json:"coin"`
Status int `json:"status"`
AmountF string `json:"amountf"`
ReceivedF string `json:"receivedf"`
ConfirmsRecv int `json:"recv_confirms"`
ExpiresAt int64 `json:"time_expires"`
CreatedAt int64 `json:"time_created"`
Address string `json:"payment_address"`
Coin string `json:"coin"`
Status int `json:"status"`
Amount decimal.Decimal `json:"amountf"`
Received decimal.Decimal `json:"receivedf"`
ConfirmsRecv int `json:"recv_confirms"`
ExpiresAt int64 `json:"time_expires"`
CreatedAt int64 `json:"time_created"`
}
if err := json.Unmarshal(b, &txInfoRaw); err != nil {
return err
}
amount, err := parseAmount(txInfoRaw.AmountF)
if err != nil {
return err
}
received, err := parseAmount(txInfoRaw.ReceivedF)
if err != nil {
return err
}
*info = TransactionInfo{
Address: txInfoRaw.Address,
Coin: Currency(txInfoRaw.Coin),
Amount: *amount,
Received: *received,
Coin: CurrencySymbol(txInfoRaw.Coin),
Amount: txInfoRaw.Amount,
Received: txInfoRaw.Received,
ConfirmsReceived: txInfoRaw.ConfirmsRecv,
Status: Status(txInfoRaw.Status),
ExpiresAt: time.Unix(txInfoRaw.ExpiresAt, 0),
@ -233,9 +226,9 @@ func (infos *TransactionInfos) UnmarshalJSON(b []byte) error {
// CreateTX defines parameters for transaction creating.
type CreateTX struct {
Amount big.Float
CurrencyIn Currency
CurrencyOut Currency
Amount decimal.Decimal
CurrencyIn *monetary.Currency
CurrencyOut *monetary.Currency
BuyerEmail string
}
@ -246,10 +239,18 @@ type Transactions struct {
// Create creates new transaction.
func (t Transactions) Create(ctx context.Context, params *CreateTX) (*Transaction, error) {
cpSymbolIn, ok := currencySymbols[params.CurrencyIn]
if !ok {
return nil, Error.New("can't identify coinpayments currency symbol for %q", params.CurrencyIn.Name())
}
cpSymbolOut, ok := currencySymbols[params.CurrencyOut]
if !ok {
return nil, Error.New("can't identify coinpayments currency symbol for %q", params.CurrencyOut.Name())
}
values := make(url.Values)
values.Set("amount", params.Amount.Text('f', -1))
values.Set("currency1", params.CurrencyIn.String())
values.Set("currency2", params.CurrencyOut.String())
values.Set("amount", params.Amount.String())
values.Set("currency1", string(cpSymbolIn))
values.Set("currency2", string(cpSymbolOut))
values.Set("buyer_email", params.BuyerEmail)
tx := new(Transaction)

View File

@ -4,14 +4,15 @@
package coinpayments_test
import (
"math/big"
"testing"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
"storj.io/common/testcontext"
"storj.io/storj/satellite"
"storj.io/storj/satellite/payments/coinpayments"
"storj.io/storj/satellite/payments/monetary"
"storj.io/storj/satellite/satellitedb/satellitedbtest"
)
@ -36,9 +37,9 @@ func TestListInfos(t *testing.T) {
for x := 0; x < 27; x++ {
tx, err := payments.Create(ctx,
&coinpayments.CreateTX{
Amount: *big.NewFloat(100),
CurrencyIn: coinpayments.CurrencySTORJ,
CurrencyOut: coinpayments.CurrencySTORJ,
Amount: decimal.NewFromInt(100),
CurrencyIn: monetary.StorjToken,
CurrencyOut: monetary.StorjToken,
BuyerEmail: "test@test.com",
},
)

View File

@ -12,10 +12,10 @@ import (
// ErrNoAuthorizationKey is error that indicates that there is no authorization key.
var ErrNoAuthorizationKey = Error.New("no authorization key")
// GetTransacationKeyFromURL parses provided raw url string
// GetTransactionKeyFromURL parses provided raw url string
// and extracts authorization key from it. Returns ErrNoAuthorizationKey if
// there is no authorization key and error if rawurl cannot be parsed.
func GetTransacationKeyFromURL(rawurl string) (string, error) {
func GetTransactionKeyFromURL(rawurl string) (string, error) {
u, err := url.Parse(rawurl)
if err != nil {
return "", errs.Wrap(err)

View File

@ -17,7 +17,7 @@ func TestGetCheckoutURL(t *testing.T) {
url := coinpayments.GetCheckoutURL(expected, "id")
key, err := coinpayments.GetTransacationKeyFromURL(url)
key, err := coinpayments.GetTransactionKeyFromURL(url)
require.NoError(t, err)
assert.Equal(t, expected, key)

View File

@ -0,0 +1,172 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
package monetary
import (
"fmt"
"math"
"math/big"
"github.com/shopspring/decimal"
"github.com/zeebo/errs"
)
// Currency represents a currency for the purpose of representing amounts in
// that currency. Currency instances have a name, a symbol, and a number of
// supported decimal places of supported precision.
type Currency struct {
name string
symbol string
decimalPlaces int32
}
// NewCurrency creates a new Currency instance.
func NewCurrency(name, symbol string, decimalPlaces int32) *Currency {
return &Currency{name: name, symbol: symbol, decimalPlaces: decimalPlaces}
}
// Name returns the name of the currency.
func (c *Currency) Name() string {
return c.name
}
// Symbol returns the symbol of the currency.
func (c *Currency) Symbol() string {
return c.symbol
}
var (
// StorjToken is the currency for the STORJ ERC20 token, which powers
// most payments on the current Storj network.
StorjToken = NewCurrency("STORJ Token", "STORJ", 8)
// USDollars is the currency of United States dollars, where fractional
// cents are not supported.
USDollars = NewCurrency("US dollars", "USD", 2)
// Bitcoin is the currency for the well-known cryptocurrency Bitcoin
// (a.k.a. BTC).
Bitcoin = NewCurrency("Bitcoin (BTC)", "BTC", 8)
// LiveGoats is the currency of live goats, which some Storj network
// satellites may elect to support for payments.
LiveGoats = NewCurrency("Live goats", "goats", 0)
// Error is a class of errors encountered in the monetary package.
Error = errs.Class("monetary error")
)
// Amount represents a monetary amount, encapsulating a value and a currency.
//
// The value of the Amount is represented in "base units", meaning units of the
// smallest indivisible portion of the given currency. For example, when
// the currency is USDollars, the base unit would be cents.
type Amount struct {
baseUnits int64
currency *Currency
}
// AsFloat returns the monetary value in currency units expressed as a
// floating point number. _Warning_ may lose precision! (float64 has the
// equivalent of 53 bits of precision, as defined by big.Float.)
func (a Amount) AsFloat() float64 {
return float64(a.baseUnits) * math.Pow10(int(-a.currency.decimalPlaces))
}
// AsBigFloat returns the monetary value in currency units expressed as an
// instance of *big.Float with precision=64. _Warning_ may lose precision!
func (a Amount) AsBigFloat() *big.Float {
return a.AsBigFloatWithPrecision(64)
}
// AsBigFloatWithPrecision returns the monetary value in currency units
// expressed as an instance of *big.Float with the given precision.
// _Warning_ this may lose precision if the specified precision is not
// large enough!
func (a Amount) AsBigFloatWithPrecision(p uint) *big.Float {
stringVal := a.AsDecimal().String()
bf, _, err := big.ParseFloat(stringVal, 10, p, big.ToNearestEven)
if err != nil {
// it does not appear that this is possible, after a review of
// decimal.Decimal{}.String() and big.ParseFloat().
panic(fmt.Sprintf("could not parse output of Decimal.String() (%q) as big.Float: %v", stringVal, err))
}
return bf
}
// AsDecimal returns the monetary value in currency units expressed as an
// arbitrary precision decimal number.
func (a Amount) AsDecimal() decimal.Decimal {
d := decimal.NewFromInt(a.baseUnits)
return d.Shift(-a.currency.decimalPlaces)
}
// BaseUnits returns the monetary value expressed in its base units.
func (a Amount) BaseUnits() int64 {
return a.baseUnits
}
// Currency returns the currency of the amount.
func (a Amount) Currency() *Currency {
return a.currency
}
// Equal returns true if a and other are in the same currency and have the
// same value.
func (a Amount) Equal(other Amount) bool {
return a.currency == other.currency && a.baseUnits == other.baseUnits
}
// AmountFromBaseUnits creates a new Amount instance from the given count of
// base units and in the given currency.
func AmountFromBaseUnits(units int64, currency *Currency) Amount {
return Amount{
baseUnits: units,
currency: currency,
}
}
// AmountFromDecimal creates a new Amount instance from the given decimal
// value and in the given currency. The decimal value is expected to be in
// currency units.
//
// Example:
//
// AmountFromDecimal(decimal.NewFromFloat(3.50), USDollars) == Amount{baseUnits: 350, currency: USDollars}
func AmountFromDecimal(d decimal.Decimal, currency *Currency) Amount {
return AmountFromBaseUnits(d.Shift(currency.decimalPlaces).Round(0).IntPart(), currency)
}
// AmountFromString creates a new Amount instance from the given base 10
// value and in the given currency. The string is expected to give the
// value of the amount in currency units.
func AmountFromString(s string, currency *Currency) (Amount, error) {
d, err := decimal.NewFromString(s)
if err != nil {
return Amount{}, Error.Wrap(err)
}
return AmountFromDecimal(d, currency), nil
}
// AmountFromBigFloat creates a new Amount instance from the given floating
// point value and in the given currency. The big.Float is expected to give
// the value of the amount in currency units.
func AmountFromBigFloat(f *big.Float, currency *Currency) (Amount, error) {
dec, err := DecimalFromBigFloat(f)
if err != nil {
return Amount{}, err
}
return AmountFromDecimal(dec, currency), nil
}
// DecimalFromBigFloat creates a new decimal.Decimal instance from the given
// floating point value.
func DecimalFromBigFloat(f *big.Float) (decimal.Decimal, error) {
if f.IsInf() {
return decimal.Decimal{}, Error.New("Cannot represent infinite amount")
}
// This is probably not computationally ideal, but it should be the most
// straightforward way to convert (unless/until the decimal package adds
// a NewFromBigFloat method).
stringVal := f.Text('e', -1)
dec, err := decimal.NewFromString(stringVal)
return dec, Error.Wrap(err)
}

View File

@ -0,0 +1,99 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
package monetary
import (
"math/big"
"testing"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var (
// If we use more than 18 decimal places, baseUnits values will overflow int64
// even if there is only one digit to the left of the decimal point.
manyDigitsCurrency = &Currency{name: "manyDigitsCurrency", symbol: "mdc", decimalPlaces: 18}
noDigitsCurrency = &Currency{name: "noDigitsCurrency", symbol: "ndc", decimalPlaces: 0}
)
func TestAmountFromBigFloatAndAmountAsBigFloat(t *testing.T) {
parseFloat := func(s string) *big.Float {
bf, _, err := big.ParseFloat(s, 10, 256, big.ToNearestEven)
if err != nil {
t.Fatalf("failed to parse %q as float: %v", s, err)
}
return bf
}
tests := []struct {
name string
floatValue *big.Float
currency *Currency
baseUnits int64
wantErr bool
}{
{"zero", big.NewFloat(0), StorjToken, 0, false},
{"one", big.NewFloat(1), USDollars, 100, false},
{"negative", big.NewFloat(-1), Bitcoin, -100000000, false},
{"smallest", big.NewFloat(1e-8), StorjToken, 1, false},
{"minus smallest", big.NewFloat(-1e-8), StorjToken, -1, false},
{"one+delta", parseFloat("1.000000000000000001"), manyDigitsCurrency, 1000000000000000001, false},
{"minus one+delta", parseFloat("-1.000000000000000001"), manyDigitsCurrency, -1000000000000000001, false},
{"large number", parseFloat("4611686018427387904.0"), noDigitsCurrency, 4611686018427387904, false},
{"infinity", parseFloat("Inf"), StorjToken, 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := AmountFromBigFloat(tt.floatValue, tt.currency)
if (err != nil) != tt.wantErr {
t.Errorf("AmountFromBigFloat() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr {
return
}
want := Amount{baseUnits: tt.baseUnits, currency: tt.currency}
assert.Equal(t, want, got)
assert.Equal(t, tt.baseUnits, got.BaseUnits())
fullPrecValue, err := tt.floatValue.MarshalText()
require.NoError(t, err)
gotAsFloat := got.AsBigFloatWithPrecision(tt.floatValue.Prec())
fullPrecGot, err := gotAsFloat.MarshalText()
require.NoError(t, err)
assert.Truef(t, tt.floatValue.Cmp(gotAsFloat) == 0,
"(expected) %v != (got) %v", string(fullPrecValue), string(fullPrecGot))
})
}
}
func TestAmountFromDecimalAndAmountAsDecimal(t *testing.T) {
tests := []struct {
name string
decimalValue decimal.Decimal
currency *Currency
baseUnits int64
wantErr bool
}{
{"zero", decimal.Decimal{}, StorjToken, 0, false},
{"one", decimal.NewFromInt(1), USDollars, 100, false},
{"negative", decimal.NewFromInt(-1), Bitcoin, -100000000, false},
{"smallest", decimal.NewFromFloat(1e-8), StorjToken, 1, false},
{"one+delta", decimal.RequireFromString("1.000000000000000001"), manyDigitsCurrency, 1000000000000000001, false},
{"minus one+delta", decimal.RequireFromString("-1.000000000000000001"), manyDigitsCurrency, -1000000000000000001, false},
{"large number", decimal.RequireFromString("4611686018427387904.0"), noDigitsCurrency, 4611686018427387904, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := AmountFromDecimal(tt.decimalValue, tt.currency)
want := Amount{baseUnits: tt.baseUnits, currency: tt.currency}
assert.Equal(t, want, got)
assert.Equal(t, tt.baseUnits, got.BaseUnits())
assert.Truef(t, tt.decimalValue.Equal(got.AsDecimal()),
"%v != %v", tt.decimalValue, got.AsDecimal())
})
}
}

View File

@ -5,27 +5,29 @@ package stripecoinpayments
import (
"context"
"math"
"math/big"
"time"
"github.com/shopspring/decimal"
"github.com/zeebo/errs"
"go.uber.org/zap"
"storj.io/common/sync2"
"storj.io/storj/satellite/payments/monetary"
)
// convertToCents convert amount to cents with given rate.
func convertToCents(rate, amount *big.Float) int64 {
f, _ := new(big.Float).Mul(amount, rate).Float64()
return int64(math.Round(f * 100))
// convertToCents convert amount to USD cents with given rate.
func convertToCents(rate decimal.Decimal, amount monetary.Amount) int64 {
amountDecimal := amount.AsDecimal()
usd := amountDecimal.Mul(rate)
usdCents := usd.Shift(2)
return usdCents.Round(0).IntPart()
}
// convertFromCents convert amount in cents to big.Float with given rate.
func convertFromCents(rate *big.Float, amount int64) *big.Float {
a := new(big.Float).SetInt64(amount)
a = a.Quo(a, new(big.Float).SetInt64(100))
return new(big.Float).Quo(a, rate)
// convertFromCents convert amount in cents to a StorjTokenAmount with given rate.
func convertFromCents(rate decimal.Decimal, usdCents int64) monetary.Amount {
usd := decimal.NewFromInt(usdCents).Shift(-2)
numStorj := usd.Div(rate)
return monetary.AmountFromDecimal(numStorj, monetary.USDollars)
}
// ErrConversion defines version service error.

View File

@ -8,7 +8,6 @@ import (
"errors"
"fmt"
"math"
"math/big"
"strconv"
"strings"
"sync"
@ -25,6 +24,7 @@ import (
"storj.io/storj/satellite/console"
"storj.io/storj/satellite/payments"
"storj.io/storj/satellite/payments/coinpayments"
"storj.io/storj/satellite/payments/monetary"
)
var (
@ -199,7 +199,7 @@ func (service *Service) updateTransactions(ctx context.Context, ids TransactionA
TransactionUpdate{
TransactionID: id,
Status: info.Status,
Received: info.Received,
Received: monetary.AmountFromDecimal(info.Received, monetary.StorjToken),
},
)
@ -275,7 +275,7 @@ func (service *Service) applyTransactionBalance(ctx context.Context, tx Transact
return err
}
cents := convertToCents(rate, &tx.Received)
cents := convertToCents(rate, tx.Received)
if cents <= 0 {
service.log.Warn("Trying to deposit non-positive amount.",
@ -321,7 +321,7 @@ func (service *Service) applyTransactionBalance(ctx context.Context, tx Transact
Description: stripe.String(StripeDepositTransactionDescription),
}
params.AddMetadata("txID", tx.ID.String())
params.AddMetadata("storj_amount", tx.Amount.String())
params.AddMetadata("storj_amount", tx.Amount.AsDecimal().String())
params.AddMetadata("storj_usd_rate", rate.String())
_, err = service.stripeClient.CustomerBalanceTransactions().New(params)
if err != nil {
@ -370,26 +370,26 @@ func (service *Service) UpdateRates(ctx context.Context) (err error) {
}
// GetRate returns conversion rate for specified currencies.
func (service *Service) GetRate(ctx context.Context, curr1, curr2 coinpayments.Currency) (_ *big.Float, err error) {
func (service *Service) GetRate(ctx context.Context, curr1, curr2 *monetary.Currency) (_ decimal.Decimal, err error) {
defer mon.Task()(&ctx)(&err)
service.mu.Lock()
defer service.mu.Unlock()
if service.ratesErr != nil {
return nil, Error.Wrap(err)
return decimal.Decimal{}, Error.Wrap(err)
}
info1, ok := service.rates[curr1]
if !ok {
return nil, Error.New("no rate for currency %s", curr1)
return decimal.Decimal{}, Error.New("no rate for currency %s", curr1.Name())
}
info2, ok := service.rates[curr2]
if !ok {
return nil, Error.New("no rate for currency %s", curr2)
return decimal.Decimal{}, Error.New("no rate for currency %s", curr2.Name())
}
return new(big.Float).Quo(&info1.RateBTC, &info2.RateBTC), nil
return info1.RateBTC.Div(info2.RateBTC), nil
}
// PrepareInvoiceProjectRecords iterates through all projects and creates invoice records if

View File

@ -16,6 +16,7 @@ import (
"storj.io/common/uuid"
"storj.io/storj/satellite/payments"
"storj.io/storj/satellite/payments/coinpayments"
"storj.io/storj/satellite/payments/monetary"
)
const (
@ -60,18 +61,18 @@ func (tokens *storjTokens) Deposit(ctx context.Context, userID uuid.UUID, amount
return nil, Error.Wrap(err)
}
rate, err := tokens.service.GetRate(ctx, coinpayments.CurrencySTORJ, coinpayments.CurrencyUSD)
rate, err := tokens.service.GetRate(ctx, monetary.StorjToken, monetary.USDollars)
if err != nil {
return nil, Error.Wrap(err)
}
tokenAmount := convertFromCents(rate, amount).SetPrec(payments.STORJTokenPrecision)
tokenAmount := convertFromCents(rate, amount)
tx, err := tokens.service.coinPayments.Transactions().Create(ctx,
&coinpayments.CreateTX{
Amount: *tokenAmount,
CurrencyIn: coinpayments.CurrencySTORJ,
CurrencyOut: coinpayments.CurrencySTORJ,
Amount: tokenAmount.AsDecimal(),
CurrencyIn: monetary.StorjToken,
CurrencyOut: monetary.StorjToken,
BuyerEmail: c.Email,
},
)
@ -79,7 +80,7 @@ func (tokens *storjTokens) Deposit(ctx context.Context, userID uuid.UUID, amount
return nil, Error.Wrap(err)
}
key, err := coinpayments.GetTransacationKeyFromURL(tx.CheckoutURL)
key, err := coinpayments.GetTransactionKeyFromURL(tx.CheckoutURL)
if err != nil {
return nil, Error.Wrap(err)
}
@ -105,8 +106,8 @@ func (tokens *storjTokens) Deposit(ctx context.Context, userID uuid.UUID, amount
return &payments.Transaction{
ID: payments.TransactionID(tx.ID),
Amount: *payments.TokenAmountFromBigFloat(&tx.Amount),
Rate: *rate,
Amount: tx.Amount,
Rate: rate,
Address: tx.Address,
Status: payments.TransactionStatusPending,
Timeout: tx.Timeout,
@ -149,10 +150,10 @@ func (tokens *storjTokens) ListTransactionInfos(ctx context.Context, userID uuid
infos = append(infos,
payments.TransactionInfo{
ID: []byte(tx.ID),
Amount: *payments.TokenAmountFromBigFloat(&tx.Amount),
Received: *payments.TokenAmountFromBigFloat(&tx.Received),
AmountCents: convertToCents(rate, &tx.Amount),
ReceivedCents: convertToCents(rate, &tx.Received),
Amount: tx.Amount,
Received: tx.Received,
AmountCents: convertToCents(rate, tx.Amount),
ReceivedCents: convertToCents(rate, tx.Received),
Address: tx.Address,
Status: status,
Link: link,

View File

@ -5,13 +5,14 @@ package stripecoinpayments
import (
"context"
"math/big"
"time"
"github.com/shopspring/decimal"
"github.com/zeebo/errs"
"storj.io/common/uuid"
"storj.io/storj/satellite/payments/coinpayments"
"storj.io/storj/satellite/payments/monetary"
)
// ErrTransactionConsumed is thrown when trying to consume already consumed transaction.
@ -29,9 +30,9 @@ type TransactionsDB interface {
// Consume marks transaction as consumed, so it won't participate in apply account balance loop.
Consume(ctx context.Context, id coinpayments.TransactionID) error
// LockRate locks conversion rate for transaction.
LockRate(ctx context.Context, id coinpayments.TransactionID, rate *big.Float) error
LockRate(ctx context.Context, id coinpayments.TransactionID, rate decimal.Decimal) error
// GetLockedRate returns locked conversion rate for transaction or error if non exists.
GetLockedRate(ctx context.Context, id coinpayments.TransactionID) (*big.Float, error)
GetLockedRate(ctx context.Context, id coinpayments.TransactionID) (decimal.Decimal, error)
// ListAccount returns all transaction for specific user.
ListAccount(ctx context.Context, userID uuid.UUID) ([]Transaction, error)
// ListPending returns TransactionsPage with pending transactions.
@ -45,8 +46,8 @@ type Transaction struct {
ID coinpayments.TransactionID
AccountID uuid.UUID
Address string
Amount big.Float
Received big.Float
Amount monetary.Amount
Received monetary.Amount
Status coinpayments.Status
Key string
Timeout time.Duration
@ -57,7 +58,7 @@ type Transaction struct {
type TransactionUpdate struct {
TransactionID coinpayments.TransactionID
Status coinpayments.Status
Received big.Float
Received monetary.Amount
}
// TransactionsPage holds set of transaction and indicates if

View File

@ -6,11 +6,11 @@ package stripecoinpayments_test
import (
"encoding/base64"
"errors"
"math/big"
"sync"
"testing"
"time"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stripe/stripe-go/v72"
@ -24,6 +24,7 @@ import (
"storj.io/storj/private/testplanet"
"storj.io/storj/satellite"
"storj.io/storj/satellite/payments/coinpayments"
"storj.io/storj/satellite/payments/monetary"
"storj.io/storj/satellite/payments/stripecoinpayments"
"storj.io/storj/satellite/satellitedb/satellitedbtest"
)
@ -32,17 +33,18 @@ func TestTransactionsDB(t *testing.T) {
satellitedbtest.Run(t, func(ctx *testcontext.Context, t *testing.T, db satellite.DB) {
transactions := db.StripeCoinPayments().Transactions()
amount, ok := new(big.Float).SetPrec(1000).SetString("2.0000000000000000005")
require.True(t, ok)
received, ok := new(big.Float).SetPrec(1000).SetString("1.0000000000000000003")
require.True(t, ok)
amount, err := monetary.AmountFromString("2.0000000000000000005", monetary.StorjToken)
require.NoError(t, err)
received, err := monetary.AmountFromString("1.0000000000000000003", monetary.StorjToken)
require.NoError(t, err)
userID := testrand.UUID()
createTx := stripecoinpayments.Transaction{
ID: "testID",
AccountID: uuid.UUID{1, 2, 3},
AccountID: userID,
Address: "testAddress",
Amount: *amount,
Received: *received,
Amount: amount,
Received: received,
Status: coinpayments.StatusPending,
Key: "testKey",
Timeout: time.Second * 60,
@ -51,29 +53,30 @@ func TestTransactionsDB(t *testing.T) {
t.Run("insert", func(t *testing.T) {
tx, err := transactions.Insert(ctx, createTx)
require.NoError(t, err)
require.NotNil(t, tx)
compareTransactions(t, createTx, *tx)
requireSaneTimestamp(t, tx.CreatedAt)
txs, err := transactions.ListAccount(ctx, userID)
require.NoError(t, err)
require.Len(t, txs, 1)
compareTransactions(t, createTx, txs[0])
})
t.Run("update", func(t *testing.T) {
received, ok := new(big.Float).SetPrec(1000).SetString("6.0000000000000000001")
require.True(t, ok)
received, err := monetary.AmountFromString("6.0000000000000000001", monetary.StorjToken)
require.NoError(t, err)
update := stripecoinpayments.TransactionUpdate{
TransactionID: createTx.ID,
Status: coinpayments.StatusReceived,
Received: *received,
Received: received,
}
err := transactions.Update(ctx, []stripecoinpayments.TransactionUpdate{update}, nil)
err = transactions.Update(ctx, []stripecoinpayments.TransactionUpdate{update}, nil)
require.NoError(t, err)
page, err := transactions.ListPending(ctx, 0, 1, time.Now())
require.NoError(t, err)
require.NotNil(t, page.Transactions)
require.Equal(t, 1, len(page.Transactions))
require.Len(t, page.Transactions, 1)
assert.Equal(t, createTx.ID, page.Transactions[0].ID)
assert.Equal(t, update.Received, page.Transactions[0].Received)
assert.Equal(t, update.Status, page.Transactions[0].Status)
@ -83,7 +86,7 @@ func TestTransactionsDB(t *testing.T) {
{
TransactionID: createTx.ID,
Status: coinpayments.StatusCompleted,
Received: *received,
Received: received,
},
},
coinpayments.TransactionIDList{
@ -115,24 +118,34 @@ func TestTransactionsDB(t *testing.T) {
})
}
func requireSaneTimestamp(t *testing.T, when time.Time) {
// ensure time value is sane. I apologize to you people of the future when this starts breaking
require.Truef(t, when.After(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)),
"%s seems too small to be a valid creation timestamp", when)
require.Truef(t, when.Before(time.Date(2500, 1, 1, 0, 0, 0, 0, time.UTC)),
"%s seems too large to be a valid creation timestamp", when)
}
func TestConcurrentConsume(t *testing.T) {
satellitedbtest.Run(t, func(ctx *testcontext.Context, t *testing.T, db satellite.DB) {
transactions := db.StripeCoinPayments().Transactions()
const concurrentTries = 30
amount, ok := new(big.Float).SetPrec(1000).SetString("2.0000000000000000005")
require.True(t, ok)
received, ok := new(big.Float).SetPrec(1000).SetString("1.0000000000000000003")
require.True(t, ok)
amount, err := monetary.AmountFromString("2.0000000000000000005", monetary.StorjToken)
require.NoError(t, err)
received, err := monetary.AmountFromString("1.0000000000000000003", monetary.StorjToken)
require.NoError(t, err)
userID := testrand.UUID()
txID := coinpayments.TransactionID("testID")
tx, err := transactions.Insert(ctx,
stripecoinpayments.Transaction{
ID: "testID",
AccountID: uuid.UUID{1, 2, 3},
ID: txID,
AccountID: userID,
Address: "testAddress",
Amount: *amount,
Received: *received,
Amount: amount,
Received: received,
Status: coinpayments.StatusPending,
Key: "testKey",
Timeout: time.Second * 60,
@ -144,7 +157,7 @@ func TestConcurrentConsume(t *testing.T) {
[]stripecoinpayments.TransactionUpdate{{
TransactionID: tx.ID,
Status: coinpayments.StatusCompleted,
Received: *received,
Received: received,
}},
coinpayments.TransactionIDList{
tx.ID,
@ -194,10 +207,10 @@ func TestTransactionsDBList(t *testing.T) {
)
// create transactions
amount, ok := new(big.Float).SetPrec(1000).SetString("4.0000000000000000005")
require.True(t, ok)
received, ok := new(big.Float).SetPrec(1000).SetString("5.0000000000000000003")
require.True(t, ok)
amount, err := monetary.AmountFromString("4.0000000000000000005", monetary.StorjToken)
require.NoError(t, err)
received, err := monetary.AmountFromString("5.0000000000000000003", monetary.StorjToken)
require.NoError(t, err)
var txs []stripecoinpayments.Transaction
for i := 0; i < transactionCount; i++ {
@ -214,8 +227,8 @@ func TestTransactionsDBList(t *testing.T) {
ID: coinpayments.TransactionID(id),
AccountID: uuid.UUID{},
Address: addr,
Amount: *amount,
Received: *received,
Amount: amount,
Received: received,
Status: status,
Key: key,
}
@ -312,12 +325,12 @@ func TestTransactionsDBRates(t *testing.T) {
satellitedbtest.Run(t, func(ctx *testcontext.Context, t *testing.T, db satellite.DB) {
transactions := db.StripeCoinPayments().Transactions()
val, ok := new(big.Float).SetPrec(1000).SetString("4.0000000000000000005")
require.True(t, ok)
val, err := decimal.NewFromString("4.000000000000000005")
require.NoError(t, err)
const txID = "tx_id"
err := transactions.LockRate(ctx, txID, val)
err = transactions.LockRate(ctx, txID, val)
require.NoError(t, err)
rate, err := transactions.GetLockedRate(ctx, txID)
@ -355,17 +368,17 @@ func TestTransactions_ApplyTransactionBalance(t *testing.T) {
// Emulate a deposit through CoinPayments.
txID := coinpayments.TransactionID("testID")
storjAmount, ok := new(big.Float).SetString("100")
require.True(t, ok)
storjUSDRate, ok := new(big.Float).SetString("0.2")
require.True(t, ok)
storjAmount, err := monetary.AmountFromString("100", monetary.StorjToken)
require.NoError(t, err)
storjUSDRate, err := decimal.NewFromString("0.2")
require.NoError(t, err)
createTx := stripecoinpayments.Transaction{
ID: txID,
AccountID: userID,
Address: "testAddress",
Amount: *storjAmount,
Received: *storjAmount,
Amount: storjAmount,
Received: storjAmount,
Status: coinpayments.StatusPending,
Key: "testKey",
Timeout: time.Second * 60,
@ -378,7 +391,7 @@ func TestTransactions_ApplyTransactionBalance(t *testing.T) {
update := stripecoinpayments.TransactionUpdate{
TransactionID: createTx.ID,
Status: coinpayments.StatusReceived,
Received: *storjAmount,
Received: storjAmount,
}
err = transactions.Update(ctx, []stripecoinpayments.TransactionUpdate{update}, coinpayments.TransactionIDList{createTx.ID})

View File

@ -5,10 +5,12 @@ package payments
import (
"context"
"math/big"
"time"
"github.com/shopspring/decimal"
"storj.io/common/uuid"
"storj.io/storj/satellite/payments/monetary"
)
// StorjTokens defines all payments STORJ token related functionality.
@ -53,8 +55,8 @@ func (id TransactionID) String() string {
// accepts user funds on a specific wallet address.
type Transaction struct {
ID TransactionID
Amount TokenAmount
Rate big.Float
Amount monetary.Amount
Rate decimal.Decimal
Address string
Status TransactionStatus
Timeout time.Duration
@ -66,8 +68,8 @@ type Transaction struct {
// such as links and expiration time.
type TransactionInfo struct {
ID TransactionID
Amount TokenAmount
Received TokenAmount
Amount monetary.Amount
Received monetary.Amount
AmountCents int64
ReceivedCents int64
Address string
@ -77,49 +79,6 @@ type TransactionInfo struct {
CreatedAt time.Time
}
// TokenAmount is a wrapper type for STORJ token amount.
// Uses big.Float as inner representation. Precision is set to 32
// so it can properly handle 8 digits after point which is STORJ token
// decimal set.
type TokenAmount struct {
inner big.Float
}
// STORJTokenPrecision defines STORJ token precision.
const STORJTokenPrecision = 32
// NewTokenAmount creates new zeroed TokenAmount with fixed precision.
func NewTokenAmount() *TokenAmount {
return &TokenAmount{inner: *new(big.Float).SetPrec(STORJTokenPrecision)}
}
// BigFloat returns inner representation of TokenAmount.
func (amount *TokenAmount) BigFloat() *big.Float {
f := new(big.Float).Set(&amount.inner)
return f
}
// String representation of TokenValue.
func (amount *TokenAmount) String() string {
return amount.inner.Text('f', -1)
}
// ParseTokenAmount parses string representing floating point and returns
// TokenAmount.
func ParseTokenAmount(s string) (*TokenAmount, error) {
inner, _, err := big.ParseFloat(s, 10, STORJTokenPrecision, big.ToNearestEven)
if err != nil {
return nil, err
}
return &TokenAmount{inner: *inner}, nil
}
// TokenAmountFromBigFloat converts big.Float to TokenAmount.
func TokenAmountFromBigFloat(f *big.Float) *TokenAmount {
inner := (*f).SetMode(big.ToNearestEven).SetPrec(STORJTokenPrecision)
return &TokenAmount{inner: *inner}
}
// DepositBonus defines a bonus received for depositing tokens.
type DepositBonus struct {
TransactionID TransactionID

View File

@ -8,10 +8,12 @@ import (
"math/big"
"time"
"github.com/shopspring/decimal"
"github.com/zeebo/errs"
"storj.io/common/uuid"
"storj.io/storj/satellite/payments/coinpayments"
"storj.io/storj/satellite/payments/monetary"
"storj.io/storj/satellite/payments/stripecoinpayments"
"storj.io/storj/satellite/satellitedb/dbx"
)
@ -45,11 +47,11 @@ type coinPaymentsTransactions struct {
func (db *coinPaymentsTransactions) Insert(ctx context.Context, tx stripecoinpayments.Transaction) (_ *stripecoinpayments.Transaction, err error) {
defer mon.Task()(&ctx)(&err)
amount, err := tx.Amount.GobEncode()
amount, err := tx.Amount.AsBigFloat().GobEncode()
if err != nil {
return nil, errs.Wrap(err)
}
received, err := tx.Received.GobEncode()
received, err := tx.Received.AsBigFloat().GobEncode()
if err != nil {
return nil, errs.Wrap(err)
}
@ -81,7 +83,7 @@ func (db *coinPaymentsTransactions) Update(ctx context.Context, updates []stripe
return db.db.WithTx(ctx, func(ctx context.Context, tx *dbx.Tx) error {
for _, update := range updates {
received, err := update.Received.GobEncode()
received, err := update.Received.AsBigFloat().GobEncode()
if err != nil {
return errs.Wrap(err)
}
@ -147,10 +149,10 @@ func (db *coinPaymentsTransactions) Consume(ctx context.Context, id coinpayments
}
// LockRate locks conversion rate for transaction.
func (db *coinPaymentsTransactions) LockRate(ctx context.Context, id coinpayments.TransactionID, rate *big.Float) (err error) {
func (db *coinPaymentsTransactions) LockRate(ctx context.Context, id coinpayments.TransactionID, rate decimal.Decimal) (err error) {
defer mon.Task()(&ctx)(&err)
buff, err := rate.GobEncode()
buff, err := rate.BigFloat().GobEncode()
if err != nil {
return errs.Wrap(err)
}
@ -163,19 +165,23 @@ func (db *coinPaymentsTransactions) LockRate(ctx context.Context, id coinpayment
}
// GetLockedRate returns locked conversion rate for transaction or error if non exists.
func (db *coinPaymentsTransactions) GetLockedRate(ctx context.Context, id coinpayments.TransactionID) (_ *big.Float, err error) {
func (db *coinPaymentsTransactions) GetLockedRate(ctx context.Context, id coinpayments.TransactionID) (_ decimal.Decimal, err error) {
defer mon.Task()(&ctx)(&err)
dbxRate, err := db.db.Get_StripecoinpaymentsTxConversionRate_By_TxId(ctx,
dbx.StripecoinpaymentsTxConversionRate_TxId(id.String()),
)
if err != nil {
return nil, err
return decimal.Decimal{}, err
}
rate := new(big.Float)
if err = rate.GobDecode(dbxRate.Rate); err != nil {
return nil, errs.Wrap(err)
var rateF big.Float
if err = rateF.GobDecode(dbxRate.Rate); err != nil {
return decimal.Decimal{}, errs.Wrap(err)
}
rate, err := monetary.DecimalFromBigFloat(&rateF)
if err != nil {
return decimal.Decimal{}, errs.Wrap(err)
}
return rate, nil
@ -248,12 +254,17 @@ func (db *coinPaymentsTransactions) ListPending(ctx context.Context, offset int6
return stripecoinpayments.TransactionsPage{}, err
}
var amount, received big.Float
if err := amount.GobDecode(amountB); err != nil {
return stripecoinpayments.TransactionsPage{}, errs.Wrap(err)
// TODO: the currency here should be passed in to this function or stored
// in the database.
currency := monetary.StorjToken
amount, err := monetaryAmountFromGobEncodedBigFloat(amountB, currency)
if err != nil {
return stripecoinpayments.TransactionsPage{}, err
}
if err := received.GobDecode(receivedB); err != nil {
return stripecoinpayments.TransactionsPage{}, errs.Wrap(err)
received, err := monetaryAmountFromGobEncodedBigFloat(receivedB, currency)
if err != nil {
return stripecoinpayments.TransactionsPage{}, err
}
page.Transactions = append(page.Transactions,
@ -283,7 +294,7 @@ func (db *coinPaymentsTransactions) ListPending(ctx context.Context, offset int6
return page, nil
}
// List Unapplied returns TransactionsPage with a pending or completed status, that should be applied to account balance.
// ListUnapplied returns TransactionsPage with a pending or completed status, that should be applied to account balance.
func (db *coinPaymentsTransactions) ListUnapplied(ctx context.Context, offset int64, limit int, before time.Time) (_ stripecoinpayments.TransactionsPage, err error) {
defer mon.Task()(&ctx)(&err)
@ -326,11 +337,16 @@ func (db *coinPaymentsTransactions) ListUnapplied(ctx context.Context, offset in
return stripecoinpayments.TransactionsPage{}, err
}
var amount, received big.Float
if err := amount.GobDecode(amountB); err != nil {
// TODO: the currency here should be passed in to this function or stored
// in the database.
currency := monetary.StorjToken
amount, err := monetaryAmountFromGobEncodedBigFloat(amountB, currency)
if err != nil {
return stripecoinpayments.TransactionsPage{}, errs.Wrap(err)
}
if err := received.GobDecode(receivedB); err != nil {
received, err := monetaryAmountFromGobEncodedBigFloat(receivedB, currency)
if err != nil {
return stripecoinpayments.TransactionsPage{}, errs.Wrap(err)
}
@ -368,11 +384,16 @@ func fromDBXCoinpaymentsTransaction(dbxCPTX *dbx.CoinpaymentsTransaction) (*stri
return nil, errs.Wrap(err)
}
var amount, received big.Float
if err := amount.GobDecode(dbxCPTX.Amount); err != nil {
// TODO: the currency here should be passed in to this function or stored
// in the database.
currency := monetary.StorjToken
amount, err := monetaryAmountFromGobEncodedBigFloat(dbxCPTX.Amount, currency)
if err != nil {
return nil, errs.Wrap(err)
}
if err := received.GobDecode(dbxCPTX.Received); err != nil {
received, err := monetaryAmountFromGobEncodedBigFloat(dbxCPTX.Received, currency)
if err != nil {
return nil, errs.Wrap(err)
}
@ -388,3 +409,11 @@ func fromDBXCoinpaymentsTransaction(dbxCPTX *dbx.CoinpaymentsTransaction) (*stri
CreatedAt: dbxCPTX.CreatedAt,
}, nil
}
func monetaryAmountFromGobEncodedBigFloat(encoded []byte, currency *monetary.Currency) (_ monetary.Amount, err error) {
var bf big.Float
if err := bf.GobDecode(encoded); err != nil {
return monetary.Amount{}, Error.Wrap(err)
}
return monetary.AmountFromBigFloat(&bf, currency)
}