Skip to content

Commit

Permalink
satellite/payments/storjscan: add client and list all payments API call
Browse files Browse the repository at this point in the history
Change-Id: I1f5065b3d15cc93f4b42868941e82e04af364565
  • Loading branch information
rikysya committed May 20, 2022
1 parent 2b0016a commit 0bf1252
Show file tree
Hide file tree
Showing 6 changed files with 389 additions and 10 deletions.
57 changes: 47 additions & 10 deletions private/blockchain/types.go
Expand Up @@ -6,6 +6,7 @@ package blockchain
import (
"encoding/hex"
"encoding/json"
"reflect"

"github.com/zeebo/errs"
)
Expand All @@ -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[:] }

Expand All @@ -39,20 +35,61 @@ 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[:] }

// Hex gets string representation of the underlying address.
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')
}
25 changes: 25 additions & 0 deletions satellite/payments/storjscan/blockchaintest/blockchaintest.go
@@ -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 changes: 116 additions & 0 deletions satellite/payments/storjscan/client.go
@@ -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 changes: 127 additions & 0 deletions satellite/payments/storjscan/client_test.go
@@ -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 changes: 8 additions & 0 deletions satellite/payments/storjscan/storjscan.go
@@ -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()

1 comment on commit 0bf1252

@storjrobot
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This commit has been mentioned on Storj Community Forum (official). There might be relevant details there:

https://forum.storj.io/t/release-preparation-v1-56/18583/1

Please sign in to comment.