satellite/payments/storjscan: add client and list all payments API call
Change-Id: I1f5065b3d15cc93f4b42868941e82e04af364565
This commit is contained in:
parent
2b0016af62
commit
0bf12523e1
@ -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')
|
||||
}
|
||||
|
@ -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
|
||||
}
|
116
satellite/payments/storjscan/client.go
Normal file
116
satellite/payments/storjscan/client.go
Normal 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
|
||||
}
|
127
satellite/payments/storjscan/client_test.go
Normal file
127
satellite/payments/storjscan/client_test.go
Normal 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())
|
||||
})
|
||||
}
|
8
satellite/payments/storjscan/storjscan.go
Normal file
8
satellite/payments/storjscan/storjscan.go
Normal 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()
|
66
satellite/payments/storjscan/storjscantest/mock.go
Normal file
66
satellite/payments/storjscan/storjscantest/mock.go
Normal 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)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user