service backend
All checks were successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful

This commit is contained in:
Stephan D
2025-11-07 18:35:26 +01:00
parent 20e8f9acc4
commit 62a6631b9a
537 changed files with 48453 additions and 0 deletions

32
api/fx/oracle/.air.toml Normal file
View File

@@ -0,0 +1,32 @@
# Config file for Air in TOML format
root = "./../.."
tmp_dir = "tmp"
[build]
cmd = "go build -o app -ldflags \"-X 'github.com/tech/sendico/fx/oracle/internal/appversion.BuildUser=$(whoami)' -X 'github.com/tech/sendico/fx/oracle/internal/appversion.Version=$APP_V' -X 'github.com/tech/sendico/fx/oracle/internal/appversion.Branch=$BUILD_BRANCH' -X 'github.com/tech/sendico/fx/oracle/internal/appversion.Revision=$GIT_REV' -X 'github.com/tech/sendico/fx/oracle/internal/appversion.BuildDate=$(date)'\""
bin = "./app"
full_bin = "./app --debug --config.file=config.yml"
include_ext = ["go", "yaml", "yml"]
exclude_dir = ["fx/oracle/tmp", "pkg/.git", "fx/oracle/env"]
exclude_regex = ["_test\\.go"]
exclude_unchanged = true
follow_symlink = true
log = "air.log"
delay = 0
stop_on_error = true
send_interrupt = true
kill_delay = 500
args_bin = []
[log]
time = false
[color]
main = "magenta"
watcher = "cyan"
build = "yellow"
runner = "green"
[misc]
clean_on_exit = true

3
api/fx/oracle/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
internal/generated
.gocache
app

View File

@@ -0,0 +1,252 @@
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(),
}
}

View File

@@ -0,0 +1,116 @@
package client
import (
"context"
"testing"
"time"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
"google.golang.org/grpc"
)
type stubOracle struct {
latestResp *oraclev1.LatestRateResponse
latestErr error
quoteResp *oraclev1.GetQuoteResponse
quoteErr error
lastLatest *oraclev1.LatestRateRequest
lastQuote *oraclev1.GetQuoteRequest
}
func (s *stubOracle) LatestRate(ctx context.Context, in *oraclev1.LatestRateRequest, _ ...grpc.CallOption) (*oraclev1.LatestRateResponse, error) {
s.lastLatest = in
return s.latestResp, s.latestErr
}
func (s *stubOracle) GetQuote(ctx context.Context, in *oraclev1.GetQuoteRequest, _ ...grpc.CallOption) (*oraclev1.GetQuoteResponse, error) {
s.lastQuote = in
return s.quoteResp, s.quoteErr
}
func TestLatestRate(t *testing.T) {
expectedTime := time.Date(2024, 1, 1, 15, 0, 0, 0, time.UTC)
stub := &stubOracle{
latestResp: &oraclev1.LatestRateResponse{
Rate: &oraclev1.RateSnapshot{
Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"},
Mid: &moneyv1.Decimal{Value: "1.1000"},
Bid: &moneyv1.Decimal{Value: "1.0995"},
Ask: &moneyv1.Decimal{Value: "1.1005"},
SpreadBps: &moneyv1.Decimal{Value: "5"},
Provider: "ECB",
RateRef: "ECB-20240101",
AsofUnixMs: expectedTime.UnixMilli(),
},
},
}
client := NewWithClient(Config{}, stub)
resp, err := client.LatestRate(context.Background(), LatestRateParams{
Meta: RequestMeta{
TenantRef: "tenant",
OrganizationRef: "org",
},
Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"},
Provider: "ECB",
})
if err != nil {
t.Fatalf("LatestRate returned error: %v", err)
}
if stub.lastLatest.GetProvider() != "ECB" {
t.Fatalf("expected provider to propagate, got %s", stub.lastLatest.GetProvider())
}
if resp.Provider != "ECB" || resp.RateRef != "ECB-20240101" {
t.Fatalf("unexpected response: %+v", resp)
}
if !resp.AsOf.Equal(expectedTime) {
t.Fatalf("expected as-of %s, got %s", expectedTime, resp.AsOf)
}
}
func TestGetQuote(t *testing.T) {
expiresAt := time.Date(2024, 2, 2, 12, 0, 0, 0, time.UTC)
stub := &stubOracle{
quoteResp: &oraclev1.GetQuoteResponse{
Quote: &oraclev1.Quote{
QuoteRef: "quote-123",
Pair: &fxv1.CurrencyPair{Base: "GBP", Quote: "USD"},
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
Price: &moneyv1.Decimal{Value: "1.2500"},
BaseAmount: &moneyv1.Money{Amount: "100.00", Currency: "GBP"},
QuoteAmount: &moneyv1.Money{Amount: "125.00", Currency: "USD"},
ExpiresAtUnixMs: expiresAt.UnixMilli(),
Provider: "Test",
RateRef: "test-ref",
Firm: true,
},
},
}
client := NewWithClient(Config{}, stub)
resp, err := client.GetQuote(context.Background(), GetQuoteParams{
Pair: &fxv1.CurrencyPair{Base: "GBP", Quote: "USD"},
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
BaseAmount: &moneyv1.Money{Amount: "100.00", Currency: "GBP"},
Firm: true,
TTL: 2 * time.Second,
})
if err != nil {
t.Fatalf("GetQuote returned error: %v", err)
}
if stub.lastQuote.GetFirm() != true {
t.Fatalf("expected firm flag to propagate")
}
if stub.lastQuote.GetTtlMs() == 0 {
t.Fatalf("expected ttl to be populated")
}
if resp.QuoteRef != "quote-123" || resp.Price != "1.2500" || !resp.ExpiresAt.Equal(expiresAt) {
t.Fatalf("unexpected quote response: %+v", resp)
}
}

View File

@@ -0,0 +1,20 @@
package client
import "time"
// Config captures connection settings for the FX oracle gRPC service.
type Config struct {
Address string
DialTimeout time.Duration
CallTimeout time.Duration
Insecure bool
}
func (c *Config) setDefaults() {
if c.DialTimeout <= 0 {
c.DialTimeout = 5 * time.Second
}
if c.CallTimeout <= 0 {
c.CallTimeout = 3 * time.Second
}
}

View File

@@ -0,0 +1,60 @@
package client
import (
"context"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
// Fake implements Client for tests.
type Fake struct {
LatestRateFn func(ctx context.Context, req LatestRateParams) (*RateSnapshot, error)
GetQuoteFn func(ctx context.Context, req GetQuoteParams) (*Quote, error)
CloseFn func() error
}
func (f *Fake) LatestRate(ctx context.Context, req LatestRateParams) (*RateSnapshot, error) {
if f.LatestRateFn != nil {
return f.LatestRateFn(ctx, req)
}
return &RateSnapshot{
Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"},
Mid: "1.1000",
Bid: "1.0995",
Ask: "1.1005",
SpreadBps: "5",
Provider: "fake",
RateRef: "fake",
}, nil
}
func (f *Fake) GetQuote(ctx context.Context, req GetQuoteParams) (*Quote, error) {
if f.GetQuoteFn != nil {
return f.GetQuoteFn(ctx, req)
}
return &Quote{
QuoteRef: "fake-quote",
Pair: req.Pair,
Side: req.Side,
Price: "1.1000",
BaseAmount: &moneyv1.Money{
Amount: "100.00",
Currency: req.Pair.GetBase(),
},
QuoteAmount: &moneyv1.Money{
Amount: "110.00",
Currency: req.Pair.GetQuote(),
},
Provider: "fake",
RateRef: "fake",
Firm: req.Firm,
}, nil
}
func (f *Fake) Close() error {
if f.CloseFn != nil {
return f.CloseFn()
}
return nil
}

34
api/fx/oracle/config.yml Normal file
View File

@@ -0,0 +1,34 @@
runtime:
shutdown_timeout_seconds: 15
grpc:
network: tcp
address: ":50051"
enable_reflection: true
enable_health: true
metrics:
address: ":9400"
database:
driver: mongodb
settings:
host_env: FX_MONGO_HOST
port_env: FX_MONGO_PORT
database_env: FX_MONGO_DATABASE
user_env: FX_MONGO_USER
password_env: FX_MONGO_PASSWORD
auth_source_env: FX_MONGO_AUTH_SOURCE
replica_set_env: FX_MONGO_REPLICA_SET
messaging:
driver: NATS
settings:
url_env: NATS_URL
host_env: NATS_HOST
port_env: NATS_PORT
username_env: NATS_USER
password_env: NATS_PASSWORD
broker_name: FX Oracle
max_reconnects: 10
reconnect_wait: 5

1
api/fx/oracle/env/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.env.api

54
api/fx/oracle/go.mod Normal file
View File

@@ -0,0 +1,54 @@
module github.com/tech/sendico/fx/oracle
go 1.25.3
replace github.com/tech/sendico/pkg => ../../pkg
replace github.com/tech/sendico/fx/storage => ../storage
require (
github.com/google/uuid v1.6.0
github.com/prometheus/client_golang v1.23.2
github.com/tech/sendico/fx/storage v0.0.0
github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.0
google.golang.org/grpc v1.76.0
google.golang.org/protobuf v1.36.10
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/casbin/casbin/v2 v2.132.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/go-chi/chi/v5 v5.2.3 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/klauspost/compress v1.18.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.47.0 // indirect
github.com/nats-io/nkeys v0.4.11 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.2 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/crypto v0.43.0 // indirect
golang.org/x/net v0.46.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 // indirect
)

225
api/fx/oracle/go.sum Normal file
View File

@@ -0,0 +1,225 @@
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/casbin/casbin/v2 v2.132.0 h1:73hGmOszGSL3hTVquwkAi98XLl3gPJ+BxB6D7G9Fxtk=
github.com/casbin/casbin/v2 v2.132.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/mongodb-adapter/v3 v3.7.0 h1:w9c3bea1BGK4eZTAmk17JkY52yv/xSZDSHKji8q+z6E=
github.com/casbin/mongodb-adapter/v3 v3.7.0/go.mod h1:F1mu4ojoJVE/8VhIMxMedhjfwRDdIXgANYs6Sd0MgVA=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg=
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo=
github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0=
github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8=
github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw=
github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8=
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY=
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0/go.mod h1:R6tMjTojRiaoo89fh/hf7tOmfzohdqSU17R9DwSVSog=
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 h1:tRPGkdGHuewF4UisLzzHHr1spKw92qLM98nIzxbC0wY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,27 @@
package appversion
import (
"github.com/tech/sendico/pkg/version"
vf "github.com/tech/sendico/pkg/version/factory"
)
// Build information. Populated at build-time.
var (
Version string
Revision string
Branch string
BuildUser string
BuildDate string
)
func Create() version.Printer {
vi := version.Info{
Program: "MeetX Connectica FX Oracle Service",
Revision: Revision,
Branch: Branch,
BuildUser: BuildUser,
BuildDate: BuildDate,
Version: Version,
}
return vf.Create(&vi)
}

View File

@@ -0,0 +1,101 @@
package serverimp
import (
"context"
"os"
"time"
"github.com/tech/sendico/fx/oracle/internal/service/oracle"
"github.com/tech/sendico/fx/storage"
mongostorage "github.com/tech/sendico/fx/storage/mongo"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/db"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/server/grpcapp"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
)
type Imp struct {
logger mlogger.Logger
file string
debug bool
config *grpcapp.Config
app *grpcapp.App[storage.Repository]
}
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
return &Imp{
logger: logger.Named("server"),
file: file,
debug: debug,
}, nil
}
func (i *Imp) Shutdown() {
if i.app == nil {
return
}
timeout := 15 * time.Second
if i.config != nil && i.config.Runtime != nil {
timeout = i.config.Runtime.ShutdownTimeout()
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
i.app.Shutdown(ctx)
cancel()
}
func (i *Imp) Start() error {
cfg, err := i.loadConfig()
if err != nil {
return err
}
i.config = cfg
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
return mongostorage.New(logger, conn)
}
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
return oracle.NewService(logger, repo, producer), nil
}
app, err := grpcapp.NewApp(i.logger, "fx_oracle", cfg, i.debug, repoFactory, serviceFactory)
if err != nil {
return err
}
i.app = app
return i.app.Start()
}
func (i *Imp) loadConfig() (*grpcapp.Config, error) {
data, err := os.ReadFile(i.file)
if err != nil {
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
return nil, err
}
cfg := &grpcapp.Config{}
if err := yaml.Unmarshal(data, cfg); err != nil {
i.logger.Error("Failed to parse configuration", zap.Error(err))
return nil, err
}
if cfg.Runtime == nil {
cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: 15}
}
if cfg.GRPC == nil {
cfg.GRPC = &routers.GRPCConfig{
Network: "tcp",
Address: ":50051",
EnableReflection: true,
EnableHealth: true,
}
}
return cfg, nil
}

View File

@@ -0,0 +1,11 @@
package server
import (
serverimp "github.com/tech/sendico/fx/oracle/internal/server/internal"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/server"
)
func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
return serverimp.Create(logger, file, debug)
}

View File

@@ -0,0 +1,223 @@
package oracle
import (
"math/big"
"strings"
"time"
"github.com/google/uuid"
"github.com/tech/sendico/fx/storage/model"
"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"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type quoteComputation struct {
pair *model.Pair
rate *model.RateSnapshot
sideProto fxv1.Side
sideModel model.QuoteSide
price *big.Rat
baseInput *big.Rat
quoteInput *big.Rat
amountType model.QuoteAmountType
baseRounded *big.Rat
quoteRounded *big.Rat
priceRounded *big.Rat
baseScale uint32
quoteScale uint32
priceScale uint32
provider string
}
func newQuoteComputation(pair *model.Pair, rate *model.RateSnapshot, side fxv1.Side, provider string) (*quoteComputation, error) {
if pair == nil || rate == nil {
return nil, merrors.InvalidArgument("oracle: missing pair or rate")
}
sideModel := protoSideToModel(side)
if sideModel == "" {
return nil, merrors.InvalidArgument("oracle: unsupported side")
}
price, err := priceFromRate(rate, side)
if err != nil {
return nil, err
}
if strings.TrimSpace(provider) == "" {
provider = rate.Provider
}
return &quoteComputation{
pair: pair,
rate: rate,
sideProto: side,
sideModel: sideModel,
price: price,
baseScale: pair.BaseMeta.Decimals,
quoteScale: pair.QuoteMeta.Decimals,
priceScale: pair.QuoteMeta.Decimals,
provider: provider,
}, nil
}
func (qc *quoteComputation) withBaseInput(m *moneyv1.Money) error {
if m == nil {
return merrors.InvalidArgument("oracle: base amount missing")
}
if !strings.EqualFold(m.GetCurrency(), qc.pair.Pair.Base) {
return merrors.InvalidArgument("oracle: base amount currency mismatch")
}
val, err := ratFromString(m.GetAmount())
if err != nil {
return err
}
qc.baseInput = val
qc.amountType = model.QuoteAmountTypeBase
return nil
}
func (qc *quoteComputation) withQuoteInput(m *moneyv1.Money) error {
if m == nil {
return merrors.InvalidArgument("oracle: quote amount missing")
}
if !strings.EqualFold(m.GetCurrency(), qc.pair.Pair.Quote) {
return merrors.InvalidArgument("oracle: quote amount currency mismatch")
}
val, err := ratFromString(m.GetAmount())
if err != nil {
return err
}
qc.quoteInput = val
qc.amountType = model.QuoteAmountTypeQuote
return nil
}
func (qc *quoteComputation) compute() error {
var baseRaw, quoteRaw *big.Rat
switch qc.amountType {
case model.QuoteAmountTypeBase:
baseRaw = qc.baseInput
quoteRaw = mulRat(qc.baseInput, qc.price)
case model.QuoteAmountTypeQuote:
quoteRaw = qc.quoteInput
base, err := divRat(qc.quoteInput, qc.price)
if err != nil {
return err
}
baseRaw = base
default:
return merrors.InvalidArgument("oracle: amount type not set")
}
var err error
qc.baseRounded, err = roundRatToScale(baseRaw, qc.baseScale, qc.pair.BaseMeta.Rounding)
if err != nil {
return err
}
qc.quoteRounded, err = roundRatToScale(quoteRaw, qc.quoteScale, qc.pair.QuoteMeta.Rounding)
if err != nil {
return err
}
qc.priceRounded, err = roundRatToScale(qc.price, qc.priceScale, qc.pair.QuoteMeta.Rounding)
if err != nil {
return err
}
return nil
}
func (qc *quoteComputation) buildModelQuote(firm bool, expiryMillis int64, req *oraclev1.GetQuoteRequest) (*model.Quote, error) {
if qc.baseRounded == nil || qc.quoteRounded == nil || qc.priceRounded == nil {
return nil, merrors.Internal("oracle: computation not executed")
}
quote := &model.Quote{
QuoteRef: uuid.NewString(),
Firm: firm,
Status: model.QuoteStatusIssued,
Pair: qc.pair.Pair,
Side: qc.sideModel,
Price: formatRat(qc.priceRounded, qc.priceScale),
BaseAmount: model.Money{
Currency: qc.pair.Pair.Base,
Amount: formatRat(qc.baseRounded, qc.baseScale),
},
QuoteAmount: model.Money{
Currency: qc.pair.Pair.Quote,
Amount: formatRat(qc.quoteRounded, qc.quoteScale),
},
AmountType: qc.amountType,
RateRef: qc.rate.RateRef,
Provider: qc.provider,
PreferredProvider: req.GetPreferredProvider(),
RequestedTTLMs: req.GetTtlMs(),
MaxAgeToleranceMs: int64(req.GetMaxAgeMs()),
Meta: buildQuoteMeta(req.GetMeta()),
}
if firm {
quote.ExpiresAtUnixMs = expiryMillis
expiry := time.UnixMilli(expiryMillis)
quote.ExpiresAt = &expiry
}
return quote, nil
}
func buildQuoteMeta(meta *oraclev1.RequestMeta) *model.QuoteMeta {
if meta == nil {
return nil
}
trace := meta.GetTrace()
qm := &model.QuoteMeta{
RequestRef: deriveRequestRef(meta, trace),
TenantRef: meta.GetTenantRef(),
TraceRef: deriveTraceRef(meta, trace),
IdempotencyKey: deriveIdempotencyKey(meta, trace),
}
if org := strings.TrimSpace(meta.GetOrganizationRef()); org != "" {
if objID, err := primitive.ObjectIDFromHex(org); err == nil {
qm.SetOrganizationRef(objID)
}
}
return qm
}
func protoSideToModel(side fxv1.Side) model.QuoteSide {
switch side {
case fxv1.Side_BUY_BASE_SELL_QUOTE:
return model.QuoteSideBuyBaseSellQuote
case fxv1.Side_SELL_BASE_BUY_QUOTE:
return model.QuoteSideSellBaseBuyQuote
default:
return ""
}
}
func computeExpiry(now time.Time, ttlMs int64) (int64, error) {
if ttlMs <= 0 {
return 0, merrors.InvalidArgument("oracle: ttl must be positive")
}
return now.Add(time.Duration(ttlMs) * time.Millisecond).UnixMilli(), nil
}
func deriveRequestRef(meta *oraclev1.RequestMeta, trace *tracev1.TraceContext) string {
if trace != nil && trace.GetRequestRef() != "" {
return trace.GetRequestRef()
}
return meta.GetRequestRef()
}
func deriveTraceRef(meta *oraclev1.RequestMeta, trace *tracev1.TraceContext) string {
if trace != nil && trace.GetTraceRef() != "" {
return trace.GetTraceRef()
}
return meta.GetTraceRef()
}
func deriveIdempotencyKey(meta *oraclev1.RequestMeta, trace *tracev1.TraceContext) string {
if trace != nil && trace.GetIdempotencyKey() != "" {
return trace.GetIdempotencyKey()
}
return meta.GetIdempotencyKey()
}

View File

@@ -0,0 +1,221 @@
package oracle
import (
"context"
"fmt"
"math/big"
"strings"
"time"
"github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/merrors"
)
type priceSet struct {
bid *big.Rat
ask *big.Rat
mid *big.Rat
}
func (s *Service) computeCrossRate(ctx context.Context, pair *model.Pair, provider string) (*model.RateSnapshot, error) {
if pair == nil || pair.Cross == nil || !pair.Cross.Enabled {
return nil, merrors.ErrNoData
}
baseSnap, err := s.fetchCrossLegSnapshot(ctx, pair.Cross.BaseLeg, provider)
if err != nil {
return nil, err
}
quoteSnap, err := s.fetchCrossLegSnapshot(ctx, pair.Cross.QuoteLeg, provider)
if err != nil {
return nil, err
}
basePrices, err := buildPriceSet(baseSnap)
if err != nil {
return nil, err
}
quotePrices, err := buildPriceSet(quoteSnap)
if err != nil {
return nil, err
}
if pair.Cross.BaseLeg.Invert {
basePrices, err = invertPriceSet(basePrices)
if err != nil {
return nil, err
}
}
if pair.Cross.QuoteLeg.Invert {
quotePrices, err = invertPriceSet(quotePrices)
if err != nil {
return nil, err
}
}
result := multiplyPriceSets(basePrices, quotePrices)
if result.ask.Cmp(result.bid) < 0 {
result.ask, result.bid = result.bid, result.ask
}
spread := calcSpreadBps(result)
asOfMs := minNonZero(baseSnap.AsOfUnixMs, quoteSnap.AsOfUnixMs)
if asOfMs == 0 {
asOfMs = time.Now().UnixMilli()
}
asOf := time.UnixMilli(asOfMs)
rateRef := fmt.Sprintf("cross|%s/%s|%s|%s+%s", pair.Pair.Base, pair.Pair.Quote, provider, baseSnap.RateRef, quoteSnap.RateRef)
return &model.RateSnapshot{
RateRef: rateRef,
Pair: pair.Pair,
Provider: provider,
Mid: formatPrice(result.mid),
Bid: formatPrice(result.bid),
Ask: formatPrice(result.ask),
SpreadBps: formatPrice(spread),
AsOfUnixMs: asOfMs,
AsOf: &asOf,
Source: "cross_rate",
ProviderRef: rateRef,
}, nil
}
func (s *Service) fetchCrossLegSnapshot(ctx context.Context, leg model.CrossRateLeg, fallbackProvider string) (*model.RateSnapshot, error) {
provider := fallbackProvider
if strings.TrimSpace(leg.Provider) != "" {
provider = leg.Provider
}
if provider == "" {
return nil, merrors.InvalidArgument("oracle: cross leg provider missing")
}
return s.storage.Rates().LatestSnapshot(ctx, leg.Pair, provider)
}
func buildPriceSet(rate *model.RateSnapshot) (priceSet, error) {
if rate == nil {
return priceSet{}, merrors.InvalidArgument("oracle: cross rate requires underlying snapshot")
}
ask, err := parsePrice(rate.Ask)
if err != nil {
return priceSet{}, err
}
bid, err := parsePrice(rate.Bid)
if err != nil {
return priceSet{}, err
}
mid, err := parsePrice(rate.Mid)
if err != nil {
return priceSet{}, err
}
if ask == nil && bid == nil {
if mid == nil {
return priceSet{}, merrors.InvalidArgument("oracle: cross rate snapshot missing price data")
}
ask = new(big.Rat).Set(mid)
bid = new(big.Rat).Set(mid)
}
if ask == nil && mid != nil {
ask = new(big.Rat).Set(mid)
}
if bid == nil && mid != nil {
bid = new(big.Rat).Set(mid)
}
if ask == nil || bid == nil {
return priceSet{}, merrors.InvalidArgument("oracle: cross rate snapshot missing bid/ask data")
}
ps := priceSet{
bid: new(big.Rat).Set(bid),
ask: new(big.Rat).Set(ask),
mid: averageOrMid(bid, ask, mid),
}
if ps.ask.Cmp(ps.bid) < 0 {
ps.ask, ps.bid = ps.bid, ps.ask
}
return ps, nil
}
func parsePrice(value string) (*big.Rat, error) {
if strings.TrimSpace(value) == "" {
return nil, nil
}
return ratFromString(value)
}
func averageOrMid(bid, ask, mid *big.Rat) *big.Rat {
if mid != nil {
return new(big.Rat).Set(mid)
}
sum := new(big.Rat).Add(bid, ask)
return sum.Quo(sum, big.NewRat(2, 1))
}
func invertPriceSet(ps priceSet) (priceSet, error) {
if ps.ask.Sign() == 0 || ps.bid.Sign() == 0 {
return priceSet{}, merrors.InvalidArgument("oracle: cannot invert zero price")
}
one := big.NewRat(1, 1)
invBid := new(big.Rat).Quo(one, ps.ask)
invAsk := new(big.Rat).Quo(one, ps.bid)
var invMid *big.Rat
if ps.mid != nil && ps.mid.Sign() != 0 {
invMid = new(big.Rat).Quo(one, ps.mid)
} else {
invMid = averageOrMid(invBid, invAsk, nil)
}
result := priceSet{
bid: invBid,
ask: invAsk,
mid: invMid,
}
if result.ask.Cmp(result.bid) < 0 {
result.ask, result.bid = result.bid, result.ask
}
return result, nil
}
func multiplyPriceSets(a, b priceSet) priceSet {
result := priceSet{
bid: mulRat(a.bid, b.bid),
ask: mulRat(a.ask, b.ask),
}
result.mid = averageOrMid(result.bid, result.ask, nil)
return result
}
func calcSpreadBps(ps priceSet) *big.Rat {
if ps.mid == nil || ps.mid.Sign() == 0 {
return nil
}
spread := new(big.Rat).Sub(ps.ask, ps.bid)
if spread.Sign() < 0 {
spread.Neg(spread)
}
spread.Quo(spread, ps.mid)
spread.Mul(spread, big.NewRat(10000, 1))
return spread
}
func minNonZero(values ...int64) int64 {
var result int64
for _, v := range values {
if v <= 0 {
continue
}
if result == 0 || v < result {
result = v
}
}
return result
}
func formatPrice(r *big.Rat) string {
if r == nil {
return ""
}
return r.FloatString(8)
}

View File

@@ -0,0 +1,67 @@
package oracle
import (
"math/big"
"strings"
"time"
"github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/decimal"
"github.com/tech/sendico/pkg/merrors"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
)
// Convenience aliases to pkg/decimal for backward compatibility
var (
ratFromString = decimal.RatFromString
mulRat = decimal.MulRat
divRat = decimal.DivRat
formatRat = decimal.FormatRat
)
// roundRatToScale wraps pkg/decimal.RoundRatToScale with model RoundingMode conversion
func roundRatToScale(value *big.Rat, scale uint32, mode model.RoundingMode) (*big.Rat, error) {
return decimal.RoundRatToScale(value, scale, convertRoundingMode(mode))
}
// convertRoundingMode converts fx/storage model.RoundingMode to pkg/decimal.RoundingMode
func convertRoundingMode(mode model.RoundingMode) decimal.RoundingMode {
switch mode {
case model.RoundingModeHalfEven:
return decimal.RoundingModeHalfEven
case model.RoundingModeHalfUp:
return decimal.RoundingModeHalfUp
case model.RoundingModeDown:
return decimal.RoundingModeDown
case model.RoundingModeUnspecified:
return decimal.RoundingModeUnspecified
default:
return decimal.RoundingModeHalfEven
}
}
func priceFromRate(rate *model.RateSnapshot, side fxv1.Side) (*big.Rat, error) {
var priceStr string
switch side {
case fxv1.Side_BUY_BASE_SELL_QUOTE:
priceStr = rate.Ask
case fxv1.Side_SELL_BASE_BUY_QUOTE:
priceStr = rate.Bid
default:
priceStr = ""
}
if strings.TrimSpace(priceStr) == "" {
priceStr = rate.Mid
}
if strings.TrimSpace(priceStr) == "" {
return nil, merrors.InvalidArgument("oracle: rate snapshot missing price")
}
return ratFromString(priceStr)
}
func timeFromUnixMilli(ms int64) time.Time {
return time.Unix(0, ms*int64(time.Millisecond))
}

View File

@@ -0,0 +1,65 @@
package oracle
import (
"strings"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
var (
metricsOnce sync.Once
rpcRequestsTotal *prometheus.CounterVec
rpcLatency *prometheus.HistogramVec
)
func initMetrics() {
metricsOnce.Do(func() {
rpcRequestsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Namespace: "fx",
Subsystem: "oracle",
Name: "requests_total",
Help: "Total number of FX oracle RPC calls handled.",
},
[]string{"method", "result"},
)
rpcLatency = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Namespace: "fx",
Subsystem: "oracle",
Name: "request_latency_seconds",
Help: "Latency of FX oracle RPC calls.",
Buckets: prometheus.DefBuckets,
},
[]string{"method", "result"},
)
})
}
func observeRPC(start time.Time, method string, err error) {
result := labelFromError(err)
rpcRequestsTotal.WithLabelValues(method, result).Inc()
rpcLatency.WithLabelValues(method, result).Observe(time.Since(start).Seconds())
}
func labelFromError(err error) string {
if err == nil {
return strings.ToLower(codes.OK.String())
}
st, ok := status.FromError(err)
if !ok {
return "error"
}
code := st.Code()
if code == codes.OK {
return strings.ToLower(code.String())
}
return strings.ToLower(code.String())
}

View File

@@ -0,0 +1,402 @@
package oracle
import (
"context"
"errors"
"strings"
"time"
"github.com/tech/sendico/fx/storage"
"github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
pmessaging "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
"go.uber.org/zap"
"google.golang.org/grpc"
)
type serviceError string
func (e serviceError) Error() string {
return string(e)
}
var (
errSideRequired = serviceError("oracle: side is required")
errAmountsMutuallyExclusive = serviceError("oracle: exactly one amount must be provided")
errAmountRequired = serviceError("oracle: amount is required")
errQuoteRefRequired = serviceError("oracle: quote_ref is required")
errEmptyRequest = serviceError("oracle: request payload is empty")
errLedgerTxnRefRequired = serviceError("oracle: ledger_txn_ref is required")
)
type Service struct {
logger mlogger.Logger
storage storage.Repository
producer pmessaging.Producer
oraclev1.UnimplementedOracleServer
}
func NewService(logger mlogger.Logger, repo storage.Repository, prod pmessaging.Producer) *Service {
initMetrics()
return &Service{
logger: logger.Named("oracle"),
storage: repo,
producer: prod,
}
}
func (s *Service) Register(router routers.GRPC) error {
return router.Register(func(reg grpc.ServiceRegistrar) {
oraclev1.RegisterOracleServer(reg, s)
})
}
func (s *Service) GetQuote(ctx context.Context, req *oraclev1.GetQuoteRequest) (*oraclev1.GetQuoteResponse, error) {
start := time.Now()
responder := s.getQuoteResponder(ctx, req)
resp, err := responder(ctx)
observeRPC(start, "GetQuote", err)
return resp, err
}
func (s *Service) ValidateQuote(ctx context.Context, req *oraclev1.ValidateQuoteRequest) (*oraclev1.ValidateQuoteResponse, error) {
start := time.Now()
responder := s.validateQuoteResponder(ctx, req)
resp, err := responder(ctx)
observeRPC(start, "ValidateQuote", err)
return resp, err
}
func (s *Service) ConsumeQuote(ctx context.Context, req *oraclev1.ConsumeQuoteRequest) (*oraclev1.ConsumeQuoteResponse, error) {
start := time.Now()
responder := s.consumeQuoteResponder(ctx, req)
resp, err := responder(ctx)
observeRPC(start, "ConsumeQuote", err)
return resp, err
}
func (s *Service) LatestRate(ctx context.Context, req *oraclev1.LatestRateRequest) (*oraclev1.LatestRateResponse, error) {
start := time.Now()
responder := s.latestRateResponder(ctx, req)
resp, err := responder(ctx)
observeRPC(start, "LatestRate", err)
return resp, err
}
func (s *Service) ListPairs(ctx context.Context, req *oraclev1.ListPairsRequest) (*oraclev1.ListPairsResponse, error) {
start := time.Now()
responder := s.listPairsResponder(ctx, req)
resp, err := responder(ctx)
observeRPC(start, "ListPairs", err)
return resp, err
}
func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteRequest) gsresponse.Responder[oraclev1.GetQuoteResponse] {
if req == nil {
req = &oraclev1.GetQuoteRequest{}
}
s.logger.Debug("Handling GetQuote", zap.String("pair", req.GetPair().GetBase()+"/"+req.GetPair().GetQuote()), zap.Bool("firm", req.GetFirm()))
if req.GetSide() == fxv1.Side_SIDE_UNSPECIFIED {
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errSideRequired)
}
if req.GetBaseAmount() != nil && req.GetQuoteAmount() != nil {
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errAmountsMutuallyExclusive)
}
if req.GetBaseAmount() == nil && req.GetQuoteAmount() == nil {
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errAmountRequired)
}
if err := s.pingStorage(ctx); err != nil {
s.logger.Warn("Storage unavailable during GetQuote", zap.Error(err))
return gsresponse.Unavailable[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
}
pairMsg := req.GetPair()
if pairMsg == nil || strings.TrimSpace(pairMsg.GetBase()) == "" || strings.TrimSpace(pairMsg.GetQuote()) == "" {
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errEmptyRequest)
}
pairKey := model.CurrencyPair{Base: strings.ToUpper(pairMsg.GetBase()), Quote: strings.ToUpper(pairMsg.GetQuote())}
pair, err := s.storage.Pairs().Get(ctx, pairKey)
if err != nil {
switch {
case errors.Is(err, merrors.ErrNoData):
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, merrors.InvalidArgument("pair_not_supported"))
default:
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
}
}
provider := req.GetPreferredProvider()
if provider == "" {
provider = pair.DefaultProvider
}
if provider == "" && len(pair.Providers) > 0 {
provider = pair.Providers[0]
}
rate, err := s.getLatestRate(ctx, pair, provider)
if err != nil {
switch {
case errors.Is(err, merrors.ErrNoData):
return gsresponse.FailedPrecondition[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, "rate_not_found", err)
default:
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
}
}
now := time.Now()
if maxAge := req.GetMaxAgeMs(); maxAge > 0 {
age := now.UnixMilli() - rate.AsOfUnixMs
if age > int64(maxAge) {
s.logger.Warn("Rate snapshot stale", zap.Int64("age_ms", age), zap.Int32("max_age_ms", req.GetMaxAgeMs()), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider))
return gsresponse.FailedPrecondition[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, "stale_rate", merrors.InvalidArgument("rate older than allowed window"))
}
}
comp, err := newQuoteComputation(pair, rate, req.GetSide(), provider)
if err != nil {
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
}
if req.GetBaseAmount() != nil {
if err := comp.withBaseInput(req.GetBaseAmount()); err != nil {
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
}
} else if req.GetQuoteAmount() != nil {
if err := comp.withQuoteInput(req.GetQuoteAmount()); err != nil {
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
}
}
if err := comp.compute(); err != nil {
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
}
expiresAt := int64(0)
if req.GetFirm() {
expiry, err := computeExpiry(now, req.GetTtlMs())
if err != nil {
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
}
expiresAt = expiry
}
quoteModel, err := comp.buildModelQuote(req.GetFirm(), expiresAt, req)
if err != nil {
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
}
if req.GetFirm() {
if err := s.storage.Quotes().Issue(ctx, quoteModel); err != nil {
switch {
case errors.Is(err, merrors.ErrDataConflict):
return gsresponse.Conflict[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
default:
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
}
}
s.logger.Info("Firm quote stored", zap.String("quote_ref", quoteModel.QuoteRef), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", quoteModel.Provider), zap.Int64("expires_at_ms", quoteModel.ExpiresAtUnixMs))
}
resp := &oraclev1.GetQuoteResponse{
Meta: buildResponseMeta(req.GetMeta()),
Quote: quoteModelToProto(quoteModel),
}
return gsresponse.Success(resp)
}
func (s *Service) validateQuoteResponder(ctx context.Context, req *oraclev1.ValidateQuoteRequest) gsresponse.Responder[oraclev1.ValidateQuoteResponse] {
if req == nil {
req = &oraclev1.ValidateQuoteRequest{}
}
s.logger.Debug("Handling ValidateQuote", zap.String("quote_ref", req.GetQuoteRef()))
if req.GetQuoteRef() == "" {
return gsresponse.InvalidArgument[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, errQuoteRefRequired)
}
if err := s.pingStorage(ctx); err != nil {
s.logger.Warn("Storage unavailable during ValidateQuote", zap.Error(err))
return gsresponse.Unavailable[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, err)
}
quote, err := s.storage.Quotes().GetByRef(ctx, req.GetQuoteRef())
if err != nil {
switch {
case errors.Is(err, merrors.ErrNoData):
resp := &oraclev1.ValidateQuoteResponse{
Meta: buildResponseMeta(req.GetMeta()),
Quote: nil,
Valid: false,
Reason: "not_found",
}
return gsresponse.Success(resp)
default:
return gsresponse.Internal[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, err)
}
}
now := time.Now()
valid := true
reason := ""
if quote.IsExpired(now) {
valid = false
reason = "expired"
} else if quote.Status == model.QuoteStatusConsumed {
valid = false
reason = "consumed"
}
resp := &oraclev1.ValidateQuoteResponse{
Meta: buildResponseMeta(req.GetMeta()),
Quote: quoteModelToProto(quote),
Valid: valid,
Reason: reason,
}
return gsresponse.Success(resp)
}
func (s *Service) consumeQuoteResponder(ctx context.Context, req *oraclev1.ConsumeQuoteRequest) gsresponse.Responder[oraclev1.ConsumeQuoteResponse] {
if req == nil {
req = &oraclev1.ConsumeQuoteRequest{}
}
s.logger.Debug("Handling ConsumeQuote", zap.String("quote_ref", req.GetQuoteRef()), zap.String("ledger_txn_ref", req.GetLedgerTxnRef()))
if req.GetQuoteRef() == "" {
return gsresponse.InvalidArgument[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, errQuoteRefRequired)
}
if req.GetLedgerTxnRef() == "" {
return gsresponse.InvalidArgument[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, errLedgerTxnRefRequired)
}
if err := s.pingStorage(ctx); err != nil {
s.logger.Warn("Storage unavailable during ConsumeQuote", zap.Error(err))
return gsresponse.Unavailable[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
}
_, err := s.storage.Quotes().Consume(ctx, req.GetQuoteRef(), req.GetLedgerTxnRef(), time.Now())
if err != nil {
switch {
case errors.Is(err, storage.ErrQuoteExpired):
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "expired", err)
case errors.Is(err, storage.ErrQuoteConsumed):
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "consumed", err)
case errors.Is(err, storage.ErrQuoteNotFirm):
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "not_firm", err)
case errors.Is(err, merrors.ErrNoData):
return gsresponse.NotFound[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
default:
return gsresponse.Internal[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
}
}
resp := &oraclev1.ConsumeQuoteResponse{
Meta: buildResponseMeta(req.GetMeta()),
Consumed: true,
Reason: "consumed",
}
s.logger.Debug("Quote consumed", zap.String("quote_ref", req.GetQuoteRef()), zap.String("ledger_txn_ref", req.GetLedgerTxnRef()))
return gsresponse.Success(resp)
}
func (s *Service) latestRateResponder(ctx context.Context, req *oraclev1.LatestRateRequest) gsresponse.Responder[oraclev1.LatestRateResponse] {
if req == nil {
req = &oraclev1.LatestRateRequest{}
}
s.logger.Debug("Handling LatestRate", zap.String("pair", req.GetPair().GetBase()+"/"+req.GetPair().GetQuote()))
if err := s.pingStorage(ctx); err != nil {
s.logger.Warn("Storage unavailable during LatestRate", zap.Error(err))
return gsresponse.Unavailable[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
}
pairMsg := req.GetPair()
if pairMsg == nil || strings.TrimSpace(pairMsg.GetBase()) == "" || strings.TrimSpace(pairMsg.GetQuote()) == "" {
return gsresponse.InvalidArgument[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, errEmptyRequest)
}
pair := model.CurrencyPair{Base: strings.ToUpper(pairMsg.GetBase()), Quote: strings.ToUpper(pairMsg.GetQuote())}
pairMeta, err := s.storage.Pairs().Get(ctx, pair)
if err != nil {
switch {
case errors.Is(err, merrors.ErrNoData):
return gsresponse.NotFound[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
default:
return gsresponse.Internal[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
}
}
provider := req.GetProvider()
if provider == "" {
provider = pairMeta.DefaultProvider
}
if provider == "" && len(pairMeta.Providers) > 0 {
provider = pairMeta.Providers[0]
}
rate, err := s.getLatestRate(ctx, pairMeta, provider)
if err != nil {
switch {
case errors.Is(err, merrors.ErrNoData):
return gsresponse.NotFound[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
default:
return gsresponse.Internal[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
}
}
resp := &oraclev1.LatestRateResponse{
Meta: buildResponseMeta(req.GetMeta()),
Rate: rateModelToProto(rate),
}
return gsresponse.Success(resp)
}
func (s *Service) listPairsResponder(ctx context.Context, req *oraclev1.ListPairsRequest) gsresponse.Responder[oraclev1.ListPairsResponse] {
if req == nil {
req = &oraclev1.ListPairsRequest{}
}
s.logger.Debug("Handling ListPairs")
if err := s.pingStorage(ctx); err != nil {
s.logger.Warn("Storage unavailable during ListPairs", zap.Error(err))
return gsresponse.Unavailable[oraclev1.ListPairsResponse](s.logger, mservice.FXOracle, err)
}
pairs, err := s.storage.Pairs().ListEnabled(ctx)
if err != nil {
return gsresponse.Internal[oraclev1.ListPairsResponse](s.logger, mservice.FXOracle, err)
}
result := make([]*oraclev1.PairMeta, 0, len(pairs))
for _, pair := range pairs {
result = append(result, pairModelToProto(pair))
}
resp := &oraclev1.ListPairsResponse{
Meta: buildResponseMeta(req.GetMeta()),
Pairs: result,
}
s.logger.Debug("ListPairs returning metadata", zap.Int("pairs", len(resp.GetPairs())))
return gsresponse.Success(resp)
}
func (s *Service) pingStorage(ctx context.Context) error {
if s.storage == nil {
return nil
}
return s.storage.Ping(ctx)
}
func (s *Service) getLatestRate(ctx context.Context, pair *model.Pair, provider string) (*model.RateSnapshot, error) {
rate, err := s.storage.Rates().LatestSnapshot(ctx, pair.Pair, provider)
if err == nil {
return rate, nil
}
if !errors.Is(err, merrors.ErrNoData) {
return nil, err
}
crossRate, crossErr := s.computeCrossRate(ctx, pair, provider)
if crossErr != nil {
if errors.Is(crossErr, merrors.ErrNoData) {
return nil, err
}
return nil, crossErr
}
s.logger.Debug("Derived cross rate", zap.String("pair", pair.Pair.Base+"/"+pair.Pair.Quote), zap.String("provider", provider))
return crossRate, nil
}
var _ oraclev1.OracleServer = (*Service)(nil)

View File

@@ -0,0 +1,467 @@
package oracle
import (
"context"
"strings"
"testing"
"time"
"github.com/tech/sendico/fx/storage"
"github.com/tech/sendico/fx/storage/model"
"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"
"go.uber.org/zap"
)
type repositoryStub struct {
rates storage.RatesStore
quotes storage.QuotesStore
pairs storage.PairStore
currencies storage.CurrencyStore
pingErr error
}
func (r *repositoryStub) Ping(ctx context.Context) error { return r.pingErr }
func (r *repositoryStub) Rates() storage.RatesStore { return r.rates }
func (r *repositoryStub) Quotes() storage.QuotesStore { return r.quotes }
func (r *repositoryStub) Pairs() storage.PairStore { return r.pairs }
func (r *repositoryStub) Currencies() storage.CurrencyStore {
return r.currencies
}
type ratesStoreStub struct {
latestFn func(ctx context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error)
}
func (r *ratesStoreStub) UpsertSnapshot(ctx context.Context, snapshot *model.RateSnapshot) error {
return nil
}
func (r *ratesStoreStub) LatestSnapshot(ctx context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error) {
if r.latestFn != nil {
return r.latestFn(ctx, pair, provider)
}
return nil, merrors.ErrNoData
}
type quotesStoreStub struct {
issueFn func(ctx context.Context, quote *model.Quote) error
getFn func(ctx context.Context, ref string) (*model.Quote, error)
consumeFn func(ctx context.Context, ref, ledger string, when time.Time) (*model.Quote, error)
}
func (q *quotesStoreStub) Issue(ctx context.Context, quote *model.Quote) error {
if q.issueFn != nil {
return q.issueFn(ctx, quote)
}
return nil
}
func (q *quotesStoreStub) GetByRef(ctx context.Context, ref string) (*model.Quote, error) {
if q.getFn != nil {
return q.getFn(ctx, ref)
}
return nil, merrors.ErrNoData
}
func (q *quotesStoreStub) Consume(ctx context.Context, ref, ledger string, when time.Time) (*model.Quote, error) {
if q.consumeFn != nil {
return q.consumeFn(ctx, ref, ledger, when)
}
return nil, nil
}
func (q *quotesStoreStub) ExpireIssuedBefore(ctx context.Context, cutoff time.Time) (int, error) {
return 0, nil
}
type pairStoreStub struct {
getFn func(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error)
listFn func(ctx context.Context) ([]*model.Pair, error)
}
func (p *pairStoreStub) ListEnabled(ctx context.Context) ([]*model.Pair, error) {
if p.listFn != nil {
return p.listFn(ctx)
}
return nil, nil
}
func (p *pairStoreStub) Get(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
if p.getFn != nil {
return p.getFn(ctx, pair)
}
return nil, merrors.ErrNoData
}
func (p *pairStoreStub) Upsert(ctx context.Context, pair *model.Pair) error { return nil }
type currencyStoreStub struct{}
func (currencyStoreStub) Get(ctx context.Context, code string) (*model.Currency, error) {
return nil, merrors.ErrNoData
}
func (currencyStoreStub) List(ctx context.Context, codes ...string) ([]*model.Currency, error) {
return nil, nil
}
func (currencyStoreStub) Upsert(ctx context.Context, currency *model.Currency) error { return nil }
func TestServiceGetQuoteFirm(t *testing.T) {
repo := &repositoryStub{}
repo.pairs = &pairStoreStub{
getFn: func(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
return &model.Pair{
Pair: pair,
BaseMeta: model.CurrencySettings{Code: pair.Base, Decimals: 2, Rounding: model.RoundingModeHalfEven},
QuoteMeta: model.CurrencySettings{Code: pair.Quote, Decimals: 2, Rounding: model.RoundingModeHalfEven},
}, nil
},
}
repo.rates = &ratesStoreStub{
latestFn: func(ctx context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error) {
return &model.RateSnapshot{
Pair: pair,
Provider: provider,
Ask: "1.10",
Bid: "1.08",
RateRef: "rate#1",
AsOfUnixMs: time.Now().UnixMilli(),
}, nil
},
}
savedQuote := &model.Quote{}
repo.quotes = &quotesStoreStub{
issueFn: func(ctx context.Context, quote *model.Quote) error {
*savedQuote = *quote
return nil
},
}
repo.currencies = currencyStoreStub{}
svc := NewService(zap.NewNop(), repo, nil)
req := &oraclev1.GetQuoteRequest{
Meta: &oraclev1.RequestMeta{
TenantRef: "tenant",
Trace: &tracev1.TraceContext{RequestRef: "req"},
},
Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"},
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
AmountInput: &oraclev1.GetQuoteRequest_BaseAmount{BaseAmount: &moneyv1.Money{
Currency: "USD",
Amount: "100",
}},
Firm: true,
TtlMs: 60000,
}
resp, err := svc.GetQuote(context.Background(), req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.GetQuote().GetFirm() != true {
t.Fatalf("expected firm quote")
}
if resp.GetQuote().GetQuoteAmount().GetAmount() != "110.00" {
t.Fatalf("unexpected quote amount: %s", resp.GetQuote().GetQuoteAmount().GetAmount())
}
if savedQuote.QuoteRef == "" {
t.Fatalf("expected quote persisted")
}
}
func TestServiceGetQuoteRateNotFound(t *testing.T) {
repo := &repositoryStub{
pairs: &pairStoreStub{
getFn: func(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
return &model.Pair{
Pair: pair,
BaseMeta: model.CurrencySettings{Code: pair.Base, Decimals: 2, Rounding: model.RoundingModeHalfEven},
QuoteMeta: model.CurrencySettings{Code: pair.Quote, Decimals: 2, Rounding: model.RoundingModeHalfEven},
}, nil
},
},
rates: &ratesStoreStub{latestFn: func(context.Context, model.CurrencyPair, string) (*model.RateSnapshot, error) {
return nil, merrors.ErrNoData
}},
}
svc := NewService(zap.NewNop(), repo, nil)
_, err := svc.GetQuote(context.Background(), &oraclev1.GetQuoteRequest{
Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"},
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
AmountInput: &oraclev1.GetQuoteRequest_BaseAmount{BaseAmount: &moneyv1.Money{Currency: "USD", Amount: "1"}},
})
if err == nil {
t.Fatalf("expected error")
}
}
func TestServiceGetQuoteCrossRate(t *testing.T) {
repo := &repositoryStub{}
targetPair := model.CurrencyPair{Base: "EUR", Quote: "RUB"}
baseLegPair := model.CurrencyPair{Base: "USDT", Quote: "EUR"}
quoteLegPair := model.CurrencyPair{Base: "USDT", Quote: "RUB"}
repo.pairs = &pairStoreStub{
getFn: func(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
if pair != targetPair {
t.Fatalf("unexpected pair lookup: %v", pair)
}
return &model.Pair{
Pair: pair,
BaseMeta: model.CurrencySettings{Code: pair.Base, Decimals: 2, Rounding: model.RoundingModeHalfEven},
QuoteMeta: model.CurrencySettings{Code: pair.Quote, Decimals: 2, Rounding: model.RoundingModeHalfEven},
DefaultProvider: "CROSSPROV",
Cross: &model.CrossRateConfig{
Enabled: true,
BaseLeg: model.CrossRateLeg{
Pair: baseLegPair,
Invert: true,
},
QuoteLeg: model.CrossRateLeg{
Pair: quoteLegPair,
},
},
}, nil
},
}
repo.rates = &ratesStoreStub{
latestFn: func(ctx context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error) {
switch pair {
case targetPair:
return nil, merrors.ErrNoData
case baseLegPair:
return &model.RateSnapshot{
Pair: pair,
Provider: provider,
Ask: "0.90",
Bid: "0.90",
Mid: "0.90",
RateRef: "base-leg",
AsOfUnixMs: 1_000,
}, nil
case quoteLegPair:
return &model.RateSnapshot{
Pair: pair,
Provider: provider,
Ask: "90",
Bid: "90",
Mid: "90",
RateRef: "quote-leg",
AsOfUnixMs: 2_000,
}, nil
default:
return nil, merrors.ErrNoData
}
},
}
repo.quotes = &quotesStoreStub{}
repo.currencies = currencyStoreStub{}
svc := NewService(zap.NewNop(), repo, nil)
req := &oraclev1.GetQuoteRequest{
Pair: &fxv1.CurrencyPair{Base: "EUR", Quote: "RUB"},
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
AmountInput: &oraclev1.GetQuoteRequest_BaseAmount{BaseAmount: &moneyv1.Money{Currency: "EUR", Amount: "1"}},
}
resp, err := svc.GetQuote(context.Background(), req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.GetQuote().GetPrice().GetValue() != "100.00" {
t.Fatalf("unexpected cross price: %s", resp.GetQuote().GetPrice().GetValue())
}
if resp.GetQuote().GetQuoteAmount().GetAmount() != "100.00" {
t.Fatalf("unexpected cross quote amount: %s", resp.GetQuote().GetQuoteAmount().GetAmount())
}
if !strings.HasPrefix(resp.GetQuote().GetRateRef(), "cross|") {
t.Fatalf("expected cross rate ref, got %s", resp.GetQuote().GetRateRef())
}
if resp.GetQuote().GetProvider() != "CROSSPROV" {
t.Fatalf("unexpected provider: %s", resp.GetQuote().GetProvider())
}
}
func TestServiceLatestRateCross(t *testing.T) {
repo := &repositoryStub{}
targetPair := model.CurrencyPair{Base: "EUR", Quote: "RUB"}
baseLegPair := model.CurrencyPair{Base: "USDT", Quote: "EUR"}
quoteLegPair := model.CurrencyPair{Base: "USDT", Quote: "RUB"}
repo.pairs = &pairStoreStub{
getFn: func(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
if pair != targetPair {
t.Fatalf("unexpected pair lookup: %v", pair)
}
return &model.Pair{
Pair: pair,
BaseMeta: model.CurrencySettings{Code: pair.Base, Decimals: 2, Rounding: model.RoundingModeHalfEven},
QuoteMeta: model.CurrencySettings{Code: pair.Quote, Decimals: 2, Rounding: model.RoundingModeHalfEven},
DefaultProvider: "CROSSPROV",
Cross: &model.CrossRateConfig{
Enabled: true,
BaseLeg: model.CrossRateLeg{
Pair: baseLegPair,
Invert: true,
},
QuoteLeg: model.CrossRateLeg{
Pair: quoteLegPair,
},
},
}, nil
},
}
repo.rates = &ratesStoreStub{
latestFn: func(ctx context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error) {
switch pair {
case targetPair:
return nil, merrors.ErrNoData
case baseLegPair:
return &model.RateSnapshot{
Pair: pair,
Provider: provider,
Ask: "0.90",
Bid: "0.90",
Mid: "0.90",
RateRef: "base-leg",
AsOfUnixMs: 1_000,
}, nil
case quoteLegPair:
return &model.RateSnapshot{
Pair: pair,
Provider: provider,
Ask: "90",
Bid: "90",
Mid: "90",
RateRef: "quote-leg",
AsOfUnixMs: 2_000,
}, nil
default:
return nil, merrors.ErrNoData
}
},
}
repo.quotes = &quotesStoreStub{}
repo.currencies = currencyStoreStub{}
svc := NewService(zap.NewNop(), repo, nil)
resp, err := svc.LatestRate(context.Background(), &oraclev1.LatestRateRequest{
Pair: &fxv1.CurrencyPair{Base: "EUR", Quote: "RUB"},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.GetRate().GetMid().GetValue() != "100.00000000" {
t.Fatalf("unexpected mid price: %s", resp.GetRate().GetMid().GetValue())
}
if resp.GetRate().GetProvider() != "CROSSPROV" {
t.Fatalf("unexpected provider: %s", resp.GetRate().GetProvider())
}
if !strings.HasPrefix(resp.GetRate().GetRateRef(), "cross|") {
t.Fatalf("expected cross rate ref, got %s", resp.GetRate().GetRateRef())
}
}
func TestServiceValidateQuote(t *testing.T) {
now := time.Now().Add(time.Minute)
repo := &repositoryStub{
quotes: &quotesStoreStub{
getFn: func(context.Context, string) (*model.Quote, error) {
return &model.Quote{
QuoteRef: "q1",
Pair: model.CurrencyPair{Base: "USD", Quote: "EUR"},
Side: model.QuoteSideBuyBaseSellQuote,
Price: "1.10",
BaseAmount: model.Money{Currency: "USD", Amount: "100"},
QuoteAmount: model.Money{Currency: "EUR", Amount: "110"},
ExpiresAtUnixMs: now.UnixMilli(),
Status: model.QuoteStatusIssued,
}, nil
},
},
}
svc := NewService(zap.NewNop(), repo, nil)
resp, err := svc.ValidateQuote(context.Background(), &oraclev1.ValidateQuoteRequest{QuoteRef: "q1"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !resp.GetValid() {
t.Fatalf("expected quote valid")
}
}
func TestServiceConsumeQuoteExpired(t *testing.T) {
repo := &repositoryStub{
quotes: &quotesStoreStub{
consumeFn: func(context.Context, string, string, time.Time) (*model.Quote, error) {
return nil, storage.ErrQuoteExpired
},
},
}
svc := NewService(zap.NewNop(), repo, nil)
_, err := svc.ConsumeQuote(context.Background(), &oraclev1.ConsumeQuoteRequest{QuoteRef: "q1", LedgerTxnRef: "ledger"})
if err == nil {
t.Fatalf("expected error")
}
}
func TestServiceLatestRateSuccess(t *testing.T) {
repo := &repositoryStub{
rates: &ratesStoreStub{latestFn: func(_ context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error) {
if pair != (model.CurrencyPair{Base: "USD", Quote: "EUR"}) {
t.Fatalf("unexpected pair: %v", pair)
}
if provider != "DEFAULT" {
t.Fatalf("unexpected provider: %s", provider)
}
return &model.RateSnapshot{Pair: pair, RateRef: "rate", Provider: provider}, nil
}},
pairs: &pairStoreStub{
getFn: func(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
return &model.Pair{
Pair: pair,
BaseMeta: model.CurrencySettings{Code: pair.Base, Decimals: 2, Rounding: model.RoundingModeHalfEven},
QuoteMeta: model.CurrencySettings{Code: pair.Quote, Decimals: 2, Rounding: model.RoundingModeHalfEven},
DefaultProvider: "DEFAULT",
}, nil
},
},
}
svc := NewService(zap.NewNop(), repo, nil)
resp, err := svc.LatestRate(context.Background(), &oraclev1.LatestRateRequest{Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"}})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.GetRate().GetRateRef() != "rate" {
t.Fatalf("unexpected rate ref")
}
}
func TestServiceListPairs(t *testing.T) {
repo := &repositoryStub{
pairs: &pairStoreStub{listFn: func(context.Context) ([]*model.Pair, error) {
return []*model.Pair{{Pair: model.CurrencyPair{Base: "USD", Quote: "EUR"}}}, nil
}},
}
svc := NewService(zap.NewNop(), repo, nil)
resp, err := svc.ListPairs(context.Background(), &oraclev1.ListPairsRequest{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(resp.GetPairs()) != 1 {
t.Fatalf("expected one pair")
}
}

View File

@@ -0,0 +1,126 @@
package oracle
import (
"strings"
"github.com/tech/sendico/fx/storage/model"
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"
)
func buildResponseMeta(meta *oraclev1.RequestMeta) *oraclev1.ResponseMeta {
resp := &oraclev1.ResponseMeta{}
if meta == nil {
return resp
}
resp.RequestRef = meta.GetRequestRef()
resp.TraceRef = meta.GetTraceRef()
trace := meta.GetTrace()
if trace == nil {
trace = &tracev1.TraceContext{
RequestRef: meta.GetRequestRef(),
IdempotencyKey: meta.GetIdempotencyKey(),
TraceRef: meta.GetTraceRef(),
}
}
resp.Trace = trace
return resp
}
func quoteModelToProto(q *model.Quote) *oraclev1.Quote {
if q == nil {
return nil
}
return &oraclev1.Quote{
QuoteRef: q.QuoteRef,
Pair: &fxv1.CurrencyPair{Base: q.Pair.Base, Quote: q.Pair.Quote},
Side: sideModelToProto(q.Side),
Price: decimalStringToProto(q.Price),
BaseAmount: moneyModelToProto(&q.BaseAmount),
QuoteAmount: moneyModelToProto(&q.QuoteAmount),
ExpiresAtUnixMs: q.ExpiresAtUnixMs,
Provider: q.Provider,
RateRef: q.RateRef,
Firm: q.Firm,
}
}
func moneyModelToProto(m *model.Money) *moneyv1.Money {
if m == nil {
return nil
}
return &moneyv1.Money{Currency: m.Currency, Amount: m.Amount}
}
func sideModelToProto(side model.QuoteSide) fxv1.Side {
switch side {
case model.QuoteSideBuyBaseSellQuote:
return fxv1.Side_BUY_BASE_SELL_QUOTE
case model.QuoteSideSellBaseBuyQuote:
return fxv1.Side_SELL_BASE_BUY_QUOTE
default:
return fxv1.Side_SIDE_UNSPECIFIED
}
}
func rateModelToProto(rate *model.RateSnapshot) *oraclev1.RateSnapshot {
if rate == nil {
return nil
}
return &oraclev1.RateSnapshot{
Pair: &fxv1.CurrencyPair{Base: rate.Pair.Base, Quote: rate.Pair.Quote},
Mid: decimalStringToProto(rate.Mid),
Bid: decimalStringToProto(rate.Bid),
Ask: decimalStringToProto(rate.Ask),
AsofUnixMs: rate.AsOfUnixMs,
Provider: rate.Provider,
RateRef: rate.RateRef,
SpreadBps: decimalStringToProto(rate.SpreadBps),
}
}
func pairModelToProto(pair *model.Pair) *oraclev1.PairMeta {
if pair == nil {
return nil
}
return &oraclev1.PairMeta{
Pair: &fxv1.CurrencyPair{Base: pair.Pair.Base, Quote: pair.Pair.Quote},
BaseMeta: currencySettingsToProto(&pair.BaseMeta),
QuoteMeta: currencySettingsToProto(&pair.QuoteMeta),
}
}
func currencySettingsToProto(c *model.CurrencySettings) *moneyv1.CurrencyMeta {
if c == nil {
return nil
}
return &moneyv1.CurrencyMeta{
Code: c.Code,
Decimals: c.Decimals,
Rounding: roundingModeToProto(c.Rounding),
}
}
func roundingModeToProto(mode model.RoundingMode) moneyv1.RoundingMode {
switch mode {
case model.RoundingModeHalfUp:
return moneyv1.RoundingMode_ROUND_HALF_UP
case model.RoundingModeDown:
return moneyv1.RoundingMode_ROUND_DOWN
case model.RoundingModeHalfEven, model.RoundingModeUnspecified:
return moneyv1.RoundingMode_ROUND_HALF_EVEN
default:
return moneyv1.RoundingMode_ROUNDING_MODE_UNSPECIFIED
}
}
func decimalStringToProto(value string) *moneyv1.Decimal {
if strings.TrimSpace(value) == "" {
return nil
}
return &moneyv1.Decimal{Value: value}
}

17
api/fx/oracle/main.go Normal file
View File

@@ -0,0 +1,17 @@
package main
import (
"github.com/tech/sendico/fx/oracle/internal/appversion"
si "github.com/tech/sendico/fx/oracle/internal/server"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/server"
smain "github.com/tech/sendico/pkg/server/main"
)
func factory(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
return si.Create(logger, file, debug)
}
func main() {
smain.RunServer("main", appversion.Create(), factory)
}