package gateway import ( "context" "errors" "strings" "github.com/shopspring/decimal" "github.com/tech/sendico/chain/gateway/storage/model" "github.com/tech/sendico/pkg/api/routers/gsresponse" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mservice" gatewayv1 "github.com/tech/sendico/pkg/proto/chain/gateway/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" "go.uber.org/zap" "google.golang.org/protobuf/types/known/timestamppb" ) func (s *Service) submitTransferHandler(ctx context.Context, req *gatewayv1.SubmitTransferRequest) gsresponse.Responder[gatewayv1.SubmitTransferResponse] { if err := s.ensureRepository(ctx); err != nil { return gsresponse.Unavailable[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, err) } if req == nil { return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("nil request")) } idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey()) if idempotencyKey == "" { return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required")) } organizationRef := strings.TrimSpace(req.GetOrganizationRef()) if organizationRef == "" { return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required")) } sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef()) if sourceWalletRef == "" { return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required")) } amount := req.GetAmount() if amount == nil { return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("amount is required")) } amountCurrency := strings.ToUpper(strings.TrimSpace(amount.GetCurrency())) if amountCurrency == "" { return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("amount.currency is required")) } amountValue := strings.TrimSpace(amount.GetAmount()) if amountValue == "" { return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("amount.amount is required")) } sourceWallet, err := s.storage.Wallets().Get(ctx, sourceWalletRef) if err != nil { if errors.Is(err, merrors.ErrNoData) { return gsresponse.NotFound[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, err) } return gsresponse.Auto[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, err) } if !strings.EqualFold(sourceWallet.OrganizationRef, organizationRef) { return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref mismatch with wallet")) } networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network)) networkCfg, ok := s.networks[networkKey] if !ok { return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet")) } destination, err := s.resolveDestination(ctx, req.GetDestination(), sourceWallet) if err != nil { if errors.Is(err, merrors.ErrNoData) { return gsresponse.NotFound[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, err) } return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, err) } fees, feeSum, err := convertFees(req.GetFees(), amountCurrency) if err != nil { return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, err) } amountDec, err := decimal.NewFromString(amountValue) if err != nil { return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("invalid amount")) } netDec := amountDec.Sub(feeSum) if netDec.IsNegative() { return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("fees exceed amount")) } netAmount := cloneMoney(amount) netAmount.Amount = netDec.String() transfer := &model.Transfer{ IdempotencyKey: idempotencyKey, TransferRef: generateTransferRef(), OrganizationRef: organizationRef, SourceWalletRef: sourceWalletRef, Destination: destination, Network: sourceWallet.Network, TokenSymbol: sourceWallet.TokenSymbol, ContractAddress: sourceWallet.ContractAddress, RequestedAmount: cloneMoney(amount), NetAmount: netAmount, Fees: fees, Status: model.TransferStatusPending, ClientReference: strings.TrimSpace(req.GetClientReference()), LastStatusAt: s.clock.Now().UTC(), } saved, err := s.storage.Transfers().Create(ctx, transfer) if err != nil { if errors.Is(err, merrors.ErrDataConflict) { s.logger.Debug("transfer already exists", zap.String("transfer_ref", transfer.TransferRef), zap.String("idempotency_key", idempotencyKey)) return gsresponse.Success(&gatewayv1.SubmitTransferResponse{Transfer: s.toProtoTransfer(saved)}) } return gsresponse.Auto[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, err) } if s.executor != nil { s.launchTransferExecution(saved.TransferRef, sourceWalletRef, networkCfg) } return gsresponse.Success(&gatewayv1.SubmitTransferResponse{Transfer: s.toProtoTransfer(saved)}) } func (s *Service) getTransferHandler(ctx context.Context, req *gatewayv1.GetTransferRequest) gsresponse.Responder[gatewayv1.GetTransferResponse] { if err := s.ensureRepository(ctx); err != nil { return gsresponse.Unavailable[gatewayv1.GetTransferResponse](s.logger, mservice.ChainGateway, err) } if req == nil { return gsresponse.InvalidArgument[gatewayv1.GetTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("nil request")) } transferRef := strings.TrimSpace(req.GetTransferRef()) if transferRef == "" { return gsresponse.InvalidArgument[gatewayv1.GetTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("transfer_ref is required")) } transfer, err := s.storage.Transfers().Get(ctx, transferRef) if err != nil { if errors.Is(err, merrors.ErrNoData) { return gsresponse.NotFound[gatewayv1.GetTransferResponse](s.logger, mservice.ChainGateway, err) } return gsresponse.Auto[gatewayv1.GetTransferResponse](s.logger, mservice.ChainGateway, err) } return gsresponse.Success(&gatewayv1.GetTransferResponse{Transfer: s.toProtoTransfer(transfer)}) } func (s *Service) listTransfersHandler(ctx context.Context, req *gatewayv1.ListTransfersRequest) gsresponse.Responder[gatewayv1.ListTransfersResponse] { if err := s.ensureRepository(ctx); err != nil { return gsresponse.Unavailable[gatewayv1.ListTransfersResponse](s.logger, mservice.ChainGateway, err) } filter := model.TransferFilter{} if req != nil { filter.SourceWalletRef = strings.TrimSpace(req.GetSourceWalletRef()) filter.DestinationWalletRef = strings.TrimSpace(req.GetDestinationWalletRef()) if status := transferStatusToModel(req.GetStatus()); status != "" { filter.Status = status } if page := req.GetPage(); page != nil { filter.Cursor = strings.TrimSpace(page.GetCursor()) filter.Limit = page.GetLimit() } } result, err := s.storage.Transfers().List(ctx, filter) if err != nil { return gsresponse.Auto[gatewayv1.ListTransfersResponse](s.logger, mservice.ChainGateway, err) } protoTransfers := make([]*gatewayv1.Transfer, 0, len(result.Items)) for _, transfer := range result.Items { protoTransfers = append(protoTransfers, s.toProtoTransfer(transfer)) } resp := &gatewayv1.ListTransfersResponse{ Transfers: protoTransfers, Page: &paginationv1.CursorPageResponse{NextCursor: result.NextCursor}, } return gsresponse.Success(resp) } func (s *Service) estimateTransferFeeHandler(ctx context.Context, req *gatewayv1.EstimateTransferFeeRequest) gsresponse.Responder[gatewayv1.EstimateTransferFeeResponse] { if err := s.ensureRepository(ctx); err != nil { return gsresponse.Unavailable[gatewayv1.EstimateTransferFeeResponse](s.logger, mservice.ChainGateway, err) } if req == nil || req.GetAmount() == nil { return gsresponse.InvalidArgument[gatewayv1.EstimateTransferFeeResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("amount is required")) } currency := req.GetAmount().GetCurrency() fee := &moneyv1.Money{ Currency: currency, Amount: "0", } resp := &gatewayv1.EstimateTransferFeeResponse{ NetworkFee: fee, EstimationContext: "not_implemented", } return gsresponse.Success(resp) } func (s *Service) toProtoTransfer(transfer *model.Transfer) *gatewayv1.Transfer { if transfer == nil { return nil } destination := &gatewayv1.TransferDestination{} if transfer.Destination.ManagedWalletRef != "" { destination.Destination = &gatewayv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: transfer.Destination.ManagedWalletRef} } else if transfer.Destination.ExternalAddress != "" { destination.Destination = &gatewayv1.TransferDestination_ExternalAddress{ExternalAddress: transfer.Destination.ExternalAddress} } destination.Memo = transfer.Destination.Memo protoFees := make([]*gatewayv1.ServiceFeeBreakdown, 0, len(transfer.Fees)) for _, fee := range transfer.Fees { protoFees = append(protoFees, &gatewayv1.ServiceFeeBreakdown{ FeeCode: fee.FeeCode, Amount: cloneMoney(fee.Amount), Description: fee.Description, }) } asset := &gatewayv1.Asset{ Chain: chainEnumFromName(transfer.Network), TokenSymbol: transfer.TokenSymbol, ContractAddress: transfer.ContractAddress, } return &gatewayv1.Transfer{ TransferRef: transfer.TransferRef, IdempotencyKey: transfer.IdempotencyKey, OrganizationRef: transfer.OrganizationRef, SourceWalletRef: transfer.SourceWalletRef, Destination: destination, Asset: asset, RequestedAmount: cloneMoney(transfer.RequestedAmount), NetAmount: cloneMoney(transfer.NetAmount), Fees: protoFees, Status: transferStatusToProto(transfer.Status), TransactionHash: transfer.TxHash, FailureReason: transfer.FailureReason, CreatedAt: timestamppb.New(transfer.CreatedAt.UTC()), UpdatedAt: timestamppb.New(transfer.UpdatedAt.UTC()), } } func (s *Service) resolveDestination(ctx context.Context, dest *gatewayv1.TransferDestination, source *model.ManagedWallet) (model.TransferDestination, error) { if dest == nil { return model.TransferDestination{}, merrors.InvalidArgument("destination is required") } managedRef := strings.TrimSpace(dest.GetManagedWalletRef()) external := strings.TrimSpace(dest.GetExternalAddress()) if managedRef != "" && external != "" { return model.TransferDestination{}, merrors.InvalidArgument("destination must be managed_wallet_ref or external_address") } if managedRef != "" { wallet, err := s.storage.Wallets().Get(ctx, managedRef) if err != nil { return model.TransferDestination{}, err } if !strings.EqualFold(wallet.Network, source.Network) { return model.TransferDestination{}, merrors.InvalidArgument("destination wallet network mismatch") } if strings.TrimSpace(wallet.DepositAddress) == "" { return model.TransferDestination{}, merrors.InvalidArgument("destination wallet missing deposit address") } return model.TransferDestination{ ManagedWalletRef: wallet.WalletRef, Memo: strings.TrimSpace(dest.GetMemo()), }, nil } if external == "" { return model.TransferDestination{}, merrors.InvalidArgument("destination is required") } return model.TransferDestination{ ExternalAddress: strings.ToLower(external), Memo: strings.TrimSpace(dest.GetMemo()), }, nil } func convertFees(fees []*gatewayv1.ServiceFeeBreakdown, currency string) ([]model.ServiceFee, decimal.Decimal, error) { result := make([]model.ServiceFee, 0, len(fees)) sum := decimal.NewFromInt(0) for _, fee := range fees { if fee == nil || fee.GetAmount() == nil { return nil, decimal.Decimal{}, merrors.InvalidArgument("fee amount is required") } amtCurrency := strings.ToUpper(strings.TrimSpace(fee.GetAmount().GetCurrency())) if amtCurrency != strings.ToUpper(currency) { return nil, decimal.Decimal{}, merrors.InvalidArgument("fee currency mismatch") } amtValue := strings.TrimSpace(fee.GetAmount().GetAmount()) if amtValue == "" { return nil, decimal.Decimal{}, merrors.InvalidArgument("fee amount is required") } dec, err := decimal.NewFromString(amtValue) if err != nil { return nil, decimal.Decimal{}, merrors.InvalidArgument("invalid fee amount") } if dec.IsNegative() { return nil, decimal.Decimal{}, merrors.InvalidArgument("fee amount must be non-negative") } sum = sum.Add(dec) result = append(result, model.ServiceFee{ FeeCode: strings.TrimSpace(fee.GetFeeCode()), Amount: cloneMoney(fee.GetAmount()), Description: strings.TrimSpace(fee.GetDescription()), }) } return result, sum, nil }