Improved payment handling

This commit is contained in:
Stephan D
2026-02-25 19:25:51 +01:00
parent da11be526a
commit af4b68f4c7
65 changed files with 3890 additions and 259 deletions

View File

@@ -15,7 +15,6 @@ type config struct {
*grpcapp.Config `yaml:",inline"`
Fees clientConfig `yaml:"fees"`
Oracle clientConfig `yaml:"oracle"`
Gateway clientConfig `yaml:"gateway"`
QuoteRetentionHrs int `yaml:"quote_retention_hours"`
}

View File

@@ -7,7 +7,6 @@ import (
"time"
oracleclient "github.com/tech/sendico/fx/oracle/client"
chainclient "github.com/tech/sendico/gateway/chain/client"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
"go.uber.org/zap"
"google.golang.org/grpc"
@@ -47,18 +46,12 @@ func (i *Imp) initDependencies(cfg *config) *clientDependencies {
}
}
if gatewayAddress := cfg.Gateway.resolveAddress(); gatewayAddress != "" {
client, err := chainclient.New(context.Background(), chainclient.Config{
Address: gatewayAddress,
DialTimeout: cfg.Gateway.dialTimeout(),
CallTimeout: cfg.Gateway.callTimeout(),
Insecure: cfg.Gateway.InsecureTransport,
})
if err != nil {
i.logger.Warn("Failed to initialise chain gateway client", zap.Error(err), zap.String("address", gatewayAddress))
} else {
deps.gatewayClient = client
}
if i != nil && i.discoveryReg != nil {
i.discoveryClients = newDiscoveryClientResolver(i.logger, i.discoveryReg)
deps.gatewayResolver = discoveryChainGatewayResolver{resolver: i.discoveryClients}
deps.gatewayInvokeResolver = discoveryGatewayInvokeResolver{resolver: i.discoveryClients}
} else if i != nil && i.logger != nil {
i.logger.Warn("Discovery registry unavailable; chain gateway clients disabled")
}
return deps
@@ -72,9 +65,9 @@ func (i *Imp) closeDependencies() {
_ = i.deps.oracleClient.Close()
i.deps.oracleClient = nil
}
if i.deps.gatewayClient != nil {
_ = i.deps.gatewayClient.Close()
i.deps.gatewayClient = nil
if i.discoveryClients != nil {
i.discoveryClients.Close()
i.discoveryClients = nil
}
if i.deps.feesConn != nil {
_ = i.deps.feesConn.Close()

View File

@@ -0,0 +1,276 @@
package serverimp
import (
"context"
"fmt"
"net"
"net/url"
"sort"
"strings"
"sync"
"time"
chainclient "github.com/tech/sendico/gateway/chain/client"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
const discoveryLogThrottle = 30 * time.Second
type discoveryEndpoint struct {
address string
insecure bool
raw string
}
func (e discoveryEndpoint) key() string {
return fmt.Sprintf("%s|%t", e.address, e.insecure)
}
type discoveryClientResolver struct {
logger mlogger.Logger
registry *discovery.Registry
mu sync.Mutex
chainClients map[string]chainclient.Client
lastSelection map[string]string
lastMissing map[string]time.Time
}
func newDiscoveryClientResolver(logger mlogger.Logger, registry *discovery.Registry) *discoveryClientResolver {
if logger != nil {
logger = logger.Named("discovery_clients")
}
return &discoveryClientResolver{
logger: logger,
registry: registry,
chainClients: map[string]chainclient.Client{},
lastSelection: map[string]string{},
lastMissing: map[string]time.Time{},
}
}
func (r *discoveryClientResolver) Close() {
if r == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
for key, client := range r.chainClients {
if client != nil {
_ = client.Close()
}
delete(r.chainClients, key)
}
}
type discoveryGatewayInvokeResolver struct {
resolver *discoveryClientResolver
}
func (r discoveryGatewayInvokeResolver) Resolve(ctx context.Context, invokeURI string) (chainclient.Client, error) {
if r.resolver == nil {
return nil, merrors.NoData("discovery: chain gateway unavailable")
}
return r.resolver.ChainClientByInvokeURI(ctx, invokeURI)
}
type discoveryChainGatewayResolver struct {
resolver *discoveryClientResolver
}
func (r discoveryChainGatewayResolver) Resolve(ctx context.Context, network string) (chainclient.Client, error) {
if r.resolver == nil {
return nil, merrors.NoData("discovery: chain gateway unavailable")
}
return r.resolver.ChainClientByNetwork(ctx, network)
}
func (r *discoveryClientResolver) ChainClientByInvokeURI(ctx context.Context, invokeURI string) (chainclient.Client, error) {
endpoint, err := parseDiscoveryEndpoint(invokeURI)
if err != nil {
r.logMissing("chain", "invalid chain gateway invoke uri", invokeURI, err)
return nil, err
}
if ctx == nil {
ctx = context.Background()
}
r.mu.Lock()
defer r.mu.Unlock()
if client, ok := r.chainClients[endpoint.key()]; ok && client != nil {
return client, nil
}
client, dialErr := chainclient.New(ctx, chainclient.Config{
Address: endpoint.address,
Insecure: endpoint.insecure,
})
if dialErr != nil {
r.logMissing("chain", "failed to dial chain gateway", endpoint.raw, dialErr)
return nil, dialErr
}
r.chainClients[endpoint.key()] = client
return client, nil
}
func (r *discoveryClientResolver) ChainClientByNetwork(ctx context.Context, network string) (chainclient.Client, error) {
entry, ok := r.findChainEntry(network)
if !ok {
if strings.TrimSpace(network) == "" {
return nil, merrors.NoData("discovery: chain gateway unavailable")
}
return nil, merrors.NoData(fmt.Sprintf("discovery: chain gateway unavailable for network %s", strings.ToUpper(strings.TrimSpace(network))))
}
return r.ChainClientByInvokeURI(ctx, entry.InvokeURI)
}
func (r *discoveryClientResolver) findChainEntry(network string) (*discovery.RegistryEntry, bool) {
if r == nil || r.registry == nil {
r.logMissing("chain", "discovery registry unavailable", "", nil)
return nil, false
}
network = strings.ToUpper(strings.TrimSpace(network))
entries := r.registry.List(time.Now(), true)
matches := make([]discovery.RegistryEntry, 0)
for _, entry := range entries {
if discovery.NormalizeRail(entry.Rail) != discovery.RailCrypto {
continue
}
if strings.TrimSpace(entry.InvokeURI) == "" {
continue
}
if network != "" && !strings.EqualFold(strings.TrimSpace(entry.Network), network) {
continue
}
matches = append(matches, entry)
}
if len(matches) == 0 {
r.logMissing("chain", "discovery chain entry missing", "", nil)
return nil, false
}
sort.Slice(matches, func(i, j int) bool {
if matches[i].RoutingPriority != matches[j].RoutingPriority {
return matches[i].RoutingPriority > matches[j].RoutingPriority
}
if matches[i].ID != matches[j].ID {
return matches[i].ID < matches[j].ID
}
return matches[i].InstanceID < matches[j].InstanceID
})
entry := matches[0]
entryKey := discoveryEntryKey(entry)
r.logSelection("chain", entryKey, entry)
return &entry, true
}
func (r *discoveryClientResolver) logSelection(key, entryKey string, entry discovery.RegistryEntry) {
if r == nil {
return
}
r.mu.Lock()
last := r.lastSelection[key]
if last == entryKey {
r.mu.Unlock()
return
}
r.lastSelection[key] = entryKey
r.mu.Unlock()
if r.logger == nil {
return
}
r.logger.Info("Discovery endpoint selected",
zap.String("service_key", key),
zap.String("service", entry.Service),
zap.String("rail", entry.Rail),
zap.String("network", entry.Network),
zap.String("entry_id", entry.ID),
zap.String("instance_id", entry.InstanceID),
zap.String("invoke_uri", entry.InvokeURI))
}
func (r *discoveryClientResolver) logMissing(key, message, invokeURI string, err error) {
if r == nil {
return
}
now := time.Now()
r.mu.Lock()
last := r.lastMissing[key]
if !last.IsZero() && now.Sub(last) < discoveryLogThrottle {
r.mu.Unlock()
return
}
r.lastMissing[key] = now
r.mu.Unlock()
if r.logger == nil {
return
}
fields := []zap.Field{zap.String("service_key", key)}
if invokeURI != "" {
fields = append(fields, zap.String("invoke_uri", strings.TrimSpace(invokeURI)))
}
if err != nil {
fields = append(fields, zap.Error(err))
}
r.logger.Warn(message, fields...)
}
func discoveryEntryKey(entry discovery.RegistryEntry) string {
return fmt.Sprintf("%s|%s|%s|%s|%s|%s",
strings.TrimSpace(entry.Service),
strings.TrimSpace(entry.ID),
strings.TrimSpace(entry.InstanceID),
strings.TrimSpace(entry.Rail),
strings.TrimSpace(entry.Network),
strings.TrimSpace(entry.InvokeURI))
}
func parseDiscoveryEndpoint(raw string) (discoveryEndpoint, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return discoveryEndpoint{}, merrors.InvalidArgument("discovery: invoke uri is required")
}
if !strings.Contains(raw, "://") {
if _, _, splitErr := net.SplitHostPort(raw); splitErr != nil {
return discoveryEndpoint{}, merrors.InvalidArgument("discovery: invoke uri must include host:port")
}
return discoveryEndpoint{address: raw, insecure: true, raw: raw}, nil
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Scheme == "" {
if err != nil {
return discoveryEndpoint{}, err
}
return discoveryEndpoint{}, merrors.InvalidArgument("discovery: invoke uri must include host:port")
}
switch strings.ToLower(strings.TrimSpace(parsed.Scheme)) {
case "grpc":
address := strings.TrimSpace(parsed.Host)
if _, _, splitErr := net.SplitHostPort(address); splitErr != nil {
return discoveryEndpoint{}, merrors.InvalidArgument("discovery: invoke uri must include host:port")
}
return discoveryEndpoint{address: address, insecure: true, raw: raw}, nil
case "grpcs":
address := strings.TrimSpace(parsed.Host)
if _, _, splitErr := net.SplitHostPort(address); splitErr != nil {
return discoveryEndpoint{}, merrors.InvalidArgument("discovery: invoke uri must include host:port")
}
return discoveryEndpoint{address: address, insecure: false, raw: raw}, nil
case "dns", "passthrough":
return discoveryEndpoint{address: raw, insecure: true, raw: raw}, nil
default:
return discoveryEndpoint{}, merrors.InvalidArgument("discovery: unsupported invoke uri scheme")
}
}

View File

@@ -51,8 +51,11 @@ func (i *Imp) Start() error {
if i.deps.oracleClient != nil {
opts = append(opts, quotesvc.WithOracleClient(i.deps.oracleClient))
}
if i.deps.gatewayClient != nil {
opts = append(opts, quotesvc.WithChainGatewayClient(i.deps.gatewayClient))
if i.deps.gatewayResolver != nil {
opts = append(opts, quotesvc.WithChainGatewayResolver(i.deps.gatewayResolver))
}
if i.deps.gatewayInvokeResolver != nil {
opts = append(opts, quotesvc.WithGatewayInvokeResolver(i.deps.gatewayInvokeResolver))
}
}
if registry := quotesvc.NewDiscoveryGatewayRegistry(logger, i.discoveryReg); registry != nil {

View File

@@ -2,7 +2,7 @@ package serverimp
import (
oracleclient "github.com/tech/sendico/fx/oracle/client"
chainclient "github.com/tech/sendico/gateway/chain/client"
quotesvc "github.com/tech/sendico/payments/quotation/internal/service/quotation"
"github.com/tech/sendico/payments/storage"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/mlogger"
@@ -17,10 +17,11 @@ type quoteService interface {
}
type clientDependencies struct {
feesConn *grpc.ClientConn
feesClient feesv1.FeeEngineClient
oracleClient oracleclient.Client
gatewayClient chainclient.Client
feesConn *grpc.ClientConn
feesClient feesv1.FeeEngineClient
oracleClient oracleclient.Client
gatewayResolver quotesvc.ChainGatewayResolver
gatewayInvokeResolver quotesvc.GatewayInvokeResolver
}
type Imp struct {
@@ -36,4 +37,5 @@ type Imp struct {
discoveryWatcher *discovery.RegistryWatcher
discoveryReg *discovery.Registry
discoveryAnnouncer *discovery.Announcer
discoveryClients *discoveryClientResolver
}

View File

@@ -0,0 +1,202 @@
package quotation
import (
"context"
"errors"
"sort"
"strings"
"github.com/tech/sendico/payments/storage/model"
chainpkg "github.com/tech/sendico/pkg/chain"
"github.com/tech/sendico/pkg/merrors"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.uber.org/zap"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type managedWalletNetworkResolver struct {
resolver ChainGatewayResolver
gatewayRegistry GatewayRegistry
gatewayInvokeResolver GatewayInvokeResolver
logger *zap.Logger
}
func newManagedWalletNetworkResolver(core *Service) *managedWalletNetworkResolver {
if core == nil {
return nil
}
logger := core.logger
if logger == nil {
logger = zap.NewNop()
}
return &managedWalletNetworkResolver{
resolver: core.deps.gateway.resolver,
gatewayRegistry: core.deps.gatewayRegistry,
gatewayInvokeResolver: core.deps.gatewayInvokeResolver,
logger: logger,
}
}
func (r *managedWalletNetworkResolver) ResolveManagedWalletNetwork(ctx context.Context, managedWalletRef string) (string, error) {
if r == nil {
return "", merrors.NoData("chain gateway unavailable")
}
walletRef := strings.TrimSpace(managedWalletRef)
if walletRef == "" {
return "", merrors.InvalidArgument("managed_wallet_ref is required")
}
var discoveryErr error
if r.gatewayRegistry != nil && r.gatewayInvokeResolver != nil {
network, err := r.resolveFromDiscoveredGateways(ctx, walletRef)
if err == nil {
return network, nil
}
discoveryErr = err
if r.logger != nil {
r.logger.Warn("Managed wallet network lookup via discovery failed",
zap.String("wallet_ref", walletRef),
zap.Error(err),
)
}
}
if r.resolver == nil {
if discoveryErr != nil {
return "", discoveryErr
}
return "", merrors.NoData("chain gateway unavailable")
}
client, err := r.resolver.Resolve(ctx, "")
if err != nil {
return "", err
}
if client == nil {
return "", merrors.NoData("chain gateway unavailable")
}
resp, err := client.GetManagedWallet(ctx, &chainv1.GetManagedWalletRequest{WalletRef: walletRef})
if err != nil {
return "", err
}
return managedWalletNetworkFromResponse(resp)
}
func (r *managedWalletNetworkResolver) resolveFromDiscoveredGateways(ctx context.Context, walletRef string) (string, error) {
entries, err := r.gatewayRegistry.List(ctx)
if err != nil {
return "", err
}
type candidate struct {
gatewayID string
instanceID string
network string
invokeURI string
}
candidates := make([]candidate, 0, len(entries))
seenInvokeURI := map[string]struct{}{}
for _, entry := range entries {
if entry == nil || !entry.IsEnabled || entry.Rail != model.RailCrypto {
continue
}
invokeURI := strings.TrimSpace(entry.InvokeURI)
if invokeURI == "" {
continue
}
key := strings.ToLower(invokeURI)
if _, exists := seenInvokeURI[key]; exists {
continue
}
seenInvokeURI[key] = struct{}{}
candidates = append(candidates, candidate{
gatewayID: strings.TrimSpace(entry.ID),
instanceID: strings.TrimSpace(entry.InstanceID),
network: strings.ToUpper(strings.TrimSpace(entry.Network)),
invokeURI: invokeURI,
})
}
if len(candidates) == 0 {
return "", merrors.NoData("chain gateway unavailable")
}
sort.Slice(candidates, func(i, j int) bool {
if candidates[i].gatewayID != candidates[j].gatewayID {
return candidates[i].gatewayID < candidates[j].gatewayID
}
if candidates[i].instanceID != candidates[j].instanceID {
return candidates[i].instanceID < candidates[j].instanceID
}
return candidates[i].invokeURI < candidates[j].invokeURI
})
var firstErr error
for _, candidate := range candidates {
client, resolveErr := r.gatewayInvokeResolver.Resolve(ctx, candidate.invokeURI)
if resolveErr != nil {
if firstErr == nil {
firstErr = resolveErr
}
continue
}
resp, lookupErr := client.GetManagedWallet(ctx, &chainv1.GetManagedWalletRequest{WalletRef: walletRef})
if lookupErr != nil {
if isManagedWalletNotFound(lookupErr) {
continue
}
if firstErr == nil {
firstErr = lookupErr
}
continue
}
network, extractErr := managedWalletNetworkFromResponse(resp)
if extractErr != nil {
if firstErr == nil {
firstErr = extractErr
}
continue
}
if r.logger != nil {
r.logger.Debug("Resolved managed wallet network from discovered gateway",
zap.String("wallet_ref", walletRef),
zap.String("gateway_id", candidate.gatewayID),
zap.String("instance_id", candidate.instanceID),
zap.String("gateway_network", candidate.network),
zap.String("resolved_network", network),
)
}
return network, nil
}
if firstErr != nil {
return "", firstErr
}
return "", merrors.NoData("managed wallet not found in discovered gateways")
}
func managedWalletNetworkFromResponse(resp *chainv1.GetManagedWalletResponse) (string, error) {
wallet := resp.GetWallet()
if wallet == nil || wallet.GetAsset() == nil {
return "", merrors.NoData("managed wallet asset is missing")
}
network := strings.ToUpper(strings.TrimSpace(chainpkg.NetworkAlias(wallet.GetAsset().GetChain())))
if network == "" || network == "UNSPECIFIED" {
return "", merrors.NoData("managed wallet network is missing")
}
return network, nil
}
func isManagedWalletNotFound(err error) bool {
if err == nil {
return false
}
if errors.Is(err, merrors.ErrNoData) {
return true
}
if st, ok := status.FromError(err); ok && st.Code() == codes.NotFound {
return true
}
msg := strings.ToLower(strings.TrimSpace(err.Error()))
return strings.Contains(msg, "not_found")
}

View File

@@ -0,0 +1,139 @@
package quotation
import (
"context"
"errors"
"reflect"
"testing"
chainclient "github.com/tech/sendico/gateway/chain/client"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.uber.org/zap"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func TestManagedWalletNetworkResolver_ResolvesAcrossDiscoveredGateways(t *testing.T) {
invokeResolver := &fakeGatewayInvokeResolver{
clients: map[string]chainclient.Client{
"gw-a:50053": &chainclient.Fake{
GetManagedWalletFn: func(context.Context, *chainv1.GetManagedWalletRequest) (*chainv1.GetManagedWalletResponse, error) {
return nil, status.Error(codes.NotFound, "not_found")
},
},
"gw-b:50053": &chainclient.Fake{
GetManagedWalletFn: func(context.Context, *chainv1.GetManagedWalletRequest) (*chainv1.GetManagedWalletResponse, error) {
return &chainv1.GetManagedWalletResponse{
Wallet: &chainv1.ManagedWallet{
Asset: &chainv1.Asset{Chain: chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE},
},
}, nil
},
},
},
}
resolver := &managedWalletNetworkResolver{
gatewayRegistry: fakeGatewayRegistry{
items: []*model.GatewayInstanceDescriptor{
{ID: "gw-a", Rail: model.RailCrypto, IsEnabled: true, InvokeURI: "gw-a:50053"},
{ID: "gw-b", Rail: model.RailCrypto, IsEnabled: true, InvokeURI: "gw-b:50053"},
},
},
gatewayInvokeResolver: invokeResolver,
logger: zap.NewNop(),
}
network, err := resolver.ResolveManagedWalletNetwork(context.Background(), "wallet-ref")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got, want := network, "TRON_NILE"; got != want {
t.Fatalf("network mismatch: got=%q want=%q", got, want)
}
if got, want := invokeResolver.calls, []string{"gw-a:50053", "gw-b:50053"}; !reflect.DeepEqual(got, want) {
t.Fatalf("invoke calls mismatch: got=%v want=%v", got, want)
}
}
func TestManagedWalletNetworkResolver_FallbacksToChainResolver(t *testing.T) {
chainResolver := &fakeChainResolver{
client: &chainclient.Fake{
GetManagedWalletFn: func(context.Context, *chainv1.GetManagedWalletRequest) (*chainv1.GetManagedWalletResponse, error) {
return &chainv1.GetManagedWalletResponse{
Wallet: &chainv1.ManagedWallet{
Asset: &chainv1.Asset{Chain: chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_SEPOLIA},
},
}, nil
},
},
}
resolver := &managedWalletNetworkResolver{
resolver: chainResolver,
logger: zap.NewNop(),
}
network, err := resolver.ResolveManagedWalletNetwork(context.Background(), "wallet-ref")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got, want := network, "ARBITRUM_SEPOLIA"; got != want {
t.Fatalf("network mismatch: got=%q want=%q", got, want)
}
if got, want := chainResolver.args, []string{""}; !reflect.DeepEqual(got, want) {
t.Fatalf("resolver args mismatch: got=%v want=%v", got, want)
}
}
func TestManagedWalletNetworkResolver_ReturnsNoDataWhenNoGateways(t *testing.T) {
resolver := &managedWalletNetworkResolver{
gatewayRegistry: fakeGatewayRegistry{},
gatewayInvokeResolver: &fakeGatewayInvokeResolver{
clients: map[string]chainclient.Client{},
},
logger: zap.NewNop(),
}
_, err := resolver.ResolveManagedWalletNetwork(context.Background(), "wallet-ref")
if !errors.Is(err, merrors.ErrNoData) {
t.Fatalf("expected no_data error, got %v", err)
}
}
type fakeGatewayRegistry struct {
items []*model.GatewayInstanceDescriptor
err error
}
func (f fakeGatewayRegistry) List(context.Context) ([]*model.GatewayInstanceDescriptor, error) {
return f.items, f.err
}
type fakeGatewayInvokeResolver struct {
clients map[string]chainclient.Client
err error
calls []string
}
func (f *fakeGatewayInvokeResolver) Resolve(_ context.Context, invokeURI string) (chainclient.Client, error) {
f.calls = append(f.calls, invokeURI)
if f.err != nil {
return nil, f.err
}
return f.clients[invokeURI], nil
}
type fakeChainResolver struct {
client chainclient.Client
err error
args []string
}
func (f *fakeChainResolver) Resolve(_ context.Context, network string) (chainclient.Client, error) {
f.args = append(f.args, network)
if f.err != nil {
return nil, f.err
}
return f.client, nil
}

View File

@@ -74,14 +74,6 @@ func (o oracleDependency) available() bool {
return true
}
type staticChainGatewayResolver struct {
client chainclient.Client
}
func (r staticChainGatewayResolver) Resolve(context.Context, string) (chainclient.Client, error) {
return r.client, nil
}
// WithFeeEngine wires the fee engine client.
func WithFeeEngine(client feesv1.FeeEngineClient, timeout time.Duration) Option {
return func(s *Service) {
@@ -96,13 +88,6 @@ func WithOracleClient(client oracleclient.Client) Option {
}
}
// WithChainGatewayClient wires the chain gateway client.
func WithChainGatewayClient(client chainclient.Client) Option {
return func(s *Service) {
s.deps.gateway = gatewayDependency{resolver: staticChainGatewayResolver{client: client}}
}
}
// WithChainGatewayResolver wires a resolver for chain gateway clients.
func WithChainGatewayResolver(resolver ChainGatewayResolver) Option {
return func(s *Service) {

View File

@@ -53,6 +53,9 @@ func newQuoteComputationService(core *Service) *quote_computation_service.QuoteC
if core != nil && core.deps.gatewayRegistry != nil {
opts = append(opts, quote_computation_service.WithGatewayRegistry(core.deps.gatewayRegistry))
}
if resolver := newManagedWalletNetworkResolver(core); resolver != nil {
opts = append(opts, quote_computation_service.WithManagedWalletNetworkResolver(resolver))
}
if resolver := fundingProfileResolver(core); resolver != nil {
opts = append(opts, quote_computation_service.WithFundingProfileResolver(resolver))
}

View File

@@ -0,0 +1,69 @@
package quote_computation_service
import (
"context"
"strings"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
"go.uber.org/zap"
)
func (s *QuoteComputationService) enrichManagedWalletEndpointNetwork(
ctx context.Context,
endpoint *model.PaymentEndpoint,
cache map[string]string,
) error {
if s == nil || endpoint == nil {
return nil
}
if endpoint.Type != model.EndpointTypeManagedWallet || endpoint.ManagedWallet == nil {
return nil
}
walletRef := strings.TrimSpace(endpoint.ManagedWallet.ManagedWalletRef)
if walletRef == "" {
return merrors.InvalidArgument("managed_wallet_ref is required")
}
if endpoint.ManagedWallet.Asset != nil && strings.TrimSpace(endpoint.ManagedWallet.Asset.GetChain()) != "" {
return nil
}
if s.managedWalletNetworkResolver == nil {
return nil
}
network := ""
if cache != nil {
network = strings.ToUpper(strings.TrimSpace(cache[walletRef]))
}
if network == "" {
resolved, err := s.managedWalletNetworkResolver.ResolveManagedWalletNetwork(ctx, walletRef)
if err != nil {
return err
}
network = strings.ToUpper(strings.TrimSpace(resolved))
if network == "" {
return merrors.NoData("managed wallet network is missing")
}
if cache != nil {
cache[walletRef] = network
}
}
if s.logger != nil {
s.logger.Debug("Managed wallet network resolved for quote planning",
zap.String("wallet_ref", walletRef),
zap.String("network", network),
)
}
asset := endpoint.ManagedWallet.Asset
if asset == nil {
asset = &paymenttypes.Asset{}
endpoint.ManagedWallet.Asset = asset
}
asset.Chain = network
asset.TokenSymbol = strings.ToUpper(strings.TrimSpace(asset.TokenSymbol))
asset.ContractAddress = strings.TrimSpace(asset.ContractAddress)
return nil
}

View File

@@ -0,0 +1,185 @@
package quote_computation_service
import (
"context"
"errors"
"testing"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestBuildPlan_ResolvesManagedWalletNetworkFromResolver(t *testing.T) {
resolver := &fakeManagedWalletNetworkResolver{
networks: map[string]string{
"wallet-usdt-source": "TRON_NILE",
},
}
svc := New(nil,
WithManagedWalletNetworkResolver(resolver),
WithGatewayRegistry(staticGatewayRegistry{
items: []*model.GatewayInstanceDescriptor{
{
ID: "crypto-arbitrum",
InstanceID: "crypto-arbitrum",
Rail: model.RailCrypto,
Network: "ARBITRUM_SEPOLIA",
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
IsEnabled: true,
},
{
ID: "crypto-tron",
InstanceID: "crypto-tron",
Rail: model.RailCrypto,
Network: "TRON_NILE",
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
IsEnabled: true,
},
{
ID: "card-gw",
InstanceID: "card-gw",
Rail: model.RailCardPayout,
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
IsEnabled: true,
},
},
}),
)
intent := sampleCryptoToCardQuoteIntent()
intent.Source.ManagedWallet.Asset = nil
orgID := bson.NewObjectID()
planModel, err := svc.BuildPlan(context.Background(), ComputeInput{
OrganizationRef: orgID.Hex(),
OrganizationID: orgID,
BaseIdempotencyKey: "idem-wallet-network",
Intents: []*transfer_intent_hydrator.QuoteIntent{intent},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if planModel == nil || len(planModel.Items) != 1 || planModel.Items[0] == nil {
t.Fatalf("expected one plan item")
}
item := planModel.Items[0]
if got, want := item.Steps[0].GatewayID, "crypto-tron"; got != want {
t.Fatalf("unexpected source gateway: got=%q want=%q", got, want)
}
if item.Route == nil || len(item.Route.GetHops()) == 0 {
t.Fatalf("expected route hops")
}
if got, want := item.Route.GetHops()[0].GetNetwork(), "tron_nile"; got != want {
t.Fatalf("unexpected source hop network: got=%q want=%q", got, want)
}
if got, want := resolver.calls, 1; got != want {
t.Fatalf("unexpected resolver calls: got=%d want=%d", got, want)
}
}
func TestBuildPlan_ManagedWalletNetworkResolverCachesByWalletRef(t *testing.T) {
resolver := &fakeManagedWalletNetworkResolver{
networks: map[string]string{
"wallet-usdt-source": "TRON_NILE",
},
}
svc := New(nil,
WithManagedWalletNetworkResolver(resolver),
WithGatewayRegistry(staticGatewayRegistry{
items: []*model.GatewayInstanceDescriptor{
{
ID: "crypto-tron",
InstanceID: "crypto-tron",
Rail: model.RailCrypto,
Network: "TRON_NILE",
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
IsEnabled: true,
},
{
ID: "card-gw",
InstanceID: "card-gw",
Rail: model.RailCardPayout,
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
IsEnabled: true,
},
},
}),
)
intentA := sampleCryptoToCardQuoteIntent()
intentA.Ref = "intent-a"
intentA.Source.ManagedWallet.Asset = nil
intentB := sampleCryptoToCardQuoteIntent()
intentB.Ref = "intent-b"
intentB.Source.ManagedWallet.Asset = nil
orgID := bson.NewObjectID()
planModel, err := svc.BuildPlan(context.Background(), ComputeInput{
OrganizationRef: orgID.Hex(),
OrganizationID: orgID,
PreviewOnly: true,
Intents: []*transfer_intent_hydrator.QuoteIntent{intentA, intentB},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if planModel == nil || len(planModel.Items) != 2 {
t.Fatalf("expected two plan items")
}
if got, want := resolver.calls, 1; got != want {
t.Fatalf("unexpected resolver calls: got=%d want=%d", got, want)
}
}
func TestBuildPlan_FailsWhenManagedWalletNetworkResolutionFails(t *testing.T) {
resolver := &fakeManagedWalletNetworkResolver{
err: merrors.NoData("wallet not found"),
}
svc := New(nil, WithManagedWalletNetworkResolver(resolver))
intent := sampleCryptoToCardQuoteIntent()
intent.Source.ManagedWallet.Asset = nil
orgID := bson.NewObjectID()
_, err := svc.BuildPlan(context.Background(), ComputeInput{
OrganizationRef: orgID.Hex(),
OrganizationID: orgID,
BaseIdempotencyKey: "idem-wallet-network-fail",
Intents: []*transfer_intent_hydrator.QuoteIntent{intent},
})
if !errors.Is(err, merrors.ErrNoData) {
t.Fatalf("expected no_data error, got %v", err)
}
}
type fakeManagedWalletNetworkResolver struct {
networks map[string]string
err error
calls int
}
func (f *fakeManagedWalletNetworkResolver) ResolveManagedWalletNetwork(_ context.Context, managedWalletRef string) (string, error) {
f.calls++
if f.err != nil {
return "", f.err
}
if f.networks == nil {
return "", nil
}
return f.networks[managedWalletRef], nil
}

View File

@@ -51,9 +51,10 @@ func (s *QuoteComputationService) BuildPlan(ctx context.Context, in ComputeInput
BaseIdempotencyKey: strings.TrimSpace(in.BaseIdempotencyKey),
Items: make([]*QuoteComputationPlanItem, 0, len(in.Intents)),
}
managedWalletNetworks := map[string]string{}
for i, intent := range in.Intents {
item, err := s.buildPlanItem(ctx, in, i, intent)
item, err := s.buildPlanItem(ctx, in, i, intent, managedWalletNetworks)
if err != nil {
s.logger.Warn("Computation plan item build failed",
zap.String("org_ref", in.OrganizationRef),
@@ -81,6 +82,7 @@ func (s *QuoteComputationService) buildPlanItem(
in ComputeInput,
index int,
intent *transfer_intent_hydrator.QuoteIntent,
managedWalletNetworks map[string]string,
) (*QuoteComputationPlanItem, error) {
if intent == nil {
s.logger.Warn("Plan item build failed: intent is nil", zap.Int("index", index))
@@ -118,6 +120,22 @@ func (s *QuoteComputationService) buildPlanItem(
source := clonePaymentEndpoint(modelIntent.Source)
destination := clonePaymentEndpoint(modelIntent.Destination)
if err := s.enrichManagedWalletEndpointNetwork(ctx, &source, managedWalletNetworks); err != nil {
s.logger.Warn("Plan item build failed: source managed wallet network resolution error",
zap.Int("index", index),
zap.Error(err),
)
return nil, err
}
if err := s.enrichManagedWalletEndpointNetwork(ctx, &destination, managedWalletNetworks); err != nil {
s.logger.Warn("Plan item build failed: destination managed wallet network resolution error",
zap.Int("index", index),
zap.Error(err),
)
return nil, err
}
modelIntent.Source = clonePaymentEndpoint(source)
modelIntent.Destination = clonePaymentEndpoint(destination)
sourceRail, sourceNetwork, err := plan.RailFromEndpoint(source, modelIntent.Attributes, true)
if err != nil {

View File

@@ -15,15 +15,20 @@ type Core interface {
BuildQuote(ctx context.Context, in BuildQuoteInput) (*ComputedQuote, time.Time, error)
}
type ManagedWalletNetworkResolver interface {
ResolveManagedWalletNetwork(ctx context.Context, managedWalletRef string) (string, error)
}
type Option func(*QuoteComputationService)
type QuoteComputationService struct {
core Core
fundingResolver gateway_funding_profile.FundingProfileResolver
gatewayRegistry plan.GatewayRegistry
routeStore plan.RouteStore
pathFinder *graph_path_finder.GraphPathFinder
logger mlogger.Logger
core Core
fundingResolver gateway_funding_profile.FundingProfileResolver
gatewayRegistry plan.GatewayRegistry
managedWalletNetworkResolver ManagedWalletNetworkResolver
routeStore plan.RouteStore
pathFinder *graph_path_finder.GraphPathFinder
logger mlogger.Logger
}
func New(core Core, opts ...Option) *QuoteComputationService {
@@ -56,6 +61,14 @@ func WithGatewayRegistry(registry plan.GatewayRegistry) Option {
}
}
func WithManagedWalletNetworkResolver(resolver ManagedWalletNetworkResolver) Option {
return func(svc *QuoteComputationService) {
if svc != nil {
svc.managedWalletNetworkResolver = resolver
}
}
}
func WithRouteStore(store plan.RouteStore) Option {
return func(svc *QuoteComputationService) {
if svc != nil {