service backend
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
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
|
||||
}
|
||||
385
api/chain/gateway/internal/service/gateway/executor.go
Normal file
385
api/chain/gateway/internal/service/gateway/executor.go
Normal file
@@ -0,0 +1,385 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math/big"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum"
|
||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/core/types"
|
||||
"github.com/ethereum/go-ethereum/ethclient"
|
||||
"github.com/shopspring/decimal"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/tech/sendico/chain/gateway/internal/keymanager"
|
||||
"github.com/tech/sendico/chain/gateway/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// NewOnChainExecutor constructs a TransferExecutor that talks to an EVM-compatible chain.
|
||||
func NewOnChainExecutor(logger mlogger.Logger, keyManager keymanager.Manager) TransferExecutor {
|
||||
return &onChainExecutor{
|
||||
logger: logger.Named("executor"),
|
||||
keyManager: keyManager,
|
||||
clients: map[string]*ethclient.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
type onChainExecutor struct {
|
||||
logger mlogger.Logger
|
||||
keyManager keymanager.Manager
|
||||
|
||||
mu sync.Mutex
|
||||
clients map[string]*ethclient.Client
|
||||
}
|
||||
|
||||
func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Transfer, source *model.ManagedWallet, destinationAddress string, network Network) (string, error) {
|
||||
if o.keyManager == nil {
|
||||
o.logger.Error("key manager not configured")
|
||||
return "", executorInternal("key manager is not configured", nil)
|
||||
}
|
||||
rpcURL := strings.TrimSpace(network.RPCURL)
|
||||
if rpcURL == "" {
|
||||
o.logger.Error("network rpc url missing", zap.String("network", network.Name))
|
||||
return "", executorInvalid("network rpc url is not configured")
|
||||
}
|
||||
if source == nil || transfer == nil {
|
||||
o.logger.Error("transfer context missing")
|
||||
return "", executorInvalid("transfer context missing")
|
||||
}
|
||||
if strings.TrimSpace(source.KeyReference) == "" {
|
||||
o.logger.Error("source wallet missing key reference", zap.String("wallet_ref", source.WalletRef))
|
||||
return "", executorInvalid("source wallet missing key reference")
|
||||
}
|
||||
if strings.TrimSpace(source.DepositAddress) == "" {
|
||||
o.logger.Error("source wallet missing deposit address", zap.String("wallet_ref", source.WalletRef))
|
||||
return "", executorInvalid("source wallet missing deposit address")
|
||||
}
|
||||
if !common.IsHexAddress(destinationAddress) {
|
||||
o.logger.Error("invalid destination address", zap.String("transfer_ref", transfer.TransferRef), zap.String("address", destinationAddress))
|
||||
return "", executorInvalid("invalid destination address " + destinationAddress)
|
||||
}
|
||||
|
||||
o.logger.Info("submitting transfer",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("source_wallet_ref", source.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("destination", strings.ToLower(destinationAddress)),
|
||||
)
|
||||
|
||||
client, err := o.getClient(ctx, rpcURL)
|
||||
if err != nil {
|
||||
o.logger.Warn("failed to initialise rpc client",
|
||||
zap.String("network", network.Name),
|
||||
zap.String("rpc_url", rpcURL),
|
||||
zap.Error(err),
|
||||
)
|
||||
return "", err
|
||||
}
|
||||
|
||||
sourceAddress := common.HexToAddress(source.DepositAddress)
|
||||
destination := common.HexToAddress(destinationAddress)
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
nonce, err := client.PendingNonceAt(ctx, sourceAddress)
|
||||
if err != nil {
|
||||
o.logger.Warn("failed to fetch nonce",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("wallet_ref", source.WalletRef),
|
||||
zap.Error(err),
|
||||
)
|
||||
return "", executorInternal("failed to fetch nonce", err)
|
||||
}
|
||||
|
||||
gasPrice, err := client.SuggestGasPrice(ctx)
|
||||
if err != nil {
|
||||
o.logger.Warn("failed to suggest gas price",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.Error(err),
|
||||
)
|
||||
return "", executorInternal("failed to suggest gas price", err)
|
||||
}
|
||||
|
||||
var tx *types.Transaction
|
||||
var txHash string
|
||||
|
||||
chainID := new(big.Int).SetUint64(network.ChainID)
|
||||
|
||||
if strings.TrimSpace(transfer.ContractAddress) == "" {
|
||||
o.logger.Warn("native token transfer requested but not supported", zap.String("transfer_ref", transfer.TransferRef))
|
||||
return "", merrors.NotImplemented("executor: native token transfers not yet supported")
|
||||
}
|
||||
|
||||
if !common.IsHexAddress(transfer.ContractAddress) {
|
||||
o.logger.Warn("invalid token contract address",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("contract", transfer.ContractAddress),
|
||||
)
|
||||
return "", executorInvalid("invalid token contract address " + transfer.ContractAddress)
|
||||
}
|
||||
tokenAddress := common.HexToAddress(transfer.ContractAddress)
|
||||
|
||||
decimals, err := erc20Decimals(ctx, client, tokenAddress)
|
||||
if err != nil {
|
||||
o.logger.Warn("failed to read token decimals",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("contract", transfer.ContractAddress),
|
||||
zap.Error(err),
|
||||
)
|
||||
return "", err
|
||||
}
|
||||
|
||||
amount := transfer.NetAmount
|
||||
if amount == nil || strings.TrimSpace(amount.Amount) == "" {
|
||||
o.logger.Warn("transfer missing net amount", zap.String("transfer_ref", transfer.TransferRef))
|
||||
return "", executorInvalid("transfer missing net amount")
|
||||
}
|
||||
amountInt, err := toBaseUnits(amount.Amount, decimals)
|
||||
if err != nil {
|
||||
o.logger.Warn("failed to convert amount to base units",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("amount", amount.Amount),
|
||||
zap.Error(err),
|
||||
)
|
||||
return "", err
|
||||
}
|
||||
|
||||
input, err := erc20ABI.Pack("transfer", destination, amountInt)
|
||||
if err != nil {
|
||||
o.logger.Warn("failed to encode transfer call",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.Error(err),
|
||||
)
|
||||
return "", executorInternal("failed to encode transfer call", err)
|
||||
}
|
||||
|
||||
callMsg := ethereum.CallMsg{
|
||||
From: sourceAddress,
|
||||
To: &tokenAddress,
|
||||
GasPrice: gasPrice,
|
||||
Data: input,
|
||||
}
|
||||
gasLimit, err := client.EstimateGas(ctx, callMsg)
|
||||
if err != nil {
|
||||
o.logger.Warn("failed to estimate gas",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.Error(err),
|
||||
)
|
||||
return "", executorInternal("failed to estimate gas", err)
|
||||
}
|
||||
|
||||
tx = types.NewTransaction(nonce, tokenAddress, big.NewInt(0), gasLimit, gasPrice, input)
|
||||
|
||||
signedTx, err := o.keyManager.SignTransaction(ctx, source.KeyReference, tx, chainID)
|
||||
if err != nil {
|
||||
o.logger.Warn("failed to sign transaction",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("wallet_ref", source.WalletRef),
|
||||
zap.Error(err),
|
||||
)
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := client.SendTransaction(ctx, signedTx); err != nil {
|
||||
o.logger.Warn("failed to send transaction",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.Error(err),
|
||||
)
|
||||
return "", executorInternal("failed to send transaction", err)
|
||||
}
|
||||
|
||||
txHash = signedTx.Hash().Hex()
|
||||
o.logger.Info("transaction submitted",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
)
|
||||
|
||||
return txHash, nil
|
||||
}
|
||||
|
||||
func (o *onChainExecutor) getClient(ctx context.Context, rpcURL string) (*ethclient.Client, error) {
|
||||
o.mu.Lock()
|
||||
client, ok := o.clients[rpcURL]
|
||||
o.mu.Unlock()
|
||||
if ok {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
c, err := ethclient.DialContext(ctx, rpcURL)
|
||||
if err != nil {
|
||||
return nil, executorInternal("failed to connect to rpc "+rpcURL, err)
|
||||
}
|
||||
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
if existing, ok := o.clients[rpcURL]; ok {
|
||||
// Another routine initialised it in the meantime; prefer the existing client and close the new one.
|
||||
c.Close()
|
||||
return existing, nil
|
||||
}
|
||||
o.clients[rpcURL] = c
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network 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")
|
||||
}
|
||||
rpcURL := strings.TrimSpace(network.RPCURL)
|
||||
if rpcURL == "" {
|
||||
o.logger.Warn("network rpc url missing while awaiting confirmation", zap.String("tx_hash", txHash))
|
||||
return nil, executorInvalid("network rpc url is not configured")
|
||||
}
|
||||
|
||||
client, err := o.getClient(ctx, rpcURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hash := common.HexToHash(txHash)
|
||||
ticker := time.NewTicker(3 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
receipt, err := client.TransactionReceipt(ctx, hash)
|
||||
if err != nil {
|
||||
if errors.Is(err, ethereum.NotFound) {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
o.logger.Debug("transaction not yet mined",
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
)
|
||||
continue
|
||||
case <-ctx.Done():
|
||||
o.logger.Warn("context cancelled while awaiting confirmation",
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
)
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
o.logger.Warn("failed to fetch transaction receipt",
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, executorInternal("failed to fetch transaction receipt", err)
|
||||
}
|
||||
o.logger.Info("transaction confirmed",
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
|
||||
zap.Uint64("status", receipt.Status),
|
||||
)
|
||||
return receipt, nil
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
erc20ABI abi.ABI
|
||||
)
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
erc20ABI, err = abi.JSON(strings.NewReader(erc20ABIJSON))
|
||||
if err != nil {
|
||||
panic("executor: failed to parse erc20 abi: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
const erc20ABIJSON = `
|
||||
[
|
||||
{
|
||||
"constant": false,
|
||||
"inputs": [
|
||||
{ "name": "_to", "type": "address" },
|
||||
{ "name": "_value", "type": "uint256" }
|
||||
],
|
||||
"name": "transfer",
|
||||
"outputs": [{ "name": "", "type": "bool" }],
|
||||
"payable": false,
|
||||
"stateMutability": "nonpayable",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"constant": true,
|
||||
"inputs": [],
|
||||
"name": "decimals",
|
||||
"outputs": [{ "name": "", "type": "uint8" }],
|
||||
"payable": false,
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
}
|
||||
]`
|
||||
|
||||
func erc20Decimals(ctx context.Context, client *ethclient.Client, token common.Address) (uint8, error) {
|
||||
callData, err := erc20ABI.Pack("decimals")
|
||||
if err != nil {
|
||||
return 0, executorInternal("failed to encode decimals call", err)
|
||||
}
|
||||
msg := ethereum.CallMsg{
|
||||
To: &token,
|
||||
Data: callData,
|
||||
}
|
||||
output, err := client.CallContract(ctx, msg, nil)
|
||||
if err != nil {
|
||||
return 0, executorInternal("decimals call failed", err)
|
||||
}
|
||||
values, err := erc20ABI.Unpack("decimals", output)
|
||||
if err != nil {
|
||||
return 0, executorInternal("failed to unpack decimals", err)
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return 0, executorInternal("decimals call returned no data", nil)
|
||||
}
|
||||
decimals, ok := values[0].(uint8)
|
||||
if !ok {
|
||||
return 0, executorInternal("decimals call returned unexpected type", nil)
|
||||
}
|
||||
return decimals, nil
|
||||
}
|
||||
|
||||
func toBaseUnits(amount string, decimals uint8) (*big.Int, error) {
|
||||
value, err := decimal.NewFromString(strings.TrimSpace(amount))
|
||||
if err != nil {
|
||||
return nil, executorInvalid("invalid amount " + amount + ": " + err.Error())
|
||||
}
|
||||
if value.IsNegative() {
|
||||
return nil, executorInvalid("amount must be positive")
|
||||
}
|
||||
multiplier := decimal.NewFromInt(1).Shift(int32(decimals))
|
||||
scaled := value.Mul(multiplier)
|
||||
if !scaled.Equal(scaled.Truncate(0)) {
|
||||
return nil, executorInvalid("amount " + amount + " exceeds token precision")
|
||||
}
|
||||
return scaled.BigInt(), nil
|
||||
}
|
||||
|
||||
func executorInvalid(msg string) error {
|
||||
return merrors.InvalidArgument("executor: " + msg)
|
||||
}
|
||||
|
||||
func executorInternal(msg string, err error) error {
|
||||
if err != nil {
|
||||
msg = msg + ": " + err.Error()
|
||||
}
|
||||
return merrors.Internal("executor: " + msg)
|
||||
}
|
||||
65
api/chain/gateway/internal/service/gateway/metrics.go
Normal file
65
api/chain/gateway/internal/service/gateway/metrics.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
)
|
||||
|
||||
var (
|
||||
metricsOnce sync.Once
|
||||
|
||||
rpcLatency *prometheus.HistogramVec
|
||||
rpcStatus *prometheus.CounterVec
|
||||
)
|
||||
|
||||
func initMetrics() {
|
||||
metricsOnce.Do(func() {
|
||||
rpcLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Namespace: "sendico",
|
||||
Subsystem: "chain_gateway",
|
||||
Name: "rpc_latency_seconds",
|
||||
Help: "Latency distribution for chain gateway RPC handlers.",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
}, []string{"method"})
|
||||
|
||||
rpcStatus = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: "sendico",
|
||||
Subsystem: "chain_gateway",
|
||||
Name: "rpc_requests_total",
|
||||
Help: "Total number of RPC invocations grouped by method and status.",
|
||||
}, []string{"method", "status"})
|
||||
})
|
||||
}
|
||||
|
||||
func observeRPC(method string, err error, duration time.Duration) {
|
||||
if rpcLatency != nil {
|
||||
rpcLatency.WithLabelValues(method).Observe(duration.Seconds())
|
||||
}
|
||||
if rpcStatus != nil {
|
||||
rpcStatus.WithLabelValues(method, statusLabel(err)).Inc()
|
||||
}
|
||||
}
|
||||
|
||||
func statusLabel(err error) string {
|
||||
switch {
|
||||
case err == nil:
|
||||
return "ok"
|
||||
case errors.Is(err, merrors.ErrInvalidArg):
|
||||
return "invalid_argument"
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
return "not_found"
|
||||
case errors.Is(err, merrors.ErrDataConflict):
|
||||
return "conflict"
|
||||
case errors.Is(err, merrors.ErrAccessDenied):
|
||||
return "denied"
|
||||
case errors.Is(err, merrors.ErrInternal):
|
||||
return "internal"
|
||||
default:
|
||||
return "error"
|
||||
}
|
||||
}
|
||||
90
api/chain/gateway/internal/service/gateway/options.go
Normal file
90
api/chain/gateway/internal/service/gateway/options.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/chain/gateway/internal/keymanager"
|
||||
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) {
|
||||
s.keyManager = manager
|
||||
}
|
||||
}
|
||||
|
||||
// WithTransferExecutor configures the executor responsible for on-chain submissions.
|
||||
func WithTransferExecutor(executor TransferExecutor) Option {
|
||||
return func(s *Service) {
|
||||
s.executor = executor
|
||||
}
|
||||
}
|
||||
|
||||
// WithNetworks configures supported blockchain networks.
|
||||
func WithNetworks(networks []Network) Option {
|
||||
return func(s *Service) {
|
||||
if len(networks) == 0 {
|
||||
return
|
||||
}
|
||||
if s.networks == nil {
|
||||
s.networks = make(map[string]Network, len(networks))
|
||||
}
|
||||
for _, network := range networks {
|
||||
if network.Name == "" {
|
||||
continue
|
||||
}
|
||||
clone := network
|
||||
if clone.TokenConfigs == nil {
|
||||
clone.TokenConfigs = []TokenContract{}
|
||||
}
|
||||
for i := range clone.TokenConfigs {
|
||||
clone.TokenConfigs[i].Symbol = strings.ToUpper(strings.TrimSpace(clone.TokenConfigs[i].Symbol))
|
||||
clone.TokenConfigs[i].ContractAddress = strings.ToLower(strings.TrimSpace(clone.TokenConfigs[i].ContractAddress))
|
||||
}
|
||||
clone.Name = strings.ToLower(strings.TrimSpace(clone.Name))
|
||||
s.networks[clone.Name] = clone
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithServiceWallet configures the service wallet binding.
|
||||
func WithServiceWallet(wallet ServiceWallet) Option {
|
||||
return func(s *Service) {
|
||||
s.serviceWallet = wallet
|
||||
}
|
||||
}
|
||||
|
||||
// WithClock overrides the service clock.
|
||||
func WithClock(clk clockpkg.Clock) Option {
|
||||
return func(s *Service) {
|
||||
if clk != nil {
|
||||
s.clock = clk
|
||||
}
|
||||
}
|
||||
}
|
||||
214
api/chain/gateway/internal/service/gateway/service.go
Normal file
214
api/chain/gateway/internal/service/gateway/service.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
gatewayv1 "github.com/tech/sendico/chain/gateway/internal/generated/service/gateway/v1"
|
||||
"github.com/tech/sendico/chain/gateway/internal/keymanager"
|
||||
"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"
|
||||
msg "github.com/tech/sendico/pkg/messaging"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
type serviceError string
|
||||
|
||||
func (e serviceError) Error() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
var (
|
||||
errStorageUnavailable = serviceError("chain_gateway: storage not initialised")
|
||||
)
|
||||
|
||||
// Service implements the ChainGatewayService RPC contract.
|
||||
type Service struct {
|
||||
logger mlogger.Logger
|
||||
storage storage.Repository
|
||||
producer msg.Producer
|
||||
clock clockpkg.Clock
|
||||
|
||||
networks map[string]Network
|
||||
serviceWallet ServiceWallet
|
||||
keyManager keymanager.Manager
|
||||
executor TransferExecutor
|
||||
|
||||
gatewayv1.UnimplementedChainGatewayServiceServer
|
||||
}
|
||||
|
||||
// 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"),
|
||||
storage: repo,
|
||||
producer: producer,
|
||||
clock: clockpkg.System{},
|
||||
networks: map[string]Network{},
|
||||
}
|
||||
|
||||
initMetrics()
|
||||
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(svc)
|
||||
}
|
||||
}
|
||||
|
||||
if svc.clock == nil {
|
||||
svc.clock = clockpkg.System{}
|
||||
}
|
||||
if svc.networks == nil {
|
||||
svc.networks = map[string]Network{}
|
||||
}
|
||||
|
||||
return svc
|
||||
}
|
||||
|
||||
// Register wires the service onto the provided gRPC router.
|
||||
func (s *Service) Register(router routers.GRPC) error {
|
||||
return router.Register(func(reg grpc.ServiceRegistrar) {
|
||||
gatewayv1.RegisterChainGatewayServiceServer(reg, s)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) CreateManagedWallet(ctx context.Context, req *gatewayv1.CreateManagedWalletRequest) (*gatewayv1.CreateManagedWalletResponse, error) {
|
||||
return executeUnary(ctx, s, "CreateManagedWallet", s.createManagedWalletHandler, req)
|
||||
}
|
||||
|
||||
func (s *Service) GetManagedWallet(ctx context.Context, req *gatewayv1.GetManagedWalletRequest) (*gatewayv1.GetManagedWalletResponse, error) {
|
||||
return executeUnary(ctx, s, "GetManagedWallet", s.getManagedWalletHandler, req)
|
||||
}
|
||||
|
||||
func (s *Service) ListManagedWallets(ctx context.Context, req *gatewayv1.ListManagedWalletsRequest) (*gatewayv1.ListManagedWalletsResponse, error) {
|
||||
return executeUnary(ctx, s, "ListManagedWallets", s.listManagedWalletsHandler, req)
|
||||
}
|
||||
|
||||
func (s *Service) GetWalletBalance(ctx context.Context, req *gatewayv1.GetWalletBalanceRequest) (*gatewayv1.GetWalletBalanceResponse, error) {
|
||||
return executeUnary(ctx, s, "GetWalletBalance", s.getWalletBalanceHandler, req)
|
||||
}
|
||||
|
||||
func (s *Service) SubmitTransfer(ctx context.Context, req *gatewayv1.SubmitTransferRequest) (*gatewayv1.SubmitTransferResponse, error) {
|
||||
return executeUnary(ctx, s, "SubmitTransfer", s.submitTransferHandler, req)
|
||||
}
|
||||
|
||||
func (s *Service) GetTransfer(ctx context.Context, req *gatewayv1.GetTransferRequest) (*gatewayv1.GetTransferResponse, error) {
|
||||
return executeUnary(ctx, s, "GetTransfer", s.getTransferHandler, req)
|
||||
}
|
||||
|
||||
func (s *Service) ListTransfers(ctx context.Context, req *gatewayv1.ListTransfersRequest) (*gatewayv1.ListTransfersResponse, error) {
|
||||
return executeUnary(ctx, s, "ListTransfers", s.listTransfersHandler, req)
|
||||
}
|
||||
|
||||
func (s *Service) EstimateTransferFee(ctx context.Context, req *gatewayv1.EstimateTransferFeeRequest) (*gatewayv1.EstimateTransferFeeResponse, error) {
|
||||
return executeUnary(ctx, s, "EstimateTransferFee", s.estimateTransferFeeHandler, req)
|
||||
}
|
||||
|
||||
func (s *Service) ensureRepository(ctx context.Context) error {
|
||||
if s.storage == nil {
|
||||
return errStorageUnavailable
|
||||
}
|
||||
return s.storage.Ping(ctx)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
556
api/chain/gateway/internal/service/gateway/service_test.go
Normal file
556
api/chain/gateway/internal/service/gateway/service_test.go
Normal file
@@ -0,0 +1,556 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
igatewayv1 "github.com/tech/sendico/chain/gateway/internal/generated/service/gateway/v1"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/tech/sendico/chain/gateway/internal/keymanager"
|
||||
"github.com/tech/sendico/chain/gateway/storage"
|
||||
"github.com/tech/sendico/chain/gateway/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
|
||||
|
||||
"github.com/ethereum/go-ethereum/core/types"
|
||||
)
|
||||
|
||||
const (
|
||||
walletDefaultLimit int64 = 50
|
||||
walletMaxLimit int64 = 200
|
||||
transferDefaultLimit int64 = 50
|
||||
transferMaxLimit int64 = 200
|
||||
depositDefaultLimit int64 = 100
|
||||
depositMaxLimit int64 = 500
|
||||
)
|
||||
|
||||
func TestCreateManagedWallet_Idempotent(t *testing.T) {
|
||||
svc, repo := newTestService(t)
|
||||
|
||||
ctx := context.Background()
|
||||
req := &igatewayv1.CreateManagedWalletRequest{
|
||||
IdempotencyKey: "idem-1",
|
||||
OrganizationRef: "org-1",
|
||||
OwnerRef: "owner-1",
|
||||
Asset: &igatewayv1.Asset{
|
||||
Chain: igatewayv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET,
|
||||
TokenSymbol: "USDC",
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := svc.CreateManagedWallet(ctx, req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp.GetWallet())
|
||||
firstRef := resp.GetWallet().GetWalletRef()
|
||||
require.NotEmpty(t, firstRef)
|
||||
|
||||
resp2, err := svc.CreateManagedWallet(ctx, req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, firstRef, resp2.GetWallet().GetWalletRef())
|
||||
|
||||
// ensure stored only once
|
||||
require.Equal(t, 1, repo.wallets.count())
|
||||
}
|
||||
|
||||
func TestSubmitTransfer_ManagedDestination(t *testing.T) {
|
||||
svc, repo := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// create source wallet
|
||||
srcResp, err := svc.CreateManagedWallet(ctx, &igatewayv1.CreateManagedWalletRequest{
|
||||
IdempotencyKey: "idem-src",
|
||||
OrganizationRef: "org-1",
|
||||
OwnerRef: "owner-1",
|
||||
Asset: &igatewayv1.Asset{
|
||||
Chain: igatewayv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET,
|
||||
TokenSymbol: "USDC",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
srcRef := srcResp.GetWallet().GetWalletRef()
|
||||
|
||||
// destination wallet
|
||||
dstResp, err := svc.CreateManagedWallet(ctx, &igatewayv1.CreateManagedWalletRequest{
|
||||
IdempotencyKey: "idem-dst",
|
||||
OrganizationRef: "org-1",
|
||||
OwnerRef: "owner-2",
|
||||
Asset: &igatewayv1.Asset{
|
||||
Chain: igatewayv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET,
|
||||
TokenSymbol: "USDC",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
dstRef := dstResp.GetWallet().GetWalletRef()
|
||||
|
||||
transferResp, err := svc.SubmitTransfer(ctx, &igatewayv1.SubmitTransferRequest{
|
||||
IdempotencyKey: "transfer-1",
|
||||
OrganizationRef: "org-1",
|
||||
SourceWalletRef: srcRef,
|
||||
Destination: &igatewayv1.TransferDestination{
|
||||
Destination: &igatewayv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: dstRef},
|
||||
},
|
||||
Amount: &moneyv1.Money{Currency: "USDC", Amount: "100"},
|
||||
Fees: []*igatewayv1.ServiceFeeBreakdown{
|
||||
{
|
||||
FeeCode: "service",
|
||||
Amount: &moneyv1.Money{Currency: "USDC", Amount: "5"},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, transferResp.GetTransfer())
|
||||
require.Equal(t, "95", transferResp.GetTransfer().GetNetAmount().GetAmount())
|
||||
|
||||
stored := repo.transfers.get(transferResp.GetTransfer().GetTransferRef())
|
||||
require.NotNil(t, stored)
|
||||
require.Equal(t, model.TransferStatusPending, stored.Status)
|
||||
|
||||
// GetTransfer
|
||||
getResp, err := svc.GetTransfer(ctx, &igatewayv1.GetTransferRequest{TransferRef: stored.TransferRef})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, stored.TransferRef, getResp.GetTransfer().GetTransferRef())
|
||||
|
||||
// ListTransfers
|
||||
listResp, err := svc.ListTransfers(ctx, &igatewayv1.ListTransfersRequest{
|
||||
SourceWalletRef: srcRef,
|
||||
Page: &paginationv1.CursorPageRequest{Limit: 10},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, listResp.GetTransfers(), 1)
|
||||
require.Equal(t, stored.TransferRef, listResp.GetTransfers()[0].GetTransferRef())
|
||||
}
|
||||
|
||||
func TestGetWalletBalance_NotFound(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.GetWalletBalance(ctx, &igatewayv1.GetWalletBalanceRequest{WalletRef: "missing"})
|
||||
require.Error(t, err)
|
||||
st, _ := status.FromError(err)
|
||||
require.Equal(t, codes.NotFound, st.Code())
|
||||
}
|
||||
|
||||
// ---- in-memory storage implementation ----
|
||||
|
||||
type inMemoryRepository struct {
|
||||
wallets *inMemoryWallets
|
||||
transfers *inMemoryTransfers
|
||||
deposits *inMemoryDeposits
|
||||
}
|
||||
|
||||
func newInMemoryRepository() *inMemoryRepository {
|
||||
return &inMemoryRepository{
|
||||
wallets: newInMemoryWallets(),
|
||||
transfers: newInMemoryTransfers(),
|
||||
deposits: newInMemoryDeposits(),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *inMemoryRepository) Ping(context.Context) error { return nil }
|
||||
func (r *inMemoryRepository) Wallets() storage.WalletsStore { return r.wallets }
|
||||
func (r *inMemoryRepository) Transfers() storage.TransfersStore { return r.transfers }
|
||||
func (r *inMemoryRepository) Deposits() storage.DepositsStore { return r.deposits }
|
||||
|
||||
// Wallets store
|
||||
|
||||
type inMemoryWallets struct {
|
||||
mu sync.Mutex
|
||||
wallets map[string]*model.ManagedWallet
|
||||
byIdemp map[string]string
|
||||
balances map[string]*model.WalletBalance
|
||||
}
|
||||
|
||||
func newInMemoryWallets() *inMemoryWallets {
|
||||
return &inMemoryWallets{
|
||||
wallets: make(map[string]*model.ManagedWallet),
|
||||
byIdemp: make(map[string]string),
|
||||
balances: make(map[string]*model.WalletBalance),
|
||||
}
|
||||
}
|
||||
|
||||
func (w *inMemoryWallets) count() int {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return len(w.wallets)
|
||||
}
|
||||
|
||||
func (w *inMemoryWallets) Create(ctx context.Context, wallet *model.ManagedWallet) (*model.ManagedWallet, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
if wallet == nil {
|
||||
return nil, merrors.InvalidArgument("walletsStore: nil wallet")
|
||||
}
|
||||
wallet.Normalize()
|
||||
if wallet.IdempotencyKey == "" {
|
||||
return nil, merrors.InvalidArgument("walletsStore: empty idempotencyKey")
|
||||
}
|
||||
|
||||
if existingRef, ok := w.byIdemp[wallet.IdempotencyKey]; ok {
|
||||
existing := w.wallets[existingRef]
|
||||
return existing, merrors.ErrDataConflict
|
||||
}
|
||||
|
||||
if wallet.WalletRef == "" {
|
||||
wallet.WalletRef = primitive.NewObjectID().Hex()
|
||||
}
|
||||
if wallet.GetID() == nil || wallet.GetID().IsZero() {
|
||||
wallet.SetID(primitive.NewObjectID())
|
||||
} else {
|
||||
wallet.Update()
|
||||
}
|
||||
|
||||
w.wallets[wallet.WalletRef] = wallet
|
||||
w.byIdemp[wallet.IdempotencyKey] = wallet.WalletRef
|
||||
return wallet, nil
|
||||
}
|
||||
|
||||
func (w *inMemoryWallets) Get(ctx context.Context, walletRef string) (*model.ManagedWallet, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
wallet, ok := w.wallets[strings.TrimSpace(walletRef)]
|
||||
if !ok {
|
||||
return nil, merrors.NoData("wallet not found")
|
||||
}
|
||||
return wallet, nil
|
||||
}
|
||||
|
||||
func (w *inMemoryWallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*model.ManagedWalletList, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
items := make([]*model.ManagedWallet, 0, len(w.wallets))
|
||||
for _, wallet := range w.wallets {
|
||||
if filter.OrganizationRef != "" && !strings.EqualFold(wallet.OrganizationRef, filter.OrganizationRef) {
|
||||
continue
|
||||
}
|
||||
if filter.OwnerRef != "" && !strings.EqualFold(wallet.OwnerRef, filter.OwnerRef) {
|
||||
continue
|
||||
}
|
||||
if filter.Network != "" && !strings.EqualFold(wallet.Network, filter.Network) {
|
||||
continue
|
||||
}
|
||||
if filter.TokenSymbol != "" && !strings.EqualFold(wallet.TokenSymbol, filter.TokenSymbol) {
|
||||
continue
|
||||
}
|
||||
items = append(items, wallet)
|
||||
}
|
||||
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].ID.Timestamp().Before(items[j].ID.Timestamp())
|
||||
})
|
||||
|
||||
startIndex := 0
|
||||
if cursor := strings.TrimSpace(filter.Cursor); cursor != "" {
|
||||
if oid, err := primitive.ObjectIDFromHex(cursor); err == nil {
|
||||
for idx, item := range items {
|
||||
if item.ID.Timestamp().After(oid.Timestamp()) {
|
||||
startIndex = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
limit := int(sanitizeLimit(filter.Limit, walletDefaultLimit, walletMaxLimit))
|
||||
end := startIndex + limit
|
||||
hasMore := false
|
||||
if end < len(items) {
|
||||
hasMore = true
|
||||
items = items[startIndex:end]
|
||||
} else {
|
||||
items = items[startIndex:]
|
||||
}
|
||||
|
||||
nextCursor := ""
|
||||
if hasMore && len(items) > 0 {
|
||||
nextCursor = items[len(items)-1].ID.Hex()
|
||||
}
|
||||
|
||||
return &model.ManagedWalletList{Items: items, NextCursor: nextCursor}, nil
|
||||
}
|
||||
|
||||
func (w *inMemoryWallets) SaveBalance(ctx context.Context, balance *model.WalletBalance) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if balance == nil {
|
||||
return merrors.InvalidArgument("walletsStore: nil balance")
|
||||
}
|
||||
balance.Normalize()
|
||||
if balance.WalletRef == "" {
|
||||
return merrors.InvalidArgument("walletsStore: empty walletRef for balance")
|
||||
}
|
||||
if balance.CalculatedAt.IsZero() {
|
||||
balance.CalculatedAt = time.Now().UTC()
|
||||
}
|
||||
existing, ok := w.balances[balance.WalletRef]
|
||||
if !ok {
|
||||
if balance.GetID() == nil || balance.GetID().IsZero() {
|
||||
balance.SetID(primitive.NewObjectID())
|
||||
}
|
||||
w.balances[balance.WalletRef] = balance
|
||||
return nil
|
||||
}
|
||||
existing.Available = balance.Available
|
||||
existing.PendingInbound = balance.PendingInbound
|
||||
existing.PendingOutbound = balance.PendingOutbound
|
||||
existing.CalculatedAt = balance.CalculatedAt
|
||||
existing.Update()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *inMemoryWallets) GetBalance(ctx context.Context, walletRef string) (*model.WalletBalance, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
balance, ok := w.balances[strings.TrimSpace(walletRef)]
|
||||
if !ok {
|
||||
return nil, merrors.NoData("wallet balance not found")
|
||||
}
|
||||
return balance, nil
|
||||
}
|
||||
|
||||
// Transfers store
|
||||
|
||||
type inMemoryTransfers struct {
|
||||
mu sync.Mutex
|
||||
items map[string]*model.Transfer
|
||||
byIdemp map[string]string
|
||||
}
|
||||
|
||||
func newInMemoryTransfers() *inMemoryTransfers {
|
||||
return &inMemoryTransfers{
|
||||
items: make(map[string]*model.Transfer),
|
||||
byIdemp: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *inMemoryTransfers) Create(ctx context.Context, transfer *model.Transfer) (*model.Transfer, error) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if transfer == nil {
|
||||
return nil, merrors.InvalidArgument("transfersStore: nil transfer")
|
||||
}
|
||||
transfer.Normalize()
|
||||
if transfer.IdempotencyKey == "" {
|
||||
return nil, merrors.InvalidArgument("transfersStore: empty idempotencyKey")
|
||||
}
|
||||
if ref, ok := t.byIdemp[transfer.IdempotencyKey]; ok {
|
||||
return t.items[ref], merrors.ErrDataConflict
|
||||
}
|
||||
if transfer.TransferRef == "" {
|
||||
transfer.TransferRef = primitive.NewObjectID().Hex()
|
||||
}
|
||||
if transfer.GetID() == nil || transfer.GetID().IsZero() {
|
||||
transfer.SetID(primitive.NewObjectID())
|
||||
} else {
|
||||
transfer.Update()
|
||||
}
|
||||
t.items[transfer.TransferRef] = transfer
|
||||
t.byIdemp[transfer.IdempotencyKey] = transfer.TransferRef
|
||||
return transfer, nil
|
||||
}
|
||||
|
||||
func (t *inMemoryTransfers) Get(ctx context.Context, transferRef string) (*model.Transfer, error) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
transfer, ok := t.items[strings.TrimSpace(transferRef)]
|
||||
if !ok {
|
||||
return nil, merrors.NoData("transfer not found")
|
||||
}
|
||||
return transfer, nil
|
||||
}
|
||||
|
||||
func (t *inMemoryTransfers) List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
items := make([]*model.Transfer, 0, len(t.items))
|
||||
for _, transfer := range t.items {
|
||||
if filter.SourceWalletRef != "" && !strings.EqualFold(transfer.SourceWalletRef, filter.SourceWalletRef) {
|
||||
continue
|
||||
}
|
||||
if filter.DestinationWalletRef != "" && !strings.EqualFold(transfer.Destination.ManagedWalletRef, filter.DestinationWalletRef) {
|
||||
continue
|
||||
}
|
||||
if filter.Status != "" && transfer.Status != filter.Status {
|
||||
continue
|
||||
}
|
||||
items = append(items, transfer)
|
||||
}
|
||||
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].ID.Timestamp().Before(items[j].ID.Timestamp())
|
||||
})
|
||||
|
||||
start := 0
|
||||
if cursor := strings.TrimSpace(filter.Cursor); cursor != "" {
|
||||
if oid, err := primitive.ObjectIDFromHex(cursor); err == nil {
|
||||
for idx, item := range items {
|
||||
if item.ID.Timestamp().After(oid.Timestamp()) {
|
||||
start = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
limit := int(sanitizeLimit(filter.Limit, transferDefaultLimit, transferMaxLimit))
|
||||
end := start + limit
|
||||
hasMore := false
|
||||
if end < len(items) {
|
||||
hasMore = true
|
||||
items = items[start:end]
|
||||
} else {
|
||||
items = items[start:]
|
||||
}
|
||||
|
||||
nextCursor := ""
|
||||
if hasMore && len(items) > 0 {
|
||||
nextCursor = items[len(items)-1].ID.Hex()
|
||||
}
|
||||
|
||||
return &model.TransferList{Items: items, NextCursor: nextCursor}, nil
|
||||
}
|
||||
|
||||
func (t *inMemoryTransfers) UpdateStatus(ctx context.Context, transferRef string, status model.TransferStatus, failureReason string, txHash string) (*model.Transfer, error) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
transfer, ok := t.items[strings.TrimSpace(transferRef)]
|
||||
if !ok {
|
||||
return nil, merrors.NoData("transfer not found")
|
||||
}
|
||||
transfer.Status = status
|
||||
if status == model.TransferStatusFailed {
|
||||
transfer.FailureReason = strings.TrimSpace(failureReason)
|
||||
} else {
|
||||
transfer.FailureReason = ""
|
||||
}
|
||||
transfer.TxHash = strings.TrimSpace(txHash)
|
||||
transfer.LastStatusAt = time.Now().UTC()
|
||||
transfer.Update()
|
||||
return transfer, nil
|
||||
}
|
||||
|
||||
// helper for tests
|
||||
func (t *inMemoryTransfers) get(ref string) *model.Transfer {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.items[ref]
|
||||
}
|
||||
|
||||
// Deposits store (minimal for tests)
|
||||
|
||||
type inMemoryDeposits struct {
|
||||
mu sync.Mutex
|
||||
items map[string]*model.Deposit
|
||||
}
|
||||
|
||||
func newInMemoryDeposits() *inMemoryDeposits {
|
||||
return &inMemoryDeposits{items: make(map[string]*model.Deposit)}
|
||||
}
|
||||
|
||||
func (d *inMemoryDeposits) Record(ctx context.Context, deposit *model.Deposit) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if deposit == nil {
|
||||
return merrors.InvalidArgument("depositsStore: nil deposit")
|
||||
}
|
||||
deposit.Normalize()
|
||||
if deposit.DepositRef == "" {
|
||||
return merrors.InvalidArgument("depositsStore: empty depositRef")
|
||||
}
|
||||
if existing, ok := d.items[deposit.DepositRef]; ok {
|
||||
existing.Status = deposit.Status
|
||||
existing.LastStatusAt = time.Now().UTC()
|
||||
existing.Update()
|
||||
return nil
|
||||
}
|
||||
if deposit.GetID() == nil || deposit.GetID().IsZero() {
|
||||
deposit.SetID(primitive.NewObjectID())
|
||||
}
|
||||
if deposit.ObservedAt.IsZero() {
|
||||
deposit.ObservedAt = time.Now().UTC()
|
||||
}
|
||||
if deposit.RecordedAt.IsZero() {
|
||||
deposit.RecordedAt = time.Now().UTC()
|
||||
}
|
||||
deposit.LastStatusAt = time.Now().UTC()
|
||||
d.items[deposit.DepositRef] = deposit
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *inMemoryDeposits) ListPending(ctx context.Context, network string, limit int32) ([]*model.Deposit, error) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
results := make([]*model.Deposit, 0)
|
||||
for _, deposit := range d.items {
|
||||
if deposit.Status != model.DepositStatusPending {
|
||||
continue
|
||||
}
|
||||
if network != "" && !strings.EqualFold(deposit.Network, network) {
|
||||
continue
|
||||
}
|
||||
results = append(results, deposit)
|
||||
}
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
return results[i].ObservedAt.Before(results[j].ObservedAt)
|
||||
})
|
||||
limitVal := int(sanitizeLimit(limit, depositDefaultLimit, depositMaxLimit))
|
||||
if len(results) > limitVal {
|
||||
results = results[:limitVal]
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// shared helpers
|
||||
|
||||
func sanitizeLimit(requested int32, def, max int64) int64 {
|
||||
if requested <= 0 {
|
||||
return def
|
||||
}
|
||||
if requested > int32(max) {
|
||||
return max
|
||||
}
|
||||
return int64(requested)
|
||||
}
|
||||
|
||||
func newTestService(_ *testing.T) (*Service, *inMemoryRepository) {
|
||||
repo := newInMemoryRepository()
|
||||
logger := zap.NewNop()
|
||||
svc := NewService(logger, repo, nil,
|
||||
WithKeyManager(&fakeKeyManager{}),
|
||||
WithNetworks([]Network{{
|
||||
Name: "ethereum_mainnet",
|
||||
TokenConfigs: []TokenContract{
|
||||
{Symbol: "USDC", ContractAddress: "0xusdc"},
|
||||
},
|
||||
}}),
|
||||
WithServiceWallet(ServiceWallet{Network: "ethereum_mainnet", Address: "0xservice"}),
|
||||
)
|
||||
return svc, repo
|
||||
}
|
||||
|
||||
type fakeKeyManager struct{}
|
||||
|
||||
func (f *fakeKeyManager) CreateManagedWalletKey(ctx context.Context, walletRef string, network string) (*keymanager.ManagedWalletKey, error) {
|
||||
return &keymanager.ManagedWalletKey{
|
||||
KeyID: fmt.Sprintf("%s/%s", strings.ToLower(network), walletRef),
|
||||
Address: "0x" + strings.Repeat("a", 40),
|
||||
PublicKey: strings.Repeat("b", 128),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *fakeKeyManager) SignTransaction(ctx context.Context, keyID string, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error) {
|
||||
return tx, nil
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum/core/types"
|
||||
"github.com/tech/sendico/chain/gateway/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, network Network) {
|
||||
if s.executor == nil {
|
||||
return
|
||||
}
|
||||
|
||||
go func(ref, walletRef string, net Network) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if err := s.executeTransfer(ctx, ref, walletRef, net); err != nil {
|
||||
s.logger.Error("failed to execute transfer", zap.String("transfer_ref", ref), zap.Error(err))
|
||||
}
|
||||
}(transferRef, sourceWalletRef, network)
|
||||
}
|
||||
|
||||
func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWalletRef string, network Network) error {
|
||||
transfer, err := s.storage.Transfers().Get(ctx, transferRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sourceWallet, err := s.storage.Wallets().Get(ctx, sourceWalletRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusSigning, "", ""); err != nil {
|
||||
s.logger.Warn("failed to update transfer status to signing", zap.String("transfer_ref", transferRef), zap.Error(err))
|
||||
}
|
||||
|
||||
destinationAddress, err := s.destinationAddress(ctx, transfer.Destination)
|
||||
if err != nil {
|
||||
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
|
||||
return err
|
||||
}
|
||||
|
||||
txHash, err := s.executor.SubmitTransfer(ctx, transfer, sourceWallet, destinationAddress, network)
|
||||
if err != nil {
|
||||
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusSubmitted, "", txHash); err != nil {
|
||||
s.logger.Warn("failed to update transfer status to submitted", zap.String("transfer_ref", transferRef), zap.Error(err))
|
||||
}
|
||||
|
||||
receiptCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
||||
defer cancel()
|
||||
receipt, err := s.executor.AwaitConfirmation(receiptCtx, network, txHash)
|
||||
if err != nil {
|
||||
if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) {
|
||||
s.logger.Warn("failed to await transfer confirmation", zap.String("transfer_ref", transferRef), zap.Error(err))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if receipt != nil && receipt.Status == types.ReceiptStatusSuccessful {
|
||||
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusConfirmed, "", txHash); err != nil {
|
||||
s.logger.Warn("failed to update transfer status to confirmed", zap.String("transfer_ref", transferRef), zap.Error(err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, "transaction reverted", txHash); err != nil {
|
||||
s.logger.Warn("failed to update transfer status to failed", zap.String("transfer_ref", transferRef), zap.Error(err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) destinationAddress(ctx context.Context, dest model.TransferDestination) (string, error) {
|
||||
if ref := strings.TrimSpace(dest.ManagedWalletRef); ref != "" {
|
||||
wallet, err := s.storage.Wallets().Get(ctx, ref)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(wallet.DepositAddress) == "" {
|
||||
return "", merrors.Internal("destination wallet missing deposit address")
|
||||
}
|
||||
return wallet.DepositAddress, nil
|
||||
}
|
||||
if addr := strings.TrimSpace(dest.ExternalAddress); addr != "" {
|
||||
return strings.ToLower(addr), nil
|
||||
}
|
||||
return "", merrors.InvalidArgument("transfer destination address not resolved")
|
||||
}
|
||||
309
api/chain/gateway/internal/service/gateway/transfer_handlers.go
Normal file
309
api/chain/gateway/internal/service/gateway/transfer_handlers.go
Normal file
@@ -0,0 +1,309 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
gatewayv1 "github.com/tech/sendico/chain/gateway/internal/generated/service/gateway/v1"
|
||||
"github.com/tech/sendico/chain/gateway/storage/model"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
|
||||
"github.com/shopspring/decimal"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
func (s *Service) submitTransferHandler(ctx context.Context, req *gatewayv1.SubmitTransferRequest) gsresponse.Responder[gatewayv1.SubmitTransferResponse] {
|
||||
if err := s.ensureRepository(ctx); err != nil {
|
||||
return gsresponse.Unavailable[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if req == nil {
|
||||
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
|
||||
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
|
||||
if idempotencyKey == "" {
|
||||
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required"))
|
||||
}
|
||||
organizationRef := strings.TrimSpace(req.GetOrganizationRef())
|
||||
if organizationRef == "" {
|
||||
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required"))
|
||||
}
|
||||
sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef())
|
||||
if sourceWalletRef == "" {
|
||||
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required"))
|
||||
}
|
||||
amount := req.GetAmount()
|
||||
if amount == nil {
|
||||
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("amount is required"))
|
||||
}
|
||||
amountCurrency := strings.ToUpper(strings.TrimSpace(amount.GetCurrency()))
|
||||
if amountCurrency == "" {
|
||||
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("amount.currency is required"))
|
||||
}
|
||||
amountValue := strings.TrimSpace(amount.GetAmount())
|
||||
if amountValue == "" {
|
||||
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("amount.amount is required"))
|
||||
}
|
||||
|
||||
sourceWallet, err := s.storage.Wallets().Get(ctx, sourceWalletRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return gsresponse.NotFound[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
return gsresponse.Auto[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if !strings.EqualFold(sourceWallet.OrganizationRef, organizationRef) {
|
||||
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref mismatch with wallet"))
|
||||
}
|
||||
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
|
||||
networkCfg, ok := s.networks[networkKey]
|
||||
if !ok {
|
||||
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
|
||||
}
|
||||
|
||||
destination, err := s.resolveDestination(ctx, req.GetDestination(), sourceWallet)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return gsresponse.NotFound[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
fees, feeSum, err := convertFees(req.GetFees(), amountCurrency)
|
||||
if err != nil {
|
||||
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
amountDec, err := decimal.NewFromString(amountValue)
|
||||
if err != nil {
|
||||
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("invalid amount"))
|
||||
}
|
||||
netDec := amountDec.Sub(feeSum)
|
||||
if netDec.IsNegative() {
|
||||
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("fees exceed amount"))
|
||||
}
|
||||
|
||||
netAmount := cloneMoney(amount)
|
||||
netAmount.Amount = netDec.String()
|
||||
|
||||
transfer := &model.Transfer{
|
||||
IdempotencyKey: idempotencyKey,
|
||||
TransferRef: generateTransferRef(),
|
||||
OrganizationRef: organizationRef,
|
||||
SourceWalletRef: sourceWalletRef,
|
||||
Destination: destination,
|
||||
Network: sourceWallet.Network,
|
||||
TokenSymbol: sourceWallet.TokenSymbol,
|
||||
ContractAddress: sourceWallet.ContractAddress,
|
||||
RequestedAmount: cloneMoney(amount),
|
||||
NetAmount: netAmount,
|
||||
Fees: fees,
|
||||
Status: model.TransferStatusPending,
|
||||
ClientReference: strings.TrimSpace(req.GetClientReference()),
|
||||
LastStatusAt: s.clock.Now().UTC(),
|
||||
}
|
||||
|
||||
saved, err := s.storage.Transfers().Create(ctx, transfer)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
s.logger.Debug("transfer already exists", zap.String("transfer_ref", transfer.TransferRef), zap.String("idempotency_key", idempotencyKey))
|
||||
return gsresponse.Success(&gatewayv1.SubmitTransferResponse{Transfer: s.toProtoTransfer(saved)})
|
||||
}
|
||||
return gsresponse.Auto[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
if s.executor != nil {
|
||||
s.launchTransferExecution(saved.TransferRef, sourceWalletRef, networkCfg)
|
||||
}
|
||||
|
||||
return gsresponse.Success(&gatewayv1.SubmitTransferResponse{Transfer: s.toProtoTransfer(saved)})
|
||||
}
|
||||
|
||||
func (s *Service) getTransferHandler(ctx context.Context, req *gatewayv1.GetTransferRequest) gsresponse.Responder[gatewayv1.GetTransferResponse] {
|
||||
if err := s.ensureRepository(ctx); err != nil {
|
||||
return gsresponse.Unavailable[gatewayv1.GetTransferResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if req == nil {
|
||||
return gsresponse.InvalidArgument[gatewayv1.GetTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
transferRef := strings.TrimSpace(req.GetTransferRef())
|
||||
if transferRef == "" {
|
||||
return gsresponse.InvalidArgument[gatewayv1.GetTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("transfer_ref is required"))
|
||||
}
|
||||
transfer, err := s.storage.Transfers().Get(ctx, transferRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return gsresponse.NotFound[gatewayv1.GetTransferResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
return gsresponse.Auto[gatewayv1.GetTransferResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
return gsresponse.Success(&gatewayv1.GetTransferResponse{Transfer: s.toProtoTransfer(transfer)})
|
||||
}
|
||||
|
||||
func (s *Service) listTransfersHandler(ctx context.Context, req *gatewayv1.ListTransfersRequest) gsresponse.Responder[gatewayv1.ListTransfersResponse] {
|
||||
if err := s.ensureRepository(ctx); err != nil {
|
||||
return gsresponse.Unavailable[gatewayv1.ListTransfersResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
filter := model.TransferFilter{}
|
||||
if req != nil {
|
||||
filter.SourceWalletRef = strings.TrimSpace(req.GetSourceWalletRef())
|
||||
filter.DestinationWalletRef = strings.TrimSpace(req.GetDestinationWalletRef())
|
||||
if status := transferStatusToModel(req.GetStatus()); status != "" {
|
||||
filter.Status = status
|
||||
}
|
||||
if page := req.GetPage(); page != nil {
|
||||
filter.Cursor = strings.TrimSpace(page.GetCursor())
|
||||
filter.Limit = page.GetLimit()
|
||||
}
|
||||
}
|
||||
|
||||
result, err := s.storage.Transfers().List(ctx, filter)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[gatewayv1.ListTransfersResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
protoTransfers := make([]*gatewayv1.Transfer, 0, len(result.Items))
|
||||
for _, transfer := range result.Items {
|
||||
protoTransfers = append(protoTransfers, s.toProtoTransfer(transfer))
|
||||
}
|
||||
|
||||
resp := &gatewayv1.ListTransfersResponse{
|
||||
Transfers: protoTransfers,
|
||||
Page: &paginationv1.CursorPageResponse{NextCursor: result.NextCursor},
|
||||
}
|
||||
return gsresponse.Success(resp)
|
||||
}
|
||||
|
||||
func (s *Service) estimateTransferFeeHandler(ctx context.Context, req *gatewayv1.EstimateTransferFeeRequest) gsresponse.Responder[gatewayv1.EstimateTransferFeeResponse] {
|
||||
if err := s.ensureRepository(ctx); err != nil {
|
||||
return gsresponse.Unavailable[gatewayv1.EstimateTransferFeeResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if req == nil || req.GetAmount() == nil {
|
||||
return gsresponse.InvalidArgument[gatewayv1.EstimateTransferFeeResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("amount is required"))
|
||||
}
|
||||
currency := req.GetAmount().GetCurrency()
|
||||
fee := &moneyv1.Money{
|
||||
Currency: currency,
|
||||
Amount: "0",
|
||||
}
|
||||
resp := &gatewayv1.EstimateTransferFeeResponse{
|
||||
NetworkFee: fee,
|
||||
EstimationContext: "not_implemented",
|
||||
}
|
||||
return gsresponse.Success(resp)
|
||||
}
|
||||
|
||||
func (s *Service) toProtoTransfer(transfer *model.Transfer) *gatewayv1.Transfer {
|
||||
if transfer == nil {
|
||||
return nil
|
||||
}
|
||||
destination := &gatewayv1.TransferDestination{}
|
||||
if transfer.Destination.ManagedWalletRef != "" {
|
||||
destination.Destination = &gatewayv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: transfer.Destination.ManagedWalletRef}
|
||||
} else if transfer.Destination.ExternalAddress != "" {
|
||||
destination.Destination = &gatewayv1.TransferDestination_ExternalAddress{ExternalAddress: transfer.Destination.ExternalAddress}
|
||||
}
|
||||
destination.Memo = transfer.Destination.Memo
|
||||
|
||||
protoFees := make([]*gatewayv1.ServiceFeeBreakdown, 0, len(transfer.Fees))
|
||||
for _, fee := range transfer.Fees {
|
||||
protoFees = append(protoFees, &gatewayv1.ServiceFeeBreakdown{
|
||||
FeeCode: fee.FeeCode,
|
||||
Amount: cloneMoney(fee.Amount),
|
||||
Description: fee.Description,
|
||||
})
|
||||
}
|
||||
|
||||
asset := &gatewayv1.Asset{
|
||||
Chain: chainEnumFromName(transfer.Network),
|
||||
TokenSymbol: transfer.TokenSymbol,
|
||||
ContractAddress: transfer.ContractAddress,
|
||||
}
|
||||
|
||||
return &gatewayv1.Transfer{
|
||||
TransferRef: transfer.TransferRef,
|
||||
IdempotencyKey: transfer.IdempotencyKey,
|
||||
OrganizationRef: transfer.OrganizationRef,
|
||||
SourceWalletRef: transfer.SourceWalletRef,
|
||||
Destination: destination,
|
||||
Asset: asset,
|
||||
RequestedAmount: cloneMoney(transfer.RequestedAmount),
|
||||
NetAmount: cloneMoney(transfer.NetAmount),
|
||||
Fees: protoFees,
|
||||
Status: transferStatusToProto(transfer.Status),
|
||||
TransactionHash: transfer.TxHash,
|
||||
FailureReason: transfer.FailureReason,
|
||||
CreatedAt: timestamppb.New(transfer.CreatedAt.UTC()),
|
||||
UpdatedAt: timestamppb.New(transfer.UpdatedAt.UTC()),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) resolveDestination(ctx context.Context, dest *gatewayv1.TransferDestination, source *model.ManagedWallet) (model.TransferDestination, error) {
|
||||
if dest == nil {
|
||||
return model.TransferDestination{}, merrors.InvalidArgument("destination is required")
|
||||
}
|
||||
managedRef := strings.TrimSpace(dest.GetManagedWalletRef())
|
||||
external := strings.TrimSpace(dest.GetExternalAddress())
|
||||
if managedRef != "" && external != "" {
|
||||
return model.TransferDestination{}, merrors.InvalidArgument("destination must be managed_wallet_ref or external_address")
|
||||
}
|
||||
if managedRef != "" {
|
||||
wallet, err := s.storage.Wallets().Get(ctx, managedRef)
|
||||
if err != nil {
|
||||
return model.TransferDestination{}, err
|
||||
}
|
||||
if !strings.EqualFold(wallet.Network, source.Network) {
|
||||
return model.TransferDestination{}, merrors.InvalidArgument("destination wallet network mismatch")
|
||||
}
|
||||
if strings.TrimSpace(wallet.DepositAddress) == "" {
|
||||
return model.TransferDestination{}, merrors.InvalidArgument("destination wallet missing deposit address")
|
||||
}
|
||||
return model.TransferDestination{
|
||||
ManagedWalletRef: wallet.WalletRef,
|
||||
Memo: strings.TrimSpace(dest.GetMemo()),
|
||||
}, nil
|
||||
}
|
||||
if external == "" {
|
||||
return model.TransferDestination{}, merrors.InvalidArgument("destination is required")
|
||||
}
|
||||
return model.TransferDestination{
|
||||
ExternalAddress: strings.ToLower(external),
|
||||
Memo: strings.TrimSpace(dest.GetMemo()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func convertFees(fees []*gatewayv1.ServiceFeeBreakdown, currency string) ([]model.ServiceFee, decimal.Decimal, error) {
|
||||
result := make([]model.ServiceFee, 0, len(fees))
|
||||
sum := decimal.NewFromInt(0)
|
||||
for _, fee := range fees {
|
||||
if fee == nil || fee.GetAmount() == nil {
|
||||
return nil, decimal.Decimal{}, merrors.InvalidArgument("fee amount is required")
|
||||
}
|
||||
amtCurrency := strings.ToUpper(strings.TrimSpace(fee.GetAmount().GetCurrency()))
|
||||
if amtCurrency != strings.ToUpper(currency) {
|
||||
return nil, decimal.Decimal{}, merrors.InvalidArgument("fee currency mismatch")
|
||||
}
|
||||
amtValue := strings.TrimSpace(fee.GetAmount().GetAmount())
|
||||
if amtValue == "" {
|
||||
return nil, decimal.Decimal{}, merrors.InvalidArgument("fee amount is required")
|
||||
}
|
||||
dec, err := decimal.NewFromString(amtValue)
|
||||
if err != nil {
|
||||
return nil, decimal.Decimal{}, merrors.InvalidArgument("invalid fee amount")
|
||||
}
|
||||
if dec.IsNegative() {
|
||||
return nil, decimal.Decimal{}, merrors.InvalidArgument("fee amount must be non-negative")
|
||||
}
|
||||
sum = sum.Add(dec)
|
||||
result = append(result, model.ServiceFee{
|
||||
FeeCode: strings.TrimSpace(fee.GetFeeCode()),
|
||||
Amount: cloneMoney(fee.GetAmount()),
|
||||
Description: strings.TrimSpace(fee.GetDescription()),
|
||||
})
|
||||
}
|
||||
return result, sum, nil
|
||||
}
|
||||
213
api/chain/gateway/internal/service/gateway/wallet_handlers.go
Normal file
213
api/chain/gateway/internal/service/gateway/wallet_handlers.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
gatewayv1 "github.com/tech/sendico/chain/gateway/internal/generated/service/gateway/v1"
|
||||
"github.com/tech/sendico/chain/gateway/storage/model"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
func (s *Service) createManagedWalletHandler(ctx context.Context, req *gatewayv1.CreateManagedWalletRequest) gsresponse.Responder[gatewayv1.CreateManagedWalletResponse] {
|
||||
if err := s.ensureRepository(ctx); err != nil {
|
||||
return gsresponse.Unavailable[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if req == nil {
|
||||
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
|
||||
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
|
||||
if idempotencyKey == "" {
|
||||
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required"))
|
||||
}
|
||||
organizationRef := strings.TrimSpace(req.GetOrganizationRef())
|
||||
if organizationRef == "" {
|
||||
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required"))
|
||||
}
|
||||
ownerRef := strings.TrimSpace(req.GetOwnerRef())
|
||||
if ownerRef == "" {
|
||||
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("owner_ref is required"))
|
||||
}
|
||||
|
||||
asset := req.GetAsset()
|
||||
if asset == nil {
|
||||
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("asset is required"))
|
||||
}
|
||||
|
||||
chainKey, _ := chainKeyFromEnum(asset.GetChain())
|
||||
if chainKey == "" {
|
||||
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
|
||||
}
|
||||
networkCfg, ok := s.networks[chainKey]
|
||||
if !ok {
|
||||
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
|
||||
}
|
||||
|
||||
tokenSymbol := strings.ToUpper(strings.TrimSpace(asset.GetTokenSymbol()))
|
||||
if tokenSymbol == "" {
|
||||
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("asset.token_symbol is required"))
|
||||
}
|
||||
contractAddress := strings.ToLower(strings.TrimSpace(asset.GetContractAddress()))
|
||||
if contractAddress == "" {
|
||||
contractAddress = resolveContractAddress(networkCfg.TokenConfigs, tokenSymbol)
|
||||
if contractAddress == "" {
|
||||
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported token for chain"))
|
||||
}
|
||||
}
|
||||
|
||||
walletRef := generateWalletRef()
|
||||
if s.keyManager == nil {
|
||||
return gsresponse.Internal[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.Internal("key manager not configured"))
|
||||
}
|
||||
|
||||
keyInfo, err := s.keyManager.CreateManagedWalletKey(ctx, walletRef, chainKey)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if keyInfo == nil || strings.TrimSpace(keyInfo.Address) == "" {
|
||||
return gsresponse.Internal[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.Internal("key manager returned empty address"))
|
||||
}
|
||||
|
||||
wallet := &model.ManagedWallet{
|
||||
IdempotencyKey: idempotencyKey,
|
||||
WalletRef: walletRef,
|
||||
OrganizationRef: organizationRef,
|
||||
OwnerRef: ownerRef,
|
||||
Network: chainKey,
|
||||
TokenSymbol: tokenSymbol,
|
||||
ContractAddress: contractAddress,
|
||||
DepositAddress: strings.ToLower(keyInfo.Address),
|
||||
KeyReference: keyInfo.KeyID,
|
||||
Status: model.ManagedWalletStatusActive,
|
||||
Metadata: cloneMetadata(req.GetMetadata()),
|
||||
}
|
||||
|
||||
created, err := s.storage.Wallets().Create(ctx, wallet)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
s.logger.Debug("wallet already exists", zap.String("wallet_ref", walletRef), zap.String("idempotency_key", idempotencyKey))
|
||||
return gsresponse.Success(&gatewayv1.CreateManagedWalletResponse{Wallet: s.toProtoManagedWallet(created)})
|
||||
}
|
||||
return gsresponse.Auto[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
return gsresponse.Success(&gatewayv1.CreateManagedWalletResponse{Wallet: s.toProtoManagedWallet(created)})
|
||||
}
|
||||
|
||||
func (s *Service) getManagedWalletHandler(ctx context.Context, req *gatewayv1.GetManagedWalletRequest) gsresponse.Responder[gatewayv1.GetManagedWalletResponse] {
|
||||
if err := s.ensureRepository(ctx); err != nil {
|
||||
return gsresponse.Unavailable[gatewayv1.GetManagedWalletResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if req == nil {
|
||||
return gsresponse.InvalidArgument[gatewayv1.GetManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
walletRef := strings.TrimSpace(req.GetWalletRef())
|
||||
if walletRef == "" {
|
||||
return gsresponse.InvalidArgument[gatewayv1.GetManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required"))
|
||||
}
|
||||
wallet, err := s.storage.Wallets().Get(ctx, walletRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return gsresponse.NotFound[gatewayv1.GetManagedWalletResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
return gsresponse.Auto[gatewayv1.GetManagedWalletResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
return gsresponse.Success(&gatewayv1.GetManagedWalletResponse{Wallet: s.toProtoManagedWallet(wallet)})
|
||||
}
|
||||
|
||||
func (s *Service) listManagedWalletsHandler(ctx context.Context, req *gatewayv1.ListManagedWalletsRequest) gsresponse.Responder[gatewayv1.ListManagedWalletsResponse] {
|
||||
if err := s.ensureRepository(ctx); err != nil {
|
||||
return gsresponse.Unavailable[gatewayv1.ListManagedWalletsResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
filter := model.ManagedWalletFilter{}
|
||||
if req != nil {
|
||||
filter.OrganizationRef = strings.TrimSpace(req.GetOrganizationRef())
|
||||
filter.OwnerRef = strings.TrimSpace(req.GetOwnerRef())
|
||||
if asset := req.GetAsset(); asset != nil {
|
||||
filter.Network, _ = chainKeyFromEnum(asset.GetChain())
|
||||
filter.TokenSymbol = strings.TrimSpace(asset.GetTokenSymbol())
|
||||
}
|
||||
if page := req.GetPage(); page != nil {
|
||||
filter.Cursor = strings.TrimSpace(page.GetCursor())
|
||||
filter.Limit = page.GetLimit()
|
||||
}
|
||||
}
|
||||
|
||||
result, err := s.storage.Wallets().List(ctx, filter)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[gatewayv1.ListManagedWalletsResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
protoWallets := make([]*gatewayv1.ManagedWallet, 0, len(result.Items))
|
||||
for _, wallet := range result.Items {
|
||||
protoWallets = append(protoWallets, s.toProtoManagedWallet(wallet))
|
||||
}
|
||||
|
||||
resp := &gatewayv1.ListManagedWalletsResponse{
|
||||
Wallets: protoWallets,
|
||||
Page: &paginationv1.CursorPageResponse{NextCursor: result.NextCursor},
|
||||
}
|
||||
return gsresponse.Success(resp)
|
||||
}
|
||||
|
||||
func (s *Service) getWalletBalanceHandler(ctx context.Context, req *gatewayv1.GetWalletBalanceRequest) gsresponse.Responder[gatewayv1.GetWalletBalanceResponse] {
|
||||
if err := s.ensureRepository(ctx); err != nil {
|
||||
return gsresponse.Unavailable[gatewayv1.GetWalletBalanceResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if req == nil {
|
||||
return gsresponse.InvalidArgument[gatewayv1.GetWalletBalanceResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
walletRef := strings.TrimSpace(req.GetWalletRef())
|
||||
if walletRef == "" {
|
||||
return gsresponse.InvalidArgument[gatewayv1.GetWalletBalanceResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required"))
|
||||
}
|
||||
balance, err := s.storage.Wallets().GetBalance(ctx, walletRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return gsresponse.NotFound[gatewayv1.GetWalletBalanceResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
return gsresponse.Auto[gatewayv1.GetWalletBalanceResponse](s.logger, mservice.ChainGateway, err)
|
||||
}
|
||||
return gsresponse.Success(&gatewayv1.GetWalletBalanceResponse{Balance: toProtoWalletBalance(balance)})
|
||||
}
|
||||
|
||||
func (s *Service) toProtoManagedWallet(wallet *model.ManagedWallet) *gatewayv1.ManagedWallet {
|
||||
if wallet == nil {
|
||||
return nil
|
||||
}
|
||||
asset := &gatewayv1.Asset{
|
||||
Chain: chainEnumFromName(wallet.Network),
|
||||
TokenSymbol: wallet.TokenSymbol,
|
||||
ContractAddress: wallet.ContractAddress,
|
||||
}
|
||||
return &gatewayv1.ManagedWallet{
|
||||
WalletRef: wallet.WalletRef,
|
||||
OrganizationRef: wallet.OrganizationRef,
|
||||
OwnerRef: wallet.OwnerRef,
|
||||
Asset: asset,
|
||||
DepositAddress: wallet.DepositAddress,
|
||||
Status: managedWalletStatusToProto(wallet.Status),
|
||||
Metadata: cloneMetadata(wallet.Metadata),
|
||||
CreatedAt: timestamppb.New(wallet.CreatedAt.UTC()),
|
||||
UpdatedAt: timestamppb.New(wallet.UpdatedAt.UTC()),
|
||||
}
|
||||
}
|
||||
|
||||
func toProtoWalletBalance(balance *model.WalletBalance) *gatewayv1.WalletBalance {
|
||||
if balance == nil {
|
||||
return nil
|
||||
}
|
||||
return &gatewayv1.WalletBalance{
|
||||
Available: cloneMoney(balance.Available),
|
||||
PendingInbound: cloneMoney(balance.PendingInbound),
|
||||
PendingOutbound: cloneMoney(balance.PendingOutbound),
|
||||
CalculatedAt: timestamppb.New(balance.CalculatedAt.UTC()),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user