impreoved commands logging
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/bump_version Pipeline failed
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/bump_version Pipeline failed
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/tech/sendico/chain/gateway/internal/keymanager"
|
||||
vaultmanager "github.com/tech/sendico/chain/gateway/internal/keymanager/vault"
|
||||
gatewayservice "github.com/tech/sendico/chain/gateway/internal/service/gateway"
|
||||
gatewayshared "github.com/tech/sendico/chain/gateway/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/chain/gateway/storage"
|
||||
gatewaymongo "github.com/tech/sendico/chain/gateway/storage/mongo"
|
||||
"github.com/tech/sendico/pkg/api/routers"
|
||||
@@ -154,8 +155,8 @@ func (i *Imp) loadConfig() (*config, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) []gatewayservice.Network {
|
||||
result := make([]gatewayservice.Network, 0, len(chains))
|
||||
func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) []gatewayshared.Network {
|
||||
result := make([]gatewayshared.Network, 0, len(chains))
|
||||
for _, chain := range chains {
|
||||
if strings.TrimSpace(chain.Name) == "" {
|
||||
logger.Warn("skipping unnamed chain configuration")
|
||||
@@ -165,7 +166,7 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) []gatewa
|
||||
if rpcURL == "" {
|
||||
logger.Warn("chain RPC endpoint not configured", zap.String("chain", chain.Name), zap.String("env", chain.RPCURLEnv))
|
||||
}
|
||||
contracts := make([]gatewayservice.TokenContract, 0, len(chain.Tokens))
|
||||
contracts := make([]gatewayshared.TokenContract, 0, len(chain.Tokens))
|
||||
for _, token := range chain.Tokens {
|
||||
symbol := strings.TrimSpace(token.Symbol)
|
||||
if symbol == "" {
|
||||
@@ -185,13 +186,13 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) []gatewa
|
||||
}
|
||||
continue
|
||||
}
|
||||
contracts = append(contracts, gatewayservice.TokenContract{
|
||||
contracts = append(contracts, gatewayshared.TokenContract{
|
||||
Symbol: symbol,
|
||||
ContractAddress: addr,
|
||||
})
|
||||
}
|
||||
|
||||
result = append(result, gatewayservice.Network{
|
||||
result = append(result, gatewayshared.Network{
|
||||
Name: chain.Name,
|
||||
RPCURL: rpcURL,
|
||||
ChainID: chain.ChainID,
|
||||
@@ -202,7 +203,7 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) []gatewa
|
||||
return result
|
||||
}
|
||||
|
||||
func resolveServiceWallet(logger mlogger.Logger, cfg serviceWalletConfig) gatewayservice.ServiceWallet {
|
||||
func resolveServiceWallet(logger mlogger.Logger, cfg serviceWalletConfig) gatewayshared.ServiceWallet {
|
||||
address := strings.TrimSpace(cfg.Address)
|
||||
if address == "" && cfg.AddressEnv != "" {
|
||||
address = strings.TrimSpace(os.Getenv(cfg.AddressEnv))
|
||||
@@ -221,7 +222,7 @@ func resolveServiceWallet(logger mlogger.Logger, cfg serviceWalletConfig) gatewa
|
||||
logger.Warn("service wallet private key not configured", zap.String("env", cfg.PrivateKeyEnv))
|
||||
}
|
||||
|
||||
return gatewayservice.ServiceWallet{
|
||||
return gatewayshared.ServiceWallet{
|
||||
Network: cfg.Chain,
|
||||
Address: address,
|
||||
PrivateKey: privateKey,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/chain/gateway/internal/service/gateway/commands/transfer"
|
||||
"github.com/tech/sendico/chain/gateway/internal/service/gateway/commands/wallet"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
gatewayv1 "github.com/tech/sendico/pkg/proto/chain/gateway/v1"
|
||||
)
|
||||
|
||||
type Unary[TReq any, TResp any] interface {
|
||||
Execute(context.Context, *TReq) gsresponse.Responder[TResp]
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
CreateManagedWallet Unary[gatewayv1.CreateManagedWalletRequest, gatewayv1.CreateManagedWalletResponse]
|
||||
GetManagedWallet Unary[gatewayv1.GetManagedWalletRequest, gatewayv1.GetManagedWalletResponse]
|
||||
ListManagedWallets Unary[gatewayv1.ListManagedWalletsRequest, gatewayv1.ListManagedWalletsResponse]
|
||||
GetWalletBalance Unary[gatewayv1.GetWalletBalanceRequest, gatewayv1.GetWalletBalanceResponse]
|
||||
|
||||
SubmitTransfer Unary[gatewayv1.SubmitTransferRequest, gatewayv1.SubmitTransferResponse]
|
||||
GetTransfer Unary[gatewayv1.GetTransferRequest, gatewayv1.GetTransferResponse]
|
||||
ListTransfers Unary[gatewayv1.ListTransfersRequest, gatewayv1.ListTransfersResponse]
|
||||
EstimateTransfer Unary[gatewayv1.EstimateTransferFeeRequest, gatewayv1.EstimateTransferFeeResponse]
|
||||
}
|
||||
|
||||
type RegistryDeps struct {
|
||||
Wallet wallet.Deps
|
||||
Transfer transfer.Deps
|
||||
}
|
||||
|
||||
func NewRegistry(deps RegistryDeps) Registry {
|
||||
return Registry{
|
||||
CreateManagedWallet: wallet.NewCreateManagedWallet(deps.Wallet.WithLogger("wallet.create")),
|
||||
GetManagedWallet: wallet.NewGetManagedWallet(deps.Wallet.WithLogger("wallet.get")),
|
||||
ListManagedWallets: wallet.NewListManagedWallets(deps.Wallet.WithLogger("wallet.list")),
|
||||
GetWalletBalance: wallet.NewGetWalletBalance(deps.Wallet.WithLogger("wallet.balance")),
|
||||
SubmitTransfer: transfer.NewSubmitTransfer(deps.Transfer.WithLogger("transfer.submit")),
|
||||
GetTransfer: transfer.NewGetTransfer(deps.Transfer.WithLogger("transfer.get")),
|
||||
ListTransfers: transfer.NewListTransfers(deps.Transfer.WithLogger("transfer.list")),
|
||||
EstimateTransfer: transfer.NewEstimateTransfer(deps.Transfer.WithLogger("transfer.estimate_fee")),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package transfer
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/chain/gateway/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/chain/gateway/storage"
|
||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
)
|
||||
|
||||
type Deps struct {
|
||||
Logger mlogger.Logger
|
||||
Networks map[string]shared.Network
|
||||
Storage storage.Repository
|
||||
Clock clockpkg.Clock
|
||||
EnsureRepository func(context.Context) error
|
||||
LaunchExecution func(transferRef, sourceWalletRef string, network shared.Network)
|
||||
}
|
||||
|
||||
func (d Deps) WithLogger(name string) Deps {
|
||||
if d.Logger != nil {
|
||||
d.Logger = d.Logger.Named(name)
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package transfer
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"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"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type estimateTransferFeeCommand struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
func NewEstimateTransfer(deps Deps) *estimateTransferFeeCommand {
|
||||
return &estimateTransferFeeCommand{deps: deps}
|
||||
}
|
||||
|
||||
func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *gatewayv1.EstimateTransferFeeRequest) gsresponse.Responder[gatewayv1.EstimateTransferFeeResponse] {
|
||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
||||
return gsresponse.Unavailable[gatewayv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if req == nil || req.GetAmount() == nil {
|
||||
c.deps.Logger.Warn("amount missing")
|
||||
return gsresponse.InvalidArgument[gatewayv1.EstimateTransferFeeResponse](c.deps.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)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package transfer
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tech/sendico/chain/gateway/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/chain/gateway/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
gatewayv1 "github.com/tech/sendico/pkg/proto/chain/gateway/v1"
|
||||
)
|
||||
|
||||
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: shared.CloneMoney(fee.GetAmount()),
|
||||
Description: strings.TrimSpace(fee.GetDescription()),
|
||||
})
|
||||
}
|
||||
return result, sum, nil
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package transfer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type getTransferCommand struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
func NewGetTransfer(deps Deps) *getTransferCommand {
|
||||
return &getTransferCommand{deps: deps}
|
||||
}
|
||||
|
||||
func (c *getTransferCommand) Execute(ctx context.Context, req *gatewayv1.GetTransferRequest) gsresponse.Responder[gatewayv1.GetTransferResponse] {
|
||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
||||
return gsresponse.Unavailable[gatewayv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if req == nil {
|
||||
c.deps.Logger.Warn("nil request")
|
||||
return gsresponse.InvalidArgument[gatewayv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
transferRef := strings.TrimSpace(req.GetTransferRef())
|
||||
if transferRef == "" {
|
||||
c.deps.Logger.Warn("transfer_ref missing")
|
||||
return gsresponse.InvalidArgument[gatewayv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("transfer_ref is required"))
|
||||
}
|
||||
transfer, err := c.deps.Storage.Transfers().Get(ctx, transferRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
c.deps.Logger.Warn("not found", zap.String("transfer_ref", transferRef))
|
||||
return gsresponse.NotFound[gatewayv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
c.deps.Logger.Warn("storage get failed", zap.Error(err), zap.String("transfer_ref", transferRef))
|
||||
return gsresponse.Auto[gatewayv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
return gsresponse.Success(&gatewayv1.GetTransferResponse{Transfer: toProtoTransfer(transfer)})
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package transfer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/chain/gateway/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/chain/gateway/storage/model"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
gatewayv1 "github.com/tech/sendico/pkg/proto/chain/gateway/v1"
|
||||
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type listTransfersCommand struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
func NewListTransfers(deps Deps) *listTransfersCommand {
|
||||
return &listTransfersCommand{deps: deps}
|
||||
}
|
||||
|
||||
func (c *listTransfersCommand) Execute(ctx context.Context, req *gatewayv1.ListTransfersRequest) gsresponse.Responder[gatewayv1.ListTransfersResponse] {
|
||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
||||
return gsresponse.Unavailable[gatewayv1.ListTransfersResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
filter := model.TransferFilter{}
|
||||
if req != nil {
|
||||
filter.SourceWalletRef = strings.TrimSpace(req.GetSourceWalletRef())
|
||||
filter.DestinationWalletRef = strings.TrimSpace(req.GetDestinationWalletRef())
|
||||
if status := shared.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 := c.deps.Storage.Transfers().List(ctx, filter)
|
||||
if err != nil {
|
||||
c.deps.Logger.Warn("storage list failed", zap.Error(err))
|
||||
return gsresponse.Auto[gatewayv1.ListTransfersResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
protoTransfers := make([]*gatewayv1.Transfer, 0, len(result.Items))
|
||||
for _, transfer := range result.Items {
|
||||
protoTransfers = append(protoTransfers, toProtoTransfer(transfer))
|
||||
}
|
||||
|
||||
resp := &gatewayv1.ListTransfersResponse{
|
||||
Transfers: protoTransfers,
|
||||
Page: &paginationv1.CursorPageResponse{NextCursor: result.NextCursor},
|
||||
}
|
||||
return gsresponse.Success(resp)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package transfer
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/chain/gateway/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/chain/gateway/storage/model"
|
||||
gatewayv1 "github.com/tech/sendico/pkg/proto/chain/gateway/v1"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
func 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: shared.CloneMoney(fee.Amount),
|
||||
Description: fee.Description,
|
||||
})
|
||||
}
|
||||
|
||||
asset := &gatewayv1.Asset{
|
||||
Chain: shared.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: shared.CloneMoney(transfer.RequestedAmount),
|
||||
NetAmount: shared.CloneMoney(transfer.NetAmount),
|
||||
Fees: protoFees,
|
||||
Status: shared.TransferStatusToProto(transfer.Status),
|
||||
TransactionHash: transfer.TxHash,
|
||||
FailureReason: transfer.FailureReason,
|
||||
CreatedAt: timestamppb.New(transfer.CreatedAt.UTC()),
|
||||
UpdatedAt: timestamppb.New(transfer.UpdatedAt.UTC()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package transfer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tech/sendico/chain/gateway/internal/service/gateway/shared"
|
||||
"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"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type submitTransferCommand struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
func NewSubmitTransfer(deps Deps) *submitTransferCommand {
|
||||
return &submitTransferCommand{deps: deps}
|
||||
}
|
||||
|
||||
func (c *submitTransferCommand) Execute(ctx context.Context, req *gatewayv1.SubmitTransferRequest) gsresponse.Responder[gatewayv1.SubmitTransferResponse] {
|
||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
||||
return gsresponse.Unavailable[gatewayv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if req == nil {
|
||||
c.deps.Logger.Warn("nil request")
|
||||
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
|
||||
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
|
||||
if idempotencyKey == "" {
|
||||
c.deps.Logger.Warn("missing idempotency key")
|
||||
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required"))
|
||||
}
|
||||
organizationRef := strings.TrimSpace(req.GetOrganizationRef())
|
||||
if organizationRef == "" {
|
||||
c.deps.Logger.Warn("missing organization ref")
|
||||
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required"))
|
||||
}
|
||||
sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef())
|
||||
if sourceWalletRef == "" {
|
||||
c.deps.Logger.Warn("missing source wallet ref")
|
||||
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required"))
|
||||
}
|
||||
amount := req.GetAmount()
|
||||
if amount == nil {
|
||||
c.deps.Logger.Warn("missing amount")
|
||||
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount is required"))
|
||||
}
|
||||
amountCurrency := strings.ToUpper(strings.TrimSpace(amount.GetCurrency()))
|
||||
if amountCurrency == "" {
|
||||
c.deps.Logger.Warn("missing amount currency")
|
||||
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount.currency is required"))
|
||||
}
|
||||
amountValue := strings.TrimSpace(amount.GetAmount())
|
||||
if amountValue == "" {
|
||||
c.deps.Logger.Warn("missing amount value")
|
||||
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount.amount is required"))
|
||||
}
|
||||
|
||||
sourceWallet, err := c.deps.Storage.Wallets().Get(ctx, sourceWalletRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
c.deps.Logger.Warn("source wallet not found", zap.String("source_wallet_ref", sourceWalletRef))
|
||||
return gsresponse.NotFound[gatewayv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
c.deps.Logger.Warn("storage get wallet failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef))
|
||||
return gsresponse.Auto[gatewayv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if !strings.EqualFold(sourceWallet.OrganizationRef, organizationRef) {
|
||||
c.deps.Logger.Warn("organization mismatch", zap.String("wallet_org", sourceWallet.OrganizationRef), zap.String("req_org", organizationRef))
|
||||
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref mismatch with wallet"))
|
||||
}
|
||||
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
|
||||
networkCfg, ok := c.deps.Networks[networkKey]
|
||||
if !ok {
|
||||
c.deps.Logger.Warn("unsupported chain", zap.String("network", networkKey))
|
||||
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
|
||||
}
|
||||
|
||||
destination, err := c.resolveDestination(ctx, req.GetDestination(), sourceWallet)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
c.deps.Logger.Warn("destination not found", zap.String("destination_wallet_ref", req.GetDestination().GetManagedWalletRef()))
|
||||
return gsresponse.NotFound[gatewayv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
c.deps.Logger.Warn("invalid destination", zap.Error(err))
|
||||
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
fees, feeSum, err := convertFees(req.GetFees(), amountCurrency)
|
||||
if err != nil {
|
||||
c.deps.Logger.Warn("fee conversion failed", zap.Error(err))
|
||||
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
amountDec, err := decimal.NewFromString(amountValue)
|
||||
if err != nil {
|
||||
c.deps.Logger.Warn("invalid amount", zap.Error(err))
|
||||
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("invalid amount"))
|
||||
}
|
||||
netDec := amountDec.Sub(feeSum)
|
||||
if netDec.IsNegative() {
|
||||
c.deps.Logger.Warn("fees exceed amount", zap.String("amount", amountValue), zap.String("fee_sum", feeSum.String()))
|
||||
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("fees exceed amount"))
|
||||
}
|
||||
|
||||
netAmount := shared.CloneMoney(amount)
|
||||
netAmount.Amount = netDec.String()
|
||||
|
||||
transfer := &model.Transfer{
|
||||
IdempotencyKey: idempotencyKey,
|
||||
TransferRef: shared.GenerateTransferRef(),
|
||||
OrganizationRef: organizationRef,
|
||||
SourceWalletRef: sourceWalletRef,
|
||||
Destination: destination,
|
||||
Network: sourceWallet.Network,
|
||||
TokenSymbol: sourceWallet.TokenSymbol,
|
||||
ContractAddress: sourceWallet.ContractAddress,
|
||||
RequestedAmount: shared.CloneMoney(amount),
|
||||
NetAmount: netAmount,
|
||||
Fees: fees,
|
||||
Status: model.TransferStatusPending,
|
||||
ClientReference: strings.TrimSpace(req.GetClientReference()),
|
||||
LastStatusAt: c.deps.Clock.Now().UTC(),
|
||||
}
|
||||
|
||||
saved, err := c.deps.Storage.Transfers().Create(ctx, transfer)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
c.deps.Logger.Debug("transfer already exists", zap.String("transfer_ref", transfer.TransferRef), zap.String("idempotency_key", idempotencyKey))
|
||||
return gsresponse.Success(&gatewayv1.SubmitTransferResponse{Transfer: toProtoTransfer(saved)})
|
||||
}
|
||||
c.deps.Logger.Warn("storage create failed", zap.Error(err), zap.String("transfer_ref", transfer.TransferRef))
|
||||
return gsresponse.Auto[gatewayv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
if c.deps.LaunchExecution != nil {
|
||||
c.deps.LaunchExecution(saved.TransferRef, sourceWalletRef, networkCfg)
|
||||
}
|
||||
|
||||
return gsresponse.Success(&gatewayv1.SubmitTransferResponse{Transfer: toProtoTransfer(saved)})
|
||||
}
|
||||
|
||||
func (c *submitTransferCommand) resolveDestination(ctx context.Context, dest *gatewayv1.TransferDestination, source *model.ManagedWallet) (model.TransferDestination, error) {
|
||||
if dest == nil {
|
||||
c.deps.Logger.Warn("destination missing")
|
||||
return model.TransferDestination{}, merrors.InvalidArgument("destination is required")
|
||||
}
|
||||
managedRef := strings.TrimSpace(dest.GetManagedWalletRef())
|
||||
external := strings.TrimSpace(dest.GetExternalAddress())
|
||||
if managedRef != "" && external != "" {
|
||||
c.deps.Logger.Warn("both managed and external destination provided")
|
||||
return model.TransferDestination{}, merrors.InvalidArgument("destination must be managed_wallet_ref or external_address")
|
||||
}
|
||||
if managedRef != "" {
|
||||
wallet, err := c.deps.Storage.Wallets().Get(ctx, managedRef)
|
||||
if err != nil {
|
||||
c.deps.Logger.Warn("destination wallet lookup failed", zap.Error(err), zap.String("managed_wallet_ref", managedRef))
|
||||
return model.TransferDestination{}, err
|
||||
}
|
||||
if !strings.EqualFold(wallet.Network, source.Network) {
|
||||
c.deps.Logger.Warn("destination network mismatch", zap.String("source_network", source.Network), zap.String("dest_network", wallet.Network))
|
||||
return model.TransferDestination{}, merrors.InvalidArgument("destination wallet network mismatch")
|
||||
}
|
||||
if strings.TrimSpace(wallet.DepositAddress) == "" {
|
||||
c.deps.Logger.Warn("destination wallet missing deposit address", zap.String("managed_wallet_ref", managedRef))
|
||||
return model.TransferDestination{}, merrors.InvalidArgument("destination wallet missing deposit address")
|
||||
}
|
||||
return model.TransferDestination{
|
||||
ManagedWalletRef: wallet.WalletRef,
|
||||
Memo: strings.TrimSpace(dest.GetMemo()),
|
||||
}, nil
|
||||
}
|
||||
if external == "" {
|
||||
c.deps.Logger.Warn("destination external address missing")
|
||||
return model.TransferDestination{}, merrors.InvalidArgument("destination is required")
|
||||
}
|
||||
return model.TransferDestination{
|
||||
ExternalAddress: strings.ToLower(external),
|
||||
Memo: strings.TrimSpace(dest.GetMemo()),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type getWalletBalanceCommand struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
func NewGetWalletBalance(deps Deps) *getWalletBalanceCommand {
|
||||
return &getWalletBalanceCommand{deps: deps}
|
||||
}
|
||||
|
||||
func (c *getWalletBalanceCommand) Execute(ctx context.Context, req *gatewayv1.GetWalletBalanceRequest) gsresponse.Responder[gatewayv1.GetWalletBalanceResponse] {
|
||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
||||
return gsresponse.Unavailable[gatewayv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if req == nil {
|
||||
c.deps.Logger.Warn("nil request")
|
||||
return gsresponse.InvalidArgument[gatewayv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
walletRef := strings.TrimSpace(req.GetWalletRef())
|
||||
if walletRef == "" {
|
||||
c.deps.Logger.Warn("wallet_ref missing")
|
||||
return gsresponse.InvalidArgument[gatewayv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required"))
|
||||
}
|
||||
balance, err := c.deps.Storage.Wallets().GetBalance(ctx, walletRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
c.deps.Logger.Warn("not found", zap.String("wallet_ref", walletRef))
|
||||
return gsresponse.NotFound[gatewayv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
c.deps.Logger.Warn("storage get failed", zap.Error(err), zap.String("wallet_ref", walletRef))
|
||||
return gsresponse.Auto[gatewayv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
return gsresponse.Success(&gatewayv1.GetWalletBalanceResponse{Balance: toProtoWalletBalance(balance)})
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/chain/gateway/internal/service/gateway/shared"
|
||||
"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"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type createManagedWalletCommand struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
func NewCreateManagedWallet(deps Deps) *createManagedWalletCommand {
|
||||
return &createManagedWalletCommand{deps: deps}
|
||||
}
|
||||
|
||||
func (c *createManagedWalletCommand) Execute(ctx context.Context, req *gatewayv1.CreateManagedWalletRequest) gsresponse.Responder[gatewayv1.CreateManagedWalletResponse] {
|
||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
||||
return gsresponse.Unavailable[gatewayv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if req == nil {
|
||||
c.deps.Logger.Warn("nil request")
|
||||
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
|
||||
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
|
||||
if idempotencyKey == "" {
|
||||
c.deps.Logger.Warn("missing idempotency key")
|
||||
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required"))
|
||||
}
|
||||
organizationRef := strings.TrimSpace(req.GetOrganizationRef())
|
||||
if organizationRef == "" {
|
||||
c.deps.Logger.Warn("missing organization ref")
|
||||
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required"))
|
||||
}
|
||||
ownerRef := strings.TrimSpace(req.GetOwnerRef())
|
||||
if ownerRef == "" {
|
||||
c.deps.Logger.Warn("missing owner ref")
|
||||
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("owner_ref is required"))
|
||||
}
|
||||
|
||||
asset := req.GetAsset()
|
||||
if asset == nil {
|
||||
c.deps.Logger.Warn("missing asset")
|
||||
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("asset is required"))
|
||||
}
|
||||
|
||||
chainKey, _ := shared.ChainKeyFromEnum(asset.GetChain())
|
||||
if chainKey == "" {
|
||||
c.deps.Logger.Warn("unsupported chain", zap.Any("chain", asset.GetChain()))
|
||||
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
|
||||
}
|
||||
networkCfg, ok := c.deps.Networks[chainKey]
|
||||
if !ok {
|
||||
c.deps.Logger.Warn("unsupported chain in config", zap.String("chain", chainKey))
|
||||
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
|
||||
}
|
||||
|
||||
tokenSymbol := strings.ToUpper(strings.TrimSpace(asset.GetTokenSymbol()))
|
||||
if tokenSymbol == "" {
|
||||
c.deps.Logger.Warn("missing token symbol")
|
||||
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("asset.token_symbol is required"))
|
||||
}
|
||||
contractAddress := strings.ToLower(strings.TrimSpace(asset.GetContractAddress()))
|
||||
if contractAddress == "" {
|
||||
contractAddress = shared.ResolveContractAddress(networkCfg.TokenConfigs, tokenSymbol)
|
||||
if contractAddress == "" {
|
||||
c.deps.Logger.Warn("unsupported token", zap.String("token", tokenSymbol), zap.String("chain", chainKey))
|
||||
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported token for chain"))
|
||||
}
|
||||
}
|
||||
|
||||
walletRef := shared.GenerateWalletRef()
|
||||
if c.deps.KeyManager == nil {
|
||||
c.deps.Logger.Warn("key manager missing")
|
||||
return gsresponse.Internal[gatewayv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager not configured"))
|
||||
}
|
||||
|
||||
keyInfo, err := c.deps.KeyManager.CreateManagedWalletKey(ctx, walletRef, chainKey)
|
||||
if err != nil {
|
||||
c.deps.Logger.Warn("key manager error", zap.Error(err))
|
||||
return gsresponse.Auto[gatewayv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if keyInfo == nil || strings.TrimSpace(keyInfo.Address) == "" {
|
||||
c.deps.Logger.Warn("key manager returned empty address")
|
||||
return gsresponse.Internal[gatewayv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager returned empty address"))
|
||||
}
|
||||
|
||||
wallet := &model.ManagedWallet{
|
||||
IdempotencyKey: idempotencyKey,
|
||||
WalletRef: walletRef,
|
||||
OrganizationRef: organizationRef,
|
||||
OwnerRef: ownerRef,
|
||||
Network: chainKey,
|
||||
TokenSymbol: tokenSymbol,
|
||||
ContractAddress: contractAddress,
|
||||
DepositAddress: strings.ToLower(keyInfo.Address),
|
||||
KeyReference: keyInfo.KeyID,
|
||||
Status: model.ManagedWalletStatusActive,
|
||||
Metadata: shared.CloneMetadata(req.GetMetadata()),
|
||||
}
|
||||
|
||||
created, err := c.deps.Storage.Wallets().Create(ctx, wallet)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
c.deps.Logger.Debug("wallet already exists", zap.String("wallet_ref", walletRef), zap.String("idempotency_key", idempotencyKey))
|
||||
return gsresponse.Success(&gatewayv1.CreateManagedWalletResponse{Wallet: toProtoManagedWallet(created)})
|
||||
}
|
||||
c.deps.Logger.Warn("storage create failed", zap.Error(err), zap.String("wallet_ref", walletRef))
|
||||
return gsresponse.Auto[gatewayv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
return gsresponse.Success(&gatewayv1.CreateManagedWalletResponse{Wallet: toProtoManagedWallet(created)})
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/chain/gateway/internal/keymanager"
|
||||
"github.com/tech/sendico/chain/gateway/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/chain/gateway/storage"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
)
|
||||
|
||||
type Deps struct {
|
||||
Logger mlogger.Logger
|
||||
Networks map[string]shared.Network
|
||||
KeyManager keymanager.Manager
|
||||
Storage storage.Repository
|
||||
EnsureRepository func(context.Context) error
|
||||
}
|
||||
|
||||
func (d Deps) WithLogger(name string) Deps {
|
||||
if d.Logger != nil {
|
||||
d.Logger = d.Logger.Named(name)
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type getManagedWalletCommand struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
func NewGetManagedWallet(deps Deps) *getManagedWalletCommand {
|
||||
return &getManagedWalletCommand{deps: deps}
|
||||
}
|
||||
|
||||
func (c *getManagedWalletCommand) Execute(ctx context.Context, req *gatewayv1.GetManagedWalletRequest) gsresponse.Responder[gatewayv1.GetManagedWalletResponse] {
|
||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
||||
return gsresponse.Unavailable[gatewayv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if req == nil {
|
||||
c.deps.Logger.Warn("nil request")
|
||||
return gsresponse.InvalidArgument[gatewayv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
walletRef := strings.TrimSpace(req.GetWalletRef())
|
||||
if walletRef == "" {
|
||||
c.deps.Logger.Warn("wallet_ref missing")
|
||||
return gsresponse.InvalidArgument[gatewayv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required"))
|
||||
}
|
||||
wallet, err := c.deps.Storage.Wallets().Get(ctx, walletRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
c.deps.Logger.Warn("not found", zap.String("wallet_ref", walletRef))
|
||||
return gsresponse.NotFound[gatewayv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
c.deps.Logger.Warn("storage get failed", zap.Error(err), zap.String("wallet_ref", walletRef))
|
||||
return gsresponse.Auto[gatewayv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
return gsresponse.Success(&gatewayv1.GetManagedWalletResponse{Wallet: toProtoManagedWallet(wallet)})
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/chain/gateway/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/chain/gateway/storage/model"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
gatewayv1 "github.com/tech/sendico/pkg/proto/chain/gateway/v1"
|
||||
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type listManagedWalletsCommand struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
func NewListManagedWallets(deps Deps) *listManagedWalletsCommand {
|
||||
return &listManagedWalletsCommand{deps: deps}
|
||||
}
|
||||
|
||||
func (c *listManagedWalletsCommand) Execute(ctx context.Context, req *gatewayv1.ListManagedWalletsRequest) gsresponse.Responder[gatewayv1.ListManagedWalletsResponse] {
|
||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
||||
return gsresponse.Unavailable[gatewayv1.ListManagedWalletsResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
filter := model.ManagedWalletFilter{}
|
||||
if req != nil {
|
||||
filter.OrganizationRef = strings.TrimSpace(req.GetOrganizationRef())
|
||||
filter.OwnerRef = strings.TrimSpace(req.GetOwnerRef())
|
||||
if asset := req.GetAsset(); asset != nil {
|
||||
filter.Network, _ = shared.ChainKeyFromEnum(asset.GetChain())
|
||||
filter.TokenSymbol = strings.TrimSpace(asset.GetTokenSymbol())
|
||||
}
|
||||
if page := req.GetPage(); page != nil {
|
||||
filter.Cursor = strings.TrimSpace(page.GetCursor())
|
||||
filter.Limit = page.GetLimit()
|
||||
}
|
||||
}
|
||||
|
||||
result, err := c.deps.Storage.Wallets().List(ctx, filter)
|
||||
if err != nil {
|
||||
c.deps.Logger.Warn("storage list failed", zap.Error(err))
|
||||
return gsresponse.Auto[gatewayv1.ListManagedWalletsResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
protoWallets := make([]*gatewayv1.ManagedWallet, 0, len(result.Items))
|
||||
for _, wallet := range result.Items {
|
||||
protoWallets = append(protoWallets, toProtoManagedWallet(wallet))
|
||||
}
|
||||
|
||||
resp := &gatewayv1.ListManagedWalletsResponse{
|
||||
Wallets: protoWallets,
|
||||
Page: &paginationv1.CursorPageResponse{NextCursor: result.NextCursor},
|
||||
}
|
||||
return gsresponse.Success(resp)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/chain/gateway/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/chain/gateway/storage/model"
|
||||
gatewayv1 "github.com/tech/sendico/pkg/proto/chain/gateway/v1"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
func toProtoManagedWallet(wallet *model.ManagedWallet) *gatewayv1.ManagedWallet {
|
||||
if wallet == nil {
|
||||
return nil
|
||||
}
|
||||
asset := &gatewayv1.Asset{
|
||||
Chain: shared.ChainEnumFromName(wallet.Network),
|
||||
TokenSymbol: wallet.TokenSymbol,
|
||||
ContractAddress: wallet.ContractAddress,
|
||||
}
|
||||
return &gatewayv1.ManagedWallet{
|
||||
WalletRef: wallet.WalletRef,
|
||||
OrganizationRef: wallet.OrganizationRef,
|
||||
OwnerRef: wallet.OwnerRef,
|
||||
Asset: asset,
|
||||
DepositAddress: wallet.DepositAddress,
|
||||
Status: shared.ManagedWalletStatusToProto(wallet.Status),
|
||||
Metadata: shared.CloneMetadata(wallet.Metadata),
|
||||
CreatedAt: timestamppb.New(wallet.CreatedAt.UTC()),
|
||||
UpdatedAt: timestamppb.New(wallet.UpdatedAt.UTC()),
|
||||
}
|
||||
}
|
||||
|
||||
func toProtoWalletBalance(balance *model.WalletBalance) *gatewayv1.WalletBalance {
|
||||
if balance == nil {
|
||||
return nil
|
||||
}
|
||||
return &gatewayv1.WalletBalance{
|
||||
Available: shared.CloneMoney(balance.Available),
|
||||
PendingInbound: shared.CloneMoney(balance.PendingInbound),
|
||||
PendingOutbound: shared.CloneMoney(balance.PendingOutbound),
|
||||
CalculatedAt: timestamppb.New(balance.CalculatedAt.UTC()),
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
|
||||
func cloneMoney(m *moneyv1.Money) *moneyv1.Money {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return &moneyv1.Money{Amount: m.GetAmount(), Currency: m.GetCurrency()}
|
||||
}
|
||||
|
||||
func cloneMetadata(input map[string]string) map[string]string {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
clone := make(map[string]string, len(input))
|
||||
for k, v := range input {
|
||||
clone[k] = v
|
||||
}
|
||||
return clone
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/ethereum/go-ethereum/core/types"
|
||||
"github.com/ethereum/go-ethereum/ethclient"
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tech/sendico/chain/gateway/internal/service/gateway/shared"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/tech/sendico/chain/gateway/internal/keymanager"
|
||||
@@ -24,8 +25,8 @@ import (
|
||||
|
||||
// TransferExecutor handles on-chain submission of transfers.
|
||||
type TransferExecutor interface {
|
||||
SubmitTransfer(ctx context.Context, transfer *model.Transfer, source *model.ManagedWallet, destinationAddress string, network Network) (string, error)
|
||||
AwaitConfirmation(ctx context.Context, network Network, txHash string) (*types.Receipt, error)
|
||||
SubmitTransfer(ctx context.Context, transfer *model.Transfer, source *model.ManagedWallet, destinationAddress string, network shared.Network) (string, error)
|
||||
AwaitConfirmation(ctx context.Context, network shared.Network, txHash string) (*types.Receipt, error)
|
||||
}
|
||||
|
||||
// NewOnChainExecutor constructs a TransferExecutor that talks to an EVM-compatible chain.
|
||||
@@ -45,7 +46,7 @@ type onChainExecutor struct {
|
||||
clients map[string]*ethclient.Client
|
||||
}
|
||||
|
||||
func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Transfer, source *model.ManagedWallet, destinationAddress string, network Network) (string, error) {
|
||||
func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Transfer, source *model.ManagedWallet, destinationAddress string, network shared.Network) (string, error) {
|
||||
if o.keyManager == nil {
|
||||
o.logger.Error("key manager not configured")
|
||||
return "", executorInternal("key manager is not configured", nil)
|
||||
@@ -237,7 +238,7 @@ func (o *onChainExecutor) getClient(ctx context.Context, rpcURL string) (*ethcli
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network Network, txHash string) (*types.Receipt, error) {
|
||||
func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network shared.Network, txHash string) (*types.Receipt, error) {
|
||||
if strings.TrimSpace(txHash) == "" {
|
||||
o.logger.Warn("missing transaction hash for confirmation", zap.String("network", network.Name))
|
||||
return nil, executorInvalid("tx hash is required")
|
||||
|
||||
@@ -4,34 +4,13 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/chain/gateway/internal/keymanager"
|
||||
"github.com/tech/sendico/chain/gateway/internal/service/gateway/shared"
|
||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||
)
|
||||
|
||||
// Option configures the Service.
|
||||
type Option func(*Service)
|
||||
|
||||
// Network describes a supported blockchain network and known token contracts.
|
||||
type Network struct {
|
||||
Name string
|
||||
RPCURL string
|
||||
ChainID uint64
|
||||
NativeToken string
|
||||
TokenConfigs []TokenContract
|
||||
}
|
||||
|
||||
// TokenContract captures the metadata needed to work with a specific on-chain token.
|
||||
type TokenContract struct {
|
||||
Symbol string
|
||||
ContractAddress string
|
||||
}
|
||||
|
||||
// ServiceWallet captures the managed service wallet configuration.
|
||||
type ServiceWallet struct {
|
||||
Network string
|
||||
Address string
|
||||
PrivateKey string
|
||||
}
|
||||
|
||||
// WithKeyManager configures the service key manager.
|
||||
func WithKeyManager(manager keymanager.Manager) Option {
|
||||
return func(s *Service) {
|
||||
@@ -47,13 +26,13 @@ func WithTransferExecutor(executor TransferExecutor) Option {
|
||||
}
|
||||
|
||||
// WithNetworks configures supported blockchain networks.
|
||||
func WithNetworks(networks []Network) Option {
|
||||
func WithNetworks(networks []shared.Network) Option {
|
||||
return func(s *Service) {
|
||||
if len(networks) == 0 {
|
||||
return
|
||||
}
|
||||
if s.networks == nil {
|
||||
s.networks = make(map[string]Network, len(networks))
|
||||
s.networks = make(map[string]shared.Network, len(networks))
|
||||
}
|
||||
for _, network := range networks {
|
||||
if network.Name == "" {
|
||||
@@ -61,7 +40,7 @@ func WithNetworks(networks []Network) Option {
|
||||
}
|
||||
clone := network
|
||||
if clone.TokenConfigs == nil {
|
||||
clone.TokenConfigs = []TokenContract{}
|
||||
clone.TokenConfigs = []shared.TokenContract{}
|
||||
}
|
||||
for i := range clone.TokenConfigs {
|
||||
clone.TokenConfigs[i].Symbol = strings.ToUpper(strings.TrimSpace(clone.TokenConfigs[i].Symbol))
|
||||
@@ -74,7 +53,7 @@ func WithNetworks(networks []Network) Option {
|
||||
}
|
||||
|
||||
// WithServiceWallet configures the service wallet binding.
|
||||
func WithServiceWallet(wallet ServiceWallet) Option {
|
||||
func WithServiceWallet(wallet shared.ServiceWallet) Option {
|
||||
return func(s *Service) {
|
||||
s.serviceWallet = wallet
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@ package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/chain/gateway/internal/keymanager"
|
||||
"github.com/tech/sendico/chain/gateway/internal/service/gateway/commands"
|
||||
"github.com/tech/sendico/chain/gateway/internal/service/gateway/commands/transfer"
|
||||
"github.com/tech/sendico/chain/gateway/internal/service/gateway/commands/wallet"
|
||||
"github.com/tech/sendico/chain/gateway/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/chain/gateway/storage"
|
||||
"github.com/tech/sendico/chain/gateway/storage/model"
|
||||
"github.com/tech/sendico/pkg/api/routers"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||
@@ -14,7 +16,6 @@ import (
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
gatewayv1 "github.com/tech/sendico/pkg/proto/chain/gateway/v1"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
@@ -35,10 +36,11 @@ type Service struct {
|
||||
producer msg.Producer
|
||||
clock clockpkg.Clock
|
||||
|
||||
networks map[string]Network
|
||||
serviceWallet ServiceWallet
|
||||
networks map[string]shared.Network
|
||||
serviceWallet shared.ServiceWallet
|
||||
keyManager keymanager.Manager
|
||||
executor TransferExecutor
|
||||
commands commands.Registry
|
||||
|
||||
gatewayv1.UnimplementedChainGatewayServiceServer
|
||||
}
|
||||
@@ -46,11 +48,11 @@ type Service struct {
|
||||
// NewService constructs the chain gateway service skeleton.
|
||||
func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, opts ...Option) *Service {
|
||||
svc := &Service{
|
||||
logger: logger.Named("chain_gateway"),
|
||||
logger: logger.Named("service"),
|
||||
storage: repo,
|
||||
producer: producer,
|
||||
clock: clockpkg.System{},
|
||||
networks: map[string]Network{},
|
||||
networks: map[string]shared.Network{},
|
||||
}
|
||||
|
||||
initMetrics()
|
||||
@@ -65,9 +67,14 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
||||
svc.clock = clockpkg.System{}
|
||||
}
|
||||
if svc.networks == nil {
|
||||
svc.networks = map[string]Network{}
|
||||
svc.networks = map[string]shared.Network{}
|
||||
}
|
||||
|
||||
svc.commands = commands.NewRegistry(commands.RegistryDeps{
|
||||
Wallet: commandsWalletDeps(svc),
|
||||
Transfer: commandsTransferDeps(svc),
|
||||
})
|
||||
|
||||
return svc
|
||||
}
|
||||
|
||||
@@ -79,35 +86,35 @@ func (s *Service) Register(router routers.GRPC) error {
|
||||
}
|
||||
|
||||
func (s *Service) CreateManagedWallet(ctx context.Context, req *gatewayv1.CreateManagedWalletRequest) (*gatewayv1.CreateManagedWalletResponse, error) {
|
||||
return executeUnary(ctx, s, "CreateManagedWallet", s.createManagedWalletHandler, req)
|
||||
return executeUnary(ctx, s, "CreateManagedWallet", s.commands.CreateManagedWallet.Execute, req)
|
||||
}
|
||||
|
||||
func (s *Service) GetManagedWallet(ctx context.Context, req *gatewayv1.GetManagedWalletRequest) (*gatewayv1.GetManagedWalletResponse, error) {
|
||||
return executeUnary(ctx, s, "GetManagedWallet", s.getManagedWalletHandler, req)
|
||||
return executeUnary(ctx, s, "GetManagedWallet", s.commands.GetManagedWallet.Execute, req)
|
||||
}
|
||||
|
||||
func (s *Service) ListManagedWallets(ctx context.Context, req *gatewayv1.ListManagedWalletsRequest) (*gatewayv1.ListManagedWalletsResponse, error) {
|
||||
return executeUnary(ctx, s, "ListManagedWallets", s.listManagedWalletsHandler, req)
|
||||
return executeUnary(ctx, s, "ListManagedWallets", s.commands.ListManagedWallets.Execute, req)
|
||||
}
|
||||
|
||||
func (s *Service) GetWalletBalance(ctx context.Context, req *gatewayv1.GetWalletBalanceRequest) (*gatewayv1.GetWalletBalanceResponse, error) {
|
||||
return executeUnary(ctx, s, "GetWalletBalance", s.getWalletBalanceHandler, req)
|
||||
return executeUnary(ctx, s, "GetWalletBalance", s.commands.GetWalletBalance.Execute, req)
|
||||
}
|
||||
|
||||
func (s *Service) SubmitTransfer(ctx context.Context, req *gatewayv1.SubmitTransferRequest) (*gatewayv1.SubmitTransferResponse, error) {
|
||||
return executeUnary(ctx, s, "SubmitTransfer", s.submitTransferHandler, req)
|
||||
return executeUnary(ctx, s, "SubmitTransfer", s.commands.SubmitTransfer.Execute, req)
|
||||
}
|
||||
|
||||
func (s *Service) GetTransfer(ctx context.Context, req *gatewayv1.GetTransferRequest) (*gatewayv1.GetTransferResponse, error) {
|
||||
return executeUnary(ctx, s, "GetTransfer", s.getTransferHandler, req)
|
||||
return executeUnary(ctx, s, "GetTransfer", s.commands.GetTransfer.Execute, req)
|
||||
}
|
||||
|
||||
func (s *Service) ListTransfers(ctx context.Context, req *gatewayv1.ListTransfersRequest) (*gatewayv1.ListTransfersResponse, error) {
|
||||
return executeUnary(ctx, s, "ListTransfers", s.listTransfersHandler, req)
|
||||
return executeUnary(ctx, s, "ListTransfers", s.commands.ListTransfers.Execute, req)
|
||||
}
|
||||
|
||||
func (s *Service) EstimateTransferFee(ctx context.Context, req *gatewayv1.EstimateTransferFeeRequest) (*gatewayv1.EstimateTransferFeeResponse, error) {
|
||||
return executeUnary(ctx, s, "EstimateTransferFee", s.estimateTransferFeeHandler, req)
|
||||
return executeUnary(ctx, s, "EstimateTransferFee", s.commands.EstimateTransfer.Execute, req)
|
||||
}
|
||||
|
||||
func (s *Service) ensureRepository(ctx context.Context) error {
|
||||
@@ -117,98 +124,30 @@ func (s *Service) ensureRepository(ctx context.Context) error {
|
||||
return s.storage.Ping(ctx)
|
||||
}
|
||||
|
||||
func commandsWalletDeps(s *Service) wallet.Deps {
|
||||
return wallet.Deps{
|
||||
Logger: s.logger.Named("command"),
|
||||
Networks: s.networks,
|
||||
KeyManager: s.keyManager,
|
||||
Storage: s.storage,
|
||||
EnsureRepository: s.ensureRepository,
|
||||
}
|
||||
}
|
||||
|
||||
func commandsTransferDeps(s *Service) transfer.Deps {
|
||||
return transfer.Deps{
|
||||
Logger: s.logger.Named("transfer_cmd"),
|
||||
Networks: s.networks,
|
||||
Storage: s.storage,
|
||||
Clock: s.clock,
|
||||
EnsureRepository: s.ensureRepository,
|
||||
LaunchExecution: s.launchTransferExecution,
|
||||
}
|
||||
}
|
||||
|
||||
func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method string, handler func(context.Context, *TReq) gsresponse.Responder[TResp], req *TReq) (*TResp, error) {
|
||||
start := svc.clock.Now()
|
||||
resp, err := gsresponse.Unary(svc.logger, mservice.ChainGateway, handler)(ctx, req)
|
||||
observeRPC(method, err, svc.clock.Now().Sub(start))
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func resolveContractAddress(tokens []TokenContract, symbol string) string {
|
||||
upper := strings.ToUpper(symbol)
|
||||
for _, token := range tokens {
|
||||
if strings.EqualFold(token.Symbol, upper) && token.ContractAddress != "" {
|
||||
return strings.ToLower(token.ContractAddress)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func generateWalletRef() string {
|
||||
return primitive.NewObjectID().Hex()
|
||||
}
|
||||
|
||||
func generateTransferRef() string {
|
||||
return primitive.NewObjectID().Hex()
|
||||
}
|
||||
|
||||
func chainKeyFromEnum(chain gatewayv1.ChainNetwork) (string, gatewayv1.ChainNetwork) {
|
||||
if name, ok := gatewayv1.ChainNetwork_name[int32(chain)]; ok {
|
||||
key := strings.ToLower(strings.TrimPrefix(name, "CHAIN_NETWORK_"))
|
||||
return key, chain
|
||||
}
|
||||
return "", gatewayv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED
|
||||
}
|
||||
|
||||
func chainEnumFromName(name string) gatewayv1.ChainNetwork {
|
||||
if name == "" {
|
||||
return gatewayv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED
|
||||
}
|
||||
upper := strings.ToUpper(strings.ReplaceAll(strings.ReplaceAll(name, " ", "_"), "-", "_"))
|
||||
key := "CHAIN_NETWORK_" + upper
|
||||
if val, ok := gatewayv1.ChainNetwork_value[key]; ok {
|
||||
return gatewayv1.ChainNetwork(val)
|
||||
}
|
||||
return gatewayv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED
|
||||
}
|
||||
|
||||
func managedWalletStatusToProto(status model.ManagedWalletStatus) gatewayv1.ManagedWalletStatus {
|
||||
switch status {
|
||||
case model.ManagedWalletStatusActive:
|
||||
return gatewayv1.ManagedWalletStatus_MANAGED_WALLET_ACTIVE
|
||||
case model.ManagedWalletStatusSuspended:
|
||||
return gatewayv1.ManagedWalletStatus_MANAGED_WALLET_SUSPENDED
|
||||
case model.ManagedWalletStatusClosed:
|
||||
return gatewayv1.ManagedWalletStatus_MANAGED_WALLET_CLOSED
|
||||
default:
|
||||
return gatewayv1.ManagedWalletStatus_MANAGED_WALLET_STATUS_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func transferStatusToModel(status gatewayv1.TransferStatus) model.TransferStatus {
|
||||
switch status {
|
||||
case gatewayv1.TransferStatus_TRANSFER_PENDING:
|
||||
return model.TransferStatusPending
|
||||
case gatewayv1.TransferStatus_TRANSFER_SIGNING:
|
||||
return model.TransferStatusSigning
|
||||
case gatewayv1.TransferStatus_TRANSFER_SUBMITTED:
|
||||
return model.TransferStatusSubmitted
|
||||
case gatewayv1.TransferStatus_TRANSFER_CONFIRMED:
|
||||
return model.TransferStatusConfirmed
|
||||
case gatewayv1.TransferStatus_TRANSFER_FAILED:
|
||||
return model.TransferStatusFailed
|
||||
case gatewayv1.TransferStatus_TRANSFER_CANCELLED:
|
||||
return model.TransferStatusCancelled
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func transferStatusToProto(status model.TransferStatus) gatewayv1.TransferStatus {
|
||||
switch status {
|
||||
case model.TransferStatusPending:
|
||||
return gatewayv1.TransferStatus_TRANSFER_PENDING
|
||||
case model.TransferStatusSigning:
|
||||
return gatewayv1.TransferStatus_TRANSFER_SIGNING
|
||||
case model.TransferStatusSubmitted:
|
||||
return gatewayv1.TransferStatus_TRANSFER_SUBMITTED
|
||||
case model.TransferStatusConfirmed:
|
||||
return gatewayv1.TransferStatus_TRANSFER_CONFIRMED
|
||||
case model.TransferStatusFailed:
|
||||
return gatewayv1.TransferStatus_TRANSFER_FAILED
|
||||
case model.TransferStatusCancelled:
|
||||
return gatewayv1.TransferStatus_TRANSFER_CANCELLED
|
||||
default:
|
||||
return gatewayv1.TransferStatus_TRANSFER_STATUS_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/tech/sendico/chain/gateway/internal/keymanager"
|
||||
"github.com/tech/sendico/chain/gateway/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/chain/gateway/storage"
|
||||
"github.com/tech/sendico/chain/gateway/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
@@ -530,13 +531,13 @@ func newTestService(_ *testing.T) (*Service, *inMemoryRepository) {
|
||||
logger := zap.NewNop()
|
||||
svc := NewService(logger, repo, nil,
|
||||
WithKeyManager(&fakeKeyManager{}),
|
||||
WithNetworks([]Network{{
|
||||
WithNetworks([]shared.Network{{
|
||||
Name: "ethereum_mainnet",
|
||||
TokenConfigs: []TokenContract{
|
||||
TokenConfigs: []shared.TokenContract{
|
||||
{Symbol: "USDC", ContractAddress: "0xusdc"},
|
||||
},
|
||||
}}),
|
||||
WithServiceWallet(ServiceWallet{Network: "ethereum_mainnet", Address: "0xservice"}),
|
||||
WithServiceWallet(shared.ServiceWallet{Network: "ethereum_mainnet", Address: "0xservice"}),
|
||||
)
|
||||
return svc, repo
|
||||
}
|
||||
|
||||
142
api/chain/gateway/internal/service/gateway/shared/helpers.go
Normal file
142
api/chain/gateway/internal/service/gateway/shared/helpers.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/chain/gateway/storage/model"
|
||||
gatewayv1 "github.com/tech/sendico/pkg/proto/chain/gateway/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
// CloneMoney defensively copies a Money proto.
|
||||
func CloneMoney(m *moneyv1.Money) *moneyv1.Money {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return &moneyv1.Money{Amount: m.GetAmount(), Currency: m.GetCurrency()}
|
||||
}
|
||||
|
||||
// CloneMetadata defensively copies metadata maps.
|
||||
func CloneMetadata(input map[string]string) map[string]string {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
clone := make(map[string]string, len(input))
|
||||
for k, v := range input {
|
||||
clone[k] = v
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
// ResolveContractAddress finds a token contract for a symbol in a case-insensitive manner.
|
||||
func ResolveContractAddress(tokens []TokenContract, symbol string) string {
|
||||
upper := strings.ToUpper(symbol)
|
||||
for _, token := range tokens {
|
||||
if strings.EqualFold(token.Symbol, upper) && token.ContractAddress != "" {
|
||||
return strings.ToLower(token.ContractAddress)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func GenerateWalletRef() string {
|
||||
return primitive.NewObjectID().Hex()
|
||||
}
|
||||
|
||||
func GenerateTransferRef() string {
|
||||
return primitive.NewObjectID().Hex()
|
||||
}
|
||||
|
||||
func ChainKeyFromEnum(chain gatewayv1.ChainNetwork) (string, gatewayv1.ChainNetwork) {
|
||||
if name, ok := gatewayv1.ChainNetwork_name[int32(chain)]; ok {
|
||||
key := strings.ToLower(strings.TrimPrefix(name, "CHAIN_NETWORK_"))
|
||||
return key, chain
|
||||
}
|
||||
return "", gatewayv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED
|
||||
}
|
||||
|
||||
func ChainEnumFromName(name string) gatewayv1.ChainNetwork {
|
||||
if name == "" {
|
||||
return gatewayv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED
|
||||
}
|
||||
upper := strings.ToUpper(strings.ReplaceAll(strings.ReplaceAll(name, " ", "_"), "-", "_"))
|
||||
key := "CHAIN_NETWORK_" + upper
|
||||
if val, ok := gatewayv1.ChainNetwork_value[key]; ok {
|
||||
return gatewayv1.ChainNetwork(val)
|
||||
}
|
||||
return gatewayv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED
|
||||
}
|
||||
|
||||
func ManagedWalletStatusToProto(status model.ManagedWalletStatus) gatewayv1.ManagedWalletStatus {
|
||||
switch status {
|
||||
case model.ManagedWalletStatusActive:
|
||||
return gatewayv1.ManagedWalletStatus_MANAGED_WALLET_ACTIVE
|
||||
case model.ManagedWalletStatusSuspended:
|
||||
return gatewayv1.ManagedWalletStatus_MANAGED_WALLET_SUSPENDED
|
||||
case model.ManagedWalletStatusClosed:
|
||||
return gatewayv1.ManagedWalletStatus_MANAGED_WALLET_CLOSED
|
||||
default:
|
||||
return gatewayv1.ManagedWalletStatus_MANAGED_WALLET_STATUS_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func TransferStatusToModel(status gatewayv1.TransferStatus) model.TransferStatus {
|
||||
switch status {
|
||||
case gatewayv1.TransferStatus_TRANSFER_PENDING:
|
||||
return model.TransferStatusPending
|
||||
case gatewayv1.TransferStatus_TRANSFER_SIGNING:
|
||||
return model.TransferStatusSigning
|
||||
case gatewayv1.TransferStatus_TRANSFER_SUBMITTED:
|
||||
return model.TransferStatusSubmitted
|
||||
case gatewayv1.TransferStatus_TRANSFER_CONFIRMED:
|
||||
return model.TransferStatusConfirmed
|
||||
case gatewayv1.TransferStatus_TRANSFER_FAILED:
|
||||
return model.TransferStatusFailed
|
||||
case gatewayv1.TransferStatus_TRANSFER_CANCELLED:
|
||||
return model.TransferStatusCancelled
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func TransferStatusToProto(status model.TransferStatus) gatewayv1.TransferStatus {
|
||||
switch status {
|
||||
case model.TransferStatusPending:
|
||||
return gatewayv1.TransferStatus_TRANSFER_PENDING
|
||||
case model.TransferStatusSigning:
|
||||
return gatewayv1.TransferStatus_TRANSFER_SIGNING
|
||||
case model.TransferStatusSubmitted:
|
||||
return gatewayv1.TransferStatus_TRANSFER_SUBMITTED
|
||||
case model.TransferStatusConfirmed:
|
||||
return gatewayv1.TransferStatus_TRANSFER_CONFIRMED
|
||||
case model.TransferStatusFailed:
|
||||
return gatewayv1.TransferStatus_TRANSFER_FAILED
|
||||
case model.TransferStatusCancelled:
|
||||
return gatewayv1.TransferStatus_TRANSFER_CANCELLED
|
||||
default:
|
||||
return gatewayv1.TransferStatus_TRANSFER_STATUS_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
// Network describes a supported blockchain network and known token contracts.
|
||||
type Network struct {
|
||||
Name string
|
||||
RPCURL string
|
||||
ChainID uint64
|
||||
NativeToken string
|
||||
TokenConfigs []TokenContract
|
||||
}
|
||||
|
||||
// TokenContract captures the metadata needed to work with a specific on-chain token.
|
||||
type TokenContract struct {
|
||||
Symbol string
|
||||
ContractAddress string
|
||||
}
|
||||
|
||||
// ServiceWallet captures the managed service wallet configuration.
|
||||
type ServiceWallet struct {
|
||||
Network string
|
||||
Address string
|
||||
PrivateKey string
|
||||
}
|
||||
@@ -10,14 +10,16 @@ import (
|
||||
"github.com/tech/sendico/chain/gateway/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/tech/sendico/chain/gateway/internal/service/gateway/shared"
|
||||
)
|
||||
|
||||
func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, network Network) {
|
||||
func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, network shared.Network) {
|
||||
if s.executor == nil {
|
||||
return
|
||||
}
|
||||
|
||||
go func(ref, walletRef string, net Network) {
|
||||
go func(ref, walletRef string, net shared.Network) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
@@ -27,7 +29,7 @@ func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, n
|
||||
}(transferRef, sourceWalletRef, network)
|
||||
}
|
||||
|
||||
func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWalletRef string, network Network) error {
|
||||
func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWalletRef string, network shared.Network) error {
|
||||
transfer, err := s.storage.Transfers().Get(ctx, transferRef)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,309 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
func (s *Service) createManagedWalletHandler(ctx context.Context, req *gatewayv1.CreateManagedWalletRequest) gsresponse.Responder[gatewayv1.CreateManagedWalletResponse] {
|
||||
if err := s.ensureRepository(ctx); err != nil {
|
||||
return gsresponse.Unavailable[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if req == nil {
|
||||
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
|
||||
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
|
||||
if idempotencyKey == "" {
|
||||
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required"))
|
||||
}
|
||||
organizationRef := strings.TrimSpace(req.GetOrganizationRef())
|
||||
if organizationRef == "" {
|
||||
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required"))
|
||||
}
|
||||
ownerRef := strings.TrimSpace(req.GetOwnerRef())
|
||||
if ownerRef == "" {
|
||||
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("owner_ref is required"))
|
||||
}
|
||||
|
||||
asset := req.GetAsset()
|
||||
if asset == nil {
|
||||
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("asset is required"))
|
||||
}
|
||||
|
||||
chainKey, _ := chainKeyFromEnum(asset.GetChain())
|
||||
if chainKey == "" {
|
||||
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
|
||||
}
|
||||
networkCfg, ok := s.networks[chainKey]
|
||||
if !ok {
|
||||
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
|
||||
}
|
||||
|
||||
tokenSymbol := strings.ToUpper(strings.TrimSpace(asset.GetTokenSymbol()))
|
||||
if tokenSymbol == "" {
|
||||
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("asset.token_symbol is required"))
|
||||
}
|
||||
contractAddress := strings.ToLower(strings.TrimSpace(asset.GetContractAddress()))
|
||||
if contractAddress == "" {
|
||||
contractAddress = resolveContractAddress(networkCfg.TokenConfigs, tokenSymbol)
|
||||
if contractAddress == "" {
|
||||
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported token for chain"))
|
||||
}
|
||||
}
|
||||
|
||||
walletRef := generateWalletRef()
|
||||
if s.keyManager == nil {
|
||||
return gsresponse.Internal[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.Internal("key manager not configured"))
|
||||
}
|
||||
|
||||
keyInfo, err := s.keyManager.CreateManagedWalletKey(ctx, walletRef, chainKey)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if keyInfo == nil || strings.TrimSpace(keyInfo.Address) == "" {
|
||||
return gsresponse.Internal[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.Internal("key manager returned empty address"))
|
||||
}
|
||||
|
||||
wallet := &model.ManagedWallet{
|
||||
IdempotencyKey: idempotencyKey,
|
||||
WalletRef: walletRef,
|
||||
OrganizationRef: organizationRef,
|
||||
OwnerRef: ownerRef,
|
||||
Network: chainKey,
|
||||
TokenSymbol: tokenSymbol,
|
||||
ContractAddress: contractAddress,
|
||||
DepositAddress: strings.ToLower(keyInfo.Address),
|
||||
KeyReference: keyInfo.KeyID,
|
||||
Status: model.ManagedWalletStatusActive,
|
||||
Metadata: cloneMetadata(req.GetMetadata()),
|
||||
}
|
||||
|
||||
created, err := s.storage.Wallets().Create(ctx, wallet)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
s.logger.Debug("wallet already exists", zap.String("wallet_ref", walletRef), zap.String("idempotency_key", idempotencyKey))
|
||||
return gsresponse.Success(&gatewayv1.CreateManagedWalletResponse{Wallet: s.toProtoManagedWallet(created)})
|
||||
}
|
||||
return gsresponse.Auto[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
return gsresponse.Success(&gatewayv1.CreateManagedWalletResponse{Wallet: s.toProtoManagedWallet(created)})
|
||||
}
|
||||
|
||||
func (s *Service) getManagedWalletHandler(ctx context.Context, req *gatewayv1.GetManagedWalletRequest) gsresponse.Responder[gatewayv1.GetManagedWalletResponse] {
|
||||
if err := s.ensureRepository(ctx); err != nil {
|
||||
return gsresponse.Unavailable[gatewayv1.GetManagedWalletResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if req == nil {
|
||||
return gsresponse.InvalidArgument[gatewayv1.GetManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
walletRef := strings.TrimSpace(req.GetWalletRef())
|
||||
if walletRef == "" {
|
||||
return gsresponse.InvalidArgument[gatewayv1.GetManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required"))
|
||||
}
|
||||
wallet, err := s.storage.Wallets().Get(ctx, walletRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return gsresponse.NotFound[gatewayv1.GetManagedWalletResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
return gsresponse.Auto[gatewayv1.GetManagedWalletResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
return gsresponse.Success(&gatewayv1.GetManagedWalletResponse{Wallet: s.toProtoManagedWallet(wallet)})
|
||||
}
|
||||
|
||||
func (s *Service) listManagedWalletsHandler(ctx context.Context, req *gatewayv1.ListManagedWalletsRequest) gsresponse.Responder[gatewayv1.ListManagedWalletsResponse] {
|
||||
if err := s.ensureRepository(ctx); err != nil {
|
||||
return gsresponse.Unavailable[gatewayv1.ListManagedWalletsResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
filter := model.ManagedWalletFilter{}
|
||||
if req != nil {
|
||||
filter.OrganizationRef = strings.TrimSpace(req.GetOrganizationRef())
|
||||
filter.OwnerRef = strings.TrimSpace(req.GetOwnerRef())
|
||||
if asset := req.GetAsset(); asset != nil {
|
||||
filter.Network, _ = chainKeyFromEnum(asset.GetChain())
|
||||
filter.TokenSymbol = strings.TrimSpace(asset.GetTokenSymbol())
|
||||
}
|
||||
if page := req.GetPage(); page != nil {
|
||||
filter.Cursor = strings.TrimSpace(page.GetCursor())
|
||||
filter.Limit = page.GetLimit()
|
||||
}
|
||||
}
|
||||
|
||||
result, err := s.storage.Wallets().List(ctx, filter)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[gatewayv1.ListManagedWalletsResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
protoWallets := make([]*gatewayv1.ManagedWallet, 0, len(result.Items))
|
||||
for _, wallet := range result.Items {
|
||||
protoWallets = append(protoWallets, s.toProtoManagedWallet(wallet))
|
||||
}
|
||||
|
||||
resp := &gatewayv1.ListManagedWalletsResponse{
|
||||
Wallets: protoWallets,
|
||||
Page: &paginationv1.CursorPageResponse{NextCursor: result.NextCursor},
|
||||
}
|
||||
return gsresponse.Success(resp)
|
||||
}
|
||||
|
||||
func (s *Service) getWalletBalanceHandler(ctx context.Context, req *gatewayv1.GetWalletBalanceRequest) gsresponse.Responder[gatewayv1.GetWalletBalanceResponse] {
|
||||
if err := s.ensureRepository(ctx); err != nil {
|
||||
return gsresponse.Unavailable[gatewayv1.GetWalletBalanceResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if req == nil {
|
||||
return gsresponse.InvalidArgument[gatewayv1.GetWalletBalanceResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
walletRef := strings.TrimSpace(req.GetWalletRef())
|
||||
if walletRef == "" {
|
||||
return gsresponse.InvalidArgument[gatewayv1.GetWalletBalanceResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required"))
|
||||
}
|
||||
balance, err := s.storage.Wallets().GetBalance(ctx, walletRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return gsresponse.NotFound[gatewayv1.GetWalletBalanceResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
return gsresponse.Auto[gatewayv1.GetWalletBalanceResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
return gsresponse.Success(&gatewayv1.GetWalletBalanceResponse{Balance: toProtoWalletBalance(balance)})
|
||||
}
|
||||
|
||||
func (s *Service) toProtoManagedWallet(wallet *model.ManagedWallet) *gatewayv1.ManagedWallet {
|
||||
if wallet == nil {
|
||||
return nil
|
||||
}
|
||||
asset := &gatewayv1.Asset{
|
||||
Chain: chainEnumFromName(wallet.Network),
|
||||
TokenSymbol: wallet.TokenSymbol,
|
||||
ContractAddress: wallet.ContractAddress,
|
||||
}
|
||||
return &gatewayv1.ManagedWallet{
|
||||
WalletRef: wallet.WalletRef,
|
||||
OrganizationRef: wallet.OrganizationRef,
|
||||
OwnerRef: wallet.OwnerRef,
|
||||
Asset: asset,
|
||||
DepositAddress: wallet.DepositAddress,
|
||||
Status: managedWalletStatusToProto(wallet.Status),
|
||||
Metadata: cloneMetadata(wallet.Metadata),
|
||||
CreatedAt: timestamppb.New(wallet.CreatedAt.UTC()),
|
||||
UpdatedAt: timestamppb.New(wallet.UpdatedAt.UTC()),
|
||||
}
|
||||
}
|
||||
|
||||
func toProtoWalletBalance(balance *model.WalletBalance) *gatewayv1.WalletBalance {
|
||||
if balance == nil {
|
||||
return nil
|
||||
}
|
||||
return &gatewayv1.WalletBalance{
|
||||
Available: cloneMoney(balance.Available),
|
||||
PendingInbound: cloneMoney(balance.PendingInbound),
|
||||
PendingOutbound: cloneMoney(balance.PendingOutbound),
|
||||
CalculatedAt: timestamppb.New(balance.CalculatedAt.UTC()),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user