253 lines
6.6 KiB
Go
253 lines
6.6 KiB
Go
package client
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
|
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
|
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/credentials"
|
|
"google.golang.org/grpc/credentials/insecure"
|
|
)
|
|
|
|
// Client exposes typed helpers around the oracle gRPC API.
|
|
type Client interface {
|
|
LatestRate(ctx context.Context, req LatestRateParams) (*RateSnapshot, error)
|
|
GetQuote(ctx context.Context, req GetQuoteParams) (*Quote, error)
|
|
Close() error
|
|
}
|
|
|
|
// RequestMeta carries optional multi-tenant context for oracle calls.
|
|
type RequestMeta struct {
|
|
TenantRef string
|
|
OrganizationRef string
|
|
Trace *tracev1.TraceContext
|
|
}
|
|
|
|
type LatestRateParams struct {
|
|
Meta RequestMeta
|
|
Pair *fxv1.CurrencyPair
|
|
Provider string
|
|
}
|
|
|
|
type RateSnapshot struct {
|
|
Pair *fxv1.CurrencyPair
|
|
Mid string
|
|
Bid string
|
|
Ask string
|
|
SpreadBps string
|
|
Provider string
|
|
RateRef string
|
|
AsOf time.Time
|
|
}
|
|
|
|
type GetQuoteParams struct {
|
|
Meta RequestMeta
|
|
Pair *fxv1.CurrencyPair
|
|
Side fxv1.Side
|
|
BaseAmount *moneyv1.Money
|
|
QuoteAmount *moneyv1.Money
|
|
Firm bool
|
|
TTL time.Duration
|
|
PreferredProvider string
|
|
MaxAge time.Duration
|
|
}
|
|
|
|
type Quote struct {
|
|
QuoteRef string
|
|
Pair *fxv1.CurrencyPair
|
|
Side fxv1.Side
|
|
Price string
|
|
BaseAmount *moneyv1.Money
|
|
QuoteAmount *moneyv1.Money
|
|
ExpiresAt time.Time
|
|
Provider string
|
|
RateRef string
|
|
Firm bool
|
|
}
|
|
|
|
type grpcOracleClient interface {
|
|
GetQuote(ctx context.Context, in *oraclev1.GetQuoteRequest, opts ...grpc.CallOption) (*oraclev1.GetQuoteResponse, error)
|
|
LatestRate(ctx context.Context, in *oraclev1.LatestRateRequest, opts ...grpc.CallOption) (*oraclev1.LatestRateResponse, error)
|
|
}
|
|
|
|
type oracleClient struct {
|
|
cfg Config
|
|
conn *grpc.ClientConn
|
|
client grpcOracleClient
|
|
}
|
|
|
|
// New dials the oracle endpoint and returns a ready client.
|
|
func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, error) {
|
|
cfg.setDefaults()
|
|
if strings.TrimSpace(cfg.Address) == "" {
|
|
return nil, errors.New("oracle: address is required")
|
|
}
|
|
|
|
dialCtx, cancel := context.WithTimeout(ctx, cfg.DialTimeout)
|
|
defer cancel()
|
|
|
|
dialOpts := make([]grpc.DialOption, 0, len(opts)+1)
|
|
dialOpts = append(dialOpts, opts...)
|
|
|
|
if cfg.Insecure {
|
|
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
|
} else {
|
|
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
|
|
}
|
|
|
|
conn, err := grpc.DialContext(dialCtx, cfg.Address, dialOpts...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("oracle: dial %s: %w", cfg.Address, err)
|
|
}
|
|
|
|
return &oracleClient{
|
|
cfg: cfg,
|
|
conn: conn,
|
|
client: oraclev1.NewOracleClient(conn),
|
|
}, nil
|
|
}
|
|
|
|
// NewWithClient injects a pre-built oracle client (useful for tests).
|
|
func NewWithClient(cfg Config, oc grpcOracleClient) Client {
|
|
cfg.setDefaults()
|
|
return &oracleClient{
|
|
cfg: cfg,
|
|
client: oc,
|
|
}
|
|
}
|
|
|
|
func (c *oracleClient) Close() error {
|
|
if c.conn != nil {
|
|
return c.conn.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *oracleClient) LatestRate(ctx context.Context, req LatestRateParams) (*RateSnapshot, error) {
|
|
if req.Pair == nil {
|
|
return nil, errors.New("oracle: pair is required")
|
|
}
|
|
|
|
callCtx, cancel := c.callContext(ctx)
|
|
defer cancel()
|
|
|
|
resp, err := c.client.LatestRate(callCtx, &oraclev1.LatestRateRequest{
|
|
Meta: toProtoMeta(req.Meta),
|
|
Pair: req.Pair,
|
|
Provider: req.Provider,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("oracle: latest rate: %w", err)
|
|
}
|
|
if resp.GetRate() == nil {
|
|
return nil, errors.New("oracle: latest rate: empty payload")
|
|
}
|
|
return fromProtoRate(resp.GetRate()), nil
|
|
}
|
|
|
|
func (c *oracleClient) GetQuote(ctx context.Context, req GetQuoteParams) (*Quote, error) {
|
|
if req.Pair == nil {
|
|
return nil, errors.New("oracle: pair is required")
|
|
}
|
|
if req.Side == fxv1.Side_SIDE_UNSPECIFIED {
|
|
return nil, errors.New("oracle: side is required")
|
|
}
|
|
|
|
baseSupplied := req.BaseAmount != nil
|
|
quoteSupplied := req.QuoteAmount != nil
|
|
if baseSupplied == quoteSupplied {
|
|
return nil, errors.New("oracle: exactly one of base_amount or quote_amount must be set")
|
|
}
|
|
|
|
callCtx, cancel := c.callContext(ctx)
|
|
defer cancel()
|
|
|
|
protoReq := &oraclev1.GetQuoteRequest{
|
|
Meta: toProtoMeta(req.Meta),
|
|
Pair: req.Pair,
|
|
Side: req.Side,
|
|
Firm: req.Firm,
|
|
PreferredProvider: req.PreferredProvider,
|
|
}
|
|
if req.TTL > 0 {
|
|
protoReq.TtlMs = req.TTL.Milliseconds()
|
|
}
|
|
if req.MaxAge > 0 {
|
|
protoReq.MaxAgeMs = int32(req.MaxAge.Milliseconds())
|
|
}
|
|
if baseSupplied {
|
|
protoReq.AmountInput = &oraclev1.GetQuoteRequest_BaseAmount{BaseAmount: req.BaseAmount}
|
|
} else {
|
|
protoReq.AmountInput = &oraclev1.GetQuoteRequest_QuoteAmount{QuoteAmount: req.QuoteAmount}
|
|
}
|
|
|
|
resp, err := c.client.GetQuote(callCtx, protoReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("oracle: get quote: %w", err)
|
|
}
|
|
if resp.GetQuote() == nil {
|
|
return nil, errors.New("oracle: get quote: empty payload")
|
|
}
|
|
return fromProtoQuote(resp.GetQuote()), nil
|
|
}
|
|
|
|
func (c *oracleClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
|
if _, ok := ctx.Deadline(); ok {
|
|
return context.WithCancel(ctx)
|
|
}
|
|
return context.WithTimeout(ctx, c.cfg.CallTimeout)
|
|
}
|
|
|
|
func toProtoMeta(meta RequestMeta) *oraclev1.RequestMeta {
|
|
if meta.TenantRef == "" && meta.OrganizationRef == "" && meta.Trace == nil {
|
|
return nil
|
|
}
|
|
return &oraclev1.RequestMeta{
|
|
TenantRef: meta.TenantRef,
|
|
OrganizationRef: meta.OrganizationRef,
|
|
Trace: meta.Trace,
|
|
}
|
|
}
|
|
|
|
func fromProtoRate(rate *oraclev1.RateSnapshot) *RateSnapshot {
|
|
if rate == nil {
|
|
return nil
|
|
}
|
|
return &RateSnapshot{
|
|
Pair: rate.Pair,
|
|
Mid: rate.GetMid().GetValue(),
|
|
Bid: rate.GetBid().GetValue(),
|
|
Ask: rate.GetAsk().GetValue(),
|
|
SpreadBps: rate.GetSpreadBps().GetValue(),
|
|
Provider: rate.GetProvider(),
|
|
RateRef: rate.GetRateRef(),
|
|
AsOf: time.UnixMilli(rate.GetAsofUnixMs()),
|
|
}
|
|
}
|
|
|
|
func fromProtoQuote(quote *oraclev1.Quote) *Quote {
|
|
if quote == nil {
|
|
return nil
|
|
}
|
|
return &Quote{
|
|
QuoteRef: quote.GetQuoteRef(),
|
|
Pair: quote.Pair,
|
|
Side: quote.GetSide(),
|
|
Price: quote.GetPrice().GetValue(),
|
|
BaseAmount: quote.BaseAmount,
|
|
QuoteAmount: quote.QuoteAmount,
|
|
ExpiresAt: time.UnixMilli(quote.GetExpiresAtUnixMs()),
|
|
Provider: quote.GetProvider(),
|
|
RateRef: quote.GetRateRef(),
|
|
Firm: quote.GetFirm(),
|
|
}
|
|
}
|