From 35897f9aa1f3574d2d59916ae1121c79526f7a50 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Wed, 26 Nov 2025 21:45:38 +0100 Subject: [PATCH] impreoved commands logging --- .../internal/server/internal/serverimp.go | 15 +- .../service/gateway/commands/registry.go | 44 +++ .../service/gateway/commands/transfer/deps.go | 26 ++ .../service/gateway/commands/transfer/fee.go | 41 +++ .../service/gateway/commands/transfer/fees.go | 43 +++ .../service/gateway/commands/transfer/get.go | 47 +++ .../service/gateway/commands/transfer/list.go | 58 ++++ .../gateway/commands/transfer/proto.go | 53 +++ .../gateway/commands/transfer/submit.go | 188 +++++++++++ .../gateway/commands/wallet/balance.go | 47 +++ .../service/gateway/commands/wallet/create.go | 123 +++++++ .../service/gateway/commands/wallet/deps.go | 25 ++ .../service/gateway/commands/wallet/get.go | 47 +++ .../service/gateway/commands/wallet/list.go | 59 ++++ .../service/gateway/commands/wallet/proto.go | 42 +++ .../service/gateway/conversion_helpers.go | 21 -- .../internal/service/gateway/executor.go | 9 +- .../internal/service/gateway/options.go | 31 +- .../internal/service/gateway/service.go | 149 +++------ .../internal/service/gateway/service_test.go | 7 +- .../service/gateway/shared/helpers.go | 142 ++++++++ .../service/gateway/transfer_execution.go | 8 +- .../service/gateway/transfer_handlers.go | 309 ------------------ .../service/gateway/wallet_handlers.go | 213 ------------ frontend/pshared/lib/models/resources.dart | 30 ++ .../pshared/lib/provider/permissions.dart | 11 +- frontend/pweb/lib/app/router/router.dart | 5 - frontend/pweb/lib/main.dart | 7 +- .../pweb/lib/pages/dashboard/dashboard.dart | 9 +- frontend/pweb/lib/pages/loader.dart | 7 +- frontend/pweb/lib/utils/logout.dart | 15 + frontend/pweb/lib/widgets/sidebar/page.dart | 196 +++++------ frontend/pweb/pubspec.yaml | 2 +- 33 files changed, 1225 insertions(+), 804 deletions(-) create mode 100644 api/chain/gateway/internal/service/gateway/commands/registry.go create mode 100644 api/chain/gateway/internal/service/gateway/commands/transfer/deps.go create mode 100644 api/chain/gateway/internal/service/gateway/commands/transfer/fee.go create mode 100644 api/chain/gateway/internal/service/gateway/commands/transfer/fees.go create mode 100644 api/chain/gateway/internal/service/gateway/commands/transfer/get.go create mode 100644 api/chain/gateway/internal/service/gateway/commands/transfer/list.go create mode 100644 api/chain/gateway/internal/service/gateway/commands/transfer/proto.go create mode 100644 api/chain/gateway/internal/service/gateway/commands/transfer/submit.go create mode 100644 api/chain/gateway/internal/service/gateway/commands/wallet/balance.go create mode 100644 api/chain/gateway/internal/service/gateway/commands/wallet/create.go create mode 100644 api/chain/gateway/internal/service/gateway/commands/wallet/deps.go create mode 100644 api/chain/gateway/internal/service/gateway/commands/wallet/get.go create mode 100644 api/chain/gateway/internal/service/gateway/commands/wallet/list.go create mode 100644 api/chain/gateway/internal/service/gateway/commands/wallet/proto.go delete mode 100644 api/chain/gateway/internal/service/gateway/conversion_helpers.go create mode 100644 api/chain/gateway/internal/service/gateway/shared/helpers.go delete mode 100644 api/chain/gateway/internal/service/gateway/transfer_handlers.go delete mode 100644 api/chain/gateway/internal/service/gateway/wallet_handlers.go create mode 100644 frontend/pweb/lib/utils/logout.dart diff --git a/api/chain/gateway/internal/server/internal/serverimp.go b/api/chain/gateway/internal/server/internal/serverimp.go index a1cb7d8..ec187a6 100644 --- a/api/chain/gateway/internal/server/internal/serverimp.go +++ b/api/chain/gateway/internal/server/internal/serverimp.go @@ -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, diff --git a/api/chain/gateway/internal/service/gateway/commands/registry.go b/api/chain/gateway/internal/service/gateway/commands/registry.go new file mode 100644 index 0000000..bea855a --- /dev/null +++ b/api/chain/gateway/internal/service/gateway/commands/registry.go @@ -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")), + } +} diff --git a/api/chain/gateway/internal/service/gateway/commands/transfer/deps.go b/api/chain/gateway/internal/service/gateway/commands/transfer/deps.go new file mode 100644 index 0000000..fb18e83 --- /dev/null +++ b/api/chain/gateway/internal/service/gateway/commands/transfer/deps.go @@ -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 +} diff --git a/api/chain/gateway/internal/service/gateway/commands/transfer/fee.go b/api/chain/gateway/internal/service/gateway/commands/transfer/fee.go new file mode 100644 index 0000000..b5a2713 --- /dev/null +++ b/api/chain/gateway/internal/service/gateway/commands/transfer/fee.go @@ -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) +} diff --git a/api/chain/gateway/internal/service/gateway/commands/transfer/fees.go b/api/chain/gateway/internal/service/gateway/commands/transfer/fees.go new file mode 100644 index 0000000..b41db01 --- /dev/null +++ b/api/chain/gateway/internal/service/gateway/commands/transfer/fees.go @@ -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 +} diff --git a/api/chain/gateway/internal/service/gateway/commands/transfer/get.go b/api/chain/gateway/internal/service/gateway/commands/transfer/get.go new file mode 100644 index 0000000..42bdaa0 --- /dev/null +++ b/api/chain/gateway/internal/service/gateway/commands/transfer/get.go @@ -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)}) +} diff --git a/api/chain/gateway/internal/service/gateway/commands/transfer/list.go b/api/chain/gateway/internal/service/gateway/commands/transfer/list.go new file mode 100644 index 0000000..18598a2 --- /dev/null +++ b/api/chain/gateway/internal/service/gateway/commands/transfer/list.go @@ -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) +} diff --git a/api/chain/gateway/internal/service/gateway/commands/transfer/proto.go b/api/chain/gateway/internal/service/gateway/commands/transfer/proto.go new file mode 100644 index 0000000..dc6b3c2 --- /dev/null +++ b/api/chain/gateway/internal/service/gateway/commands/transfer/proto.go @@ -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()), + } +} diff --git a/api/chain/gateway/internal/service/gateway/commands/transfer/submit.go b/api/chain/gateway/internal/service/gateway/commands/transfer/submit.go new file mode 100644 index 0000000..759b6cd --- /dev/null +++ b/api/chain/gateway/internal/service/gateway/commands/transfer/submit.go @@ -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 +} diff --git a/api/chain/gateway/internal/service/gateway/commands/wallet/balance.go b/api/chain/gateway/internal/service/gateway/commands/wallet/balance.go new file mode 100644 index 0000000..1be34ab --- /dev/null +++ b/api/chain/gateway/internal/service/gateway/commands/wallet/balance.go @@ -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)}) +} diff --git a/api/chain/gateway/internal/service/gateway/commands/wallet/create.go b/api/chain/gateway/internal/service/gateway/commands/wallet/create.go new file mode 100644 index 0000000..3d0651e --- /dev/null +++ b/api/chain/gateway/internal/service/gateway/commands/wallet/create.go @@ -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)}) +} diff --git a/api/chain/gateway/internal/service/gateway/commands/wallet/deps.go b/api/chain/gateway/internal/service/gateway/commands/wallet/deps.go new file mode 100644 index 0000000..7fa72e1 --- /dev/null +++ b/api/chain/gateway/internal/service/gateway/commands/wallet/deps.go @@ -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 +} diff --git a/api/chain/gateway/internal/service/gateway/commands/wallet/get.go b/api/chain/gateway/internal/service/gateway/commands/wallet/get.go new file mode 100644 index 0000000..44e1b7c --- /dev/null +++ b/api/chain/gateway/internal/service/gateway/commands/wallet/get.go @@ -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)}) +} diff --git a/api/chain/gateway/internal/service/gateway/commands/wallet/list.go b/api/chain/gateway/internal/service/gateway/commands/wallet/list.go new file mode 100644 index 0000000..44fa364 --- /dev/null +++ b/api/chain/gateway/internal/service/gateway/commands/wallet/list.go @@ -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) +} diff --git a/api/chain/gateway/internal/service/gateway/commands/wallet/proto.go b/api/chain/gateway/internal/service/gateway/commands/wallet/proto.go new file mode 100644 index 0000000..a45b55f --- /dev/null +++ b/api/chain/gateway/internal/service/gateway/commands/wallet/proto.go @@ -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()), + } +} diff --git a/api/chain/gateway/internal/service/gateway/conversion_helpers.go b/api/chain/gateway/internal/service/gateway/conversion_helpers.go deleted file mode 100644 index a1803d7..0000000 --- a/api/chain/gateway/internal/service/gateway/conversion_helpers.go +++ /dev/null @@ -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 -} diff --git a/api/chain/gateway/internal/service/gateway/executor.go b/api/chain/gateway/internal/service/gateway/executor.go index 1972260..9140236 100644 --- a/api/chain/gateway/internal/service/gateway/executor.go +++ b/api/chain/gateway/internal/service/gateway/executor.go @@ -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") diff --git a/api/chain/gateway/internal/service/gateway/options.go b/api/chain/gateway/internal/service/gateway/options.go index aab4636..ec6282b 100644 --- a/api/chain/gateway/internal/service/gateway/options.go +++ b/api/chain/gateway/internal/service/gateway/options.go @@ -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 } diff --git a/api/chain/gateway/internal/service/gateway/service.go b/api/chain/gateway/internal/service/gateway/service.go index f97f47b..ea41ce2 100644 --- a/api/chain/gateway/internal/service/gateway/service.go +++ b/api/chain/gateway/internal/service/gateway/service.go @@ -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 - } -} diff --git a/api/chain/gateway/internal/service/gateway/service_test.go b/api/chain/gateway/internal/service/gateway/service_test.go index 495d523..58b9829 100644 --- a/api/chain/gateway/internal/service/gateway/service_test.go +++ b/api/chain/gateway/internal/service/gateway/service_test.go @@ -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 } diff --git a/api/chain/gateway/internal/service/gateway/shared/helpers.go b/api/chain/gateway/internal/service/gateway/shared/helpers.go new file mode 100644 index 0000000..cc4c193 --- /dev/null +++ b/api/chain/gateway/internal/service/gateway/shared/helpers.go @@ -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 +} diff --git a/api/chain/gateway/internal/service/gateway/transfer_execution.go b/api/chain/gateway/internal/service/gateway/transfer_execution.go index 252b574..e160212 100644 --- a/api/chain/gateway/internal/service/gateway/transfer_execution.go +++ b/api/chain/gateway/internal/service/gateway/transfer_execution.go @@ -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 diff --git a/api/chain/gateway/internal/service/gateway/transfer_handlers.go b/api/chain/gateway/internal/service/gateway/transfer_handlers.go deleted file mode 100644 index 70560c1..0000000 --- a/api/chain/gateway/internal/service/gateway/transfer_handlers.go +++ /dev/null @@ -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 -} diff --git a/api/chain/gateway/internal/service/gateway/wallet_handlers.go b/api/chain/gateway/internal/service/gateway/wallet_handlers.go deleted file mode 100644 index d0bc84b..0000000 --- a/api/chain/gateway/internal/service/gateway/wallet_handlers.go +++ /dev/null @@ -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()), - } -} diff --git a/frontend/pshared/lib/models/resources.dart b/frontend/pshared/lib/models/resources.dart index 62c6f59..ddd5556 100644 --- a/frontend/pshared/lib/models/resources.dart +++ b/frontend/pshared/lib/models/resources.dart @@ -45,6 +45,36 @@ enum ResourceType { @JsonValue('chain_wallets') chainWallets, + @JsonValue('chain_wallet_balances') + chainWalletBalances, + + @JsonValue('chain_transfers') + chainTransfers, + + @JsonValue('chain_deposits') + chainDeposits, + + @JsonValue('ledger') + ledger, + + @JsonValue('ledger_accounts') + ledgerAccounts, + + @JsonValue('ledger_balances') + ledgerBalances, + + @JsonValue('ledger_journal_entries') + ledgerJournalEntries, + + @JsonValue('ledger_outbox') + ledgerOutbox, + + @JsonValue('ledger_parties') + ledgerParties, + + @JsonValue('ledger_posing_lines') + ledgerPostingLines, + /// Represents permissions service @JsonValue('permissions') permissions, diff --git a/frontend/pshared/lib/provider/permissions.dart b/frontend/pshared/lib/provider/permissions.dart index 98091d4..7b6e0d5 100644 --- a/frontend/pshared/lib/provider/permissions.dart +++ b/frontend/pshared/lib/provider/permissions.dart @@ -20,6 +20,7 @@ import 'package:pshared/service/permissions.dart'; class PermissionsProvider extends ChangeNotifier { Resource _userAccess = Resource(data: null, isLoading: false, error: null); late OrganizationsProvider _organizations; + bool _isLoaded = false; void update(OrganizationsProvider venue) { _organizations = venue; @@ -54,6 +55,7 @@ class PermissionsProvider extends ChangeNotifier { final allAccess = await PermissionsService.loadAll(orgRef); _userAccess = _userAccess.copyWith(data: allAccess, isLoading: false); } + _isLoaded = true; } catch (e) { _userAccess = _userAccess.copyWith( error: e is Exception ? e : Exception(e.toString()), @@ -125,8 +127,9 @@ class PermissionsProvider extends ChangeNotifier { PolicyDescription? getPolicyDescription(String policyRef) => policyDescriptions.firstWhereOrNull((p) => p.storable.id == policyRef); // -- Permission checks -- + bool get isLoading => _userAccess.isLoading; - bool get isReady => !_userAccess.isLoading && error == null; + bool get isReady => !_userAccess.isLoading && error == null && _isLoaded; Exception? get error => _userAccess.error; bool _hasMatchingPermission( @@ -158,6 +161,12 @@ class PermissionsProvider extends ChangeNotifier { return _hasMatchingPermission(pd, Effect.allow, action, objectRef: objectRef); } + void reset() { + _userAccess = Resource(data: null, isLoading: false, error: null); + _isLoaded = false; + notifyListeners(); + } + bool canRead(ResourceType r, {Object? objectRef}) => canAccessResource(r, action: perm.Action.read, objectRef: objectRef); bool canUpdate(ResourceType r, {Object? objectRef}) => canAccessResource(r, action: perm.Action.update, objectRef: objectRef); bool canDelete(ResourceType r, {Object? objectRef}) => canAccessResource(r, action: perm.Action.delete, objectRef: objectRef); diff --git a/frontend/pweb/lib/app/router/router.dart b/frontend/pweb/lib/app/router/router.dart index 2a693e5..1b2fdce 100644 --- a/frontend/pweb/lib/app/router/router.dart +++ b/frontend/pweb/lib/app/router/router.dart @@ -1,9 +1,5 @@ -import 'package:provider/provider.dart'; - import 'package:go_router/go_router.dart'; -import 'package:pshared/provider/organizations.dart'; - import 'package:pweb/app/router/pages.dart'; import 'package:pweb/app/router/page_params.dart'; import 'package:pweb/pages/2fa/page.dart'; @@ -38,7 +34,6 @@ GoRouter createRouter() => GoRouter( builder: (context, _) => TwoFactorCodePage( onVerificationSuccess: () { // trigger organization load - context.read().load(); context.goNamed(Pages.dashboard.name); }, ), diff --git a/frontend/pweb/lib/main.dart b/frontend/pweb/lib/main.dart index 348298e..9ecdaef 100644 --- a/frontend/pweb/lib/main.dart +++ b/frontend/pweb/lib/main.dart @@ -25,7 +25,7 @@ import 'package:pweb/providers/two_factor.dart'; import 'package:pweb/providers/upload_history.dart'; import 'package:pweb/providers/wallets.dart'; import 'package:pweb/providers/wallet_transactions.dart'; -import 'package:pweb/services/amplitude.dart'; +// import 'package:pweb/services/amplitude.dart'; import 'package:pweb/services/operations.dart'; import 'package:pweb/services/payments/payment_methods.dart'; import 'package:pweb/services/payments/upload_history.dart'; @@ -44,7 +44,10 @@ void _setupLogging() { void main() async { await Constants.initialize(); - await AmplitudeService.initialize(); + + WidgetsFlutterBinding.ensureInitialized(); + + // await AmplitudeService.initialize(); _setupLogging(); diff --git a/frontend/pweb/lib/pages/dashboard/dashboard.dart b/frontend/pweb/lib/pages/dashboard/dashboard.dart index 55d8690..6ec2d48 100644 --- a/frontend/pweb/lib/pages/dashboard/dashboard.dart +++ b/frontend/pweb/lib/pages/dashboard/dashboard.dart @@ -8,6 +8,7 @@ import 'package:pweb/pages/dashboard/buttons/buttons.dart'; import 'package:pweb/pages/dashboard/payouts/multiple/title.dart'; import 'package:pweb/pages/dashboard/payouts/multiple/widget.dart'; import 'package:pweb/pages/dashboard/payouts/single/widget.dart'; +import 'package:pweb/pages/loader.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -44,8 +45,8 @@ class _DashboardPageState extends State { } @override - Widget build(BuildContext context) { - return SafeArea( + Widget build(BuildContext context) => PageViewLoader( + child: SafeArea( child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -87,6 +88,6 @@ class _DashboardPageState extends State { ], ), ), - ); - } + ), + ); } diff --git a/frontend/pweb/lib/pages/loader.dart b/frontend/pweb/lib/pages/loader.dart index da16ffe..e7a97dc 100644 --- a/frontend/pweb/lib/pages/loader.dart +++ b/frontend/pweb/lib/pages/loader.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:pweb/pages/loaders/account.dart'; +import 'package:pweb/pages/loaders/organization.dart'; import 'package:pweb/pages/loaders/permissions.dart'; @@ -11,8 +12,10 @@ class PageViewLoader extends StatelessWidget { @override Widget build(BuildContext context) => AccountLoader( - child: PermissionsLoader( - child: child, + child: OrganizationLoader( + child: PermissionsLoader( + child: child, + ), ), ); } diff --git a/frontend/pweb/lib/utils/logout.dart b/frontend/pweb/lib/utils/logout.dart new file mode 100644 index 0000000..97e030e --- /dev/null +++ b/frontend/pweb/lib/utils/logout.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/provider/account.dart'; +import 'package:pshared/provider/permissions.dart'; + +import 'package:pweb/app/router/pages.dart'; + + +void logoutUtil(BuildContext context) { + context.read().logout(); + context.read().reset(); + navigateAndReplace(context, Pages.login); +} diff --git a/frontend/pweb/lib/widgets/sidebar/page.dart b/frontend/pweb/lib/widgets/sidebar/page.dart index 315ff99..fdba1e7 100644 --- a/frontend/pweb/lib/widgets/sidebar/page.dart +++ b/frontend/pweb/lib/widgets/sidebar/page.dart @@ -1,13 +1,13 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:pshared/models/resources.dart'; -import 'package:pshared/provider/account.dart'; +import 'package:pshared/models/resources.dart'; import 'package:pshared/provider/permissions.dart'; import 'package:pweb/pages/address_book/form/page.dart'; import 'package:pweb/pages/address_book/page/page.dart'; +import 'package:pweb/pages/loader.dart'; import 'package:pweb/pages/payment_methods/page.dart'; import 'package:pweb/pages/payout_page/page.dart'; import 'package:pweb/pages/payout_page/wallet/edit/page.dart'; @@ -15,7 +15,7 @@ import 'package:pweb/pages/report/page.dart'; import 'package:pweb/pages/settings/profile/page.dart'; import 'package:pweb/pages/dashboard/dashboard.dart'; import 'package:pweb/providers/page_selector.dart'; -import 'package:pweb/app/router/pages.dart'; +import 'package:pweb/utils/logout.dart'; import 'package:pweb/widgets/appbar/app_bar.dart'; import 'package:pweb/widgets/sidebar/destinations.dart'; import 'package:pweb/widgets/sidebar/sidebar.dart'; @@ -26,114 +26,114 @@ import 'package:pweb/generated/i18n/app_localizations.dart'; class PageSelector extends StatelessWidget { const PageSelector({super.key}); - void _logout(BuildContext context) { - context.read().logout(); - navigateAndReplace(context, Pages.login); - } - @override - Widget build(BuildContext context) { - final provider = context.watch(); - final permissions = context.watch(); - final loc = AppLocalizations.of(context)!; + Widget build(BuildContext context) => PageViewLoader( + child: Builder(builder: (BuildContext context) { + final permissions = context.watch(); + if (!permissions.isReady) return Center(child: CircularProgressIndicator()); - final bool restrictedAccess = !permissions.canRead(ResourceType.chainWallets); - final allowedDestinations = restrictedAccess - ? { - PayoutDestination.settings, - PayoutDestination.methods, - PayoutDestination.editwallet, - } - : PayoutDestination.values.toSet(); + final provider = context.watch(); - final selected = allowedDestinations.contains(provider.selected) - ? provider.selected - : (restrictedAccess ? PayoutDestination.settings : PayoutDestination.dashboard); + final loc = AppLocalizations.of(context)!; - if (selected != provider.selected) { - WidgetsBinding.instance.addPostFrameCallback((_) => provider.selectPage(selected)); - } + final bool restrictedAccess = !permissions.canRead(ResourceType.chainWallets); + final allowedDestinations = restrictedAccess + ? { + PayoutDestination.settings, + PayoutDestination.methods, + PayoutDestination.editwallet, + } + : PayoutDestination.values.toSet(); - Widget content; - switch (selected) { - case PayoutDestination.dashboard: - content = DashboardPage( - onRecipientSelected: (recipient) => provider.selectRecipient(recipient), - onGoToPaymentWithoutRecipient: provider.startPaymentWithoutRecipient, - ); - break; + final selected = allowedDestinations.contains(provider.selected) + ? provider.selected + : (restrictedAccess ? PayoutDestination.settings : PayoutDestination.dashboard); - case PayoutDestination.recipients: - content = RecipientAddressBookPage( - onRecipientSelected: (recipient) => - provider.selectRecipient(recipient, fromList: true), - onAddRecipient: provider.goToAddRecipient, - onEditRecipient: provider.editRecipient, - ); - break; + if (selected != provider.selected) { + WidgetsBinding.instance.addPostFrameCallback((_) => provider.selectPage(selected)); + } - case PayoutDestination.addrecipient: - final recipient = provider.recipientProvider?.selectedRecipient; - content = AdressBookRecipientForm( - recipient: recipient, - onSaved: (_) => provider.selectPage(PayoutDestination.recipients), - ); - break; + Widget content; + switch (selected) { + case PayoutDestination.dashboard: + content = DashboardPage( + onRecipientSelected: (recipient) => provider.selectRecipient(recipient), + onGoToPaymentWithoutRecipient: provider.startPaymentWithoutRecipient, + ); + break; - case PayoutDestination.payment: - content = PaymentPage( - onBack: (_) => provider.goBackFromPayment(), - ); - break; + case PayoutDestination.recipients: + content = RecipientAddressBookPage( + onRecipientSelected: (recipient) => + provider.selectRecipient(recipient, fromList: true), + onAddRecipient: provider.goToAddRecipient, + onEditRecipient: provider.editRecipient, + ); + break; - case PayoutDestination.settings: - content = ProfileSettingsPage(); - break; + case PayoutDestination.addrecipient: + final recipient = provider.recipientProvider?.selectedRecipient; + content = AdressBookRecipientForm( + recipient: recipient, + onSaved: (_) => provider.selectPage(PayoutDestination.recipients), + ); + break; - case PayoutDestination.reports: - content = OperationHistoryPage(); - break; + case PayoutDestination.payment: + content = PaymentPage( + onBack: (_) => provider.goBackFromPayment(), + ); + break; - case PayoutDestination.methods: - content = PaymentConfigPage( - onWalletTap: provider.selectWallet, - ); - break; + case PayoutDestination.settings: + content = ProfileSettingsPage(); + break; - case PayoutDestination.editwallet: - final wallet = provider.walletsProvider?.selectedWallet; - content = wallet != null - ? WalletEditPage( - onBack: provider.goBackFromWalletEdit, - ) - : Center(child: Text(loc.noWalletSelected)); - break; + case PayoutDestination.reports: + content = OperationHistoryPage(); + break; - default: - content = Text(selected.name); - } + case PayoutDestination.methods: + content = PaymentConfigPage( + onWalletTap: provider.selectWallet, + ); + break; - return Scaffold( - appBar: PayoutAppBar( - title: Text(selected.localizedLabel(context)), - onAddFundsPressed: () {}, - onLogout: () => _logout(context), - ), - body: Padding( - padding: const EdgeInsets.only(left: 200, top: 40, right: 200), - child: Row( - spacing: 40, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - PayoutSidebar( - selected: selected, - onSelected: provider.selectPage, - onLogout: () => _logout(context), - ), - Expanded(child: content), - ], + case PayoutDestination.editwallet: + final wallet = provider.walletsProvider?.selectedWallet; + content = wallet != null + ? WalletEditPage( + onBack: provider.goBackFromWalletEdit, + ) + : Center(child: Text(loc.noWalletSelected)); + break; + + default: + content = Text(selected.name); + } + + return Scaffold( + appBar: PayoutAppBar( + title: Text(selected.localizedLabel(context)), + onAddFundsPressed: () {}, + onLogout: () => logoutUtil(context), ), - ), - ); - } + body: Padding( + padding: const EdgeInsets.only(left: 200, top: 40, right: 200), + child: Row( + spacing: 40, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + PayoutSidebar( + selected: selected, + onSelected: provider.selectPage, + onLogout: () => logoutUtil(context), + ), + Expanded(child: content), + ], + ), + ), + ); + }, + )); } diff --git a/frontend/pweb/pubspec.yaml b/frontend/pweb/pubspec.yaml index 7d5ecc6..1f71ac0 100644 --- a/frontend/pweb/pubspec.yaml +++ b/frontend/pweb/pubspec.yaml @@ -129,7 +129,7 @@ flutter_intl: enabled: true localizations_delegates: - flutter_localizations - - app_localizations + - app_localizations flutter_launcher_icons: image_path: "resources/logo.png"