Files
sendico/api/fx/oracle/client/client.go
2025-11-18 00:20:25 +01:00

253 lines
6.7 KiB
Go

package client
import (
"context"
"crypto/tls"
"fmt"
"strings"
"time"
"github.com/tech/sendico/pkg/merrors"
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, merrors.InvalidArgument("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, merrors.InternalWrap(err, fmt.Sprintf("oracle: dial %s", cfg.Address))
}
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, merrors.InvalidArgument("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, merrors.InternalWrap(err, "oracle: latest rate")
}
if resp.GetRate() == nil {
return nil, merrors.Internal("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, merrors.InvalidArgument("oracle: pair is required")
}
if req.Side == fxv1.Side_SIDE_UNSPECIFIED {
return nil, merrors.InvalidArgument("oracle: side is required")
}
baseSupplied := req.BaseAmount != nil
quoteSupplied := req.QuoteAmount != nil
if baseSupplied == quoteSupplied {
return nil, merrors.InvalidArgument("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, merrors.InternalWrap(err, "oracle: get quote")
}
if resp.GetQuote() == nil {
return nil, merrors.Internal("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(),
}
}