310 lines
13 KiB
Go
310 lines
13 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
|
|
gatewayv1 "github.com/tech/sendico/chain/gateway/internal/generated/service/gateway/v1"
|
|
"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"
|
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
|
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
|
|
"github.com/shopspring/decimal"
|
|
"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
|
|
}
|