Linting #509

Merged
tech merged 46 commits from main into dis-474 2026-02-13 16:14:15 +00:00
42 changed files with 1813 additions and 237 deletions
Showing only changes of commit 27de7a9655 - Show all commits

View File

@@ -116,7 +116,6 @@ linters:
- varnamelen - varnamelen
- wastedassign - wastedassign
- whitespace - whitespace
- wrapcheck
- wsl_v5 - wsl_v5
- zerologlint - zerologlint
# Disable specific linters. # Disable specific linters.
@@ -126,6 +125,7 @@ linters:
- gochecknoglobals - gochecknoglobals
- gomoddirectives - gomoddirectives
- wsl - wsl
- wrapcheck
# All available settings of specific linters. # All available settings of specific linters.
# See the dedicated "linters.settings" documentation section. # See the dedicated "linters.settings" documentation section.
settings: settings:

View File

@@ -0,0 +1,195 @@
# See the dedicated "version" documentation section.
version: "2"
linters:
# Default set of linters.
# The value can be:
# - `standard`: https://golangci-lint.run/docs/linters/#enabled-by-default
# - `all`: enables all linters by default.
# - `none`: disables all linters by default.
# - `fast`: enables only linters considered as "fast" (`golangci-lint help linters --json | jq '[ .[] | select(.fast==true) ] | map(.name)'`).
# Default: standard
default: all
# Enable specific linter.
enable:
- arangolint
- asasalint
- asciicheck
- bidichk
- bodyclose
- canonicalheader
- containedctx
- contextcheck
- copyloopvar
- cyclop
- decorder
- dogsled
- dupl
- dupword
- durationcheck
- embeddedstructfieldcheck
- err113
- errcheck
- errchkjson
- errname
- errorlint
- exhaustive
- exptostd
- fatcontext
- forbidigo
- forcetypeassert
- funcorder
- funlen
- ginkgolinter
- gocheckcompilerdirectives
- gochecknoglobals
- gochecknoinits
- gochecksumtype
- gocognit
- goconst
- gocritic
- gocyclo
- godoclint
- godot
- godox
- goheader
- gomodguard
- goprintffuncname
- gosec
- gosmopolitan
- govet
- grouper
- iface
- importas
- inamedparam
- ineffassign
- interfacebloat
- intrange
- iotamixing
- ireturn
- lll
- loggercheck
- maintidx
- makezero
- mirror
- misspell
- mnd
- modernize
- musttag
- nakedret
- nestif
- nilerr
- nilnesserr
- nilnil
- nlreturn
- noctx
- noinlineerr
- nolintlint
- nonamedreturns
- nosprintfhostport
- paralleltest
- perfsprint
- prealloc
- predeclared
- promlinter
- protogetter
- reassign
- recvcheck
- revive
- rowserrcheck
- sloglint
- spancheck
- sqlclosecheck
- staticcheck
- tagalign
- tagliatelle
- testableexamples
- testifylint
- testpackage
- thelper
- tparallel
- unconvert
- unparam
- unqueryvet
- unused
- usestdlibvars
- usetesting
- varnamelen
- wastedassign
- whitespace
- wsl_v5
- zerologlint
# Disable specific linters.
disable:
- depguard
- exhaustruct
- gochecknoglobals
- gomoddirectives
- wrapcheck
- wsl
# All available settings of specific linters.
# See the dedicated "linters.settings" documentation section.
settings:
wsl_v5:
allow-first-in-block: true
allow-whole-block: false
branch-max-lines: 2
# Defines a set of rules to ignore issues.
# It does not skip the analysis, and so does not ignore "typecheck" errors.
exclusions:
# Mode of the generated files analysis.
#
# - `strict`: sources are excluded by strictly following the Go generated file convention.
# Source files that have lines matching only the following regular expression will be excluded: `^// Code generated .* DO NOT EDIT\.$`
# This line must appear before the first non-comment, non-blank text in the file.
# https://go.dev/s/generatedcode
# - `lax`: sources are excluded if they contain lines like `autogenerated file`, `code generated`, `do not edit`, etc.
# - `disable`: disable the generated files exclusion.
#
# Default: strict
generated: lax
# Log a warning if an exclusion rule is unused.
# Default: false
warn-unused: true
# Predefined exclusion rules.
# Default: []
presets:
- comments
- std-error-handling
- common-false-positives
- legacy
# Excluding configuration per-path, per-linter, per-text and per-source.
rules:
# Exclude some linters from running on tests files.
- path: _test\.go
linters:
- gocyclo
- errcheck
- dupl
- gosec
# Run some linter only for test files by excluding its issues for everything else.
- path-except: _test\.go
linters:
- forbidigo
# Exclude known linters from partially hard-vendored code,
# which is impossible to exclude via `nolint` comments.
# `/` will be replaced by the current OS file path separator to properly work on Windows.
- path: internal/hmac/
text: "weak cryptographic primitive"
linters:
- gosec
# Exclude some `staticcheck` messages.
- linters:
- staticcheck
text: "SA9003:"
# Exclude `lll` issues for long lines with `go:generate`.
- linters:
- lll
source: "^//go:generate "
# Which file paths to exclude: they will be analyzed, but issues from them won't be reported.
# "/" will be replaced by the current OS file path separator to properly work on Windows.
# Default: []
paths: []
# Which file paths to not exclude.
# Default: []
paths-except: []

View File

@@ -51,16 +51,17 @@ func (a *App) Run(ctx context.Context) error {
return err return err
} }
a.logger.Debug("Metrics server initialised") a.logger.Debug("Metrics server initialised")
defer metricsSrv.Close(context.Background()) defer metricsSrv.Close(context.Background()) //nolint:contextcheck
conn, err := db.ConnectMongo(a.logger, a.cfg.Database) conn, err := db.ConnectMongo(a.logger, a.cfg.Database) //nolint:contextcheck
if err != nil { if err != nil {
return err return err
} }
defer conn.Disconnect(context.Background()) defer conn.Disconnect(context.Background()) //nolint:errcheck,contextcheck
a.logger.Debug("MongoDB connection established") a.logger.Debug("MongoDB connection established")
repo, err := mongostorage.New(a.logger, conn) repo, err := mongostorage.New(a.logger, conn) //nolint:contextcheck
if err != nil { if err != nil {
return err return err
} }
@@ -72,6 +73,7 @@ func (a *App) Run(ctx context.Context) error {
} }
var announcer *discovery.Announcer var announcer *discovery.Announcer
if cfg := a.cfg.Messaging; cfg != nil && cfg.Driver != "" { if cfg := a.cfg.Messaging; cfg != nil && cfg.Driver != "" {
broker, err := msg.CreateMessagingBroker(a.logger.Named("discovery_bus"), cfg) broker, err := msg.CreateMessagingBroker(a.logger.Named("discovery_bus"), cfg)
if err != nil { if err != nil {
@@ -84,6 +86,7 @@ func (a *App) Run(ctx context.Context) error {
Version: appversion.Create().Short(), Version: appversion.Create().Short(),
} }
announcer = discovery.NewAnnouncer(a.logger, producer, "fx_ingestor", announce) announcer = discovery.NewAnnouncer(a.logger, producer, "fx_ingestor", announce)
announcer.Start() announcer.Start()
defer announcer.Stop() defer announcer.Stop()
} }
@@ -98,6 +101,8 @@ func (a *App) Run(ctx context.Context) error {
} }
return err return err
} }
a.logger.Info("Ingestor service stopped") a.logger.Info("Ingestor service stopped")
return nil return nil
} }

View File

@@ -14,8 +14,9 @@ var (
BuildDate string BuildDate string
) )
//nolint:ireturn
func Create() version.Printer { func Create() version.Printer {
vi := version.Info{ info := version.Info{
Program: "Sendico FX Ingestor Service", Program: "Sendico FX Ingestor Service",
Revision: Revision, Revision: Revision,
Branch: Branch, Branch: Branch,
@@ -23,5 +24,6 @@ func Create() version.Printer {
BuildDate: BuildDate, BuildDate: BuildDate,
Version: Version, Version: Version,
} }
return vf.Create(&vi)
return vf.Create(&info)
} }

View File

@@ -25,6 +25,7 @@ type Config struct {
pairsBySource map[mmodel.Driver][]PairConfig pairsBySource map[mmodel.Driver][]PairConfig
} }
//nolint:cyclop
func Load(path string) (*Config, error) { func Load(path string) (*Config, error) {
if path == "" { if path == "" {
return nil, merrors.InvalidArgument("config: path is empty") return nil, merrors.InvalidArgument("config: path is empty")
@@ -36,19 +37,23 @@ func Load(path string) (*Config, error) {
} }
cfg := &Config{} cfg := &Config{}
if err := yaml.Unmarshal(data, cfg); err != nil {
err = yaml.Unmarshal(data, cfg)
if err != nil {
return nil, merrors.InternalWrap(err, "config: failed to parse yaml") return nil, merrors.InternalWrap(err, "config: failed to parse yaml")
} }
if len(cfg.Market.Sources) == 0 { if len(cfg.Market.Sources) == 0 {
return nil, merrors.InvalidArgument("config: no market sources configured") return nil, merrors.InvalidArgument("config: no market sources configured")
} }
sourceSet := make(map[mmodel.Driver]struct{}, len(cfg.Market.Sources)) sourceSet := make(map[mmodel.Driver]struct{}, len(cfg.Market.Sources))
for idx := range cfg.Market.Sources { for idx := range cfg.Market.Sources {
src := &cfg.Market.Sources[idx] src := &cfg.Market.Sources[idx]
if src.Driver.IsEmpty() { if src.Driver.IsEmpty() {
return nil, merrors.InvalidArgument("config: market source driver is empty") return nil, merrors.InvalidArgument("config: market source driver is empty")
} }
sourceSet[src.Driver] = struct{}{} sourceSet[src.Driver] = struct{}{}
} }
@@ -65,6 +70,7 @@ func Load(path string) (*Config, error) {
if driver.IsEmpty() { if driver.IsEmpty() {
return nil, merrors.InvalidArgument("config: pair source is empty") return nil, merrors.InvalidArgument("config: pair source is empty")
} }
if _, ok := sourceSet[driver]; !ok { if _, ok := sourceSet[driver]; !ok {
return nil, merrors.InvalidArgument("config: pair references unknown source: "+driver.String(), "pairs."+driver.String()) return nil, merrors.InvalidArgument("config: pair references unknown source: "+driver.String(), "pairs."+driver.String())
} }
@@ -74,10 +80,12 @@ func Load(path string) (*Config, error) {
pair := pairList[idx] pair := pairList[idx]
pair.Base = strings.ToUpper(strings.TrimSpace(pair.Base)) pair.Base = strings.ToUpper(strings.TrimSpace(pair.Base))
pair.Quote = strings.ToUpper(strings.TrimSpace(pair.Quote)) pair.Quote = strings.ToUpper(strings.TrimSpace(pair.Quote))
pair.Symbol = strings.TrimSpace(pair.Symbol) pair.Symbol = strings.TrimSpace(pair.Symbol)
if pair.Base == "" || pair.Quote == "" || pair.Symbol == "" { if pair.Base == "" || pair.Quote == "" || pair.Symbol == "" {
return nil, merrors.InvalidArgument("config: pair entries must define base, quote, and symbol", "pairs."+driver.String()) return nil, merrors.InvalidArgument("config: pair entries must define base, quote, and symbol", "pairs."+driver.String())
} }
if strings.TrimSpace(pair.Provider) == "" { if strings.TrimSpace(pair.Provider) == "" {
pair.Provider = strings.ToLower(driver.String()) pair.Provider = strings.ToLower(driver.String())
} }
@@ -87,6 +95,7 @@ func Load(path string) (*Config, error) {
Source: driver, Source: driver,
}) })
} }
pairsBySource[driver] = processed pairsBySource[driver] = processed
normalizedPairs[driver.String()] = processed normalizedPairs[driver.String()] = processed
} }
@@ -94,6 +103,7 @@ func Load(path string) (*Config, error) {
cfg.Market.Pairs = normalizedPairs cfg.Market.Pairs = normalizedPairs
cfg.pairsBySource = pairsBySource cfg.pairsBySource = pairsBySource
cfg.pairs = flattened cfg.pairs = flattened
if cfg.Database == nil { if cfg.Database == nil {
return nil, merrors.InvalidArgument("config: database configuration is required") return nil, merrors.InvalidArgument("config: database configuration is required")
} }
@@ -101,7 +111,7 @@ func Load(path string) (*Config, error) {
if cfg.Metrics != nil && cfg.Metrics.Enabled { if cfg.Metrics != nil && cfg.Metrics.Enabled {
cfg.Metrics.Address = strings.TrimSpace(cfg.Metrics.Address) cfg.Metrics.Address = strings.TrimSpace(cfg.Metrics.Address)
if cfg.Metrics.Address == "" { if cfg.Metrics.Address == "" {
cfg.Metrics.Address = ":9102" cfg.Metrics.Address = ":9102" //nolint:mnd
} }
} }
@@ -112,9 +122,11 @@ func (c *Config) PollInterval() time.Duration {
if c == nil { if c == nil {
return defaultPollInterval return defaultPollInterval
} }
if c.PollIntervalSeconds <= 0 { if c.PollIntervalSeconds <= 0 {
return defaultPollInterval return defaultPollInterval
} }
return time.Duration(c.PollIntervalSeconds) * time.Second return time.Duration(c.PollIntervalSeconds) * time.Second
} }
@@ -122,8 +134,10 @@ func (c *Config) Pairs() []Pair {
if c == nil { if c == nil {
return nil return nil
} }
out := make([]Pair, len(c.pairs)) out := make([]Pair, len(c.pairs))
copy(out, c.pairs) copy(out, c.pairs)
return out return out
} }
@@ -131,12 +145,14 @@ func (c *Config) PairsBySource() map[mmodel.Driver][]PairConfig {
if c == nil { if c == nil {
return nil return nil
} }
out := make(map[mmodel.Driver][]PairConfig, len(c.pairsBySource)) out := make(map[mmodel.Driver][]PairConfig, len(c.pairsBySource))
for driver, pairs := range c.pairsBySource { for driver, pairs := range c.pairsBySource {
cp := make([]PairConfig, len(pairs)) cp := make([]PairConfig, len(pairs))
copy(cp, pairs) copy(cp, pairs)
out[driver] = cp out[driver] = cp
} }
return out return out
} }
@@ -144,6 +160,8 @@ func (c *Config) MetricsConfig() *MetricsConfig {
if c == nil || c.Metrics == nil { if c == nil || c.Metrics == nil {
return nil return nil
} }
cp := *c.Metrics cp := *c.Metrics
return &cp return &cp
} }

View File

@@ -15,6 +15,7 @@ type PairConfig struct {
type Pair struct { type Pair struct {
PairConfig `yaml:",inline"` PairConfig `yaml:",inline"`
Source mmodel.Driver `yaml:"-"` Source mmodel.Driver `yaml:"-"`
} }

View File

@@ -28,9 +28,11 @@ func New(logger mlogger.Logger, cfg *config.Config, repo storage.Repository) (*S
if logger == nil { if logger == nil {
return nil, merrors.InvalidArgument("ingestor: nil logger") return nil, merrors.InvalidArgument("ingestor: nil logger")
} }
if cfg == nil { if cfg == nil {
return nil, merrors.InvalidArgument("ingestor: nil config") return nil, merrors.InvalidArgument("ingestor: nil config")
} }
if repo == nil { if repo == nil {
return nil, merrors.InvalidArgument("ingestor: nil repository") return nil, merrors.InvalidArgument("ingestor: nil repository")
} }
@@ -52,6 +54,7 @@ func New(logger mlogger.Logger, cfg *config.Config, repo storage.Repository) (*S
func (s *Service) Run(ctx context.Context) error { func (s *Service) Run(ctx context.Context) error {
interval := s.cfg.PollInterval() interval := s.cfg.PollInterval()
ticker := time.NewTicker(interval) ticker := time.NewTicker(interval)
defer ticker.Stop() defer ticker.Stop()
@@ -65,6 +68,7 @@ func (s *Service) Run(ctx context.Context) error {
select { select {
case <-ctx.Done(): case <-ctx.Done():
s.logger.Info("Context cancelled, stopping ingestor") s.logger.Info("Context cancelled, stopping ingestor")
return ctx.Err() return ctx.Err()
case <-ticker.C: case <-ticker.C:
if err := s.executePoll(ctx); err != nil { if err := s.executePoll(ctx); err != nil {
@@ -77,27 +81,34 @@ func (s *Service) Run(ctx context.Context) error {
func (s *Service) executePoll(ctx context.Context) error { func (s *Service) executePoll(ctx context.Context) error {
start := time.Now() start := time.Now()
err := s.pollOnce(ctx) err := s.pollOnce(ctx)
if s.metrics != nil { if s.metrics != nil {
s.metrics.observePoll(time.Since(start), err) s.metrics.observePoll(time.Since(start), err)
} }
return err return err
} }
func (s *Service) pollOnce(ctx context.Context) error { func (s *Service) pollOnce(ctx context.Context) error {
var firstErr error var firstErr error
failures := 0 failures := 0
for _, pair := range s.pairs { for _, pair := range s.pairs {
start := time.Now() start := time.Now()
err := s.upsertPair(ctx, pair) err := s.upsertPair(ctx, pair)
elapsed := time.Since(start) elapsed := time.Since(start)
if s.metrics != nil { if s.metrics != nil {
s.metrics.observePair(pair, elapsed, err) s.metrics.observePair(pair, elapsed, err)
} }
if err != nil { if err != nil {
if firstErr == nil { if firstErr == nil {
firstErr = err firstErr = err
} }
failures++ failures++
s.logger.Warn("Failed to ingest pair", s.logger.Warn("Failed to ingest pair",
zap.String("symbol", pair.Symbol), zap.String("symbol", pair.Symbol),
zap.String("source", pair.Source.String()), zap.String("source", pair.Source.String()),
@@ -110,14 +121,17 @@ func (s *Service) pollOnce(ctx context.Context) error {
) )
} }
} }
if failures > 0 { if failures > 0 {
s.logger.Warn("Ingestion poll completed with failures", zap.Int("failures", failures), zap.Int("total", len(s.pairs))) s.logger.Warn("Ingestion poll completed with failures", zap.Int("failures", failures), zap.Int("total", len(s.pairs)))
} else { } else {
s.logger.Debug("Ingestion poll completed", zap.Int("total", len(s.pairs))) s.logger.Debug("Ingestion poll completed", zap.Int("total", len(s.pairs)))
} }
return firstErr return firstErr
} }
//nolint:funlen
func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error { func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
connector, ok := s.connectors[pair.Source] connector, ok := s.connectors[pair.Source]
if !ok { if !ok {
@@ -133,6 +147,7 @@ func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
if err != nil { if err != nil {
return merrors.InvalidArgumentWrap(err, "parse bid price", "bid") return merrors.InvalidArgumentWrap(err, "parse bid price", "bid")
} }
ask, err := parseDecimal(ticker.AskPrice) ask, err := parseDecimal(ticker.AskPrice)
if err != nil { if err != nil {
return merrors.InvalidArgumentWrap(err, "parse ask price", "ask") return merrors.InvalidArgumentWrap(err, "parse ask price", "ask")
@@ -148,16 +163,18 @@ func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
} }
mid := new(big.Rat).Add(bid, ask) mid := new(big.Rat).Add(bid, ask)
mid.Quo(mid, big.NewRat(2, 1)) mid.Quo(mid, big.NewRat(2, 1)) //nolint:mnd
spread := big.NewRat(0, 1) spread := big.NewRat(0, 1)
if mid.Sign() != 0 { if mid.Sign() != 0 {
spread.Sub(ask, bid) spread.Sub(ask, bid)
if spread.Sign() < 0 { if spread.Sign() < 0 {
spread.Neg(spread) spread.Neg(spread)
} }
spread.Quo(spread, mid) spread.Quo(spread, mid)
spread.Mul(spread, big.NewRat(10000, 1)) // basis points spread.Mul(spread, big.NewRat(10000, 1)) //nolint:mnd // basis points
} }
now := time.Now().UTC() now := time.Now().UTC()
@@ -201,6 +218,7 @@ func parseDecimal(value string) (*big.Rat, error) {
if _, ok := r.SetString(value); !ok { if _, ok := r.SetString(value); !ok {
return nil, merrors.InvalidArgument("invalid decimal \""+value+"\"", "value") return nil, merrors.InvalidArgument("invalid decimal \""+value+"\"", "value")
} }
return r, nil return r, nil
} }
@@ -208,9 +226,11 @@ func invertPrices(bid, ask *big.Rat) (*big.Rat, *big.Rat) {
if bid.Sign() == 0 || ask.Sign() == 0 { if bid.Sign() == 0 || ask.Sign() == 0 {
return bid, ask return bid, ask
} }
one := big.NewRat(1, 1) one := big.NewRat(1, 1)
invBid := new(big.Rat).Quo(one, ask) // invert ask to get bid invBid := new(big.Rat).Quo(one, ask) // invert ask to get bid
invAsk := new(big.Rat).Quo(one, bid) // invert bid to get ask invAsk := new(big.Rat).Quo(one, bid) // invert bid to get ask
return invBid, invAsk return invBid, invAsk
} }
@@ -218,6 +238,7 @@ func formatDecimal(r *big.Rat) string {
if r == nil { if r == nil {
return "0" return "0"
} }
// Format with 8 decimal places, trimming trailing zeros. // Format with 8 decimal places, trimming trailing zeros.
return r.FloatString(8) return r.FloatString(8)
} }

View File

@@ -27,30 +27,33 @@ type binanceConnector struct {
} }
const defaultBinanceBaseURL = "https://api.binance.com" const defaultBinanceBaseURL = "https://api.binance.com"
const ( const (
defaultDialTimeoutSeconds = 5 * time.Second defaultDialTimeout = 5 * time.Second
defaultDialKeepAliveSeconds = 30 * time.Second defaultDialKeepAlive = 30 * time.Second
defaultTLSHandshakeTimeoutSeconds = 5 * time.Second defaultTLSHandshakeTimeout = 5 * time.Second
defaultResponseHeaderTimeoutSeconds = 10 * time.Second defaultResponseHeaderTimeout = 10 * time.Second
defaultRequestTimeoutSeconds = 10 * time.Second defaultRequestTimeout = 10 * time.Second
) )
func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Connector, error) { func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Connector, error) { //nolint:ireturn
baseURL := defaultBinanceBaseURL baseURL := defaultBinanceBaseURL
provider := strings.ToLower(mmodel.DriverBinance.String()) provider := strings.ToLower(mmodel.DriverBinance.String())
dialTimeout := defaultDialTimeoutSeconds dialTimeout := defaultDialTimeout
dialKeepAlive := defaultDialKeepAliveSeconds dialKeepAlive := defaultDialKeepAlive
tlsHandshakeTimeout := defaultTLSHandshakeTimeoutSeconds tlsHandshakeTimeout := defaultTLSHandshakeTimeout
responseHeaderTimeout := defaultResponseHeaderTimeoutSeconds responseHeaderTimeout := defaultResponseHeaderTimeout
requestTimeout := defaultRequestTimeoutSeconds requestTimeout := defaultRequestTimeout
if settings != nil { if settings != nil {
if value, ok := settings["base_url"].(string); ok && strings.TrimSpace(value) != "" { if value, ok := settings["base_url"].(string); ok && strings.TrimSpace(value) != "" {
baseURL = strings.TrimSpace(value) baseURL = strings.TrimSpace(value)
} }
if value, ok := settings["provider"].(string); ok && strings.TrimSpace(value) != "" { if value, ok := settings["provider"].(string); ok && strings.TrimSpace(value) != "" {
provider = strings.TrimSpace(value) provider = strings.TrimSpace(value)
} }
dialTimeout = common.DurationSetting(settings, "dial_timeout_seconds", dialTimeout) dialTimeout = common.DurationSetting(settings, "dial_timeout_seconds", dialTimeout)
dialKeepAlive = common.DurationSetting(settings, "dial_keep_alive_seconds", dialKeepAlive) dialKeepAlive = common.DurationSetting(settings, "dial_keep_alive_seconds", dialKeepAlive)
tlsHandshakeTimeout = common.DurationSetting(settings, "tls_handshake_timeout_seconds", tlsHandshakeTimeout) tlsHandshakeTimeout = common.DurationSetting(settings, "tls_handshake_timeout_seconds", tlsHandshakeTimeout)
@@ -96,6 +99,7 @@ func (c *binanceConnector) FetchTicker(ctx context.Context, symbol string) (*mmo
if err != nil { if err != nil {
return nil, merrors.InternalWrap(err, "binance: parse base url") return nil, merrors.InternalWrap(err, "binance: parse base url")
} }
endpoint.Path = "/api/v3/ticker/bookTicker" endpoint.Path = "/api/v3/ticker/bookTicker"
query := endpoint.Query() query := endpoint.Query()
query.Set("symbol", strings.ToUpper(strings.TrimSpace(symbol))) query.Set("symbol", strings.ToUpper(strings.TrimSpace(symbol)))
@@ -109,12 +113,14 @@ func (c *binanceConnector) FetchTicker(ctx context.Context, symbol string) (*mmo
resp, err := c.client.Do(req) resp, err := c.client.Do(req)
if err != nil { if err != nil {
c.logger.Warn("Binance request failed", zap.String("symbol", symbol), zap.Error(err)) c.logger.Warn("Binance request failed", zap.String("symbol", symbol), zap.Error(err))
return nil, merrors.InternalWrap(err, "binance: request failed") return nil, merrors.InternalWrap(err, "binance: request failed")
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
c.logger.Warn("Binance returned non-OK status", zap.String("symbol", symbol), zap.Int("status", resp.StatusCode)) c.logger.Warn("Binance returned non-OK status", zap.String("symbol", symbol), zap.Int("status", resp.StatusCode))
return nil, merrors.Internal("binance: unexpected status " + strconv.Itoa(resp.StatusCode)) return nil, merrors.Internal("binance: unexpected status " + strconv.Itoa(resp.StatusCode))
} }
@@ -124,9 +130,11 @@ func (c *binanceConnector) FetchTicker(ctx context.Context, symbol string) (*mmo
AskPrice string `json:"askPrice"` AskPrice string `json:"askPrice"`
} }
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { decodeErr := json.NewDecoder(resp.Body).Decode(&payload)
c.logger.Warn("Binance decode failed", zap.String("symbol", symbol), zap.Error(err)) if decodeErr != nil {
return nil, merrors.InternalWrap(err, "binance: decode response") c.logger.Warn("Binance decode failed", zap.String("symbol", symbol), zap.Error(decodeErr))
return nil, merrors.InternalWrap(decodeErr, "binance: decode response")
} }
return &mmodel.Ticker{ return &mmodel.Ticker{

View File

@@ -49,7 +49,7 @@ const (
defaultRequestTimeoutSeconds = 10 * time.Second defaultRequestTimeoutSeconds = 10 * time.Second
) )
func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Connector, error) { func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Connector, error) { //nolint:cyclop,ireturn
baseURL := defaultCBRBaseURL baseURL := defaultCBRBaseURL
provider := strings.ToLower(mmodel.DriverCBR.String()) provider := strings.ToLower(mmodel.DriverCBR.String())
dialTimeout := defaultDialTimeoutSeconds dialTimeout := defaultDialTimeoutSeconds
@@ -284,7 +284,7 @@ func (c *cbrConnector) fetchDailyRate(ctx context.Context, valute valuteInfo) (s
return computePrice(entry.Value, entry.Nominal) return computePrice(entry.Value, entry.Nominal)
} }
func (c *cbrConnector) fetchHistoricalRate(ctx context.Context, valute valuteInfo, date time.Time) (string, error) { func (c *cbrConnector) fetchHistoricalRate(ctx context.Context, valute valuteInfo, date time.Time) (string, error) { //nolint:funlen
query := map[string]string{ query := map[string]string{
"date_req1": date.Format("02/01/2006"), "date_req1": date.Format("02/01/2006"),
"date_req2": date.Format("02/01/2006"), "date_req2": date.Format("02/01/2006"),
@@ -366,6 +366,7 @@ func (c *cbrConnector) buildURL(path string, query map[string]string) (string, e
return "", merrors.InternalWrap(err, "cbr: parse base url") return "", merrors.InternalWrap(err, "cbr: parse base url")
} }
base.Path = strings.TrimRight(base.Path, "/") + path base.Path = strings.TrimRight(base.Path, "/") + path
q := base.Query() q := base.Query()
for key, value := range query { for key, value := range query {
q.Set(key, value) q.Set(key, value)
@@ -401,7 +402,7 @@ type valuteMapping struct {
byID map[string]valuteInfo byID map[string]valuteInfo
} }
func buildValuteMapping(logger *zap.Logger, items []valuteItem) (*valuteMapping, error) { func buildValuteMapping(logger *zap.Logger, items []valuteItem) (*valuteMapping, error) { //nolint:gocognit,nestif
byISO := make(map[string]valuteInfo, len(items)) byISO := make(map[string]valuteInfo, len(items))
byID := make(map[string]valuteInfo, len(items)) byID := make(map[string]valuteInfo, len(items))
byNum := make(map[string]string, len(items)) byNum := make(map[string]string, len(items))
@@ -453,11 +454,12 @@ func buildValuteMapping(logger *zap.Logger, items []valuteItem) (*valuteMapping,
// 2) Otherwise prefer smaller nominal // 2) Otherwise prefer smaller nominal
keepExisting := true keepExisting := true
if existing.Nominal != 1 && info.Nominal == 1 { switch {
case existing.Nominal != 1 && info.Nominal == 1:
keepExisting = false keepExisting = false
} else if existing.Nominal == 1 && info.Nominal != 1 { case existing.Nominal == 1 && info.Nominal != 1:
keepExisting = true keepExisting = true
} else if info.Nominal < existing.Nominal { case info.Nominal < existing.Nominal:
keepExisting = false keepExisting = false
} }
@@ -513,7 +515,9 @@ func buildValuteMapping(logger *zap.Logger, items []valuteItem) (*valuteMapping,
byNum[isoNum] = id byNum[isoNum] = id
} }
logger.Info("Installing currency code", zap.String("iso_code", isoChar), zap.String("id", id), zap.Int64("nominal", nominal)) logger.Info("Installing currency code",
zap.String("iso_code", isoChar), zap.String("id", id), zap.Int64("nominal", nominal),
)
byISO[isoChar] = info byISO[isoChar] = info
byID[id] = info byID[id] = info
@@ -546,6 +550,7 @@ func (d *dailyRates) find(id string) *dailyValute {
if d == nil { if d == nil {
return nil return nil
} }
for idx := range d.Valutes { for idx := range d.Valutes {
if strings.EqualFold(strings.TrimSpace(d.Valutes[idx].ID), id) { if strings.EqualFold(strings.TrimSpace(d.Valutes[idx].ID), id) {
return &d.Valutes[idx] return &d.Valutes[idx]
@@ -569,7 +574,9 @@ func (d *dynamicRates) find(id string, date time.Time) *dynamicRecord {
if d == nil { if d == nil {
return nil return nil
} }
target := date.Format("02.01.2006") target := date.Format("02.01.2006")
for idx := range d.Records { for idx := range d.Records {
rec := &d.Records[idx] rec := &d.Records[idx]
if !strings.EqualFold(strings.TrimSpace(rec.ID), id) { if !strings.EqualFold(strings.TrimSpace(rec.ID), id) {
@@ -663,7 +670,7 @@ func computePrice(value string, nominalStr string) (string, error) {
den := big.NewRat(nominal, 1) den := big.NewRat(nominal, 1)
price := new(big.Rat).Quo(r, den) price := new(big.Rat).Quo(r, den)
return price.FloatString(8), nil return price.FloatString(8), nil //nolint:mnd
} }
func formatSymbol(iso string, asOf *time.Time) string { func formatSymbol(iso string, asOf *time.Time) string {

View File

@@ -29,29 +29,36 @@ type coingeckoConnector struct {
const defaultCoinGeckoBaseURL = "https://api.coingecko.com/api/v3" const defaultCoinGeckoBaseURL = "https://api.coingecko.com/api/v3"
const ( const (
defaultDialTimeoutSeconds = 5 * time.Second defaultDialTimeout = 5 * time.Second
defaultDialKeepAliveSeconds = 30 * time.Second defaultDialKeepAlive = 30 * time.Second
defaultTLSHandshakeTimeoutSeconds = 5 * time.Second defaultTLSHandshakeTimeout = 5 * time.Second
defaultResponseHeaderTimeoutSeconds = 10 * time.Second defaultResponseHeaderTimeout = 10 * time.Second
defaultRequestTimeoutSeconds = 10 * time.Second defaultRequestTimeout = 10 * time.Second
) )
func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Connector, error) { const (
expectedSymbolParts = 2
tsToMillis = 1000
)
func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Connector, error) { //nolint:ireturn
baseURL := defaultCoinGeckoBaseURL baseURL := defaultCoinGeckoBaseURL
provider := strings.ToLower(mmodel.DriverCoinGecko.String()) provider := strings.ToLower(mmodel.DriverCoinGecko.String())
dialTimeout := defaultDialTimeoutSeconds dialTimeout := defaultDialTimeout
dialKeepAlive := defaultDialKeepAliveSeconds dialKeepAlive := defaultDialKeepAlive
tlsHandshakeTimeout := defaultTLSHandshakeTimeoutSeconds tlsHandshakeTimeout := defaultTLSHandshakeTimeout
responseHeaderTimeout := defaultResponseHeaderTimeoutSeconds responseHeaderTimeout := defaultResponseHeaderTimeout
requestTimeout := defaultRequestTimeoutSeconds requestTimeout := defaultRequestTimeout
if settings != nil { if settings != nil {
if value, ok := settings["base_url"].(string); ok && strings.TrimSpace(value) != "" { if value, ok := settings["base_url"].(string); ok && strings.TrimSpace(value) != "" {
baseURL = strings.TrimSpace(value) baseURL = strings.TrimSpace(value)
} }
if value, ok := settings["provider"].(string); ok && strings.TrimSpace(value) != "" { if value, ok := settings["provider"].(string); ok && strings.TrimSpace(value) != "" {
provider = strings.TrimSpace(value) provider = strings.TrimSpace(value)
} }
dialTimeout = common.DurationSetting(settings, "dial_timeout_seconds", dialTimeout) dialTimeout = common.DurationSetting(settings, "dial_timeout_seconds", dialTimeout)
dialKeepAlive = common.DurationSetting(settings, "dial_keep_alive_seconds", dialKeepAlive) dialKeepAlive = common.DurationSetting(settings, "dial_keep_alive_seconds", dialKeepAlive)
tlsHandshakeTimeout = common.DurationSetting(settings, "tls_handshake_timeout_seconds", tlsHandshakeTimeout) tlsHandshakeTimeout = common.DurationSetting(settings, "tls_handshake_timeout_seconds", tlsHandshakeTimeout)
@@ -88,6 +95,7 @@ func (c *coingeckoConnector) ID() mmodel.Driver {
return c.id return c.id
} }
//nolint:cyclop,funlen
func (c *coingeckoConnector) FetchTicker(ctx context.Context, symbol string) (*mmodel.Ticker, error) { func (c *coingeckoConnector) FetchTicker(ctx context.Context, symbol string) (*mmodel.Ticker, error) {
coinID, vsCurrency, err := parseSymbol(symbol) coinID, vsCurrency, err := parseSymbol(symbol)
if err != nil { if err != nil {
@@ -98,6 +106,7 @@ func (c *coingeckoConnector) FetchTicker(ctx context.Context, symbol string) (*m
if err != nil { if err != nil {
return nil, merrors.InternalWrap(err, "coingecko: parse base url") return nil, merrors.InternalWrap(err, "coingecko: parse base url")
} }
endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/simple/price" endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/simple/price"
query := endpoint.Query() query := endpoint.Query()
query.Set("ids", coinID) query.Set("ids", coinID)
@@ -113,44 +122,51 @@ func (c *coingeckoConnector) FetchTicker(ctx context.Context, symbol string) (*m
resp, err := c.client.Do(req) resp, err := c.client.Do(req)
if err != nil { if err != nil {
c.logger.Warn("CoinGecko request failed", zap.String("symbol", symbol), zap.Error(err)) c.logger.Warn("CoinGecko request failed", zap.String("symbol", symbol), zap.Error(err))
return nil, merrors.InternalWrap(err, "coingecko: request failed") return nil, merrors.InternalWrap(err, "coingecko: request failed")
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
c.logger.Warn("CoinGecko returned non-OK status", zap.String("symbol", symbol), zap.Int("status", resp.StatusCode)) c.logger.Warn("CoinGecko returned non-OK status", zap.String("symbol", symbol), zap.Int("status", resp.StatusCode))
return nil, merrors.Internal("coingecko: unexpected status " + strconv.Itoa(resp.StatusCode)) return nil, merrors.Internal("coingecko: unexpected status " + strconv.Itoa(resp.StatusCode))
} }
decoder := json.NewDecoder(resp.Body) decoder := json.NewDecoder(resp.Body)
decoder.UseNumber() decoder.UseNumber()
var payload map[string]map[string]interface{} var payload map[string]map[string]any
if err := decoder.Decode(&payload); err != nil {
c.logger.Warn("CoinGecko decode failed", zap.String("symbol", symbol), zap.Error(err)) decodeErr := decoder.Decode(&payload)
return nil, merrors.InternalWrap(err, "coingecko: decode response") if decodeErr != nil {
c.logger.Warn("CoinGecko decode failed", zap.String("symbol", symbol), zap.Error(decodeErr))
return nil, merrors.InternalWrap(decodeErr, "coingecko: decode response")
} }
coinData, ok := payload[coinID] coinData, coinFound := payload[coinID]
if !ok { if !coinFound {
return nil, merrors.Internal("coingecko: coin id not found in response") return nil, merrors.Internal("coingecko: coin id not found in response")
} }
priceValue, ok := coinData[vsCurrency]
if !ok { priceValue, priceFound := coinData[vsCurrency]
if !priceFound {
return nil, merrors.Internal("coingecko: vs currency not found in response") return nil, merrors.Internal("coingecko: vs currency not found in response")
} }
price, ok := toFloat(priceValue) price, priceOk := toFloat(priceValue)
if !ok || price <= 0 { if !priceOk || price <= 0 {
return nil, merrors.Internal("coingecko: invalid price value in response") return nil, merrors.Internal("coingecko: invalid price value in response")
} }
priceStr := strconv.FormatFloat(price, 'f', -1, 64) priceStr := strconv.FormatFloat(price, 'f', -1, 64)
timestamp := time.Now().UnixMilli() timestamp := time.Now().UnixMilli()
if tsValue, ok := coinData["last_updated_at"]; ok {
if tsFloat, ok := toFloat(tsValue); ok && tsFloat > 0 { if tsValue, tsFound := coinData["last_updated_at"]; tsFound {
tsMillis := int64(tsFloat * 1000) if tsFloat, tsOk := toFloat(tsValue); tsOk && tsFloat > 0 {
tsMillis := int64(tsFloat * tsToMillis)
if tsMillis > 0 { if tsMillis > 0 {
timestamp = tsMillis timestamp = tsMillis
} }
@@ -179,14 +195,16 @@ func parseSymbol(symbol string) (string, string, error) {
case ':', '/', '-', '_': case ':', '/', '-', '_':
return true return true
} }
return false return false
}) })
if len(parts) != 2 { if len(parts) != expectedSymbolParts {
return "", "", merrors.InvalidArgument("coingecko: symbol must be <coin_id>/<vs_currency>", "symbol") return "", "", merrors.InvalidArgument("coingecko: symbol must be <coin_id>/<vs_currency>", "symbol")
} }
coinID := strings.TrimSpace(parts[0]) coinID := strings.TrimSpace(parts[0])
vsCurrency := strings.TrimSpace(parts[1]) vsCurrency := strings.TrimSpace(parts[1])
if coinID == "" || vsCurrency == "" { if coinID == "" || vsCurrency == "" {
return "", "", merrors.InvalidArgument("coingecko: symbol contains empty segments", "symbol") return "", "", merrors.InvalidArgument("coingecko: symbol contains empty segments", "symbol")
@@ -195,28 +213,31 @@ func parseSymbol(symbol string) (string, string, error) {
return coinID, vsCurrency, nil return coinID, vsCurrency, nil
} }
func toFloat(value interface{}) (float64, bool) { func toFloat(value any) (float64, bool) {
switch v := value.(type) { switch val := value.(type) {
case json.Number: case json.Number:
f, err := v.Float64() f, err := val.Float64()
if err != nil { if err != nil {
return 0, false return 0, false
} }
return f, true return f, true
case float64: case float64:
return v, true return val, true
case float32: case float32:
return float64(v), true return float64(val), true
case int: case int:
return float64(v), true return float64(val), true
case int64: case int64:
return float64(v), true return float64(val), true
case uint64: case uint64:
return float64(v), true return float64(val), true
case string: case string:
if parsed, err := strconv.ParseFloat(v, 64); err == nil { parsed, parseErr := strconv.ParseFloat(val, 64)
if parseErr == nil {
return parsed, true return parsed, true
} }
} }
return 0, false return 0, false
} }

View File

@@ -1,4 +1,4 @@
package common package common //nolint:revive // package provides shared market connector utilities
import ( import (
"strconv" "strconv"
@@ -8,39 +8,46 @@ import (
) )
// DurationSetting reads a positive duration override from settings or returns def when the value is missing or invalid. // DurationSetting reads a positive duration override from settings or returns def when the value is missing or invalid.
//
//nolint:cyclop
func DurationSetting(settings model.SettingsT, key string, def time.Duration) time.Duration { func DurationSetting(settings model.SettingsT, key string, def time.Duration) time.Duration {
if settings == nil { if settings == nil {
return def return def
} }
value, ok := settings[key] value, ok := settings[key]
if !ok { if !ok {
return def return def
} }
switch v := value.(type) { switch val := value.(type) {
case time.Duration: case time.Duration:
if v > 0 { if val > 0 {
return v return val
} }
case int: case int:
if v > 0 { if val > 0 {
return time.Duration(v) * time.Second return time.Duration(val) * time.Second
} }
case int64: case int64:
if v > 0 { if val > 0 {
return time.Duration(v) * time.Second return time.Duration(val) * time.Second
} }
case float64: case float64:
if v > 0 { if val > 0 {
return time.Duration(v * float64(time.Second)) return time.Duration(val * float64(time.Second))
} }
case string: case string:
if parsed, err := time.ParseDuration(v); err == nil && parsed > 0 { parsed, parseErr := time.ParseDuration(val)
if parseErr == nil && parsed > 0 {
return parsed return parsed
} }
if seconds, err := strconv.ParseFloat(v, 64); err == nil && seconds > 0 {
seconds, floatErr := strconv.ParseFloat(val, 64)
if floatErr == nil && seconds > 0 {
return time.Duration(seconds * float64(time.Second)) return time.Duration(seconds * float64(time.Second))
} }
} }
return def return def
} }

View File

@@ -24,16 +24,19 @@ const (
) )
type Server interface { type Server interface {
SetStatus(health.ServiceStatus) SetStatus(status health.ServiceStatus)
Close(context.Context) Close(ctx context.Context)
} }
//nolint:ireturn
func NewServer(logger mlogger.Logger, cfg *config.MetricsConfig) (Server, error) { func NewServer(logger mlogger.Logger, cfg *config.MetricsConfig) (Server, error) {
if logger == nil { if logger == nil {
return nil, merrors.InvalidArgument("metrics: logger is nil") return nil, merrors.InvalidArgument("metrics: logger is nil")
} }
if cfg == nil || !cfg.Enabled { if cfg == nil || !cfg.Enabled {
logger.Debug("Metrics disabled; using noop server") logger.Debug("Metrics disabled; using noop server")
return noopServer{}, nil return noopServer{}, nil
} }
@@ -47,7 +50,9 @@ func NewServer(logger mlogger.Logger, cfg *config.MetricsConfig) (Server, error)
router.Handle("/metrics", promhttp.Handler()) router.Handle("/metrics", promhttp.Handler())
var healthRouter routers.Health var healthRouter routers.Health
if hr, err := routers.NewHealthRouter(metricsLogger, router, ""); err != nil {
hr, err := routers.NewHealthRouter(metricsLogger, router, "")
if err != nil {
metricsLogger.Warn("Failed to initialise health router", zap.Error(err)) metricsLogger.Warn("Failed to initialise health router", zap.Error(err))
} else { } else {
hr.SetStatus(health.SSStarting) hr.SetStatus(health.SSStarting)
@@ -60,7 +65,7 @@ func NewServer(logger mlogger.Logger, cfg *config.MetricsConfig) (Server, error)
ReadHeaderTimeout: readHeaderTimeout, ReadHeaderTimeout: readHeaderTimeout,
} }
ms := &httpServerWrapper{ wrapper := &httpServerWrapper{
logger: metricsLogger, logger: metricsLogger,
server: httpServer, server: httpServer,
health: healthRouter, health: healthRouter,
@@ -69,7 +74,9 @@ func NewServer(logger mlogger.Logger, cfg *config.MetricsConfig) (Server, error)
go func() { go func() {
metricsLogger.Info("Prometheus endpoint listening", zap.String("address", address)) metricsLogger.Info("Prometheus endpoint listening", zap.String("address", address))
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
err := httpServer.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
metricsLogger.Error("Prometheus endpoint stopped unexpectedly", zap.Error(err)) metricsLogger.Error("Prometheus endpoint stopped unexpectedly", zap.Error(err))
if healthRouter != nil { if healthRouter != nil {
healthRouter.SetStatus(health.SSTerminating) healthRouter.SetStatus(health.SSTerminating)
@@ -77,7 +84,7 @@ func NewServer(logger mlogger.Logger, cfg *config.MetricsConfig) (Server, error)
} }
}() }()
return ms, nil return wrapper, nil
} }
type httpServerWrapper struct { type httpServerWrapper struct {
@@ -91,6 +98,7 @@ func (s *httpServerWrapper) SetStatus(status health.ServiceStatus) {
if s == nil || s.health == nil { if s == nil || s.health == nil {
return return
} }
s.logger.Debug("Updating metrics health status", zap.String("status", string(status))) s.logger.Debug("Updating metrics health status", zap.String("status", string(status)))
s.health.SetStatus(status) s.health.SetStatus(status)
} }
@@ -110,10 +118,12 @@ func (s *httpServerWrapper) Close(ctx context.Context) {
return return
} }
//nolint:contextcheck
shutdownCtx := ctx shutdownCtx := ctx
if shutdownCtx == nil { if shutdownCtx == nil {
shutdownCtx = context.Background() shutdownCtx = context.Background()
} }
if s.timeout > 0 { if s.timeout > 0 {
var cancel context.CancelFunc var cancel context.CancelFunc
shutdownCtx, cancel = context.WithTimeout(shutdownCtx, s.timeout) shutdownCtx, cancel = context.WithTimeout(shutdownCtx, s.timeout)
@@ -129,6 +139,6 @@ func (s *httpServerWrapper) Close(ctx context.Context) {
type noopServer struct{} type noopServer struct{}
func (noopServer) SetStatus(health.ServiceStatus) {} func (noopServer) SetStatus(_ health.ServiceStatus) {}
func (noopServer) Close(context.Context) {} func (noopServer) Close(_ context.Context) {}

View File

@@ -26,16 +26,18 @@ func main() {
flag.Parse() flag.Parse()
logger := lf.NewLogger(*debugFlag).Named("fx_ingestor") logger := lf.NewLogger(*debugFlag).Named("fx_ingestor")
logger = logger.With(zap.String("instance_id", discovery.InstanceID()))
defer logger.Sync()
av := appversion.Create() logger = logger.With(zap.String("instance_id", discovery.InstanceID()))
defer logger.Sync() //nolint:errcheck
appVersion := appversion.Create()
if *versionFlag { if *versionFlag {
fmt.Fprintln(os.Stdout, av.Print()) fmt.Fprintln(os.Stdout, appVersion.Print())
return return
} }
logger.Info(fmt.Sprintf("Starting %s", av.Program()), zap.String("version", av.Info())) logger.Info("Starting "+appVersion.Program(), zap.String("version", appVersion.Info()))
ctx, cancel := signalctx.WithSignals(context.Background(), os.Interrupt, syscall.SIGTERM) ctx, cancel := signalctx.WithSignals(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel() defer cancel()
@@ -47,8 +49,10 @@ func main() {
if err := application.Run(ctx); err != nil { if err := application.Run(ctx); err != nil {
if errors.Is(err, context.Canceled) { if errors.Is(err, context.Canceled) {
logger.Info("FX ingestor stopped") logger.Info("FX ingestor stopped")
return return
} }
logger.Error("Ingestor terminated with error", zap.Error(err)) logger.Error("Ingestor terminated with error", zap.Error(err))
} }
} }

View File

@@ -22,7 +22,7 @@ require (
require ( require (
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260208002143-2551aa251e34 // indirect github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260212005555-3a7e5700f354 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect

View File

@@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260208002143-2551aa251e34 h1:AyAPL6pTcPPpfZsNtOTFhxyOokKBLnrbbaV42g6Z9v0= github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260212005555-3a7e5700f354 h1:BgaMXBpcqcW74afzqI3iKo07K3tC+VuyWU3/FIvLlNI=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260208002143-2551aa251e34/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260212005555-3a7e5700f354/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=

View File

@@ -199,16 +199,22 @@ func (s *Service) startDiscoveryAnnouncers() {
} }
version := appversion.Create().Short() version := appversion.Create().Short()
for _, network := range s.networks { for _, network := range s.networks {
currencies := []string{shared.NativeCurrency(network)} currencies := []discovery.CurrencyAnnouncement{{
Currency: shared.NativeCurrency(network),
Network: string(network.Name),
}}
for _, token := range network.TokenConfigs { for _, token := range network.TokenConfigs {
if token.Symbol != "" { if token.Symbol != "" {
currencies = append(currencies, token.Symbol) currencies = append(currencies, discovery.CurrencyAnnouncement{
Currency: token.Symbol,
Network: string(network.Name),
ContractAddress: token.ContractAddress,
})
} }
} }
announce := discovery.Announcement{ announce := discovery.Announcement{
Service: "CRYPTO_RAIL_GATEWAY", Service: "CRYPTO_RAIL_GATEWAY",
Rail: "CRYPTO", Rail: "CRYPTO",
Network: string(network.Name),
Operations: []string{"balance.read", "payin.crypto", "payout.crypto", "fee.send", "observe.confirm"}, Operations: []string{"balance.read", "payin.crypto", "payout.crypto", "fee.send", "observe.confirm"},
Currencies: currencies, Currencies: currencies,
InvokeURI: s.invokeURI, InvokeURI: s.invokeURI,

View File

@@ -13,7 +13,7 @@ This service now supports Monetix “payout by card”.
- `MONETIX_PROJECT_ID` integer project ID - `MONETIX_PROJECT_ID` integer project ID
- `MONETIX_SECRET_KEY` signature secret - `MONETIX_SECRET_KEY` signature secret
- Optional: `allowed_currencies`, `require_customer_address`, `request_timeout_seconds` - Optional: `allowed_currencies`, `require_customer_address`, `request_timeout_seconds`
- Gateway descriptor: `gateway.id`, optional `gateway.currencies`, `gateway.limits` - Gateway descriptor: `gateway.id`, optional `gateway.currencies`, `gateway.limits` (for per-payout minimum use `gateway.limits.per_tx_min_amount`)
- Callback server: `MNTX_GATEWAY_HTTP_PORT` (exposed as 8084), `http.callback.path`, optional `allowed_cidrs` - Callback server: `MNTX_GATEWAY_HTTP_PORT` (exposed as 8084), `http.callback.path`, optional `allowed_cidrs`
## Outbound request (CreateCardPayout) ## Outbound request (CreateCardPayout)

View File

@@ -51,7 +51,7 @@ gateway:
network: "MIR" network: "MIR"
currencies: ["RUB"] currencies: ["RUB"]
limits: limits:
min_amount: "0" per_tx_min_amount: "0"
http: http:
callback: callback:

View File

@@ -51,7 +51,7 @@ gateway:
network: "MIR" network: "MIR"
currencies: ["RUB"] currencies: ["RUB"]
limits: limits:
min_amount: "0" per_tx_min_amount: "100.00"
http: http:
callback: callback:

View File

@@ -4,9 +4,11 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"strings" "strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/gateway/mntx/internal/service/monetix" "github.com/tech/sendico/gateway/mntx/internal/service/monetix"
"github.com/tech/sendico/gateway/mntx/storage" "github.com/tech/sendico/gateway/mntx/storage"
"github.com/tech/sendico/gateway/mntx/storage/model" "github.com/tech/sendico/gateway/mntx/storage/model"
@@ -15,6 +17,7 @@ import (
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
msg "github.com/tech/sendico/pkg/messaging" msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap" "go.uber.org/zap"
@@ -27,6 +30,9 @@ type cardPayoutProcessor struct {
store storage.Repository store storage.Repository
httpClient *http.Client httpClient *http.Client
producer msg.Producer producer msg.Producer
perTxMinAmountMinor int64
perTxMinAmountMinorByCurrency map[string]int64
} }
func mergePayoutStateWithExisting(state, existing *model.CardPayout) { func mergePayoutStateWithExisting(state, existing *model.CardPayout) {
@@ -118,6 +124,90 @@ func newCardPayoutProcessor(
} }
} }
func (p *cardPayoutProcessor) applyGatewayDescriptor(descriptor *gatewayv1.GatewayInstanceDescriptor) {
if p == nil {
return
}
minAmountMinor, perCurrency := perTxMinAmountPolicy(descriptor)
p.perTxMinAmountMinor = minAmountMinor
p.perTxMinAmountMinorByCurrency = perCurrency
}
func perTxMinAmountPolicy(descriptor *gatewayv1.GatewayInstanceDescriptor) (int64, map[string]int64) {
if descriptor == nil || descriptor.GetLimits() == nil {
return 0, nil
}
limits := descriptor.GetLimits()
globalMin, _ := decimalAmountToMinor(firstNonEmpty(limits.GetPerTxMinAmount(), limits.GetMinAmount()))
perCurrency := map[string]int64{}
for currency, override := range limits.GetCurrencyLimits() {
if override == nil {
continue
}
minor, ok := decimalAmountToMinor(override.GetMinAmount())
if !ok {
continue
}
code := strings.ToUpper(strings.TrimSpace(currency))
if code == "" {
continue
}
perCurrency[code] = minor
}
if len(perCurrency) == 0 {
perCurrency = nil
}
return globalMin, perCurrency
}
func decimalAmountToMinor(raw string) (int64, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, false
}
value, err := decimal.NewFromString(raw)
if err != nil || !value.IsPositive() {
return 0, false
}
minor := value.Mul(decimal.NewFromInt(100)).Ceil().IntPart()
if minor <= 0 {
return 0, false
}
return minor, true
}
func (p *cardPayoutProcessor) validatePerTxMinimum(amountMinor int64, currency string) error {
if p == nil {
return nil
}
minAmountMinor := p.perTxMinimum(currency)
if minAmountMinor <= 0 || amountMinor >= minAmountMinor {
return nil
}
return newPayoutError("amount_below_minimum", merrors.InvalidArgument(
fmt.Sprintf("amount_minor must be at least %d", minAmountMinor),
"amount_minor",
))
}
func (p *cardPayoutProcessor) perTxMinimum(currency string) int64 {
if p == nil {
return 0
}
minAmountMinor := p.perTxMinAmountMinor
if len(p.perTxMinAmountMinorByCurrency) == 0 {
return minAmountMinor
}
code := strings.ToUpper(strings.TrimSpace(currency))
if code == "" {
return minAmountMinor
}
if override, ok := p.perTxMinAmountMinorByCurrency[code]; ok && override > 0 {
return override
}
return minAmountMinor
}
func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) { func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
if p == nil { if p == nil {
return nil, merrors.Internal("card payout processor not initialised") return nil, merrors.Internal("card payout processor not initialised")
@@ -147,6 +237,17 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
) )
return nil, err return nil, err
} }
if err := p.validatePerTxMinimum(req.GetAmountMinor(), req.GetCurrency()); err != nil {
p.logger.Warn("Card payout amount below configured minimum",
zap.String("payout_id", req.GetPayoutId()),
zap.String("customer_id", req.GetCustomerId()),
zap.Int64("amount_minor", req.GetAmountMinor()),
zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))),
zap.Int64("configured_min_amount_minor", p.perTxMinimum(req.GetCurrency())),
zap.Error(err),
)
return nil, err
}
projectID, err := p.resolveProjectID(req.GetProjectId(), "payout_id", req.GetPayoutId()) projectID, err := p.resolveProjectID(req.GetProjectId(), "payout_id", req.GetPayoutId())
if err != nil { if err != nil {
@@ -257,6 +358,17 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
) )
return nil, err return nil, err
} }
if err := p.validatePerTxMinimum(req.GetAmountMinor(), req.GetCurrency()); err != nil {
p.logger.Warn("Card token payout amount below configured minimum",
zap.String("payout_id", req.GetPayoutId()),
zap.String("customer_id", req.GetCustomerId()),
zap.Int64("amount_minor", req.GetAmountMinor()),
zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))),
zap.Int64("configured_min_amount_minor", p.perTxMinimum(req.GetCurrency())),
zap.Error(err),
)
return nil, err
}
projectID, err := p.resolveProjectID(req.GetProjectId(), "payout_id", req.GetPayoutId()) projectID, err := p.resolveProjectID(req.GetProjectId(), "payout_id", req.GetPayoutId())
if err != nil { if err != nil {

View File

@@ -14,6 +14,7 @@ import (
"github.com/tech/sendico/gateway/mntx/storage/model" "github.com/tech/sendico/gateway/mntx/storage/model"
clockpkg "github.com/tech/sendico/pkg/clock" clockpkg "github.com/tech/sendico/pkg/clock"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -119,6 +120,63 @@ func TestCardPayoutProcessor_Submit_MissingConfig(t *testing.T) {
} }
} }
func TestCardPayoutProcessor_Submit_RejectsAmountBelowConfiguredMinimum(t *testing.T) {
cfg := monetix.Config{
BaseURL: "https://monetix.test",
SecretKey: "secret",
AllowedCurrencies: []string{"RUB"},
}
repo := newMockRepository()
processor := newCardPayoutProcessor(
zap.NewNop(),
cfg,
staticClock{now: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)},
repo,
&http.Client{},
nil,
)
processor.applyGatewayDescriptor(&gatewayv1.GatewayInstanceDescriptor{
Limits: &gatewayv1.Limits{
PerTxMinAmount: "20.00",
},
})
req := validCardPayoutRequest() // 15.00 RUB
_, err := processor.Submit(context.Background(), req)
requireReason(t, err, "amount_below_minimum")
}
func TestCardPayoutProcessor_SubmitToken_RejectsAmountBelowCurrencyMinimum(t *testing.T) {
cfg := monetix.Config{
BaseURL: "https://monetix.test",
SecretKey: "secret",
AllowedCurrencies: []string{"USD"},
}
repo := newMockRepository()
processor := newCardPayoutProcessor(
zap.NewNop(),
cfg,
staticClock{now: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)},
repo,
&http.Client{},
nil,
)
processor.applyGatewayDescriptor(&gatewayv1.GatewayInstanceDescriptor{
Limits: &gatewayv1.Limits{
PerTxMinAmount: "20.00",
CurrencyLimits: map[string]*gatewayv1.LimitsOverride{
"USD": {MinAmount: "30.00"},
},
},
})
req := validCardTokenPayoutRequest() // 25.00 USD
_, err := processor.SubmitToken(context.Background(), req)
requireReason(t, err, "amount_below_minimum")
}
func TestCardPayoutProcessor_ProcessCallback(t *testing.T) { func TestCardPayoutProcessor_ProcessCallback(t *testing.T) {
cfg := monetix.Config{ cfg := monetix.Config{
SecretKey: "secret", SecretKey: "secret",

View File

@@ -85,6 +85,7 @@ func NewService(logger mlogger.Logger, opts ...Option) *Service {
} }
svc.card = newCardPayoutProcessor(svc.logger, svc.config, svc.clock, svc.storage, svc.httpClient, svc.producer) svc.card = newCardPayoutProcessor(svc.logger, svc.config, svc.clock, svc.storage, svc.httpClient, svc.producer)
svc.card.applyGatewayDescriptor(svc.gatewayDescriptor)
svc.startDiscoveryAnnouncer() svc.startDiscoveryAnnouncer()
return svc return svc
@@ -149,44 +150,132 @@ func (s *Service) startDiscoveryAnnouncer() {
if id := strings.TrimSpace(s.gatewayDescriptor.GetId()); id != "" { if id := strings.TrimSpace(s.gatewayDescriptor.GetId()); id != "" {
announce.ID = id announce.ID = id
} }
announce.Network = strings.TrimSpace(s.gatewayDescriptor.GetNetwork()) announce.Currencies = currenciesFromDescriptor(s.gatewayDescriptor)
announce.Currencies = append([]string(nil), s.gatewayDescriptor.GetCurrencies()...)
announce.Limits = limitsFromDescriptor(s.gatewayDescriptor.GetLimits())
} }
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.MntxGateway), announce) s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.MntxGateway), announce)
s.announcer.Start() s.announcer.Start()
} }
func limitsFromDescriptor(src *gatewayv1.Limits) *discovery.Limits { func currenciesFromDescriptor(src *gatewayv1.GatewayInstanceDescriptor) []discovery.CurrencyAnnouncement {
if src == nil { if src == nil {
return nil return nil
} }
limits := &discovery.Limits{ network := strings.TrimSpace(src.GetNetwork())
MinAmount: strings.TrimSpace(src.GetMinAmount()), limitsCfg := src.GetLimits()
MaxAmount: strings.TrimSpace(src.GetMaxAmount()), values := src.GetCurrencies()
VolumeLimit: map[string]string{}, if len(values) == 0 {
VelocityLimit: map[string]int{}, return nil
} }
for key, value := range src.GetVolumeLimit() { seen := map[string]bool{}
k := strings.TrimSpace(key) result := make([]discovery.CurrencyAnnouncement, 0, len(values))
v := strings.TrimSpace(value) for _, value := range values {
if k == "" || v == "" { currency := strings.ToUpper(strings.TrimSpace(value))
if currency == "" || seen[currency] {
continue continue
} }
limits.VolumeLimit[k] = v seen[currency] = true
result = append(result, discovery.CurrencyAnnouncement{
Currency: currency,
Network: network,
Limits: currencyLimitsFromDescriptor(limitsCfg, currency),
})
} }
for key, value := range src.GetVelocityLimit() { if len(result) == 0 {
k := strings.TrimSpace(key) return nil
if k == "" { }
return result
}
func currencyLimitsFromDescriptor(src *gatewayv1.Limits, currency string) *discovery.CurrencyLimits {
if src == nil {
return nil
}
amountMin := firstNonEmpty(src.GetPerTxMinAmount(), src.GetMinAmount())
amountMax := firstNonEmpty(src.GetPerTxMaxAmount(), src.GetMaxAmount())
limits := &discovery.CurrencyLimits{}
if amountMin != "" || amountMax != "" {
limits.Amount = &discovery.CurrencyAmount{
Min: amountMin,
Max: amountMax,
}
}
running := &discovery.CurrencyRunningLimits{}
for bucket, max := range src.GetVolumeLimit() {
bucket = strings.TrimSpace(bucket)
max = strings.TrimSpace(max)
if bucket == "" || max == "" {
continue continue
} }
limits.VelocityLimit[k] = int(value) running.Volume = append(running.Volume, discovery.VolumeLimit{
Window: discovery.Window{
Raw: bucket,
Named: bucket,
},
Max: max,
})
} }
if len(limits.VolumeLimit) == 0 { for bucket, max := range src.GetVelocityLimit() {
limits.VolumeLimit = nil bucket = strings.TrimSpace(bucket)
if bucket == "" || max <= 0 {
continue
} }
if len(limits.VelocityLimit) == 0 { running.Velocity = append(running.Velocity, discovery.VelocityLimit{
limits.VelocityLimit = nil Window: discovery.Window{
Raw: bucket,
Named: bucket,
},
Max: int(max),
})
}
if override := src.GetCurrencyLimits()[strings.ToUpper(strings.TrimSpace(currency))]; override != nil {
if min := strings.TrimSpace(override.GetMinAmount()); min != "" {
if limits.Amount == nil {
limits.Amount = &discovery.CurrencyAmount{}
}
limits.Amount.Min = min
}
if max := strings.TrimSpace(override.GetMaxAmount()); max != "" {
if limits.Amount == nil {
limits.Amount = &discovery.CurrencyAmount{}
}
limits.Amount.Max = max
}
if maxVolume := strings.TrimSpace(override.GetMaxVolume()); maxVolume != "" {
running.Volume = append(running.Volume, discovery.VolumeLimit{
Window: discovery.Window{
Raw: "default",
Named: "default",
},
Max: maxVolume,
})
}
if maxOps := int(override.GetMaxOps()); maxOps > 0 {
running.Velocity = append(running.Velocity, discovery.VelocityLimit{
Window: discovery.Window{
Raw: "default",
Named: "default",
},
Max: maxOps,
})
}
}
if len(running.Volume) > 0 || len(running.Velocity) > 0 {
limits.Running = running
}
if limits.Amount == nil && limits.Running == nil {
return nil
} }
return limits return limits
} }
func firstNonEmpty(values ...string) string {
for _, value := range values {
clean := strings.TrimSpace(value)
if clean != "" {
return clean
}
}
return ""
}

View File

@@ -24,7 +24,7 @@ require (
require ( require (
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260208002143-2551aa251e34 // indirect github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260212005555-3a7e5700f354 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect

View File

@@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260208002143-2551aa251e34 h1:AyAPL6pTcPPpfZsNtOTFhxyOokKBLnrbbaV42g6Z9v0= github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260212005555-3a7e5700f354 h1:BgaMXBpcqcW74afzqI3iKo07K3tC+VuyWU3/FIvLlNI=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260208002143-2551aa251e34/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260212005555-3a7e5700f354/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=

View File

@@ -203,16 +203,22 @@ func (s *Service) startDiscoveryAnnouncers() {
} }
version := appversion.Create().Short() version := appversion.Create().Short()
for _, network := range s.networks { for _, network := range s.networks {
currencies := []string{shared.NativeCurrency(network)} currencies := []discovery.CurrencyAnnouncement{{
Currency: shared.NativeCurrency(network),
Network: network.Name.String(),
}}
for _, token := range network.TokenConfigs { for _, token := range network.TokenConfigs {
if token.Symbol != "" { if token.Symbol != "" {
currencies = append(currencies, token.Symbol) currencies = append(currencies, discovery.CurrencyAnnouncement{
Currency: token.Symbol,
Network: network.Name.String(),
ContractAddress: token.ContractAddress,
})
} }
} }
announce := discovery.Announcement{ announce := discovery.Announcement{
Service: "CRYPTO_RAIL_GATEWAY", Service: "CRYPTO_RAIL_GATEWAY",
Rail: "CRYPTO", Rail: "CRYPTO",
Network: network.Name.String(),
Operations: []string{"balance.read", "payin.crypto", "payout.crypto", "fee.send", "observe.confirm"}, Operations: []string{"balance.read", "payin.crypto", "payout.crypto", "fee.send", "observe.confirm"},
Currencies: currencies, Currencies: currencies,
InvokeURI: s.invokeURI, InvokeURI: s.invokeURI,

View File

@@ -51,7 +51,7 @@ func (r *discoveryGatewayRegistry) List(_ context.Context) ([]*model.GatewayInst
InvokeURI: strings.TrimSpace(entry.InvokeURI), InvokeURI: strings.TrimSpace(entry.InvokeURI),
Currencies: normalizeCurrencies(entry.Currencies), Currencies: normalizeCurrencies(entry.Currencies),
Capabilities: capabilitiesFromOps(entry.Operations), Capabilities: capabilitiesFromOps(entry.Operations),
Limits: limitsFromDiscovery(entry.Limits), Limits: limitsFromDiscovery(entry.Limits, entry.CurrencyMeta),
Version: entry.Version, Version: entry.Version,
IsEnabled: entry.Healthy, IsEnabled: entry.Healthy,
}) })
@@ -102,16 +102,15 @@ func capabilitiesFromOps(ops []string) model.RailCapabilities {
return cap return cap
} }
func limitsFromDiscovery(src *discovery.Limits) model.Limits { func limitsFromDiscovery(src *discovery.Limits, currencies []discovery.CurrencyAnnouncement) model.Limits {
if src == nil {
return model.Limits{}
}
limits := model.Limits{ limits := model.Limits{
MinAmount: strings.TrimSpace(src.MinAmount),
MaxAmount: strings.TrimSpace(src.MaxAmount),
VolumeLimit: map[string]string{}, VolumeLimit: map[string]string{},
VelocityLimit: map[string]int{}, VelocityLimit: map[string]int{},
CurrencyLimits: map[string]model.LimitsOverride{},
} }
if src != nil {
limits.MinAmount = strings.TrimSpace(src.MinAmount)
limits.MaxAmount = strings.TrimSpace(src.MaxAmount)
for key, value := range src.VolumeLimit { for key, value := range src.VolumeLimit {
k := strings.TrimSpace(key) k := strings.TrimSpace(key)
v := strings.TrimSpace(value) v := strings.TrimSpace(value)
@@ -127,11 +126,87 @@ func limitsFromDiscovery(src *discovery.Limits) model.Limits {
} }
limits.VelocityLimit[k] = value limits.VelocityLimit[k] = value
} }
}
applyCurrencyTransferLimits(&limits, currencies)
if len(limits.VolumeLimit) == 0 { if len(limits.VolumeLimit) == 0 {
limits.VolumeLimit = nil limits.VolumeLimit = nil
} }
if len(limits.VelocityLimit) == 0 { if len(limits.VelocityLimit) == 0 {
limits.VelocityLimit = nil limits.VelocityLimit = nil
} }
if len(limits.CurrencyLimits) == 0 {
limits.CurrencyLimits = nil
}
return limits return limits
} }
func applyCurrencyTransferLimits(dst *model.Limits, currencies []discovery.CurrencyAnnouncement) {
if dst == nil || len(currencies) == 0 {
return
}
var (
commonMin string
commonMax string
commonMinInit bool
commonMaxInit bool
commonMinConsistent = true
commonMaxConsistent = true
)
for _, currency := range currencies {
code := strings.ToUpper(strings.TrimSpace(currency.Currency))
if code == "" || currency.Limits == nil || currency.Limits.Amount == nil {
commonMinConsistent = false
commonMaxConsistent = false
continue
}
min := strings.TrimSpace(currency.Limits.Amount.Min)
max := strings.TrimSpace(currency.Limits.Amount.Max)
if min != "" || max != "" {
override := dst.CurrencyLimits[code]
if min != "" {
override.MinAmount = min
}
if max != "" {
override.MaxAmount = max
}
if override.MinAmount != "" || override.MaxAmount != "" || override.MaxFee != "" || override.MaxOps > 0 || override.MaxVolume != "" {
dst.CurrencyLimits[code] = override
}
}
if min == "" {
commonMinConsistent = false
} else if !commonMinInit {
commonMin = min
commonMinInit = true
} else if commonMin != min {
commonMinConsistent = false
}
if max == "" {
commonMaxConsistent = false
} else if !commonMaxInit {
commonMax = max
commonMaxInit = true
} else if commonMax != max {
commonMaxConsistent = false
}
}
if commonMinInit && commonMinConsistent {
dst.PerTxMinAmount = firstDiscoveryLimitValue(dst.PerTxMinAmount, commonMin)
}
if commonMaxInit && commonMaxConsistent {
dst.PerTxMaxAmount = firstDiscoveryLimitValue(dst.PerTxMaxAmount, commonMax)
}
}
func firstDiscoveryLimitValue(primary, fallback string) string {
primary = strings.TrimSpace(primary)
if primary != "" {
return primary
}
return strings.TrimSpace(fallback)
}

View File

@@ -0,0 +1,62 @@
package orchestrator
import (
"testing"
"github.com/tech/sendico/pkg/discovery"
)
func TestLimitsFromDiscovery_MapsPerTxMinimumFromCurrencyMeta(t *testing.T) {
limits := limitsFromDiscovery(nil, []discovery.CurrencyAnnouncement{
{
Currency: "RUB",
Limits: &discovery.CurrencyLimits{
Amount: &discovery.CurrencyAmount{
Min: "100.00",
Max: "10000.00",
},
},
},
})
if limits.PerTxMinAmount != "100.00" {
t.Fatalf("expected per tx min 100.00, got %q", limits.PerTxMinAmount)
}
if limits.PerTxMaxAmount != "10000.00" {
t.Fatalf("expected per tx max 10000.00, got %q", limits.PerTxMaxAmount)
}
override, ok := limits.CurrencyLimits["RUB"]
if !ok {
t.Fatalf("expected RUB currency override")
}
if override.MinAmount != "100.00" {
t.Fatalf("expected RUB min override 100.00, got %q", override.MinAmount)
}
}
func TestLimitsFromDiscovery_DropsCommonPerTxMinimumWhenCurrenciesDiffer(t *testing.T) {
limits := limitsFromDiscovery(nil, []discovery.CurrencyAnnouncement{
{
Currency: "USD",
Limits: &discovery.CurrencyLimits{
Amount: &discovery.CurrencyAmount{Min: "10.00"},
},
},
{
Currency: "EUR",
Limits: &discovery.CurrencyLimits{
Amount: &discovery.CurrencyAmount{Min: "20.00"},
},
},
})
if limits.PerTxMinAmount != "" {
t.Fatalf("expected empty common per tx min, got %q", limits.PerTxMinAmount)
}
if limits.CurrencyLimits["USD"].MinAmount != "10.00" {
t.Fatalf("expected USD min override 10.00, got %q", limits.CurrencyLimits["USD"].MinAmount)
}
if limits.CurrencyLimits["EUR"].MinAmount != "20.00" {
t.Fatalf("expected EUR min override 20.00, got %q", limits.CurrencyLimits["EUR"].MinAmount)
}
}

View File

@@ -55,6 +55,9 @@ func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestrator
} }
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
} }
if note := strings.TrimSpace(record.ExecutionNote); note != "" {
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, "quote_not_executable", merrors.InvalidArgument(note))
}
intents := record.Intents intents := record.Intents
quotes := record.Quotes quotes := record.Quotes
@@ -209,6 +212,8 @@ func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.code, qerr.err) return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.code, qerr.err)
case "quote_expired": case "quote_expired":
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.code, qerr.err) return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.code, qerr.err)
case "quote_not_executable":
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.code, qerr.err)
case "quote_intent_mismatch": case "quote_intent_mismatch":
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.err) return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.err)
default: default:

View File

@@ -123,6 +123,9 @@ func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInp
if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) { if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) {
return nil, nil, nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")} return nil, nil, nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")}
} }
if note := strings.TrimSpace(record.ExecutionNote); note != "" {
return nil, nil, nil, quoteResolutionError{code: "quote_not_executable", err: merrors.InvalidArgument(note)}
}
intent, err := recordIntentFromQuote(record) intent, err := recordIntentFromQuote(record)
if err != nil { if err != nil {
return nil, nil, nil, err return nil, nil, nil, err

View File

@@ -125,6 +125,38 @@ func TestResolvePaymentQuote_Expired(t *testing.T) {
} }
} }
func TestResolvePaymentQuote_NotExecutable(t *testing.T) {
org := bson.NewObjectID()
intent := &sharedv1.PaymentIntent{
Ref: "ref-1",
Amount: &moneyv1.Money{Currency: "USD", Amount: "1"},
SettlementCurrency: "USD",
}
record := &model.PaymentQuoteRecord{
QuoteRef: "q1",
Intent: intentFromProto(intent),
Quote: &model.PaymentQuoteSnapshot{},
ExecutionNote: "quote will not be executed: amount 1 USD below per-tx min limit 10",
ExpiresAt: time.Now().Add(time.Minute),
IdempotencyKey: "idem-1",
}
svc := &Service{
storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}},
clock: clockpkg.NewSystem(),
}
_, _, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
OrgRef: org.Hex(),
OrgID: org,
Meta: &sharedv1.RequestMeta{OrganizationRef: org.Hex()},
Intent: intent,
QuoteRef: "q1",
})
if qerr, ok := err.(quoteResolutionError); !ok || qerr.code != "quote_not_executable" {
t.Fatalf("expected quote_not_executable, got %v", err)
}
}
func TestResolvePaymentQuote_QuoteRefUsesStoredIntent(t *testing.T) { func TestResolvePaymentQuote_QuoteRefUsesStoredIntent(t *testing.T) {
org := bson.NewObjectID() org := bson.NewObjectID()
intent := &sharedv1.PaymentIntent{ intent := &sharedv1.PaymentIntent{

View File

@@ -51,7 +51,7 @@ func (r *discoveryGatewayRegistry) List(_ context.Context) ([]*model.GatewayInst
InvokeURI: strings.TrimSpace(entry.InvokeURI), InvokeURI: strings.TrimSpace(entry.InvokeURI),
Currencies: normalizeCurrencies(entry.Currencies), Currencies: normalizeCurrencies(entry.Currencies),
Capabilities: capabilitiesFromOps(entry.Operations), Capabilities: capabilitiesFromOps(entry.Operations),
Limits: limitsFromDiscovery(entry.Limits), Limits: limitsFromDiscovery(entry.Limits, entry.CurrencyMeta),
Version: entry.Version, Version: entry.Version,
IsEnabled: entry.Healthy, IsEnabled: entry.Healthy,
}) })
@@ -102,16 +102,15 @@ func capabilitiesFromOps(ops []string) model.RailCapabilities {
return cap return cap
} }
func limitsFromDiscovery(src *discovery.Limits) model.Limits { func limitsFromDiscovery(src *discovery.Limits, currencies []discovery.CurrencyAnnouncement) model.Limits {
if src == nil {
return model.Limits{}
}
limits := model.Limits{ limits := model.Limits{
MinAmount: strings.TrimSpace(src.MinAmount),
MaxAmount: strings.TrimSpace(src.MaxAmount),
VolumeLimit: map[string]string{}, VolumeLimit: map[string]string{},
VelocityLimit: map[string]int{}, VelocityLimit: map[string]int{},
CurrencyLimits: map[string]model.LimitsOverride{},
} }
if src != nil {
limits.MinAmount = strings.TrimSpace(src.MinAmount)
limits.MaxAmount = strings.TrimSpace(src.MaxAmount)
for key, value := range src.VolumeLimit { for key, value := range src.VolumeLimit {
k := strings.TrimSpace(key) k := strings.TrimSpace(key)
v := strings.TrimSpace(value) v := strings.TrimSpace(value)
@@ -127,11 +126,87 @@ func limitsFromDiscovery(src *discovery.Limits) model.Limits {
} }
limits.VelocityLimit[k] = value limits.VelocityLimit[k] = value
} }
}
applyCurrencyTransferLimits(&limits, currencies)
if len(limits.VolumeLimit) == 0 { if len(limits.VolumeLimit) == 0 {
limits.VolumeLimit = nil limits.VolumeLimit = nil
} }
if len(limits.VelocityLimit) == 0 { if len(limits.VelocityLimit) == 0 {
limits.VelocityLimit = nil limits.VelocityLimit = nil
} }
if len(limits.CurrencyLimits) == 0 {
limits.CurrencyLimits = nil
}
return limits return limits
} }
func applyCurrencyTransferLimits(dst *model.Limits, currencies []discovery.CurrencyAnnouncement) {
if dst == nil || len(currencies) == 0 {
return
}
var (
commonMin string
commonMax string
commonMinInit bool
commonMaxInit bool
commonMinConsistent = true
commonMaxConsistent = true
)
for _, currency := range currencies {
code := strings.ToUpper(strings.TrimSpace(currency.Currency))
if code == "" || currency.Limits == nil || currency.Limits.Amount == nil {
commonMinConsistent = false
commonMaxConsistent = false
continue
}
min := strings.TrimSpace(currency.Limits.Amount.Min)
max := strings.TrimSpace(currency.Limits.Amount.Max)
if min != "" || max != "" {
override := dst.CurrencyLimits[code]
if min != "" {
override.MinAmount = min
}
if max != "" {
override.MaxAmount = max
}
if override.MinAmount != "" || override.MaxAmount != "" || override.MaxFee != "" || override.MaxOps > 0 || override.MaxVolume != "" {
dst.CurrencyLimits[code] = override
}
}
if min == "" {
commonMinConsistent = false
} else if !commonMinInit {
commonMin = min
commonMinInit = true
} else if commonMin != min {
commonMinConsistent = false
}
if max == "" {
commonMaxConsistent = false
} else if !commonMaxInit {
commonMax = max
commonMaxInit = true
} else if commonMax != max {
commonMaxConsistent = false
}
}
if commonMinInit && commonMinConsistent {
dst.PerTxMinAmount = firstLimitValue(dst.PerTxMinAmount, commonMin)
}
if commonMaxInit && commonMaxConsistent {
dst.PerTxMaxAmount = firstLimitValue(dst.PerTxMaxAmount, commonMax)
}
}
func firstLimitValue(primary, fallback string) string {
primary = strings.TrimSpace(primary)
if primary != "" {
return primary
}
return strings.TrimSpace(fallback)
}

View File

@@ -0,0 +1,62 @@
package quotation
import (
"testing"
"github.com/tech/sendico/pkg/discovery"
)
func TestLimitsFromDiscovery_MapsPerTxMinimumFromCurrencyMeta(t *testing.T) {
limits := limitsFromDiscovery(nil, []discovery.CurrencyAnnouncement{
{
Currency: "RUB",
Limits: &discovery.CurrencyLimits{
Amount: &discovery.CurrencyAmount{
Min: "100.00",
Max: "10000.00",
},
},
},
})
if limits.PerTxMinAmount != "100.00" {
t.Fatalf("expected per tx min 100.00, got %q", limits.PerTxMinAmount)
}
if limits.PerTxMaxAmount != "10000.00" {
t.Fatalf("expected per tx max 10000.00, got %q", limits.PerTxMaxAmount)
}
override, ok := limits.CurrencyLimits["RUB"]
if !ok {
t.Fatalf("expected RUB currency override")
}
if override.MinAmount != "100.00" {
t.Fatalf("expected RUB min override 100.00, got %q", override.MinAmount)
}
}
func TestLimitsFromDiscovery_DropsCommonPerTxMinimumWhenCurrenciesDiffer(t *testing.T) {
limits := limitsFromDiscovery(nil, []discovery.CurrencyAnnouncement{
{
Currency: "USD",
Limits: &discovery.CurrencyLimits{
Amount: &discovery.CurrencyAmount{Min: "10.00"},
},
},
{
Currency: "EUR",
Limits: &discovery.CurrencyLimits{
Amount: &discovery.CurrencyAmount{Min: "20.00"},
},
},
})
if limits.PerTxMinAmount != "" {
t.Fatalf("expected empty common per tx min, got %q", limits.PerTxMinAmount)
}
if limits.CurrencyLimits["USD"].MinAmount != "10.00" {
t.Fatalf("expected USD min override 10.00, got %q", limits.CurrencyLimits["USD"].MinAmount)
}
if limits.CurrencyLimits["EUR"].MinAmount != "20.00" {
t.Fatalf("expected EUR min override 20.00, got %q", limits.CurrencyLimits["EUR"].MinAmount)
}
}

View File

@@ -43,6 +43,11 @@ type quoteCtx struct {
hash string hash string
} }
type quotePaymentResult struct {
quote *sharedv1.PaymentQuote
executionNote string
}
func (h *quotePaymentCommand) Execute( func (h *quotePaymentCommand) Execute(
ctx context.Context, ctx context.Context,
req *quotationv1.QuotePaymentRequest, req *quotationv1.QuotePaymentRequest,
@@ -65,14 +70,15 @@ func (h *quotePaymentCommand) Execute(
return gsresponse.Unavailable[quotationv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) return gsresponse.Unavailable[quotationv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
} }
quoteProto, err := h.quotePayment(ctx, quotesStore, qc, req) result, err := h.quotePayment(ctx, quotesStore, qc, req)
if err != nil { if err != nil {
return h.mapQuoteErr(err) return h.mapQuoteErr(err)
} }
return gsresponse.Success(&quotationv1.QuotePaymentResponse{ return gsresponse.Success(&quotationv1.QuotePaymentResponse{
IdempotencyKey: req.GetIdempotencyKey(), IdempotencyKey: req.GetIdempotencyKey(),
Quote: quoteProto, Quote: result.quote,
ExecutionNote: result.executionNote,
}) })
} }
@@ -111,7 +117,7 @@ func (h *quotePaymentCommand) quotePayment(
quotesStore storage.QuotesStore, quotesStore storage.QuotesStore,
qc *quoteCtx, qc *quoteCtx,
req *quotationv1.QuotePaymentRequest, req *quotationv1.QuotePaymentRequest,
) (*sharedv1.PaymentQuote, error) { ) (*quotePaymentResult, error) {
if qc.previewOnly { if qc.previewOnly {
quote, _, err := h.engine.BuildPaymentQuote(ctx, qc.orgID, req) quote, _, err := h.engine.BuildPaymentQuote(ctx, qc.orgID, req)
@@ -120,7 +126,7 @@ func (h *quotePaymentCommand) quotePayment(
return nil, err return nil, err
} }
quote.QuoteRef = bson.NewObjectID().Hex() quote.QuoteRef = bson.NewObjectID().Hex()
return quote, nil return &quotePaymentResult{quote: quote}, nil
} }
existing, err := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey) existing, err := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey)
@@ -140,7 +146,10 @@ func (h *quotePaymentCommand) quotePayment(
zap.String("idempotency_key", qc.idempotencyKey), zap.String("idempotency_key", qc.idempotencyKey),
zap.String("quote_ref", existing.QuoteRef), zap.String("quote_ref", existing.QuoteRef),
) )
return modelQuoteToProto(existing.Quote), nil return &quotePaymentResult{
quote: modelQuoteToProto(existing.Quote),
executionNote: strings.TrimSpace(existing.ExecutionNote),
}, nil
} }
quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, qc.orgID, req) quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, qc.orgID, req)
@@ -157,8 +166,19 @@ func (h *quotePaymentCommand) quotePayment(
quoteRef := bson.NewObjectID().Hex() quoteRef := bson.NewObjectID().Hex()
quote.QuoteRef = quoteRef quote.QuoteRef = quoteRef
executionNote := ""
plan, err := h.engine.BuildPaymentPlan(ctx, qc.orgRef, qc.intent, qc.idempotencyKey, quote) plan, err := h.engine.BuildPaymentPlan(ctx, qc.orgRef, qc.intent, qc.idempotencyKey, quote)
if err != nil { if err != nil {
if errors.Is(err, merrors.ErrInvalidArg) {
executionNote = quoteNonExecutableNote(err)
h.logger.Info(
"Payment quote marked as non-executable",
mzap.ObjRef("org_ref", qc.orgRef),
zap.String("idempotency_key", qc.idempotencyKey),
zap.String("quote_ref", quoteRef),
zap.String("execution_note", executionNote),
)
} else {
h.logger.Warn( h.logger.Warn(
"Failed to build payment plan", "Failed to build payment plan",
zap.Error(err), zap.Error(err),
@@ -167,7 +187,7 @@ func (h *quotePaymentCommand) quotePayment(
) )
return nil, err return nil, err
} }
}
record := &model.PaymentQuoteRecord{ record := &model.PaymentQuoteRecord{
QuoteRef: quoteRef, QuoteRef: quoteRef,
IdempotencyKey: qc.idempotencyKey, IdempotencyKey: qc.idempotencyKey,
@@ -175,6 +195,7 @@ func (h *quotePaymentCommand) quotePayment(
Intent: intentFromProto(qc.intent), Intent: intentFromProto(qc.intent),
Quote: quoteSnapshotToModel(quote), Quote: quoteSnapshotToModel(quote),
Plan: cloneStoredPaymentPlan(plan), Plan: cloneStoredPaymentPlan(plan),
ExecutionNote: executionNote,
ExpiresAt: expiresAt, ExpiresAt: expiresAt,
} }
record.SetID(bson.NewObjectID()) record.SetID(bson.NewObjectID())
@@ -187,7 +208,10 @@ func (h *quotePaymentCommand) quotePayment(
if existing.Hash != qc.hash { if existing.Hash != qc.hash {
return nil, errIdempotencyParamMismatch return nil, errIdempotencyParamMismatch
} }
return modelQuoteToProto(existing.Quote), nil return &quotePaymentResult{
quote: modelQuoteToProto(existing.Quote),
executionNote: strings.TrimSpace(existing.ExecutionNote),
}, nil
} }
} }
return nil, err return nil, err
@@ -201,7 +225,10 @@ func (h *quotePaymentCommand) quotePayment(
zap.String("kind", qc.intent.GetKind().String()), zap.String("kind", qc.intent.GetKind().String()),
) )
return quote, nil return &quotePaymentResult{
quote: quote,
executionNote: executionNote,
}, nil
} }
func (h *quotePaymentCommand) mapQuoteErr(err error) gsresponse.Responder[quotationv1.QuotePaymentResponse] { func (h *quotePaymentCommand) mapQuoteErr(err error) gsresponse.Responder[quotationv1.QuotePaymentResponse] {
@@ -213,6 +240,16 @@ func (h *quotePaymentCommand) mapQuoteErr(err error) gsresponse.Responder[quotat
return gsresponse.Auto[quotationv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) return gsresponse.Auto[quotationv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
} }
func quoteNonExecutableNote(err error) string {
reason := strings.TrimSpace(err.Error())
reason = strings.TrimPrefix(reason, merrors.ErrInvalidArg.Error()+":")
reason = strings.TrimSpace(reason)
if reason == "" {
return "quote will not be executed"
}
return "quote will not be executed: " + reason
}
// TODO: temprorarary hashing function, replace with a proper solution later // TODO: temprorarary hashing function, replace with a proper solution later
func hashQuoteRequest(req *quotationv1.QuotePaymentRequest) string { func hashQuoteRequest(req *quotationv1.QuotePaymentRequest) string {
cloned := proto.Clone(req).(*quotationv1.QuotePaymentRequest) cloned := proto.Clone(req).(*quotationv1.QuotePaymentRequest)

View File

@@ -0,0 +1,193 @@
package quotation
import (
"context"
"strings"
"testing"
"time"
"github.com/tech/sendico/payments/storage"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestQuotePaymentStoresNonExecutableQuoteWhenPlanInvalid(t *testing.T) {
org := bson.NewObjectID()
req := &quotationv1.QuotePaymentRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: org.Hex()},
IdempotencyKey: "idem-1",
Intent: &sharedv1.PaymentIntent{
Kind: sharedv1.PaymentKind_PAYMENT_KIND_PAYOUT,
Amount: &moneyv1.Money{Currency: "USD", Amount: "1"},
SettlementCurrency: "USD",
},
}
quotesStore := &quoteCommandTestQuotesStore{
byID: make(map[string]*model.PaymentQuoteRecord),
}
engine := &quoteCommandTestEngine{
repo: quoteCommandTestRepo{quotes: quotesStore},
buildQuoteFn: func(context.Context, string, *quotationv1.QuotePaymentRequest) (*sharedv1.PaymentQuote, time.Time, error) {
return &sharedv1.PaymentQuote{
DebitAmount: &moneyv1.Money{Currency: "USD", Amount: "1"},
}, time.Now().Add(time.Hour), nil
},
buildPlanFn: func(context.Context, bson.ObjectID, *sharedv1.PaymentIntent, string, *sharedv1.PaymentQuote) (*model.PaymentPlan, error) {
return nil, merrors.InvalidArgument("plan builder: no eligible gateway instance found, last error: gateway mntx eligibility check error: amount 1 USD below per-tx min limit 10")
},
}
cmd := &quotePaymentCommand{
engine: engine,
logger: mloggerfactory.NewLogger(false),
}
resp, err := cmd.Execute(context.Background(), req)(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp == nil || resp.GetQuote() == nil {
t.Fatalf("expected quote response, got %#v", resp)
}
if note := resp.GetExecutionNote(); !strings.Contains(note, "quote will not be executed") {
t.Fatalf("expected non-executable note, got %q", note)
}
stored := quotesStore.byID[req.GetIdempotencyKey()]
if stored == nil {
t.Fatalf("expected stored quote record")
}
if stored.Plan != nil {
t.Fatalf("expected no stored payment plan for non-executable quote")
}
if stored.ExecutionNote != resp.GetExecutionNote() {
t.Fatalf("expected stored execution note %q, got %q", resp.GetExecutionNote(), stored.ExecutionNote)
}
}
func TestQuotePaymentReuseReturnsStoredExecutionNote(t *testing.T) {
org := bson.NewObjectID()
req := &quotationv1.QuotePaymentRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: org.Hex()},
IdempotencyKey: "idem-1",
Intent: &sharedv1.PaymentIntent{
Kind: sharedv1.PaymentKind_PAYMENT_KIND_PAYOUT,
Amount: &moneyv1.Money{Currency: "USD", Amount: "1"},
SettlementCurrency: "USD",
},
}
existing := &model.PaymentQuoteRecord{
QuoteRef: "q1",
IdempotencyKey: req.GetIdempotencyKey(),
Hash: hashQuoteRequest(req),
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"},
ExecutionNote: "quote will not be executed: amount 1 USD below per-tx min limit 10",
}
quotesStore := &quoteCommandTestQuotesStore{
byID: map[string]*model.PaymentQuoteRecord{
req.GetIdempotencyKey(): existing,
},
}
engine := &quoteCommandTestEngine{
repo: quoteCommandTestRepo{quotes: quotesStore},
buildQuoteFn: func(context.Context, string, *quotationv1.QuotePaymentRequest) (*sharedv1.PaymentQuote, time.Time, error) {
t.Fatalf("build quote should not be called on idempotent reuse")
return nil, time.Time{}, nil
},
buildPlanFn: func(context.Context, bson.ObjectID, *sharedv1.PaymentIntent, string, *sharedv1.PaymentQuote) (*model.PaymentPlan, error) {
t.Fatalf("build plan should not be called on idempotent reuse")
return nil, nil
},
}
cmd := &quotePaymentCommand{
engine: engine,
logger: mloggerfactory.NewLogger(false),
}
resp, err := cmd.Execute(context.Background(), req)(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp == nil {
t.Fatalf("expected response")
}
if got, want := resp.GetExecutionNote(), existing.ExecutionNote; got != want {
t.Fatalf("expected execution note %q, got %q", want, got)
}
if resp.GetQuote().GetQuoteRef() != "q1" {
t.Fatalf("expected quote_ref q1, got %q", resp.GetQuote().GetQuoteRef())
}
}
type quoteCommandTestEngine struct {
repo storage.Repository
ensureErr error
buildQuoteFn func(ctx context.Context, orgRef string, req *quotationv1.QuotePaymentRequest) (*sharedv1.PaymentQuote, time.Time, error)
buildPlanFn func(ctx context.Context, orgID bson.ObjectID, intent *sharedv1.PaymentIntent, idempotencyKey string, quote *sharedv1.PaymentQuote) (*model.PaymentPlan, error)
}
func (e *quoteCommandTestEngine) EnsureRepository(context.Context) error { return e.ensureErr }
func (e *quoteCommandTestEngine) BuildPaymentQuote(ctx context.Context, orgRef string, req *quotationv1.QuotePaymentRequest) (*sharedv1.PaymentQuote, time.Time, error) {
if e.buildQuoteFn == nil {
return nil, time.Time{}, nil
}
return e.buildQuoteFn(ctx, orgRef, req)
}
func (e *quoteCommandTestEngine) BuildPaymentPlan(ctx context.Context, orgID bson.ObjectID, intent *sharedv1.PaymentIntent, idempotencyKey string, quote *sharedv1.PaymentQuote) (*model.PaymentPlan, error) {
if e.buildPlanFn == nil {
return nil, nil
}
return e.buildPlanFn(ctx, orgID, intent, idempotencyKey, quote)
}
func (e *quoteCommandTestEngine) ResolvePaymentQuote(context.Context, quoteResolutionInput) (*sharedv1.PaymentQuote, *sharedv1.PaymentIntent, *model.PaymentPlan, error) {
return nil, nil, nil, nil
}
func (e *quoteCommandTestEngine) Repository() storage.Repository { return e.repo }
type quoteCommandTestRepo struct {
quotes storage.QuotesStore
}
func (r quoteCommandTestRepo) Ping(context.Context) error { return nil }
func (r quoteCommandTestRepo) Payments() storage.PaymentsStore { return nil }
func (r quoteCommandTestRepo) Quotes() storage.QuotesStore { return r.quotes }
func (r quoteCommandTestRepo) Routes() storage.RoutesStore { return nil }
func (r quoteCommandTestRepo) PlanTemplates() storage.PlanTemplatesStore { return nil }
type quoteCommandTestQuotesStore struct {
byID map[string]*model.PaymentQuoteRecord
}
func (s *quoteCommandTestQuotesStore) Create(_ context.Context, rec *model.PaymentQuoteRecord) error {
if s.byID == nil {
s.byID = make(map[string]*model.PaymentQuoteRecord)
}
s.byID[rec.IdempotencyKey] = rec
return nil
}
func (s *quoteCommandTestQuotesStore) GetByRef(_ context.Context, _ bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) {
for _, rec := range s.byID {
if rec != nil && rec.QuoteRef == quoteRef {
return rec, nil
}
}
return nil, storage.ErrQuoteNotFound
}
func (s *quoteCommandTestQuotesStore) GetByIdempotencyKey(_ context.Context, _ bson.ObjectID, idempotencyKey string) (*model.PaymentQuoteRecord, error) {
if rec, ok := s.byID[idempotencyKey]; ok {
return rec, nil
}
return nil, storage.ErrQuoteNotFound
}

View File

@@ -85,6 +85,9 @@ func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInp
if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) { if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) {
return nil, nil, nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")} return nil, nil, nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")}
} }
if note := strings.TrimSpace(record.ExecutionNote); note != "" {
return nil, nil, nil, quoteResolutionError{code: "quote_not_executable", err: merrors.InvalidArgument(note)}
}
intent, err := recordIntentFromQuote(record) intent, err := recordIntentFromQuote(record)
if err != nil { if err != nil {
return nil, nil, nil, err return nil, nil, nil, err

View File

@@ -20,6 +20,7 @@ type PaymentQuoteRecord struct {
Quotes []*PaymentQuoteSnapshot `bson:"quotes,omitempty" json:"quotes,omitempty"` Quotes []*PaymentQuoteSnapshot `bson:"quotes,omitempty" json:"quotes,omitempty"`
Plan *PaymentPlan `bson:"plan,omitempty" json:"plan,omitempty"` Plan *PaymentPlan `bson:"plan,omitempty" json:"plan,omitempty"`
Plans []*PaymentPlan `bson:"plans,omitempty" json:"plans,omitempty"` Plans []*PaymentPlan `bson:"plans,omitempty" json:"plans,omitempty"`
ExecutionNote string `bson:"executionNote,omitempty" json:"executionNote,omitempty"`
ExpiresAt time.Time `bson:"expiresAt" json:"expiresAt"` ExpiresAt time.Time `bson:"expiresAt" json:"expiresAt"`
PurgeAt time.Time `bson:"purgeAt,omitempty" json:"purgeAt,omitempty"` PurgeAt time.Time `bson:"purgeAt,omitempty" json:"purgeAt,omitempty"`
Hash string `bson:"hash" json:"hash"` Hash string `bson:"hash" json:"hash"`

View File

@@ -90,6 +90,7 @@ func (q *Quotes) Create(ctx context.Context, quote *model.PaymentQuoteRecord) er
if quote.IdempotencyKey == "" { if quote.IdempotencyKey == "" {
return merrors.InvalidArgument("quotesStore: idempotency key is required") return merrors.InvalidArgument("quotesStore: idempotency key is required")
} }
quote.ExecutionNote = strings.TrimSpace(quote.ExecutionNote)
if quote.ExpiresAt.IsZero() { if quote.ExpiresAt.IsZero() {
return merrors.InvalidArgument("quotesStore: expires_at is required") return merrors.InvalidArgument("quotesStore: expires_at is required")
} }

View File

@@ -21,8 +21,8 @@ func announcementFields(announce Announcement) []zap.Field {
if announce.Rail != "" { if announce.Rail != "" {
fields = append(fields, zap.String("rail", announce.Rail)) fields = append(fields, zap.String("rail", announce.Rail))
} }
if announce.Network != "" { if network := legacyNetworkFromCurrencies(announce.Currencies); network != "" {
fields = append(fields, zap.String("network", announce.Network)) fields = append(fields, zap.String("network", network))
} }
if announce.InvokeURI != "" { if announce.InvokeURI != "" {
fields = append(fields, zap.String("invoke_uri", announce.InvokeURI)) fields = append(fields, zap.String("invoke_uri", announce.InvokeURI))

View File

@@ -28,6 +28,7 @@ type GatewaySummary struct {
Rail string `json:"rail"` Rail string `json:"rail"`
Network string `json:"network,omitempty"` Network string `json:"network,omitempty"`
Currencies []string `json:"currencies,omitempty"` Currencies []string `json:"currencies,omitempty"`
CurrencyMeta []CurrencyAnnouncement `json:"currencyMeta,omitempty"`
Ops []string `json:"ops,omitempty"` Ops []string `json:"ops,omitempty"`
Limits *Limits `json:"limits,omitempty"` Limits *Limits `json:"limits,omitempty"`
Version string `json:"version,omitempty"` Version string `json:"version,omitempty"`
@@ -51,6 +52,7 @@ func (r *Registry) Lookup(now time.Time) LookupResponse {
Rail: entry.Rail, Rail: entry.Rail,
Network: entry.Network, Network: entry.Network,
Currencies: cloneStrings(entry.Currencies), Currencies: cloneStrings(entry.Currencies),
CurrencyMeta: cloneCurrencyAnnouncements(entry.CurrencyMeta),
Ops: cloneStrings(entry.Operations), Ops: cloneStrings(entry.Operations),
Limits: cloneLimits(entry.Limits), Limits: cloneLimits(entry.Limits),
Version: entry.Version, Version: entry.Version,

View File

@@ -1,6 +1,7 @@
package discovery package discovery
import ( import (
"fmt"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -19,6 +20,7 @@ type RegistryEntry struct {
Network string `json:"network,omitempty"` Network string `json:"network,omitempty"`
Operations []string `json:"operations,omitempty"` Operations []string `json:"operations,omitempty"`
Currencies []string `json:"currencies,omitempty"` Currencies []string `json:"currencies,omitempty"`
CurrencyMeta []CurrencyAnnouncement `json:"currencyMeta,omitempty"`
Limits *Limits `json:"limits,omitempty"` Limits *Limits `json:"limits,omitempty"`
InvokeURI string `json:"invokeURI,omitempty"` InvokeURI string `json:"invokeURI,omitempty"`
RoutingPriority int `json:"routingPriority,omitempty"` RoutingPriority int `json:"routingPriority,omitempty"`
@@ -200,15 +202,17 @@ func (r *Registry) List(now time.Time, onlyHealthy bool) []RegistryEntry {
func registryEntryFromAnnouncement(announce Announcement, now time.Time) RegistryEntry { func registryEntryFromAnnouncement(announce Announcement, now time.Time) RegistryEntry {
status := "ok" status := "ok"
currencies := cloneCurrencyAnnouncements(announce.Currencies)
return RegistryEntry{ return RegistryEntry{
ID: strings.TrimSpace(announce.ID), ID: strings.TrimSpace(announce.ID),
InstanceID: strings.TrimSpace(announce.InstanceID), InstanceID: strings.TrimSpace(announce.InstanceID),
Service: strings.TrimSpace(announce.Service), Service: strings.TrimSpace(announce.Service),
Rail: strings.ToUpper(strings.TrimSpace(announce.Rail)), Rail: strings.ToUpper(strings.TrimSpace(announce.Rail)),
Network: strings.ToUpper(strings.TrimSpace(announce.Network)), Network: legacyNetworkFromCurrencies(currencies),
Operations: cloneStrings(announce.Operations), Operations: cloneStrings(announce.Operations),
Currencies: cloneStrings(announce.Currencies), Currencies: legacyCurrencyCodes(currencies),
Limits: cloneLimits(announce.Limits), CurrencyMeta: currencies,
Limits: legacyLimitsFromCurrencies(currencies),
InvokeURI: strings.TrimSpace(announce.InvokeURI), InvokeURI: strings.TrimSpace(announce.InvokeURI),
RoutingPriority: announce.RoutingPriority, RoutingPriority: announce.RoutingPriority,
Version: strings.TrimSpace(announce.Version), Version: strings.TrimSpace(announce.Version),
@@ -228,12 +232,21 @@ func normalizeEntry(entry RegistryEntry) RegistryEntry {
entry.Rail = strings.ToUpper(strings.TrimSpace(entry.Rail)) entry.Rail = strings.ToUpper(strings.TrimSpace(entry.Rail))
entry.Network = strings.ToUpper(strings.TrimSpace(entry.Network)) entry.Network = strings.ToUpper(strings.TrimSpace(entry.Network))
entry.Operations = normalizeStrings(entry.Operations, false) entry.Operations = normalizeStrings(entry.Operations, false)
entry.CurrencyMeta = normalizeCurrencyAnnouncements(entry.CurrencyMeta)
if len(entry.CurrencyMeta) > 0 {
entry.Currencies = legacyCurrencyCodes(entry.CurrencyMeta)
if derivedNetwork := legacyNetworkFromCurrencies(entry.CurrencyMeta); derivedNetwork != "" {
entry.Network = derivedNetwork
}
entry.Limits = legacyLimitsFromCurrencies(entry.CurrencyMeta)
} else {
entry.Currencies = normalizeStrings(entry.Currencies, true) entry.Currencies = normalizeStrings(entry.Currencies, true)
}
entry.InvokeURI = strings.TrimSpace(entry.InvokeURI) entry.InvokeURI = strings.TrimSpace(entry.InvokeURI)
entry.Version = strings.TrimSpace(entry.Version) entry.Version = strings.TrimSpace(entry.Version)
entry.Status = strings.TrimSpace(entry.Status) entry.Status = strings.TrimSpace(entry.Status)
entry.Health = normalizeHealth(entry.Health) entry.Health = normalizeHealth(entry.Health)
if entry.Limits != nil { if len(entry.CurrencyMeta) == 0 && entry.Limits != nil {
entry.Limits = normalizeLimits(*entry.Limits) entry.Limits = normalizeLimits(*entry.Limits)
} }
return entry return entry
@@ -247,15 +260,11 @@ func normalizeAnnouncement(announce Announcement) Announcement {
} }
announce.Service = strings.TrimSpace(announce.Service) announce.Service = strings.TrimSpace(announce.Service)
announce.Rail = strings.ToUpper(strings.TrimSpace(announce.Rail)) announce.Rail = strings.ToUpper(strings.TrimSpace(announce.Rail))
announce.Network = strings.ToUpper(strings.TrimSpace(announce.Network))
announce.Operations = normalizeStrings(announce.Operations, false) announce.Operations = normalizeStrings(announce.Operations, false)
announce.Currencies = normalizeStrings(announce.Currencies, true) announce.Currencies = normalizeCurrencyAnnouncements(announce.Currencies)
announce.InvokeURI = strings.TrimSpace(announce.InvokeURI) announce.InvokeURI = strings.TrimSpace(announce.InvokeURI)
announce.Version = strings.TrimSpace(announce.Version) announce.Version = strings.TrimSpace(announce.Version)
announce.Health = normalizeHealth(announce.Health) announce.Health = normalizeHealth(announce.Health)
if announce.Limits != nil {
announce.Limits = normalizeLimits(*announce.Limits)
}
return announce return announce
} }
@@ -309,6 +318,364 @@ func cloneLimits(src *Limits) *Limits {
return &dst return &dst
} }
func normalizeCurrencyAnnouncements(values []CurrencyAnnouncement) []CurrencyAnnouncement {
if len(values) == 0 {
return nil
}
seen := map[string]bool{}
result := make([]CurrencyAnnouncement, 0, len(values))
for _, value := range values {
clean, ok := normalizeCurrencyAnnouncement(value)
if !ok {
continue
}
key := strings.Join([]string{
clean.Currency,
clean.Network,
clean.ProviderID,
clean.ContractAddress,
}, "|")
if seen[key] {
continue
}
seen[key] = true
result = append(result, clean)
}
if len(result) == 0 {
return nil
}
return result
}
func normalizeCurrencyAnnouncement(src CurrencyAnnouncement) (CurrencyAnnouncement, bool) {
src.Currency = strings.ToUpper(strings.TrimSpace(src.Currency))
if src.Currency == "" {
return CurrencyAnnouncement{}, false
}
src.Network = strings.ToUpper(strings.TrimSpace(src.Network))
src.ProviderID = strings.TrimSpace(src.ProviderID)
src.ContractAddress = strings.ToLower(strings.TrimSpace(src.ContractAddress))
if src.Decimals != nil && *src.Decimals < 0 {
src.Decimals = nil
}
src.Limits = normalizeCurrencyLimits(src.Limits)
return src, true
}
func normalizeCurrencyLimits(src *CurrencyLimits) *CurrencyLimits {
if src == nil {
return nil
}
dst := &CurrencyLimits{}
if src.Amount != nil {
amount := &CurrencyAmount{
Min: strings.TrimSpace(src.Amount.Min),
Max: strings.TrimSpace(src.Amount.Max),
}
if amount.Min != "" || amount.Max != "" {
dst.Amount = amount
}
}
if src.Running != nil {
running := &CurrencyRunningLimits{}
for _, limit := range src.Running.Volume {
max := strings.TrimSpace(limit.Max)
if max == "" {
continue
}
window := normalizeWindow(limit.Window)
if legacyWindowKey(window) == "" {
continue
}
running.Volume = append(running.Volume, VolumeLimit{
Window: window,
Max: max,
})
}
for _, limit := range src.Running.Velocity {
if limit.Max <= 0 {
continue
}
window := normalizeWindow(limit.Window)
if legacyWindowKey(window) == "" {
continue
}
running.Velocity = append(running.Velocity, VelocityLimit{
Window: window,
Max: limit.Max,
})
}
if len(running.Volume) > 0 || len(running.Velocity) > 0 {
dst.Running = running
}
}
if dst.Amount == nil && dst.Running == nil {
return nil
}
return dst
}
func normalizeWindow(src Window) Window {
src.Raw = strings.TrimSpace(src.Raw)
src.Duration = strings.TrimSpace(src.Duration)
src.Named = strings.TrimSpace(src.Named)
if src.Calendar != nil {
cal := &CalendarWindow{
Unit: CalendarUnit(strings.ToLower(strings.TrimSpace(string(src.Calendar.Unit)))),
Count: src.Calendar.Count,
}
if cal.Count <= 0 {
cal.Count = 1
}
if cal.Unit == CalendarUnitUnspecified {
cal = nil
}
src.Calendar = cal
}
return src
}
func cloneCurrencyAnnouncements(values []CurrencyAnnouncement) []CurrencyAnnouncement {
if len(values) == 0 {
return nil
}
out := make([]CurrencyAnnouncement, 0, len(values))
for _, value := range values {
cp := CurrencyAnnouncement{
Currency: value.Currency,
Network: value.Network,
ProviderID: value.ProviderID,
ContractAddress: value.ContractAddress,
}
if value.Decimals != nil {
decimals := *value.Decimals
cp.Decimals = &decimals
}
cp.Limits = cloneCurrencyLimits(value.Limits)
out = append(out, cp)
}
return out
}
func cloneCurrencyLimits(src *CurrencyLimits) *CurrencyLimits {
if src == nil {
return nil
}
dst := &CurrencyLimits{}
if src.Amount != nil {
dst.Amount = &CurrencyAmount{
Min: src.Amount.Min,
Max: src.Amount.Max,
}
}
if src.Running != nil {
running := &CurrencyRunningLimits{}
if len(src.Running.Volume) > 0 {
running.Volume = make([]VolumeLimit, 0, len(src.Running.Volume))
for _, item := range src.Running.Volume {
running.Volume = append(running.Volume, VolumeLimit{
Window: cloneWindow(item.Window),
Max: item.Max,
})
}
}
if len(src.Running.Velocity) > 0 {
running.Velocity = make([]VelocityLimit, 0, len(src.Running.Velocity))
for _, item := range src.Running.Velocity {
running.Velocity = append(running.Velocity, VelocityLimit{
Window: cloneWindow(item.Window),
Max: item.Max,
})
}
}
if len(running.Volume) > 0 || len(running.Velocity) > 0 {
dst.Running = running
}
}
if dst.Amount == nil && dst.Running == nil {
return nil
}
return dst
}
func cloneWindow(src Window) Window {
dst := Window{
Raw: src.Raw,
Duration: src.Duration,
Named: src.Named,
}
if src.Calendar != nil {
dst.Calendar = &CalendarWindow{
Unit: src.Calendar.Unit,
Count: src.Calendar.Count,
}
}
return dst
}
func legacyCurrencyCodes(values []CurrencyAnnouncement) []string {
if len(values) == 0 {
return nil
}
seen := map[string]bool{}
out := make([]string, 0, len(values))
for _, value := range values {
currency := strings.ToUpper(strings.TrimSpace(value.Currency))
if currency == "" || seen[currency] {
continue
}
seen[currency] = true
out = append(out, currency)
}
if len(out) == 0 {
return nil
}
return out
}
func legacyNetworkFromCurrencies(values []CurrencyAnnouncement) string {
if len(values) == 0 {
return ""
}
network := ""
for _, value := range values {
current := strings.ToUpper(strings.TrimSpace(value.Network))
if current == "" {
continue
}
if network == "" {
network = current
continue
}
if network != current {
return ""
}
}
return network
}
func legacyLimitsFromCurrencies(values []CurrencyAnnouncement) *Limits {
if len(values) == 0 {
return nil
}
var merged *Limits
for _, value := range values {
current := legacyLimitsFromCurrency(value.Limits)
if current == nil {
continue
}
if merged == nil {
merged = current
continue
}
if !strings.EqualFold(strings.TrimSpace(merged.MinAmount), strings.TrimSpace(current.MinAmount)) {
merged.MinAmount = ""
}
if !strings.EqualFold(strings.TrimSpace(merged.MaxAmount), strings.TrimSpace(current.MaxAmount)) {
merged.MaxAmount = ""
}
merged.VolumeLimit = intersectStringMaps(merged.VolumeLimit, current.VolumeLimit)
merged.VelocityLimit = intersectIntMaps(merged.VelocityLimit, current.VelocityLimit)
}
if merged == nil {
return nil
}
if merged.MinAmount == "" && merged.MaxAmount == "" && len(merged.VolumeLimit) == 0 && len(merged.VelocityLimit) == 0 {
return nil
}
return merged
}
func legacyLimitsFromCurrency(src *CurrencyLimits) *Limits {
if src == nil {
return nil
}
out := &Limits{}
if src.Amount != nil {
out.MinAmount = strings.TrimSpace(src.Amount.Min)
out.MaxAmount = strings.TrimSpace(src.Amount.Max)
}
if src.Running != nil {
if len(src.Running.Volume) > 0 {
out.VolumeLimit = map[string]string{}
for _, item := range src.Running.Volume {
key := legacyWindowKey(item.Window)
max := strings.TrimSpace(item.Max)
if key == "" || max == "" {
continue
}
out.VolumeLimit[key] = max
}
}
if len(src.Running.Velocity) > 0 {
out.VelocityLimit = map[string]int{}
for _, item := range src.Running.Velocity {
key := legacyWindowKey(item.Window)
if key == "" || item.Max <= 0 {
continue
}
out.VelocityLimit[key] = item.Max
}
}
}
if out.MinAmount == "" && out.MaxAmount == "" && len(out.VolumeLimit) == 0 && len(out.VelocityLimit) == 0 {
return nil
}
return out
}
func intersectStringMaps(left, right map[string]string) map[string]string {
if len(left) == 0 || len(right) == 0 {
return nil
}
out := map[string]string{}
for key, value := range left {
if rightValue, ok := right[key]; ok && strings.EqualFold(strings.TrimSpace(value), strings.TrimSpace(rightValue)) {
out[key] = value
}
}
if len(out) == 0 {
return nil
}
return out
}
func intersectIntMaps(left, right map[string]int) map[string]int {
if len(left) == 0 || len(right) == 0 {
return nil
}
out := map[string]int{}
for key, value := range left {
if rightValue, ok := right[key]; ok && value == rightValue {
out[key] = value
}
}
if len(out) == 0 {
return nil
}
return out
}
func legacyWindowKey(window Window) string {
if raw := strings.TrimSpace(window.Raw); raw != "" {
return raw
}
if named := strings.TrimSpace(window.Named); named != "" {
return named
}
if duration := strings.TrimSpace(window.Duration); duration != "" {
return duration
}
if window.Calendar != nil && window.Calendar.Unit != CalendarUnitUnspecified {
count := window.Calendar.Count
if count <= 0 {
count = 1
}
return fmt.Sprintf("%d%s", count, strings.ToLower(strings.TrimSpace(string(window.Calendar.Unit))))
}
return ""
}
func normalizeStrings(values []string, upper bool) []string { func normalizeStrings(values []string, upper bool) []string {
if len(values) == 0 { if len(values) == 0 {
return nil return nil

View File

@@ -5,6 +5,78 @@ type HealthParams struct {
TimeoutSec int `json:"timeoutSec"` TimeoutSec int `json:"timeoutSec"`
} }
// CurrencyAmount defines per-transfer min/max for a unit (fiat currency or crypto asset).
// Values are decimals in string form to avoid float issues.
type CurrencyAmount struct {
Min string `json:"min,omitempty"`
Max string `json:"max,omitempty"`
}
// CalendarUnit is used for calendar-aligned windows (e.g. 1 calendar month).
type CalendarUnit string
const (
CalendarUnitUnspecified CalendarUnit = ""
CalendarUnitHour CalendarUnit = "hour"
CalendarUnitDay CalendarUnit = "day"
CalendarUnitWeek CalendarUnit = "week"
CalendarUnitMonth CalendarUnit = "month"
)
// CalendarWindow represents a calendar-aligned window.
// Example: {unit:"month", count:1} means "1 calendar month".
type CalendarWindow struct {
Unit CalendarUnit `json:"unit"`
Count int `json:"count"`
}
// Window is a typed description of a running-limit window.
// Gateways/providers can express windows differently, so we keep:
// - Raw: original provider token/string (optional but recommended)
// and one of the normalized forms:
// - Duration: fixed window expressed as a Go-duration string (e.g. "24h", "168h")
// - Calendar: calendar-aligned window (e.g. 1 month)
// - Named: opaque provider token when you do not want/cannot normalize (e.g. "daily")
type Window struct {
// Raw may be set alongside the normalized form.
// Normalized form: exactly one of Duration/Calendar/Named SHOULD be set.
Raw string `json:"raw,omitempty"`
// Duration uses Go time.Duration format and MUST NOT use "d".
// Use "24h", "168h", "720h", etc.
Duration string `json:"duration,omitempty"` // e.g. "24h"
Calendar *CalendarWindow `json:"calendar,omitempty"` // e.g. month x1
Named string `json:"named,omitempty"` // e.g. "daily"
}
// VolumeLimit is the max total amount allowed within a window.
// Amount is a decimal string in the same unit as the announcement (currency/asset).
type VolumeLimit struct {
Window Window `json:"window"`
Max string `json:"max"`
}
// VelocityLimit is the max number of operations allowed within a window.
type VelocityLimit struct {
Window Window `json:"window"`
Max int `json:"max"`
}
// CurrencyRunningLimits groups windowed limits.
// Slices are used instead of maps to avoid relying on untyped map keys.
type CurrencyRunningLimits struct {
Volume []VolumeLimit `json:"volume,omitempty"`
Velocity []VelocityLimit `json:"velocity,omitempty"`
}
// CurrencyLimits combines per-transfer and running limits for a unit.
type CurrencyLimits struct {
Amount *CurrencyAmount `json:"amount,omitempty"`
Running *CurrencyRunningLimits `json:"running,omitempty"`
}
// Limits is a legacy flat limits shape used by lookup summaries.
// Deprecated: use CurrencyAnnouncement.Limits for announcement payloads.
type Limits struct { type Limits struct {
MinAmount string `json:"minAmount,omitempty"` MinAmount string `json:"minAmount,omitempty"`
MaxAmount string `json:"maxAmount,omitempty"` MaxAmount string `json:"maxAmount,omitempty"`
@@ -12,15 +84,29 @@ type Limits struct {
VelocityLimit map[string]int `json:"velocityLimit,omitempty"` VelocityLimit map[string]int `json:"velocityLimit,omitempty"`
} }
// CurrencyAnnouncement declares limits for a unit.
// For fiat: Currency is ISO 4217 ("EUR", "USD").
// For crypto: Currency is the ticker ("USDT", "BTC") and you SHOULD also fill Network,
// and for tokens ContractAddress + Decimals.
type CurrencyAnnouncement struct {
// Unit identity
Currency string `json:"currency"` // fiat code or crypto ticker
Network string `json:"network,omitempty"` // crypto chain or fiat rail, e.g. "tron", "ethereum", "sepa", "swift", "visa"
ProviderID string `json:"providerId,omitempty"` // specific provider identifier providing currency over network/rail (e.g. "binance", "circle")
ContractAddress string `json:"contractAddress,omitempty"` // for tokens only
Decimals *int `json:"decimals,omitempty"` // for crypto/token precision
// Limits for this unit
Limits *CurrencyLimits `json:"limits,omitempty"`
}
type Announcement struct { type Announcement struct {
ID string `json:"id"` ID string `json:"id"`
InstanceID string `bson:"instanceId" json:"instanceId"` InstanceID string `bson:"instanceId" json:"instanceId"`
Service string `json:"service"` Service string `json:"service"`
Rail string `json:"rail,omitempty"` Rail string `json:"rail,omitempty"`
Network string `json:"network,omitempty"`
Operations []string `json:"operations,omitempty"` Operations []string `json:"operations,omitempty"`
Currencies []string `json:"currencies,omitempty"` Currencies []CurrencyAnnouncement `json:"currencies,omitempty"`
Limits *Limits `json:"limits,omitempty"`
InvokeURI string `json:"invokeURI,omitempty"` InvokeURI string `json:"invokeURI,omitempty"`
RoutingPriority int `json:"routingPriority,omitempty"` RoutingPriority int `json:"routingPriority,omitempty"`
Version string `json:"version,omitempty"` Version string `json:"version,omitempty"`

View File

@@ -16,6 +16,8 @@ message QuotePaymentRequest {
message QuotePaymentResponse { message QuotePaymentResponse {
payments.shared.v1.PaymentQuote quote = 1; payments.shared.v1.PaymentQuote quote = 1;
string idempotency_key = 2; string idempotency_key = 2;
// Non-empty when quote is valid for pricing but cannot be executed.
string execution_note = 3;
} }
message QuotePaymentsRequest { message QuotePaymentsRequest {