satellite/{payments, console}: added functionality to get wallet's transactions (including pending)

Added new functionality to query storjscan for all wallet transactions (including pending).
Added new endpoint to query all wallet transactions.

Issue:
https://github.com/storj/storj/issues/5978

Change-Id: Id15fddfc9c95efcaa32aa21403cb177f9297e1ab
This commit is contained in:
Vitalii 2023-07-10 19:53:39 +03:00 committed by Storj Robot
parent 2ee0195eba
commit 583ad54d86
15 changed files with 265 additions and 45 deletions

View File

@ -547,7 +547,9 @@ func NewAPI(log *zap.Logger, full *identity.FullIdentity, db DB,
peer.Payments.StorjscanService = storjscan.NewService(log.Named("storjscan-service"),
peer.DB.Wallets(),
peer.DB.StorjscanPayments(),
peer.Payments.StorjscanClient)
peer.Payments.StorjscanClient,
pc.Storjscan.Confirmations,
pc.BonusRate)
if err != nil {
return nil, errs.Combine(err, peer.Close())
}
@ -610,6 +612,7 @@ func NewAPI(log *zap.Logger, full *identity.FullIdentity, db DB,
accountFreezeService,
peer.Console.Listener,
config.Payments.StripeCoinPayments.StripePublicKey,
config.Payments.Storjscan.Confirmations,
peer.URL(),
config.Payments.PackagePlans,
)

View File

@ -48,6 +48,7 @@ type FrontendConfig struct {
PricingPackagesEnabled bool `json:"pricingPackagesEnabled"`
NewUploadModalEnabled bool `json:"newUploadModalEnabled"`
GalleryViewEnabled bool `json:"galleryViewEnabled"`
NeededTransactionConfirmations int `json:"neededTransactionConfirmations"`
}
// Satellites is a configuration value that contains a list of satellite names and addresses.

View File

@ -466,6 +466,30 @@ func (p *Payments) WalletPayments(w http.ResponseWriter, r *http.Request) {
}
}
// WalletPaymentsWithConfirmations returns with the list of storjscan transactions (including confirmations count) for user`s wallet.
func (p *Payments) WalletPaymentsWithConfirmations(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
w.Header().Set("Content-Type", "application/json")
walletPayments, err := p.service.Payments().WalletPaymentsWithConfirmations(ctx)
if err != nil {
if console.ErrUnauthorized.Has(err) {
p.serveJSONError(ctx, w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(ctx, w, http.StatusInternalServerError, err)
return
}
if err = json.NewEncoder(w).Encode(walletPayments); err != nil {
p.log.Error("failed to encode wallet payments with confirmations", zap.Error(ErrPaymentsAPI.Wrap(err)))
}
}
// GetProjectUsagePriceModel returns the project usage price model for the user.
func (p *Payments) GetProjectUsagePriceModel(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

View File

@ -144,7 +144,8 @@ type Server struct {
userIDRateLimiter *web.RateLimiter
nodeURL storj.NodeURL
stripePublicKey string
stripePublicKey string
neededTokenPaymentConfirmations int
packagePlans paymentsconfig.PackagePlans
@ -210,20 +211,21 @@ func (a *apiAuth) RemoveAuthCookie(w http.ResponseWriter) {
}
// NewServer creates new instance of console server.
func NewServer(logger *zap.Logger, config Config, service *console.Service, oidcService *oidc.Service, mailService *mailservice.Service, analytics *analytics.Service, abTesting *abtesting.Service, accountFreezeService *console.AccountFreezeService, listener net.Listener, stripePublicKey string, nodeURL storj.NodeURL, packagePlans paymentsconfig.PackagePlans) *Server {
func NewServer(logger *zap.Logger, config Config, service *console.Service, oidcService *oidc.Service, mailService *mailservice.Service, analytics *analytics.Service, abTesting *abtesting.Service, accountFreezeService *console.AccountFreezeService, listener net.Listener, stripePublicKey string, neededTokenPaymentConfirmations int, nodeURL storj.NodeURL, packagePlans paymentsconfig.PackagePlans) *Server {
server := Server{
log: logger,
config: config,
listener: listener,
service: service,
mailService: mailService,
analytics: analytics,
abTesting: abTesting,
stripePublicKey: stripePublicKey,
ipRateLimiter: web.NewIPRateLimiter(config.RateLimit, logger),
userIDRateLimiter: NewUserIDRateLimiter(config.RateLimit, logger),
nodeURL: nodeURL,
packagePlans: packagePlans,
log: logger,
config: config,
listener: listener,
service: service,
mailService: mailService,
analytics: analytics,
abTesting: abTesting,
stripePublicKey: stripePublicKey,
neededTokenPaymentConfirmations: neededTokenPaymentConfirmations,
ipRateLimiter: web.NewIPRateLimiter(config.RateLimit, logger),
userIDRateLimiter: NewUserIDRateLimiter(config.RateLimit, logger),
nodeURL: nodeURL,
packagePlans: packagePlans,
}
logger.Debug("Starting Satellite Console server.", zap.Stringer("Address", server.listener.Addr()))
@ -332,6 +334,7 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, oidc
paymentsRouter.HandleFunc("/wallet", paymentController.GetWallet).Methods(http.MethodGet, http.MethodOptions)
paymentsRouter.HandleFunc("/wallet", paymentController.ClaimWallet).Methods(http.MethodPost, http.MethodOptions)
paymentsRouter.HandleFunc("/wallet/payments", paymentController.WalletPayments).Methods(http.MethodGet, http.MethodOptions)
paymentsRouter.HandleFunc("/wallet/payments-with-confirmations", paymentController.WalletPaymentsWithConfirmations).Methods(http.MethodGet, http.MethodOptions)
paymentsRouter.HandleFunc("/billing-history", paymentController.BillingHistory).Methods(http.MethodGet, http.MethodOptions)
paymentsRouter.Handle("/coupon/apply", server.userIDRateLimiter.Limit(http.HandlerFunc(paymentController.ApplyCouponCode))).Methods(http.MethodPatch, http.MethodOptions)
paymentsRouter.HandleFunc("/coupon", paymentController.GetCoupon).Methods(http.MethodGet, http.MethodOptions)
@ -718,6 +721,7 @@ func (server *Server) frontendConfigHandler(w http.ResponseWriter, r *http.Reque
PricingPackagesEnabled: server.config.PricingPackagesEnabled,
NewUploadModalEnabled: server.config.NewUploadModalEnabled,
GalleryViewEnabled: server.config.GalleryViewEnabled,
NeededTransactionConfirmations: server.neededTokenPaymentConfirmations,
}
err := json.NewEncoder(w).Encode(&cfg)

View File

@ -3091,6 +3091,12 @@ func EtherscanURL(tx string) string {
// ErrWalletNotClaimed shows that no address is claimed by the user.
var ErrWalletNotClaimed = errs.Class("wallet is not claimed")
// TestSwapDepositWallets replaces the existing handler for deposit wallets with
// the one specified for use in testing.
func (payment Payments) TestSwapDepositWallets(dw payments.DepositWallets) {
payment.service.depositWallets = dw
}
// ClaimWallet requests a new wallet for the users to be used for payments. If wallet is already claimed,
// it will return with the info without error.
func (payment Payments) ClaimWallet(ctx context.Context) (_ WalletInfo, err error) {
@ -3211,6 +3217,27 @@ func (payment Payments) WalletPayments(ctx context.Context) (_ WalletPayments, e
}, nil
}
// WalletPaymentsWithConfirmations returns with all the native blockchain payments (including pending) for a user's wallet.
func (payment Payments) WalletPaymentsWithConfirmations(ctx context.Context) (paymentsWithConfirmations []payments.WalletPaymentWithConfirmations, err error) {
defer mon.Task()(&ctx)(&err)
user, err := GetUser(ctx)
if err != nil {
return nil, Error.Wrap(err)
}
address, err := payment.service.depositWallets.Get(ctx, user.ID)
if err != nil {
return nil, Error.Wrap(err)
}
paymentsWithConfirmations, err = payment.service.depositWallets.PaymentsWithConfirmations(ctx, address)
if err != nil {
return nil, Error.Wrap(err)
}
return
}
// Purchase makes a purchase of `price` amount with description of `desc` and payment method with id of `paymentMethodID`.
// If a paid invoice with the same description exists, then we assume this is a retried request and don't create and pay
// another invoice.

View File

@ -1702,6 +1702,86 @@ func TestPaymentsWalletPayments(t *testing.T) {
})
}
type mockDepositWallets struct {
address blockchain.Address
payments []payments.WalletPaymentWithConfirmations
}
func (dw mockDepositWallets) Claim(_ context.Context, _ uuid.UUID) (blockchain.Address, error) {
return dw.address, nil
}
func (dw mockDepositWallets) Get(_ context.Context, _ uuid.UUID) (blockchain.Address, error) {
return dw.address, nil
}
func (dw mockDepositWallets) Payments(_ context.Context, _ blockchain.Address, _ int, _ int64) (p []payments.WalletPayment, err error) {
return
}
func (dw mockDepositWallets) PaymentsWithConfirmations(_ context.Context, _ blockchain.Address) ([]payments.WalletPaymentWithConfirmations, error) {
return dw.payments, nil
}
func TestWalletPaymentsWithConfirmations(t *testing.T) {
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 0,
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
sat := planet.Satellites[0]
service := sat.API.Console.Service
paymentsService := service.Payments()
user, err := sat.AddUser(ctx, console.CreateUser{
FullName: "Test User",
Email: "test@mail.test",
Password: "example",
}, 1)
require.NoError(t, err)
now := time.Now()
wallet := blockchaintest.NewAddress()
var expected []payments.WalletPaymentWithConfirmations
for i := 0; i < 3; i++ {
expected = append(expected, payments.WalletPaymentWithConfirmations{
From: blockchaintest.NewAddress().Hex(),
To: wallet.Hex(),
TokenValue: currency.AmountFromBaseUnits(int64(i), currency.StorjToken).AsDecimal(),
USDValue: currency.AmountFromBaseUnits(int64(i), currency.USDollarsMicro).AsDecimal(),
Status: payments.PaymentStatusConfirmed,
BlockHash: blockchaintest.NewHash().Hex(),
BlockNumber: int64(i),
Transaction: blockchaintest.NewHash().Hex(),
LogIndex: i,
Timestamp: now,
Confirmations: int64(i),
BonusTokens: decimal.NewFromInt(int64(i)),
})
}
paymentsService.TestSwapDepositWallets(mockDepositWallets{address: wallet, payments: expected})
reqCtx := console.WithUser(ctx, user)
walletPayments, err := paymentsService.WalletPaymentsWithConfirmations(reqCtx)
require.NoError(t, err)
require.NotZero(t, len(walletPayments))
for i, wp := range walletPayments {
require.Equal(t, expected[i].From, wp.From)
require.Equal(t, expected[i].To, wp.To)
require.Equal(t, expected[i].TokenValue, wp.TokenValue)
require.Equal(t, expected[i].USDValue, wp.USDValue)
require.Equal(t, expected[i].Status, wp.Status)
require.Equal(t, expected[i].BlockHash, wp.BlockHash)
require.Equal(t, expected[i].BlockNumber, wp.BlockNumber)
require.Equal(t, expected[i].Transaction, wp.Transaction)
require.Equal(t, expected[i].LogIndex, wp.LogIndex)
require.Equal(t, expected[i].Timestamp, wp.Timestamp)
}
})
}
func TestPaymentsPurchase(t *testing.T) {
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 0,

View File

@ -496,7 +496,9 @@ func New(log *zap.Logger, full *identity.FullIdentity, db DB,
peer.Payments.StorjscanService = storjscan.NewService(log.Named("storjscan-service"),
peer.DB.Wallets(),
peer.DB.StorjscanPayments(),
peer.Payments.StorjscanClient)
peer.Payments.StorjscanClient,
pc.Storjscan.Confirmations,
pc.BonusRate)
if err != nil {
return nil, errs.Combine(err, peer.Close())
}

View File

@ -105,6 +105,12 @@ type Transaction struct {
CreatedAt time.Time
}
// CalculateBonusAmount calculates bonus for given currency amount and bonus rate.
func CalculateBonusAmount(amount currency.Amount, bonusRate int64) currency.Amount {
bonusUnits := amount.BaseUnits() * bonusRate / 100
return currency.AmountFromBaseUnits(bonusUnits, amount.Currency())
}
func prepareBonusTransaction(bonusRate int64, source string, transaction Transaction) (Transaction, bool) {
// Bonus transactions only apply when enabled (i.e. positive rate) and
// for StorjScan transactions.
@ -120,7 +126,7 @@ func prepareBonusTransaction(bonusRate int64, source string, transaction Transac
return Transaction{
UserID: transaction.UserID,
Amount: calculateBonusAmount(transaction.Amount, bonusRate),
Amount: CalculateBonusAmount(transaction.Amount, bonusRate),
Description: fmt.Sprintf("STORJ Token Bonus (%d%%)", bonusRate),
Source: StorjScanBonusSource,
Status: TransactionStatusCompleted,
@ -129,8 +135,3 @@ func prepareBonusTransaction(bonusRate int64, source string, transaction Transac
Metadata: append([]byte(nil), transaction.Metadata...),
}, true
}
func calculateBonusAmount(amount currency.Amount, bonusRate int64) currency.Amount {
bonusUnits := amount.BaseUnits() * bonusRate / 100
return currency.AmountFromBaseUnits(bonusUnits, amount.Currency())
}

View File

@ -65,7 +65,7 @@ func (chore *Chore) Run(ctx context.Context) (err error) {
return nil
}
latestPayments, err := chore.client.Payments(ctx, from)
latestPayments, err := chore.client.AllPayments(ctx, from)
if err != nil {
chore.log.Error("error retrieving payments", zap.Error(ChoreErr.Wrap(err)))
return nil

View File

@ -67,13 +67,30 @@ func NewClient(endpoint, identifier, secret string) *Client {
}
}
// Payments retrieves all payments after specified block for wallets associated with particular API key.
func (client *Client) Payments(ctx context.Context, from int64) (_ LatestPayments, err error) {
// AllPayments retrieves all payments after specified block for wallets associated with particular API key.
func (client *Client) AllPayments(ctx context.Context, from int64) (payments LatestPayments, err error) {
defer mon.Task()(&ctx)(&err)
p := client.endpoint + "/api/v0/tokens/payments"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p, nil)
payments, err = client.getPayments(ctx, p, from)
return
}
// Payments retrieves payments after specified block for given address associated with particular API key.
func (client *Client) Payments(ctx context.Context, from int64, address string) (payments LatestPayments, err error) {
defer mon.Task()(&ctx)(&err)
p := client.endpoint + "/api/v0/tokens/payments/" + address
payments, err = client.getPayments(ctx, p, from)
return
}
func (client *Client) getPayments(ctx context.Context, path string, from int64) (_ LatestPayments, err error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, path, nil)
if err != nil {
return LatestPayments{}, ClientErr.Wrap(err)
}

View File

@ -69,17 +69,17 @@ func TestClientMocked(t *testing.T) {
}))
defer server.Close()
client := storjscan.NewClient(server.URL, "eu", "secret")
client := storjscan.NewClient(server.URL, identifier, secret)
t.Run("all payments from 0", func(t *testing.T) {
actual, err := client.Payments(ctx, 0)
actual, err := client.AllPayments(ctx, 0)
require.NoError(t, err)
require.Equal(t, latestBlock, actual.LatestBlock)
require.Equal(t, len(payments), len(actual.Payments))
require.Equal(t, payments, actual.Payments)
})
t.Run("payments from 50", func(t *testing.T) {
actual, err := client.Payments(ctx, 50)
t.Run("all payments from 50", func(t *testing.T) {
actual, err := client.AllPayments(ctx, 50)
require.NoError(t, err)
require.Equal(t, latestBlock, actual.LatestBlock)
require.Equal(t, 50, len(actual.Payments))
@ -104,7 +104,7 @@ func TestClientMockedUnauthorized(t *testing.T) {
t.Run("empty credentials", func(t *testing.T) {
client := storjscan.NewClient(server.URL, "", "")
_, err := client.Payments(ctx, 0)
_, err := client.AllPayments(ctx, 0)
require.Error(t, err)
require.True(t, storjscan.ClientErrUnauthorized.Has(err))
require.Equal(t, "identifier is invalid", errs.Unwrap(err).Error())
@ -112,7 +112,7 @@ func TestClientMockedUnauthorized(t *testing.T) {
t.Run("invalid identifier", func(t *testing.T) {
client := storjscan.NewClient(server.URL, "invalid", "secret")
_, err := client.Payments(ctx, 0)
_, err := client.AllPayments(ctx, 0)
require.Error(t, err)
require.True(t, storjscan.ClientErrUnauthorized.Has(err))
require.Equal(t, "identifier is invalid", errs.Unwrap(err).Error())
@ -120,7 +120,7 @@ func TestClientMockedUnauthorized(t *testing.T) {
t.Run("invalid secret", func(t *testing.T) {
client := storjscan.NewClient(server.URL, "eu", "invalid")
_, err := client.Payments(ctx, 0)
_, err := client.AllPayments(ctx, 0)
require.Error(t, err)
require.True(t, storjscan.ClientErrUnauthorized.Has(err))
require.Equal(t, "secret is invalid", errs.Unwrap(err).Error())

View File

@ -35,19 +35,23 @@ var _ billing.PaymentType = (*Service)(nil)
// Service exposes API to interact with storjscan payments provider.
type Service struct {
log *zap.Logger
walletsDB WalletsDB
paymentsDB PaymentsDB
client *Client
log *zap.Logger
walletsDB WalletsDB
paymentsDB PaymentsDB
client *Client
neededConfirmations int
bonusRate int64
}
// NewService creates new storjscan service instance.
func NewService(log *zap.Logger, walletsDB WalletsDB, paymentsDB PaymentsDB, client *Client) *Service {
func NewService(log *zap.Logger, walletsDB WalletsDB, paymentsDB PaymentsDB, client *Client, neededConfirmations int, bonusRate int64) *Service {
return &Service{
log: log,
walletsDB: walletsDB,
paymentsDB: paymentsDB,
client: client,
log: log,
walletsDB: walletsDB,
paymentsDB: paymentsDB,
client: client,
neededConfirmations: neededConfirmations,
bonusRate: bonusRate,
}
}
@ -116,6 +120,45 @@ func (service *Service) Payments(ctx context.Context, wallet blockchain.Address,
return walletPayments, nil
}
// PaymentsWithConfirmations returns payments with confirmations count for a particular wallet.
func (service *Service) PaymentsWithConfirmations(ctx context.Context, wallet blockchain.Address) (_ []payments.WalletPaymentWithConfirmations, err error) {
defer mon.Task()(&ctx)(&err)
latestPayments, err := service.client.Payments(ctx, 0, wallet.Hex())
if err != nil {
return nil, ErrService.Wrap(err)
}
var walletPayments []payments.WalletPaymentWithConfirmations
for _, pmnt := range latestPayments.Payments {
confirmations := latestPayments.LatestBlock.Number - pmnt.BlockNumber
var status payments.PaymentStatus
if confirmations >= int64(service.neededConfirmations) {
status = payments.PaymentStatusConfirmed
} else {
status = payments.PaymentStatusPending
}
walletPayments = append(walletPayments, payments.WalletPaymentWithConfirmations{
From: pmnt.From.Hex(),
To: pmnt.To.Hex(),
TokenValue: pmnt.TokenValue.AsDecimal(),
USDValue: pmnt.USDValue.AsDecimal(),
Status: status,
BlockHash: pmnt.BlockHash.Hex(),
BlockNumber: pmnt.BlockNumber,
Transaction: pmnt.Transaction.Hex(),
LogIndex: pmnt.LogIndex,
Timestamp: pmnt.Timestamp,
Confirmations: confirmations,
BonusTokens: billing.CalculateBonusAmount(pmnt.TokenValue, service.bonusRate).AsDecimal(),
})
}
return walletPayments, nil
}
// Source defines the billing transaction source for storjscan payments.
func (service *Service) Source() string {
return billing.StorjScanSource

View File

@ -99,7 +99,7 @@ func TestServicePayments(t *testing.T) {
err := paymentsDB.InsertBatch(ctx, cachedPayments)
require.NoError(t, err)
service := storjscan.NewService(zaptest.NewLogger(t), db.Wallets(), paymentsDB, nil)
service := storjscan.NewService(zaptest.NewLogger(t), db.Wallets(), paymentsDB, nil, 15, 10)
t.Run("wallet 1", func(t *testing.T) {
expected := []payments.WalletPayment{walletPayments[3], walletPayments[1], walletPayments[0]}
@ -151,7 +151,7 @@ func TestServiceWallets(t *testing.T) {
err = db.Wallets().Add(ctx, userID3, walletAddress3)
require.NoError(t, err)
service := storjscan.NewService(zaptest.NewLogger(t), db.Wallets(), db.StorjscanPayments(), nil)
service := storjscan.NewService(zaptest.NewLogger(t), db.Wallets(), db.StorjscanPayments(), nil, 15, 10)
t.Run("get Wallet", func(t *testing.T) {
actual, err := service.Get(ctx, userID1)

View File

@ -34,6 +34,8 @@ type DepositWallets interface {
Get(ctx context.Context, userID uuid.UUID) (blockchain.Address, error)
// Payments returns payments for a particular wallet.
Payments(ctx context.Context, wallet blockchain.Address, limit int, offset int64) ([]WalletPayment, error)
// PaymentsWithConfirmations returns payments with confirmations count for a particular wallet.
PaymentsWithConfirmations(ctx context.Context, wallet blockchain.Address) ([]WalletPaymentWithConfirmations, error)
}
// TransactionStatus defines allowed statuses
@ -121,3 +123,19 @@ type WalletPayment struct {
LogIndex int `json:"logIndex"`
Timestamp time.Time `json:"timestamp"`
}
// WalletPaymentWithConfirmations holds storj token payment data with confirmations count.
type WalletPaymentWithConfirmations struct {
From string `json:"from"`
To string `json:"to"`
TokenValue decimal.Decimal `json:"tokenValue"`
USDValue decimal.Decimal `json:"usdValue"`
Status PaymentStatus `json:"status"`
BlockHash string `json:"blockHash"`
BlockNumber int64 `json:"blockNumber"`
Transaction string `json:"transaction"`
LogIndex int `json:"logIndex"`
Timestamp time.Time `json:"timestamp"`
Confirmations int64 `json:"confirmations"`
BonusTokens decimal.Decimal `json:"bonusTokens"`
}

View File

@ -68,7 +68,7 @@ func TestClientPayments(t *testing.T) {
err = stack.App.TokenPrice.Service.SavePrice(ctx, blockTime.Add(-30*time.Second), price)
require.NoError(t, err)
pmnts, err := planet.Satellites[0].API.Payments.StorjscanClient.Payments(ctx, 0)
pmnts, err := planet.Satellites[0].API.Payments.StorjscanClient.AllPayments(ctx, 0)
require.NoError(t, err)
require.Equal(t, block.Number().Int64(), pmnts.LatestBlock.Number)
require.Len(t, pmnts.Payments, 1)