move api/server to api/edge/bff

This commit is contained in:
Stephan D
2026-02-28 00:39:20 +01:00
parent 34182af3b8
commit 98db0e4e9e
248 changed files with 406 additions and 18 deletions

View File

@@ -0,0 +1,264 @@
package walletapiimp
import (
"context"
"crypto/tls"
"net/http"
"strings"
"sync"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
)
func (a *WalletAPI) getWalletBalance(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
orgRef, err := a.oph.GetRef(r)
if err != nil {
a.logger.Warn("Failed to parse organization reference for wallet balance", zap.Error(err), zap.String(a.oph.Name(), a.oph.GetID(r)))
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
}
walletRef := strings.TrimSpace(a.wph.GetID(r))
if walletRef == "" {
return response.BadReference(a.logger, a.Name(), a.wph.Name(), a.wph.GetID(r), merrors.InvalidArgument("wallet reference is required"))
}
ctx := r.Context()
res, err := a.enf.Enforce(ctx, a.balancesPermissionRef, account.ID, orgRef, bson.NilObjectID, model.ActionRead)
if err != nil {
a.logger.Warn("Failed to check wallet balance permissions", zap.Error(err), mutil.PLog(a.oph, r), zap.String("wallet_ref", walletRef))
return response.Auto(a.logger, a.Name(), err)
}
if !res {
a.logger.Debug("Access denied when reading wallet balance", mutil.PLog(a.oph, r), zap.String("wallet_ref", walletRef))
return response.AccessDenied(a.logger, a.Name(), "wallet balance read permission denied")
}
if a.discovery == nil {
return response.Internal(a.logger, mservice.ChainGateway, merrors.Internal("discovery client is not configured"))
}
// Discover CRYPTO rail gateways
lookupCtx, cancel := context.WithTimeout(ctx, discoveryLookupTimeout)
defer cancel()
lookupResp, err := a.discovery.Lookup(lookupCtx)
if err != nil {
a.logger.Warn("Failed to lookup discovery registry", zap.Error(err))
return response.Auto(a.logger, a.Name(), err)
}
// Filter gateways by CRYPTO rail
cryptoGateways := filterCryptoGateways(lookupResp.Gateways)
if len(cryptoGateways) == 0 {
a.logger.Debug("No CRYPTO rail gateways found in discovery")
return response.Auto(a.logger, a.Name(), merrors.NoData("no crypto gateways available"))
}
a.logger.Debug("Resolved CRYPTO gateways for wallet balance lookup",
zap.String("organization_ref", orgRef.Hex()),
zap.String("wallet_ref", walletRef),
zap.Int("gateway_count", len(cryptoGateways)))
route, routeErr := a.walletRoute(ctx, orgRef.Hex(), walletRef)
if routeErr != nil {
a.logger.Warn("Failed to resolve wallet route", zap.Error(routeErr), zap.String("wallet_ref", walletRef), zap.String("organization_ref", orgRef.Hex()))
}
if route != nil {
a.logger.Debug("Resolved stored wallet route",
zap.String("organization_ref", orgRef.Hex()),
zap.String("wallet_ref", walletRef),
zap.String("route_network", route.Network),
zap.String("route_gateway_id", route.GatewayID))
preferred := findGatewayForRoute(cryptoGateways, route)
if preferred != nil {
a.logger.Debug("Using preferred gateway from stored wallet route",
zap.String("organization_ref", orgRef.Hex()),
zap.String("wallet_ref", walletRef),
zap.String("gateway_id", preferred.ID),
zap.String("network", preferred.Network),
zap.String("invoke_uri", preferred.InvokeURI))
bal, preferredErr := a.queryGatewayBalance(ctx, *preferred, walletRef)
if preferredErr == nil && bal != nil {
a.rememberWalletRoute(ctx, orgRef.Hex(), walletRef, preferred.Network, preferred.ID)
a.logger.Debug("Wallet balance resolved via preferred gateway",
zap.String("organization_ref", orgRef.Hex()),
zap.String("wallet_ref", walletRef),
zap.String("gateway_id", preferred.ID),
zap.String("network", preferred.Network))
return sresponse.WalletBalanceFromConnector(a.logger, bal, token)
}
if preferredErr != nil {
a.logger.Debug("Preferred gateway balance lookup failed, falling back to fan-out",
zap.String("wallet_ref", walletRef),
zap.String("network", route.Network),
zap.String("gateway_id", preferred.ID),
zap.String("invoke_uri", preferred.InvokeURI),
zap.Error(preferredErr))
} else {
a.logger.Debug("Preferred gateway returned empty balance, falling back to fan-out",
zap.String("wallet_ref", walletRef),
zap.String("network", route.Network),
zap.String("gateway_id", preferred.ID),
zap.String("invoke_uri", preferred.InvokeURI))
}
cryptoGateways = dropGatewayByInvokeURI(cryptoGateways, preferred.InvokeURI)
if len(cryptoGateways) == 0 {
if preferredErr != nil {
a.logger.Warn("Failed to fetch wallet balance from preferred gateway", zap.Error(preferredErr), zap.String("wallet_ref", walletRef))
return response.Auto(a.logger, a.Name(), preferredErr)
}
a.logger.Warn("Wallet balance not found on preferred gateway", zap.String("wallet_ref", walletRef))
return response.Auto(a.logger, a.Name(), merrors.NoData("wallet not found"))
}
} else {
a.logger.Warn("Stored wallet route did not match any healthy discovery gateway",
zap.String("organization_ref", orgRef.Hex()),
zap.String("wallet_ref", walletRef),
zap.String("route_network", route.Network),
zap.String("route_gateway_id", route.GatewayID))
}
} else {
a.logger.Debug("Stored wallet route not found; using gateway fallback",
zap.String("organization_ref", orgRef.Hex()),
zap.String("wallet_ref", walletRef))
}
// Fall back to querying remaining gateways in parallel.
a.logger.Debug("Starting fallback wallet balance fan-out",
zap.String("organization_ref", orgRef.Hex()),
zap.String("wallet_ref", walletRef),
zap.Int("gateway_count", len(cryptoGateways)))
bal, err := a.queryBalanceFromGateways(ctx, cryptoGateways, orgRef.Hex(), walletRef)
if err != nil {
a.logger.Warn("Failed to fetch wallet balance from gateways", zap.Error(err), zap.String("wallet_ref", walletRef))
return response.Auto(a.logger, a.Name(), err)
}
if bal == nil {
a.logger.Warn("Wallet balance not found on any gateway", zap.String("wallet_ref", walletRef))
return response.Auto(a.logger, a.Name(), merrors.NoData("wallet not found"))
}
return sresponse.WalletBalanceFromConnector(a.logger, bal, token)
}
func (a *WalletAPI) queryBalanceFromGateways(ctx context.Context, gateways []discovery.GatewaySummary, organizationRef string, walletRef string) (*connectorv1.Balance, error) {
var mu sync.Mutex
var wg sync.WaitGroup
var result *connectorv1.Balance
var lastErr error
selectedGatewayID := ""
selectedNetwork := ""
a.logger.Debug("Querying wallet balance across gateways",
zap.String("organization_ref", organizationRef),
zap.String("wallet_ref", walletRef),
zap.Int("gateway_count", len(gateways)))
for _, gw := range gateways {
wg.Add(1)
go func(gateway discovery.GatewaySummary) {
defer wg.Done()
bal, err := a.queryGatewayBalance(ctx, gateway, walletRef)
if err != nil {
a.logger.Debug("Failed to query gateway for balance",
zap.String("gateway_id", gateway.ID),
zap.String("invoke_uri", gateway.InvokeURI),
zap.String("wallet_ref", walletRef),
zap.Error(err))
mu.Lock()
lastErr = err
mu.Unlock()
return
}
if bal != nil {
mu.Lock()
if result == nil {
result = bal
a.rememberWalletRoute(ctx, organizationRef, walletRef, gateway.Network, gateway.ID)
selectedGatewayID = gateway.ID
selectedNetwork = gateway.Network
a.logger.Debug("Found wallet balance on gateway",
zap.String("gateway_id", gateway.ID),
zap.String("network", gateway.Network),
zap.String("wallet_ref", walletRef))
}
mu.Unlock()
}
}(gw)
}
wg.Wait()
if result != nil {
a.logger.Debug("Wallet balance fan-out completed with result",
zap.String("organization_ref", organizationRef),
zap.String("wallet_ref", walletRef),
zap.String("gateway_id", selectedGatewayID),
zap.String("network", selectedNetwork))
return result, nil
}
if lastErr != nil {
a.logger.Debug("Wallet balance fan-out completed with errors",
zap.String("organization_ref", organizationRef),
zap.String("wallet_ref", walletRef),
zap.Error(lastErr))
return nil, lastErr
}
a.logger.Debug("Wallet balance fan-out completed without result",
zap.String("organization_ref", organizationRef),
zap.String("wallet_ref", walletRef))
return nil, nil
}
func (a *WalletAPI) queryGatewayBalance(ctx context.Context, gateway discovery.GatewaySummary, walletRef string) (*connectorv1.Balance, error) {
// Create connection with timeout
dialCtx, cancel := context.WithTimeout(ctx, a.dialTimeout)
defer cancel()
var dialOpts []grpc.DialOption
if a.insecure {
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
} else {
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
}
conn, err := grpc.DialContext(dialCtx, gateway.InvokeURI, dialOpts...)
if err != nil {
return nil, merrors.InternalWrap(err, "dial gateway")
}
defer conn.Close()
client := connectorv1.NewConnectorServiceClient(conn)
// Call with timeout
callCtx, callCancel := context.WithTimeout(ctx, a.callTimeout)
defer callCancel()
req := &connectorv1.GetBalanceRequest{
AccountRef: &connectorv1.AccountRef{
AccountId: walletRef,
},
}
resp, err := client.GetBalance(callCtx, req)
if err != nil {
return nil, err
}
return resp.GetBalance(), nil
}

View File

@@ -0,0 +1,203 @@
package walletapiimp
import (
"context"
"crypto/tls"
"encoding/json"
"net/http"
"strings"
"github.com/google/uuid"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/mutil/mzap"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
"github.com/tech/sendico/server/interface/api/srequest"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/types/known/structpb"
)
func (a *WalletAPI) create(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
orgRef, err := a.oph.GetRef(r)
if err != nil {
a.logger.Warn("Failed to parse organization reference for wallet list", zap.Error(err), zap.String(a.oph.Name(), a.oph.GetID(r)))
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
}
var sr srequest.CreateWallet
if err := json.NewDecoder(r.Body).Decode(&sr); err != nil {
a.logger.Warn("Failed to decode wallet creation request request", zap.Error(err), mzap.StorableRef(account))
return response.BadPayload(a.logger, a.Name(), err)
}
ctx := r.Context()
res, err := a.enf.Enforce(ctx, a.walletsPermissionRef, account.ID, orgRef, bson.NilObjectID, model.ActionCreate)
if err != nil {
a.logger.Warn("Failed to check chain wallet access permissions", zap.Error(err), mutil.PLog(a.oph, r), mzap.StorableRef(account))
return response.Auto(a.logger, a.Name(), err)
}
if !res {
a.logger.Debug("Access denied when listing organization wallets", mutil.PLog(a.oph, r), mzap.StorableRef(account))
return response.AccessDenied(a.logger, a.Name(), "wallets creation permission denied")
}
asset, err := a.assets.Resolve(ctx, sr.Asset)
if err != nil {
a.logger.Warn("Failed to resolve asset", zap.Error(err), mzap.StorableRef(account),
zap.String("chain", string(sr.Asset.Chain)), zap.String("token", sr.Asset.TokenSymbol))
return response.Auto(a.logger, a.Name(), err)
}
if a.discovery == nil {
return response.Internal(a.logger, mservice.ChainGateway, merrors.Internal("discovery client is not configured"))
}
// Find gateway for this network
lookupCtx, cancel := context.WithTimeout(ctx, discoveryLookupTimeout)
defer cancel()
lookupResp, err := a.discovery.Lookup(lookupCtx)
if err != nil {
a.logger.Warn("Failed to lookup discovery registry", zap.Error(err))
return response.Auto(a.logger, a.Name(), err)
}
// Find gateway that handles this network
networkName := strings.ToLower(string(asset.Asset.Chain))
gateway := findGatewayForNetwork(lookupResp.Gateways, networkName)
if gateway == nil {
a.logger.Warn("No gateway found for network",
zap.String("network", networkName),
zap.String("chain", string(sr.Asset.Chain)))
return response.Auto(a.logger, a.Name(), merrors.InvalidArgument("no gateway available for network: "+networkName))
}
a.logger.Debug("Selected gateway for wallet creation",
zap.String("organization_ref", orgRef.Hex()),
zap.String("network", networkName),
zap.String("gateway_id", gateway.ID),
zap.String("gateway_network", gateway.Network),
zap.String("invoke_uri", gateway.InvokeURI))
var ownerRef string
if sr.OwnerRef != nil && !sr.OwnerRef.IsZero() {
ownerRef = sr.OwnerRef.Hex()
}
// Build params for connector OpenAccount
params := map[string]interface{}{
"organization_ref": orgRef.Hex(),
"network": networkName,
"token_symbol": asset.Asset.TokenSymbol,
"contract_address": asset.Asset.ContractAddress,
}
if sr.Description.Description != nil {
params["description"] = *sr.Description.Description
}
params["metadata"] = map[string]interface{}{
"source": "create",
"login": account.Login,
}
paramsStruct, _ := structpb.NewStruct(params)
assetString := networkName + "-" + asset.Asset.TokenSymbol
req := &connectorv1.OpenAccountRequest{
IdempotencyKey: uuid.NewString(),
Kind: connectorv1.AccountKind_CHAIN_MANAGED_WALLET,
Asset: assetString,
OwnerRef: ownerRef,
Label: sr.Description.Name,
Params: paramsStruct,
}
// Connect to gateway and create wallet
walletRef, err := a.createWalletOnGateway(ctx, *gateway, req)
if err != nil {
a.logger.Warn("Failed to create managed wallet", zap.Error(err),
mzap.ObjRef("organization_ref", orgRef), mzap.StorableRef(account),
zap.String("gateway_id", gateway.ID), zap.String("network", gateway.Network))
return response.Auto(a.logger, a.Name(), err)
}
a.logger.Info("Managed wallet created for organization", mzap.ObjRef("organization_ref", orgRef),
zap.String("wallet_ref", walletRef), mzap.StorableRef(account),
zap.String("gateway_id", gateway.ID), zap.String("network", gateway.Network))
a.rememberWalletRoute(ctx, orgRef.Hex(), walletRef, networkName, gateway.ID)
a.rememberWalletRoute(ctx, orgRef.Hex(), walletRef, gateway.Network, gateway.ID)
a.logger.Debug("Persisted wallet route after wallet creation",
zap.String("organization_ref", orgRef.Hex()),
zap.String("wallet_ref", walletRef),
zap.String("network", networkName),
zap.String("gateway_id", gateway.ID))
return sresponse.Success(a.logger, token)
}
func findGatewayForNetwork(gateways []discovery.GatewaySummary, network string) *discovery.GatewaySummary {
network = strings.ToLower(strings.TrimSpace(network))
for _, gw := range gateways {
if !strings.EqualFold(gw.Rail, cryptoRail) || !gw.Healthy || strings.TrimSpace(gw.InvokeURI) == "" {
continue
}
// Check if gateway network matches
gwNetwork := strings.ToLower(strings.TrimSpace(gw.Network))
if gwNetwork == network {
return &gw
}
// Also check if network starts with gateway network prefix (e.g., "tron" matches "tron_mainnet")
if strings.HasPrefix(network, gwNetwork) || strings.HasPrefix(gwNetwork, network) {
return &gw
}
}
return nil
}
func (a *WalletAPI) createWalletOnGateway(ctx context.Context, gateway discovery.GatewaySummary, req *connectorv1.OpenAccountRequest) (string, error) {
// Create connection with timeout
dialCtx, cancel := context.WithTimeout(ctx, a.dialTimeout)
defer cancel()
var dialOpts []grpc.DialOption
if a.insecure {
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
} else {
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
}
conn, err := grpc.DialContext(dialCtx, gateway.InvokeURI, dialOpts...)
if err != nil {
return "", merrors.InternalWrap(err, "dial gateway")
}
defer conn.Close()
client := connectorv1.NewConnectorServiceClient(conn)
// Call with timeout
callCtx, callCancel := context.WithTimeout(ctx, a.callTimeout)
defer callCancel()
resp, err := client.OpenAccount(callCtx, req)
if err != nil {
return "", err
}
if resp.GetError() != nil {
return "", merrors.Internal(resp.GetError().GetMessage())
}
account := resp.GetAccount()
if account == nil || account.GetRef() == nil {
return "", merrors.Internal("gateway returned empty account")
}
return strings.TrimSpace(account.GetRef().GetAccountId()), nil
}

View File

@@ -0,0 +1,247 @@
package walletapiimp
import (
"context"
"crypto/tls"
"net/http"
"strings"
"sync"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/mutil/mzap"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/types/known/wrapperspb"
)
func (a *WalletAPI) listWallets(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
orgRef, err := a.oph.GetRef(r)
if err != nil {
a.logger.Warn("Failed to parse organization reference for wallet list", zap.Error(err), zap.String(a.oph.Name(), a.oph.GetID(r)))
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
}
ctx := r.Context()
hasReadPermission, err := a.enf.Enforce(ctx, a.walletsPermissionRef, account.ID, orgRef, bson.NilObjectID, model.ActionRead)
if err != nil {
a.logger.Warn("Failed to check chain wallet access permissions", zap.Error(err), mutil.PLog(a.oph, r))
return response.Auto(a.logger, a.Name(), err)
}
if a.discovery == nil {
return response.Internal(a.logger, mservice.ChainGateway, merrors.Internal("discovery client is not configured"))
}
// Discover CRYPTO rail gateways
lookupCtx, cancel := context.WithTimeout(ctx, discoveryLookupTimeout)
defer cancel()
lookupResp, err := a.discovery.Lookup(lookupCtx)
if err != nil {
a.logger.Warn("Failed to lookup discovery registry", zap.Error(err))
return response.Auto(a.logger, a.Name(), err)
}
// Filter gateways by CRYPTO rail
cryptoGateways := filterCryptoGateways(lookupResp.Gateways)
if len(cryptoGateways) == 0 {
a.logger.Debug("No CRYPTO rail gateways found in discovery")
return sresponse.Wallets(a.logger, nil, token)
}
a.logger.Debug("Resolved CRYPTO gateways for wallet list",
zap.String("organization_ref", orgRef.Hex()),
zap.Int("gateway_count", len(cryptoGateways)))
// Build request
req := &connectorv1.ListAccountsRequest{
OrganizationRef: orgRef.Hex(),
Kind: connectorv1.AccountKind_CHAIN_MANAGED_WALLET,
}
// If user has read permission, return all wallets in organization.
// Otherwise, filter to only wallets owned by the requesting account.
if !hasReadPermission {
req.OwnerRefFilter = wrapperspb.String(account.ID.Hex())
a.logger.Debug("Filtering wallets by owner due to limited permissions",
mzap.ObjRef("owner_ref", account.ID), mutil.PLog(a.oph, r))
}
// Query all gateways in parallel
allAccounts := a.queryAllGateways(ctx, cryptoGateways, req)
dedupedAccounts := dedupeAccountsByWalletRef(allAccounts)
a.logger.Debug("Wallet list fan-out completed",
zap.String("organization_ref", orgRef.Hex()),
zap.Int("accounts_raw", len(allAccounts)),
zap.Int("accounts_deduped", len(dedupedAccounts)),
zap.Int("gateway_count", len(cryptoGateways)))
if len(dedupedAccounts) != len(allAccounts) {
a.logger.Debug("Deduplicated duplicate wallets from gateway fan-out",
zap.Int("before", len(allAccounts)),
zap.Int("after", len(dedupedAccounts)))
}
for _, account := range dedupedAccounts {
a.rememberWalletRoute(ctx, orgRef.Hex(), accountWalletRef(account), accountNetwork(account), "")
}
return sresponse.WalletsFromAccounts(a.logger, dedupedAccounts, token)
}
func filterCryptoGateways(gateways []discovery.GatewaySummary) []discovery.GatewaySummary {
result := make([]discovery.GatewaySummary, 0)
indexByInvokeURI := map[string]int{}
for _, gw := range gateways {
if strings.EqualFold(gw.Rail, cryptoRail) && gw.Healthy && strings.TrimSpace(gw.InvokeURI) != "" {
invokeURI := strings.ToLower(strings.TrimSpace(gw.InvokeURI))
if idx, ok := indexByInvokeURI[invokeURI]; ok {
// Keep the entry with higher priority if the same backend was announced multiple times.
if gw.RoutingPriority > result[idx].RoutingPriority {
result[idx] = gw
}
continue
}
indexByInvokeURI[invokeURI] = len(result)
result = append(result, gw)
}
}
return result
}
func dedupeAccountsByWalletRef(accounts []*connectorv1.Account) []*connectorv1.Account {
if len(accounts) == 0 {
return nil
}
result := make([]*connectorv1.Account, 0, len(accounts))
seen := make(map[string]struct{}, len(accounts))
for _, account := range accounts {
if account == nil {
continue
}
walletRef := accountWalletRef(account)
if walletRef == "" {
// If ref is missing, keep item to avoid dropping potentially valid records.
result = append(result, account)
continue
}
if _, ok := seen[walletRef]; ok {
continue
}
seen[walletRef] = struct{}{}
result = append(result, account)
}
return result
}
func accountWalletRef(account *connectorv1.Account) string {
if account == nil {
return ""
}
if ref := account.GetRef(); ref != nil {
accountID := strings.TrimSpace(ref.GetAccountId())
if accountID != "" {
return accountID
}
}
details := account.GetProviderDetails()
if details == nil {
return ""
}
field, ok := details.GetFields()["wallet_ref"]
if !ok || field == nil {
return ""
}
return strings.TrimSpace(field.GetStringValue())
}
func (a *WalletAPI) queryAllGateways(ctx context.Context, gateways []discovery.GatewaySummary, req *connectorv1.ListAccountsRequest) []*connectorv1.Account {
var mu sync.Mutex
var wg sync.WaitGroup
allAccounts := make([]*connectorv1.Account, 0)
a.logger.Debug("Starting wallet list gateway fan-out",
zap.String("organization_ref", strings.TrimSpace(req.GetOrganizationRef())),
zap.Int("gateway_count", len(gateways)))
for _, gw := range gateways {
wg.Add(1)
go func(gateway discovery.GatewaySummary) {
defer wg.Done()
accounts, err := a.queryGateway(ctx, gateway, req)
if err != nil {
a.logger.Warn("Failed to query gateway",
zap.String("gateway_id", gateway.ID),
zap.String("invoke_uri", gateway.InvokeURI),
zap.String("network", gateway.Network),
zap.Error(err))
return
}
mu.Lock()
allAccounts = append(allAccounts, accounts...)
mu.Unlock()
for _, account := range accounts {
a.rememberWalletRoute(ctx, strings.TrimSpace(req.GetOrganizationRef()), accountWalletRef(account), accountNetwork(account), gateway.ID)
}
a.logger.Debug("Queried gateway successfully",
zap.String("gateway_id", gateway.ID),
zap.String("network", gateway.Network),
zap.Int("accounts_count", len(accounts)))
}(gw)
}
wg.Wait()
a.logger.Debug("Finished wallet list gateway fan-out",
zap.String("organization_ref", strings.TrimSpace(req.GetOrganizationRef())),
zap.Int("accounts_raw", len(allAccounts)),
zap.Int("gateway_count", len(gateways)))
return allAccounts
}
func (a *WalletAPI) queryGateway(ctx context.Context, gateway discovery.GatewaySummary, req *connectorv1.ListAccountsRequest) ([]*connectorv1.Account, error) {
// Create connection with timeout
dialCtx, cancel := context.WithTimeout(ctx, a.dialTimeout)
defer cancel()
var dialOpts []grpc.DialOption
if a.insecure {
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
} else {
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
}
conn, err := grpc.DialContext(dialCtx, gateway.InvokeURI, dialOpts...)
if err != nil {
return nil, merrors.InternalWrap(err, "dial gateway")
}
defer conn.Close()
client := connectorv1.NewConnectorServiceClient(conn)
// Call with timeout
callCtx, callCancel := context.WithTimeout(ctx, a.callTimeout)
defer callCancel()
resp, err := client.ListAccounts(callCtx, req)
if err != nil {
return nil, err
}
return resp.GetAccounts(), nil
}

View File

@@ -0,0 +1,116 @@
package walletapiimp
import (
"testing"
"github.com/tech/sendico/pkg/discovery"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
"google.golang.org/protobuf/types/known/structpb"
)
func TestFilterCryptoGateways_DedupesByInvokeURI(t *testing.T) {
gateways := []discovery.GatewaySummary{
{
ID: "gw-low",
Rail: "CRYPTO",
Healthy: true,
InvokeURI: "dev-tron-gateway:50071",
Network: "TRON_MAINNET",
RoutingPriority: 10,
},
{
ID: "gw-high",
Rail: "CRYPTO",
Healthy: true,
InvokeURI: "dev-tron-gateway:50071",
Network: "TRON_NILE",
RoutingPriority: 20,
},
{
ID: "gw-chain",
Rail: "CRYPTO",
Healthy: true,
InvokeURI: "dev-chain-gateway:50070",
Network: "ARBITRUM_SEPOLIA",
RoutingPriority: 5,
},
{
ID: "gw-unhealthy",
Rail: "CRYPTO",
Healthy: false,
InvokeURI: "dev-unhealthy:50070",
Network: "TRON_MAINNET",
RoutingPriority: 99,
},
}
filtered := filterCryptoGateways(gateways)
if len(filtered) != 2 {
t.Fatalf("expected 2 filtered gateways, got %d", len(filtered))
}
byInvoke := map[string]discovery.GatewaySummary{}
for _, gw := range filtered {
byInvoke[gw.InvokeURI] = gw
}
tron, ok := byInvoke["dev-tron-gateway:50071"]
if !ok {
t.Fatalf("expected tron gateway entry")
}
if tron.ID != "gw-high" {
t.Fatalf("expected higher-priority duplicate to win, got %q", tron.ID)
}
if _, ok := byInvoke["dev-chain-gateway:50070"]; !ok {
t.Fatalf("expected chain gateway entry")
}
}
func TestDedupeAccountsByWalletRef(t *testing.T) {
detailsA, err := structpb.NewStruct(map[string]interface{}{"wallet_ref": "wallet-3"})
if err != nil {
t.Fatalf("build provider details: %v", err)
}
detailsB, err := structpb.NewStruct(map[string]interface{}{"wallet_ref": "wallet-3"})
if err != nil {
t.Fatalf("build provider details: %v", err)
}
accounts := []*connectorv1.Account{
{Ref: &connectorv1.AccountRef{AccountId: "wallet-1"}},
{Ref: &connectorv1.AccountRef{AccountId: "wallet-1"}}, // duplicate
{Ref: &connectorv1.AccountRef{AccountId: "wallet-2"}},
{ProviderDetails: detailsA},
{ProviderDetails: detailsB}, // duplicate via provider_details.wallet_ref
{Ref: &connectorv1.AccountRef{AccountId: ""}}, // kept: missing ref
nil, // ignored
}
deduped := dedupeAccountsByWalletRef(accounts)
if len(deduped) != 4 {
t.Fatalf("expected 4 accounts after dedupe, got %d", len(deduped))
}
seen := map[string]int{}
for _, acc := range deduped {
if acc == nil {
t.Fatalf("deduped account should never be nil")
}
ref := accountWalletRef(acc)
seen[ref]++
}
if seen["wallet-1"] != 1 {
t.Fatalf("expected wallet-1 once, got %d", seen["wallet-1"])
}
if seen["wallet-2"] != 1 {
t.Fatalf("expected wallet-2 once, got %d", seen["wallet-2"])
}
if seen["wallet-3"] != 1 {
t.Fatalf("expected wallet-3 once, got %d", seen["wallet-3"])
}
if seen[""] != 1 {
t.Fatalf("expected one account with missing wallet ref, got %d", seen[""])
}
}

View File

@@ -0,0 +1,136 @@
package walletapiimp
import (
"context"
"errors"
"strings"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
"go.uber.org/zap"
)
func normalizeNetworkName(raw string) string {
value := strings.TrimSpace(raw)
if value == "" {
return ""
}
if idx := strings.Index(value, "-"); idx > 0 {
value = value[:idx]
}
value = strings.ToLower(value)
value = strings.TrimPrefix(value, "chain_network_")
return strings.TrimSpace(value)
}
func (a *WalletAPI) rememberWalletRoute(ctx context.Context, organizationRef string, walletRef string, network string, gatewayID string) {
if a.routes == nil {
return
}
walletRef = strings.TrimSpace(walletRef)
organizationRef = strings.TrimSpace(organizationRef)
network = normalizeNetworkName(network)
gatewayID = strings.TrimSpace(gatewayID)
if walletRef == "" || organizationRef == "" || (network == "" && gatewayID == "") {
return
}
if err := a.routes.Upsert(ctx, &model.ChainWalletRoute{
OrganizationRef: organizationRef,
WalletRef: walletRef,
Network: network,
GatewayID: gatewayID,
}); err != nil {
a.logger.Warn("Failed to persist wallet route",
zap.String("organization_ref", organizationRef),
zap.String("wallet_ref", walletRef),
zap.String("network", network),
zap.String("gateway_id", gatewayID),
zap.Error(err))
} else {
a.logger.Debug("Persisted wallet route",
zap.String("organization_ref", organizationRef),
zap.String("wallet_ref", walletRef),
zap.String("network", network),
zap.String("gateway_id", gatewayID))
}
}
func (a *WalletAPI) walletRoute(ctx context.Context, organizationRef string, walletRef string) (*model.ChainWalletRoute, error) {
if a.routes == nil {
return nil, nil
}
walletRef = strings.TrimSpace(walletRef)
organizationRef = strings.TrimSpace(organizationRef)
if walletRef == "" || organizationRef == "" {
return nil, nil
}
route, err := a.routes.Get(ctx, organizationRef, walletRef)
if err != nil {
if errors.Is(err, merrors.ErrNoData) {
return nil, nil
}
return nil, err
}
return route, nil
}
func findGatewayForRoute(gateways []discovery.GatewaySummary, route *model.ChainWalletRoute) *discovery.GatewaySummary {
if route == nil {
return nil
}
gatewayID := strings.TrimSpace(route.GatewayID)
if gatewayID != "" {
for _, gw := range gateways {
if strings.EqualFold(strings.TrimSpace(gw.ID), gatewayID) &&
strings.EqualFold(gw.Rail, cryptoRail) &&
gw.Healthy &&
strings.TrimSpace(gw.InvokeURI) != "" {
return &gw
}
}
}
return findGatewayForNetwork(gateways, route.Network)
}
func accountNetwork(account *connectorv1.Account) string {
if account == nil {
return ""
}
if details := account.GetProviderDetails(); details != nil {
if field, ok := details.GetFields()["network"]; ok && field != nil {
if network := normalizeNetworkName(field.GetStringValue()); network != "" {
return network
}
}
}
return normalizeNetworkName(account.GetAsset())
}
func dropGatewayByInvokeURI(gateways []discovery.GatewaySummary, invokeURI string) []discovery.GatewaySummary {
invokeURI = strings.ToLower(strings.TrimSpace(invokeURI))
if invokeURI == "" || len(gateways) == 0 {
return gateways
}
result := make([]discovery.GatewaySummary, 0, len(gateways))
for _, gw := range gateways {
if strings.ToLower(strings.TrimSpace(gw.InvokeURI)) == invokeURI {
continue
}
result = append(result, gw)
}
return result
}

View File

@@ -0,0 +1,93 @@
package walletapiimp
import (
"testing"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/model"
)
func TestFindGatewayForRoute_PrefersGatewayID(t *testing.T) {
gateways := []discovery.GatewaySummary{
{
ID: "gw-fallback",
Rail: "CRYPTO",
Healthy: true,
InvokeURI: "chain-gw:50070",
Network: "ethereum_mainnet",
},
{
ID: "gw-route",
Rail: "CRYPTO",
Healthy: true,
InvokeURI: "tron-gw:50071",
Network: "tron_mainnet",
},
}
route := &model.ChainWalletRoute{
WalletRef: "wallet-1",
Network: "ethereum_mainnet",
GatewayID: "gw-route",
}
selected := findGatewayForRoute(gateways, route)
if selected == nil {
t.Fatal("expected selected gateway")
}
if selected.ID != "gw-route" {
t.Fatalf("expected gw-route, got %q", selected.ID)
}
}
func TestFindGatewayForRoute_FallsBackToNetwork(t *testing.T) {
gateways := []discovery.GatewaySummary{
{
ID: "gw-chain",
Rail: "CRYPTO",
Healthy: true,
InvokeURI: "chain-gw:50070",
Network: "ethereum_mainnet",
},
{
ID: "gw-tron",
Rail: "CRYPTO",
Healthy: true,
InvokeURI: "tron-gw:50071",
Network: "tron_mainnet",
},
}
route := &model.ChainWalletRoute{
WalletRef: "wallet-1",
Network: "tron_mainnet",
GatewayID: "unknown",
}
selected := findGatewayForRoute(gateways, route)
if selected == nil {
t.Fatal("expected selected gateway")
}
if selected.ID != "gw-tron" {
t.Fatalf("expected gw-tron, got %q", selected.ID)
}
}
func TestNormalizeNetworkName(t *testing.T) {
tests := []struct {
in string
want string
}{
{in: "CHAIN_NETWORK_TRON_MAINNET", want: "tron_mainnet"},
{in: "tron_mainnet-USDT", want: "tron_mainnet"},
{in: " ethereum_mainnet ", want: "ethereum_mainnet"},
{in: "", want: ""},
}
for _, tc := range tests {
got := normalizeNetworkName(tc.in)
if got != tc.want {
t.Fatalf("normalizeNetworkName(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}

View File

@@ -0,0 +1,138 @@
package walletapiimp
import (
"context"
"time"
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/db/chainassets"
"github.com/tech/sendico/pkg/db/chainwalletroutes"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/merrors"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
eapi "github.com/tech/sendico/server/interface/api"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
const (
cryptoRail = "CRYPTO"
defaultDialTimeout = 5 * time.Second
defaultCallTimeout = 10 * time.Second
discoveryLookupTimeout = 3 * time.Second
)
type WalletAPI struct {
logger mlogger.Logger
discovery *discovery.Client
enf auth.Enforcer
oph mutil.ParamHelper
wph mutil.ParamHelper
walletsPermissionRef bson.ObjectID
balancesPermissionRef bson.ObjectID
assets chainassets.DB
routes chainwalletroutes.DB
// Gateway connection settings
dialTimeout time.Duration
callTimeout time.Duration
insecure bool
}
func (a *WalletAPI) Name() mservice.Type { return mservice.ChainWallets }
func (a *WalletAPI) Finish(ctx context.Context) error {
if a.discovery != nil {
a.discovery.Close()
}
return nil
}
func CreateAPI(apiCtx eapi.API) (*WalletAPI, error) {
p := &WalletAPI{
logger: apiCtx.Logger().Named(mservice.Wallets),
enf: apiCtx.Permissions().Enforcer(),
oph: mutil.CreatePH(mservice.Organizations),
wph: mutil.CreatePH(mservice.Wallets),
dialTimeout: defaultDialTimeout,
callTimeout: defaultCallTimeout,
insecure: true,
}
var err error
if p.assets, err = apiCtx.DBFactory().NewChainAsstesDB(); err != nil {
p.logger.Warn("Failed to create asstes db", zap.Error(err))
return nil, err
}
if p.routes, err = apiCtx.DBFactory().NewChainWalletRoutesDB(); err != nil {
p.logger.Warn("Failed to create chain wallet routes db", zap.Error(err))
return nil, err
}
walletsPolicy, err := apiCtx.Permissions().GetPolicyDescription(context.Background(), mservice.ChainWallets)
if err != nil {
p.logger.Warn("Failed to fetch chain wallets permission policy description", zap.Error(err))
return nil, err
}
p.walletsPermissionRef = walletsPolicy.ID
balancesPolicy, err := apiCtx.Permissions().GetPolicyDescription(context.Background(), mservice.ChainWalletBalances)
if err != nil {
p.logger.Warn("Failed to fetch chain wallet balances permission policy description", zap.Error(err))
return nil, err
}
p.balancesPermissionRef = balancesPolicy.ID
cfg := apiCtx.Config()
if cfg == nil {
p.logger.Error("Failed to fetch service configuration")
return nil, merrors.InvalidArgument("No configuration provided")
}
// Apply gateway connection settings from config
if gatewayCfg := cfg.ChainGateway; gatewayCfg != nil {
if gatewayCfg.DialTimeoutSeconds > 0 {
p.dialTimeout = time.Duration(gatewayCfg.DialTimeoutSeconds) * time.Second
}
if gatewayCfg.CallTimeoutSeconds > 0 {
p.callTimeout = time.Duration(gatewayCfg.CallTimeoutSeconds) * time.Second
}
p.insecure = gatewayCfg.Insecure
}
// Initialize discovery client
if err := p.initDiscoveryClient(cfg); err != nil {
p.logger.Warn("Failed to initialize discovery client", zap.Error(err))
// Not fatal - we can still work without discovery
}
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/"), api.Get, p.listWallets)
apiCtx.Register().AccountHandler(p.Name(), p.wph.AddRef(p.oph.AddRef("/"))+"/balance", api.Get, p.getWalletBalance)
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/"), api.Post, p.create)
return p, nil
}
func (a *WalletAPI) initDiscoveryClient(cfg *eapi.Config) error {
if cfg == nil || cfg.Mw == nil {
return nil
}
msgCfg := cfg.Mw.Messaging
if msgCfg.Driver == "" {
return nil
}
broker, err := msg.CreateMessagingBroker(a.logger.Named("discovery_bus"), &msgCfg)
if err != nil {
return err
}
client, err := discovery.NewClient(a.logger, broker, nil, string(a.Name()))
if err != nil {
return err
}
a.discovery = client
return nil
}