satellite/payments/storjscan: add client and list all payments API call

Change-Id: I1f5065b3d15cc93f4b42868941e82e04af364565
This commit is contained in:
Yaroslav Vorobiov 2022-05-10 13:13:06 +01:00
parent 2b0016af62
commit 0bf12523e1
6 changed files with 389 additions and 10 deletions

View File

@ -6,6 +6,7 @@ package blockchain
import (
"encoding/hex"
"encoding/json"
"reflect"
"github.com/zeebo/errs"
)
@ -26,11 +27,6 @@ type Hash [HashLength]byte
var _ json.Marshaler = Hash{}
// MarshalJSON implements json marshalling interface.
func (h Hash) MarshalJSON() ([]byte, error) {
return json.Marshal(h.Hex())
}
// Bytes gets the byte representation of the underlying hash.
func (h Hash) Bytes() []byte { return h[:] }
@ -39,16 +35,21 @@ func (h Hash) Hex() string {
return hex.EncodeToString(h.Bytes())
}
// MarshalJSON implements json marshalling interface.
func (h Hash) MarshalJSON() ([]byte, error) {
return json.Marshal(h.Hex())
}
// UnmarshalJSON unmarshal JSON into Hash.
func (h *Hash) UnmarshalJSON(bytes []byte) error {
return unmarshalHexString(h[:], bytes, reflect.TypeOf(Hash{}))
}
// Address is wallet address.
type Address [AddressLength]byte
var _ json.Marshaler = Address{}
// MarshalJSON implements json marshalling interface.
func (a Address) MarshalJSON() ([]byte, error) {
return json.Marshal(a.Hex())
}
// Bytes gets the byte representation of the underlying address.
func (a Address) Bytes() []byte { return a[:] }
@ -56,3 +57,39 @@ func (a Address) Bytes() []byte { return a[:] }
func (a Address) Hex() string {
return hex.EncodeToString(a.Bytes())
}
// MarshalJSON implements json marshalling interface.
func (a Address) MarshalJSON() ([]byte, error) {
return json.Marshal(a.Hex())
}
// UnmarshalJSON unmarshal JSON into Address.
func (a *Address) UnmarshalJSON(bytes []byte) error {
return unmarshalHexString(a[:], bytes, reflect.TypeOf(Address{}))
}
// unmarshalHexString decodes JSON string containing hex string into bytes.
// Copies result into dst byte slice.
func unmarshalHexString(dst, src []byte, typ reflect.Type) error {
if !isString(src) {
return &json.UnmarshalTypeError{Value: "non-string", Type: reflect.TypeOf(typ)}
}
src = src[1 : len(src)-1]
if bytesHave0xPrefix(src) {
src = src[2:]
}
_, err := hex.Decode(dst, src)
return err
}
// isString checks if JSON value is a string.
func isString(input []byte) bool {
return len(input) >= 2 && input[0] == '"' && input[len(input)-1] == '"'
}
// bytesHave0xPrefix checks if string bytes representation contains 0x prefix.
func bytesHave0xPrefix(input []byte) bool {
return len(input) >= 2 && input[0] == '0' && (input[1] == 'x' || input[1] == 'X')
}

View File

@ -0,0 +1,25 @@
// Copyright (C) 2022 Storj Labs, Inc.
// See LICENSE for copying information.
package blockchaintest
import (
"storj.io/common/testrand"
"storj.io/storj/private/blockchain"
)
// NewAddress creates new blockchain address for testing.
func NewAddress() blockchain.Address {
var address blockchain.Address
b := testrand.BytesInt(blockchain.AddressLength)
copy(address[:], b)
return address
}
// NewHash creates new blockchain hash for testing.
func NewHash() blockchain.Hash {
var h blockchain.Hash
b := testrand.BytesInt(blockchain.HashLength)
copy(h[:], b)
return h
}

View File

@ -0,0 +1,116 @@
// Copyright (C) 2022 Storj Labs, Inc.
// See LICENSE for copying information.
package storjscan
import (
"context"
"encoding/json"
"math/big"
"net/http"
"strconv"
"time"
"github.com/zeebo/errs"
"storj.io/storj/private/blockchain"
)
var (
// ClientErr is general purpose storjscan client error class.
ClientErr = errs.Class("storjscan client")
// ClientErrUnauthorized is unauthorized err storjscan client error class.
ClientErrUnauthorized = errs.Class("storjscan client unauthorized")
)
// Header holds ethereum blockchain block header data.
type Header struct {
Hash blockchain.Hash
Number int64
Timestamp time.Time
}
// Payment holds storjscan payment data.
type Payment struct {
From blockchain.Address
To blockchain.Address
TokenValue *big.Int
BlockHash blockchain.Hash
BlockNumber int64
Transaction blockchain.Hash
LogIndex int
Timestamp time.Time
}
// LatestPayments contains latest payments and latest chain block header.
type LatestPayments struct {
LatestBlock Header
Payments []Payment
}
// Client is storjscan HTTP API client.
type Client struct {
endpoint string
identifier string
secret string
http http.Client
}
// NewClient creates new storjscan API client.
func NewClient(endpoint, identifier, secret string) *Client {
return &Client{
endpoint: endpoint,
identifier: identifier,
secret: secret,
http: http.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) {
defer mon.Task()(&ctx)(&err)
p := client.endpoint + "/api/v0/tokens/payments"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p, nil)
if err != nil {
return LatestPayments{}, ClientErr.Wrap(err)
}
req.SetBasicAuth(client.identifier, client.secret)
query := req.URL.Query()
query.Set("from", strconv.FormatInt(from, 10))
req.URL.RawQuery = query.Encode()
resp, err := client.http.Do(req)
if err != nil {
return LatestPayments{}, ClientErr.Wrap(err)
}
defer func() {
err = errs.Combine(err, ClientErr.Wrap(resp.Body.Close()))
}()
if resp.StatusCode != http.StatusOK {
var data struct {
Error string `json:"error"`
}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return LatestPayments{}, ClientErr.Wrap(err)
}
switch resp.StatusCode {
case http.StatusUnauthorized:
return LatestPayments{}, ClientErrUnauthorized.New("%s", data.Error)
default:
return LatestPayments{}, ClientErr.New("%s", data.Error)
}
}
var payments LatestPayments
if err := json.NewDecoder(resp.Body).Decode(&payments); err != nil {
return LatestPayments{}, ClientErr.Wrap(err)
}
return payments, nil
}

View File

@ -0,0 +1,127 @@
// Copyright (C) 2022 Storj Labs, Inc.
// See LICENSE for copying information.
package storjscan_test
import (
"math/big"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/zeebo/errs"
"storj.io/common/testcontext"
"storj.io/storj/satellite/payments/storjscan"
"storj.io/storj/satellite/payments/storjscan/blockchaintest"
"storj.io/storj/satellite/payments/storjscan/storjscantest"
)
func TestClientMocked(t *testing.T) {
ctx := testcontext.New(t)
now := time.Now().Round(time.Second).UTC()
var payments []storjscan.Payment
for i := 0; i < 100; i++ {
payments = append(payments, storjscan.Payment{
From: blockchaintest.NewAddress(),
To: blockchaintest.NewAddress(),
TokenValue: new(big.Int).SetInt64(int64(i)),
BlockHash: blockchaintest.NewHash(),
BlockNumber: int64(i),
Transaction: blockchaintest.NewHash(),
LogIndex: i,
Timestamp: now.Add(time.Duration(i) * time.Second),
})
}
latestBlock := storjscan.Header{
Hash: payments[len(payments)-1].BlockHash,
Number: payments[len(payments)-1].BlockNumber,
Timestamp: payments[len(payments)-1].Timestamp,
}
const (
identifier = "eu"
secret = "secret"
)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var err error
if err := storjscantest.CheckAuth(r, identifier, secret); err != nil {
storjscantest.ServeJSONError(t, w, http.StatusUnauthorized, err)
return
}
var from int64
if s := r.URL.Query().Get("from"); s != "" {
from, err = strconv.ParseInt(s, 10, 64)
if err != nil {
storjscantest.ServeJSONError(t, w, http.StatusBadRequest, errs.New("from parameter is missing"))
return
}
}
storjscantest.ServePayments(t, w, from, latestBlock, payments)
}))
defer server.Close()
client := storjscan.NewClient(server.URL, "eu", "secret")
t.Run("all payments from 0", func(t *testing.T) {
actual, err := client.Payments(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)
require.NoError(t, err)
require.Equal(t, latestBlock, actual.LatestBlock)
require.Equal(t, 50, len(actual.Payments))
require.Equal(t, payments[50:], actual.Payments)
})
}
func TestClientMockedUnauthorized(t *testing.T) {
ctx := testcontext.New(t)
const (
identifier = "eu"
secret = "secret"
)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := storjscantest.CheckAuth(r, identifier, secret); err != nil {
storjscantest.ServeJSONError(t, w, http.StatusUnauthorized, err)
return
}
}))
defer server.Close()
t.Run("empty credentials", func(t *testing.T) {
client := storjscan.NewClient(server.URL, "", "")
_, err := client.Payments(ctx, 0)
require.Error(t, err)
require.True(t, storjscan.ClientErrUnauthorized.Has(err))
require.Equal(t, "identifier is invalid", errs.Unwrap(err).Error())
})
t.Run("invalid identifier", func(t *testing.T) {
client := storjscan.NewClient(server.URL, "invalid", "secret")
_, err := client.Payments(ctx, 0)
require.Error(t, err)
require.True(t, storjscan.ClientErrUnauthorized.Has(err))
require.Equal(t, "identifier is invalid", errs.Unwrap(err).Error())
})
t.Run("invalid secret", func(t *testing.T) {
client := storjscan.NewClient(server.URL, "eu", "invalid")
_, err := client.Payments(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

@ -0,0 +1,8 @@
// Copyright (C) 2022 Storj Labs, Inc.
// See LICENSE for copying information.
package storjscan
import "github.com/spacemonkeygo/monkit/v3"
var mon = monkit.Package()

View File

@ -0,0 +1,66 @@
// Copyright (C) 2022 Storj Labs, Inc.
// See LICENSE for copying information.
package storjscantest
import (
"encoding/json"
"net/http"
"testing"
"github.com/zeebo/errs"
"storj.io/storj/satellite/payments/storjscan"
)
// CheckAuth checks request auth headers against provided id and secret.
func CheckAuth(r *http.Request, identifier, secret string) error {
user, pass, ok := r.BasicAuth()
if !ok {
return errs.New("missing authorization")
}
if user != identifier {
return errs.New("identifier is invalid")
}
if pass != secret {
return errs.New("secret is invalid")
}
return nil
}
// ServePayments serves payments to response writer.
func ServePayments(t *testing.T, w http.ResponseWriter, from int64, block storjscan.Header, payments []storjscan.Payment) {
var response struct {
LatestBlock storjscan.Header
Payments []storjscan.Payment
}
response.LatestBlock = block
for _, payment := range payments {
if payment.BlockNumber < from {
continue
}
response.Payments = append(response.Payments, payment)
}
err := json.NewEncoder(w).Encode(response)
if err != nil {
t.Fatal(err)
}
}
// ServeJSONError serves JSON error to response writer.
func ServeJSONError(t *testing.T, w http.ResponseWriter, status int, err error) {
w.WriteHeader(status)
var response struct {
Error string `json:"error"`
}
response.Error = err.Error()
err = json.NewEncoder(w).Encode(response)
if err != nil {
t.Fatal(err)
}
}