impreoved commands logging
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/bump_version Pipeline failed

This commit is contained in:
Stephan D
2025-11-26 21:45:38 +01:00
parent f59ee55084
commit 35897f9aa1
33 changed files with 1225 additions and 804 deletions

View File

@@ -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,

View File

@@ -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")),
}
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)})
}

View File

@@ -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)
}

View File

@@ -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()),
}
}

View File

@@ -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
}

View File

@@ -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)})
}

View File

@@ -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)})
}

View File

@@ -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
}

View File

@@ -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)})
}

View File

@@ -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)
}

View File

@@ -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()),
}
}

View File

@@ -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
}

View File

@@ -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")

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,142 @@
package shared
import (
"strings"
"github.com/tech/sendico/chain/gateway/storage/model"
gatewayv1 "github.com/tech/sendico/pkg/proto/chain/gateway/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// CloneMoney defensively copies a Money proto.
func CloneMoney(m *moneyv1.Money) *moneyv1.Money {
if m == nil {
return nil
}
return &moneyv1.Money{Amount: m.GetAmount(), Currency: m.GetCurrency()}
}
// CloneMetadata defensively copies metadata maps.
func CloneMetadata(input map[string]string) map[string]string {
if len(input) == 0 {
return nil
}
clone := make(map[string]string, len(input))
for k, v := range input {
clone[k] = v
}
return clone
}
// ResolveContractAddress finds a token contract for a symbol in a case-insensitive manner.
func ResolveContractAddress(tokens []TokenContract, symbol string) string {
upper := strings.ToUpper(symbol)
for _, token := range tokens {
if strings.EqualFold(token.Symbol, upper) && token.ContractAddress != "" {
return strings.ToLower(token.ContractAddress)
}
}
return ""
}
func GenerateWalletRef() string {
return primitive.NewObjectID().Hex()
}
func GenerateTransferRef() string {
return primitive.NewObjectID().Hex()
}
func ChainKeyFromEnum(chain gatewayv1.ChainNetwork) (string, gatewayv1.ChainNetwork) {
if name, ok := gatewayv1.ChainNetwork_name[int32(chain)]; ok {
key := strings.ToLower(strings.TrimPrefix(name, "CHAIN_NETWORK_"))
return key, chain
}
return "", gatewayv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED
}
func ChainEnumFromName(name string) gatewayv1.ChainNetwork {
if name == "" {
return gatewayv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED
}
upper := strings.ToUpper(strings.ReplaceAll(strings.ReplaceAll(name, " ", "_"), "-", "_"))
key := "CHAIN_NETWORK_" + upper
if val, ok := gatewayv1.ChainNetwork_value[key]; ok {
return gatewayv1.ChainNetwork(val)
}
return gatewayv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED
}
func ManagedWalletStatusToProto(status model.ManagedWalletStatus) gatewayv1.ManagedWalletStatus {
switch status {
case model.ManagedWalletStatusActive:
return gatewayv1.ManagedWalletStatus_MANAGED_WALLET_ACTIVE
case model.ManagedWalletStatusSuspended:
return gatewayv1.ManagedWalletStatus_MANAGED_WALLET_SUSPENDED
case model.ManagedWalletStatusClosed:
return gatewayv1.ManagedWalletStatus_MANAGED_WALLET_CLOSED
default:
return gatewayv1.ManagedWalletStatus_MANAGED_WALLET_STATUS_UNSPECIFIED
}
}
func TransferStatusToModel(status gatewayv1.TransferStatus) model.TransferStatus {
switch status {
case gatewayv1.TransferStatus_TRANSFER_PENDING:
return model.TransferStatusPending
case gatewayv1.TransferStatus_TRANSFER_SIGNING:
return model.TransferStatusSigning
case gatewayv1.TransferStatus_TRANSFER_SUBMITTED:
return model.TransferStatusSubmitted
case gatewayv1.TransferStatus_TRANSFER_CONFIRMED:
return model.TransferStatusConfirmed
case gatewayv1.TransferStatus_TRANSFER_FAILED:
return model.TransferStatusFailed
case gatewayv1.TransferStatus_TRANSFER_CANCELLED:
return model.TransferStatusCancelled
default:
return ""
}
}
func TransferStatusToProto(status model.TransferStatus) gatewayv1.TransferStatus {
switch status {
case model.TransferStatusPending:
return gatewayv1.TransferStatus_TRANSFER_PENDING
case model.TransferStatusSigning:
return gatewayv1.TransferStatus_TRANSFER_SIGNING
case model.TransferStatusSubmitted:
return gatewayv1.TransferStatus_TRANSFER_SUBMITTED
case model.TransferStatusConfirmed:
return gatewayv1.TransferStatus_TRANSFER_CONFIRMED
case model.TransferStatusFailed:
return gatewayv1.TransferStatus_TRANSFER_FAILED
case model.TransferStatusCancelled:
return gatewayv1.TransferStatus_TRANSFER_CANCELLED
default:
return gatewayv1.TransferStatus_TRANSFER_STATUS_UNSPECIFIED
}
}
// Network describes a supported blockchain network and known token contracts.
type Network struct {
Name string
RPCURL string
ChainID uint64
NativeToken string
TokenConfigs []TokenContract
}
// TokenContract captures the metadata needed to work with a specific on-chain token.
type TokenContract struct {
Symbol string
ContractAddress string
}
// ServiceWallet captures the managed service wallet configuration.
type ServiceWallet struct {
Network string
Address string
PrivateKey string
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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()),
}
}

View File

@@ -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,

View File

@@ -20,6 +20,7 @@ import 'package:pshared/service/permissions.dart';
class PermissionsProvider extends ChangeNotifier {
Resource<UserAccess> _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);

View File

@@ -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<OrganizationsProvider>().load();
context.goNamed(Pages.dashboard.name);
},
),

View File

@@ -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();

View File

@@ -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<DashboardPage> {
}
@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<DashboardPage> {
],
),
),
),
);
}
}

View File

@@ -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,9 +12,11 @@ class PageViewLoader extends StatelessWidget {
@override
Widget build(BuildContext context) => AccountLoader(
child: OrganizationLoader(
child: PermissionsLoader(
child: child,
),
),
);
}

View File

@@ -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<AccountProvider>().logout();
context.read<PermissionsProvider>().reset();
navigateAndReplace(context, Pages.login);
}

View File

@@ -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,15 +26,14 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
class PageSelector extends StatelessWidget {
const PageSelector({super.key});
void _logout(BuildContext context) {
context.read<AccountProvider>().logout();
navigateAndReplace(context, Pages.login);
}
@override
Widget build(BuildContext context) {
final provider = context.watch<PageSelectorProvider>();
Widget build(BuildContext context) => PageViewLoader(
child: Builder(builder: (BuildContext context) {
final permissions = context.watch<PermissionsProvider>();
if (!permissions.isReady) return Center(child: CircularProgressIndicator());
final provider = context.watch<PageSelectorProvider>();
final loc = AppLocalizations.of(context)!;
final bool restrictedAccess = !permissions.canRead(ResourceType.chainWallets);
@@ -117,7 +116,7 @@ class PageSelector extends StatelessWidget {
appBar: PayoutAppBar(
title: Text(selected.localizedLabel(context)),
onAddFundsPressed: () {},
onLogout: () => _logout(context),
onLogout: () => logoutUtil(context),
),
body: Padding(
padding: const EdgeInsets.only(left: 200, top: 40, right: 200),
@@ -128,12 +127,13 @@ class PageSelector extends StatelessWidget {
PayoutSidebar(
selected: selected,
onSelected: provider.selectPage,
onLogout: () => _logout(context),
onLogout: () => logoutUtil(context),
),
Expanded(child: content),
],
),
),
);
}
},
));
}