unified gateway interface

This commit is contained in:
Stephan D
2025-12-31 17:47:32 +01:00
parent 19b7b69bd8
commit 97ba7500dc
104 changed files with 8228 additions and 1742 deletions

View File

@@ -11,13 +11,19 @@ import (
chainclient "github.com/tech/sendico/gateway/chain/client"
mntxclient "github.com/tech/sendico/gateway/mntx/client"
ledgerclient "github.com/tech/sendico/ledger/client"
"github.com/tech/sendico/payments/orchestrator/internal/appversion"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
mongostorage "github.com/tech/sendico/payments/orchestrator/storage/mongo"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/discovery"
msg "github.com/tech/sendico/pkg/messaging"
msgproducer "github.com/tech/sendico/pkg/messaging/producer"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/payments/rail"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
"github.com/tech/sendico/pkg/server/grpcapp"
"go.uber.org/zap"
@@ -32,24 +38,28 @@ type Imp struct {
file string
debug bool
config *config
app *grpcapp.App[storage.Repository]
feesConn *grpc.ClientConn
ledgerClient ledgerclient.Client
gatewayClient chainclient.Client
mntxClient mntxclient.Client
oracleClient oracleclient.Client
config *config
app *grpcapp.App[storage.Repository]
discoverySvc *discovery.RegistryService
discoveryReg *discovery.Registry
discoveryAnnouncer *discovery.Announcer
feesConn *grpc.ClientConn
ledgerClient ledgerclient.Client
gatewayClient chainclient.Client
mntxClient mntxclient.Client
oracleClient oracleclient.Client
}
type config struct {
*grpcapp.Config `yaml:",inline"`
Fees clientConfig `yaml:"fees"`
Ledger clientConfig `yaml:"ledger"`
Gateway clientConfig `yaml:"gateway"`
Mntx clientConfig `yaml:"mntx"`
Oracle clientConfig `yaml:"oracle"`
CardGateways map[string]cardGatewayRouteConfig `yaml:"card_gateways"`
FeeAccounts map[string]string `yaml:"fee_ledger_accounts"`
*grpcapp.Config `yaml:",inline"`
Fees clientConfig `yaml:"fees"`
Ledger clientConfig `yaml:"ledger"`
Gateway clientConfig `yaml:"gateway"`
Mntx clientConfig `yaml:"mntx"`
Oracle clientConfig `yaml:"oracle"`
CardGateways map[string]cardGatewayRouteConfig `yaml:"card_gateways"`
FeeAccounts map[string]string `yaml:"fee_ledger_accounts"`
GatewayInstances []gatewayInstanceConfig `yaml:"gateway_instances"`
}
type clientConfig struct {
@@ -65,6 +75,44 @@ type cardGatewayRouteConfig struct {
FeeWalletRef string `yaml:"fee_wallet_ref"`
}
type gatewayInstanceConfig struct {
ID string `yaml:"id"`
Rail string `yaml:"rail"`
Network string `yaml:"network"`
Currencies []string `yaml:"currencies"`
Capabilities gatewayCapabilitiesConfig `yaml:"capabilities"`
Limits limitsConfig `yaml:"limits"`
Version string `yaml:"version"`
IsEnabled *bool `yaml:"is_enabled"`
}
type gatewayCapabilitiesConfig struct {
CanPayIn bool `yaml:"can_pay_in"`
CanPayOut bool `yaml:"can_pay_out"`
CanReadBalance bool `yaml:"can_read_balance"`
CanSendFee bool `yaml:"can_send_fee"`
RequiresObserveConfirm bool `yaml:"requires_observe_confirm"`
}
type limitsConfig struct {
MinAmount string `yaml:"min_amount"`
MaxAmount string `yaml:"max_amount"`
PerTxMaxFee string `yaml:"per_tx_max_fee"`
PerTxMinAmount string `yaml:"per_tx_min_amount"`
PerTxMaxAmount string `yaml:"per_tx_max_amount"`
VolumeLimit map[string]string `yaml:"volume_limit"`
VelocityLimit map[string]int `yaml:"velocity_limit"`
CurrencyLimits map[string]limitsOverrideCfg `yaml:"currency_limits"`
}
type limitsOverrideCfg struct {
MaxVolume string `yaml:"max_volume"`
MinAmount string `yaml:"min_amount"`
MaxAmount string `yaml:"max_amount"`
MaxFee string `yaml:"max_fee"`
MaxOps int `yaml:"max_ops"`
}
func (c clientConfig) address() string {
return strings.TrimSpace(c.Address)
}
@@ -92,6 +140,12 @@ func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
}
func (i *Imp) Shutdown() {
if i.discoveryAnnouncer != nil {
i.discoveryAnnouncer.Stop()
}
if i.discoverySvc != nil {
i.discoverySvc.Stop()
}
if i.app != nil {
timeout := 15 * time.Second
if i.config != nil && i.config.Runtime != nil {
@@ -126,6 +180,32 @@ func (i *Imp) Start() error {
}
i.config = cfg
if cfg.Messaging != nil && cfg.Messaging.Driver != "" {
broker, err := msg.CreateMessagingBroker(i.logger.Named("discovery_bus"), cfg.Messaging)
if err != nil {
i.logger.Warn("Failed to initialise discovery broker", zap.Error(err))
} else {
producer := msgproducer.NewProducer(i.logger.Named("discovery_producer"), broker)
registry := discovery.NewRegistry()
svc, err := discovery.NewRegistryService(i.logger, broker, producer, registry, string(mservice.PaymentOrchestrator))
if err != nil {
i.logger.Warn("Failed to start discovery registry service", zap.Error(err))
} else {
svc.Start()
i.discoverySvc = svc
i.discoveryReg = registry
i.logger.Info("Discovery registry service started")
}
announce := discovery.Announcement{
Service: "PAYMENTS_ORCHESTRATOR",
Operations: []string{"payment.quote", "payment.initiate"},
Version: appversion.Create().Short(),
}
i.discoveryAnnouncer = discovery.NewAnnouncer(i.logger, producer, string(mservice.PaymentOrchestrator), announce)
i.discoveryAnnouncer.Start()
}
}
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
return mongostorage.New(logger, conn)
}
@@ -166,6 +246,9 @@ func (i *Imp) Start() error {
if gatewayClient != nil {
opts = append(opts, orchestrator.WithChainGatewayClient(gatewayClient))
}
if railGateways := buildRailGateways(gatewayClient, cfg.GatewayInstances); len(railGateways) > 0 {
opts = append(opts, orchestrator.WithRailGateways(railGateways))
}
if mntxClient != nil {
opts = append(opts, orchestrator.WithMntxGateway(mntxClient))
}
@@ -178,6 +261,9 @@ func (i *Imp) Start() error {
if feeAccounts := buildFeeLedgerAccounts(cfg.FeeAccounts); len(feeAccounts) > 0 {
opts = append(opts, orchestrator.WithFeeLedgerAccounts(feeAccounts))
}
if registry := buildGatewayRegistry(i.logger, mntxClient, cfg.GatewayInstances, i.discoveryReg); registry != nil {
opts = append(opts, orchestrator.WithGatewayRegistry(registry))
}
return orchestrator.NewService(logger, repo, opts...), nil
}
@@ -382,3 +468,178 @@ func buildFeeLedgerAccounts(src map[string]string) map[string]string {
}
return result
}
func buildGatewayRegistry(logger mlogger.Logger, mntxClient mntxclient.Client, src []gatewayInstanceConfig, registry *discovery.Registry) orchestrator.GatewayRegistry {
static := buildGatewayInstances(logger, src)
staticRegistry := orchestrator.NewGatewayRegistry(logger, mntxClient, static)
discoveryRegistry := orchestrator.NewDiscoveryGatewayRegistry(logger, registry)
return orchestrator.NewCompositeGatewayRegistry(logger, staticRegistry, discoveryRegistry)
}
func buildRailGateways(chainClient chainclient.Client, src []gatewayInstanceConfig) map[string]rail.RailGateway {
if chainClient == nil || len(src) == 0 {
return nil
}
instances := buildGatewayInstances(nil, src)
if len(instances) == 0 {
return nil
}
result := map[string]rail.RailGateway{}
for _, inst := range instances {
if inst == nil || !inst.IsEnabled {
continue
}
if inst.Rail != model.RailCrypto {
continue
}
cfg := chainclient.RailGatewayConfig{
Rail: string(inst.Rail),
Network: inst.Network,
Capabilities: rail.RailCapabilities{
CanPayIn: inst.Capabilities.CanPayIn,
CanPayOut: inst.Capabilities.CanPayOut,
CanReadBalance: inst.Capabilities.CanReadBalance,
CanSendFee: inst.Capabilities.CanSendFee,
RequiresObserveConfirm: inst.Capabilities.RequiresObserveConfirm,
},
}
result[inst.ID] = chainclient.NewRailGateway(chainClient, cfg)
}
if len(result) == 0 {
return nil
}
return result
}
func buildGatewayInstances(logger mlogger.Logger, src []gatewayInstanceConfig) []*model.GatewayInstanceDescriptor {
if len(src) == 0 {
return nil
}
if logger != nil {
logger = logger.Named("gateway_instances")
}
result := make([]*model.GatewayInstanceDescriptor, 0, len(src))
for _, cfg := range src {
id := strings.TrimSpace(cfg.ID)
if id == "" {
if logger != nil {
logger.Warn("Gateway instance skipped: missing id")
}
continue
}
rail := parseRail(cfg.Rail)
if rail == model.RailUnspecified {
if logger != nil {
logger.Warn("Gateway instance skipped: invalid rail", zap.String("id", id), zap.String("rail", cfg.Rail))
}
continue
}
enabled := true
if cfg.IsEnabled != nil {
enabled = *cfg.IsEnabled
}
result = append(result, &model.GatewayInstanceDescriptor{
ID: id,
Rail: rail,
Network: strings.ToUpper(strings.TrimSpace(cfg.Network)),
Currencies: normalizeCurrencies(cfg.Currencies),
Capabilities: model.RailCapabilities{
CanPayIn: cfg.Capabilities.CanPayIn,
CanPayOut: cfg.Capabilities.CanPayOut,
CanReadBalance: cfg.Capabilities.CanReadBalance,
CanSendFee: cfg.Capabilities.CanSendFee,
RequiresObserveConfirm: cfg.Capabilities.RequiresObserveConfirm,
},
Limits: buildGatewayLimits(cfg.Limits),
Version: strings.TrimSpace(cfg.Version),
IsEnabled: enabled,
})
}
return result
}
func parseRail(value string) model.Rail {
switch strings.ToUpper(strings.TrimSpace(value)) {
case string(model.RailCrypto):
return model.RailCrypto
case string(model.RailProviderSettlement):
return model.RailProviderSettlement
case string(model.RailLedger):
return model.RailLedger
case string(model.RailCardPayout):
return model.RailCardPayout
case string(model.RailFiatOnRamp):
return model.RailFiatOnRamp
default:
return model.RailUnspecified
}
}
func normalizeCurrencies(values []string) []string {
if len(values) == 0 {
return nil
}
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, value := range values {
clean := strings.ToUpper(strings.TrimSpace(value))
if clean == "" || seen[clean] {
continue
}
seen[clean] = true
result = append(result, clean)
}
return result
}
func buildGatewayLimits(cfg limitsConfig) model.Limits {
limits := model.Limits{
MinAmount: strings.TrimSpace(cfg.MinAmount),
MaxAmount: strings.TrimSpace(cfg.MaxAmount),
PerTxMaxFee: strings.TrimSpace(cfg.PerTxMaxFee),
PerTxMinAmount: strings.TrimSpace(cfg.PerTxMinAmount),
PerTxMaxAmount: strings.TrimSpace(cfg.PerTxMaxAmount),
}
if len(cfg.VolumeLimit) > 0 {
limits.VolumeLimit = map[string]string{}
for key, value := range cfg.VolumeLimit {
bucket := strings.TrimSpace(key)
amount := strings.TrimSpace(value)
if bucket == "" || amount == "" {
continue
}
limits.VolumeLimit[bucket] = amount
}
}
if len(cfg.VelocityLimit) > 0 {
limits.VelocityLimit = map[string]int{}
for key, value := range cfg.VelocityLimit {
bucket := strings.TrimSpace(key)
if bucket == "" {
continue
}
limits.VelocityLimit[bucket] = value
}
}
if len(cfg.CurrencyLimits) > 0 {
limits.CurrencyLimits = map[string]model.LimitsOverride{}
for key, override := range cfg.CurrencyLimits {
currency := strings.ToUpper(strings.TrimSpace(key))
if currency == "" {
continue
}
limits.CurrencyLimits[currency] = model.LimitsOverride{
MaxVolume: strings.TrimSpace(override.MaxVolume),
MinAmount: strings.TrimSpace(override.MinAmount),
MaxAmount: strings.TrimSpace(override.MaxAmount),
MaxFee: strings.TrimSpace(override.MaxFee),
MaxOps: override.MaxOps,
}
}
}
return limits
}