contrib/screener-api/client/client.go
// Package client provides a client for the screener-api.
package client
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/dubonzi/otelresty"
"github.com/go-resty/resty/v2"
"github.com/google/uuid"
"github.com/synapsecns/sanguine/core"
"github.com/synapsecns/sanguine/core/ginhelper"
"github.com/synapsecns/sanguine/core/metrics"
)
// ScreenerClient is an interface for the Screener API.
type ScreenerClient interface {
ScreenAddress(ctx context.Context, address string) (blocked bool, err error)
BlacklistAddress(ctx context.Context, appsecret string, appid string, body BlackListBody) (string, error)
}
type clientImpl struct {
rClient *resty.Client
}
// NewClient creates a new client for the Screener API.
func NewClient(metricHandler metrics.Handler, screenerURL string) (ScreenerClient, error) {
client := resty.New().
SetBaseURL(screenerURL).
OnBeforeRequest(func(_ *resty.Client, request *resty.Request) error {
request.Header.Add(ginhelper.RequestIDHeader, uuid.New().String())
return nil
})
otelresty.TraceClient(client, otelresty.WithTracerProvider(metricHandler.GetTracerProvider()))
return &clientImpl{client}, nil
}
type blockedResponse struct {
Blocked bool `json:"risk"`
}
type notFoundResponse struct {
Message string `json:"message"`
}
// ScreenAddress checks if an address is blocked by the screener API.
func (c clientImpl) ScreenAddress(ctx context.Context, address string) (bool, error) {
var blockedRes blockedResponse
resp, err := c.rClient.R().
SetContext(ctx).
SetResult(&blockedRes).
Get("/address/" + address)
if err != nil {
return false, fmt.Errorf("error from server: %s: %w", resp.Status(), err)
}
if resp.IsError() {
// The address was not found
if err := json.Unmarshal(resp.Body(), ¬FoundResponse{}); err == nil {
return false, nil
}
return false, fmt.Errorf("error from server: %s %w", resp, err)
}
return blockedRes.Blocked, nil
}
// BlacklistAddress blacklists an address with the screener API.
func (c clientImpl) BlacklistAddress(ctx context.Context, appsecret string, appid string, body BlackListBody) (string, error) {
var blacklistRes blacklistResponse
nonce := strings.ReplaceAll(uuid.New().String(), "-", "")[:32]
timestamp := fmt.Sprintf("%d", time.Now().Unix())
bodyBz, err := json.Marshal(body)
if err != nil {
return "", fmt.Errorf("error marshaling body: %w", err)
}
bodyStr, err := core.BytesToJSONString(bodyBz)
if err != nil {
return "", fmt.Errorf("could not convert bytes to json: %w", err)
}
message := fmt.Sprintf("%s;%s;%s;%s;%s;%s",
appid, timestamp, nonce, "POST", "/api/data/sync", bodyStr)
signature := GenerateSignature(appsecret, message)
resp, err := c.rClient.R().
SetContext(ctx).
SetHeader("Content-Type", "application/json").
SetHeader("X-Signature-appid", appid).
SetHeader("X-Signature-timestamp", timestamp).
SetHeader("X-Signature-nonce", nonce).
SetHeader("X-Signature-signature", signature).
SetBody(body).
SetResult(&blacklistRes).
Post("/api/data/sync/")
if err != nil {
return resp.Status(), fmt.Errorf("error from server: %s: %w", resp.String(), err)
}
if resp.IsError() {
return resp.Status(), fmt.Errorf("error from server: %s", resp.String())
}
return blacklistRes.Status, nil
}
// BlackListBody is the json payload that represents a blacklisted address.
type BlackListBody struct {
Type string `json:"type"`
ID string `json:"id"`
Data Data `json:"data"`
}
// Data is the data field in the BlackListBody.
type Data struct {
Address string `json:"address"`
Network string `json:"network"`
Tag string `json:"tag"`
Remark string `json:"remark"`
}
type blacklistResponse struct {
Status string `json:"status"`
Error string `json:"error"`
}
// GenerateSignature generates a signature for the request.
func GenerateSignature(secret, message string) string {
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(message))
return hex.EncodeToString(h.Sum(nil))
}
// NewNoOpClient creates a new no-op client for the Screener API.
// it returns false for every address.
func NewNoOpClient() (ScreenerClient, error) {
return &noOpClient{}, nil
}
type noOpClient struct{}
func (n noOpClient) ScreenAddress(_ context.Context, _ string) (bool, error) {
return false, nil
}
func (n noOpClient) RegisterAddress(_ context.Context, _ string) error {
return nil
}
func (n noOpClient) BlacklistAddress(_ context.Context, _ string, _ string, _ BlackListBody) (string, error) {
return "", nil
}
var _ ScreenerClient = noOpClient{}