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(), } }