restucturization of recipients payment methods
All checks were successful
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/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend 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

This commit is contained in:
Stephan D
2025-12-04 14:40:21 +01:00
parent 3b04753f4e
commit bf85ca062c
120 changed files with 1415 additions and 538 deletions

View File

@@ -0,0 +1,27 @@
package appversion
import (
"github.com/tech/sendico/pkg/version"
vf "github.com/tech/sendico/pkg/version/factory"
)
// Build information. Populated at build-time.
var (
Version string
Revision string
Branch string
BuildUser string
BuildDate string
)
func Create() version.Printer {
info := version.Info{
Program: "Sendico Chain Gateway Service",
Revision: Revision,
Branch: Branch,
BuildUser: BuildUser,
BuildDate: BuildDate,
Version: Version,
}
return vf.Create(&info)
}

View File

@@ -0,0 +1,13 @@
package keymanager
import "github.com/tech/sendico/pkg/model"
// Driver identifies the key management backend implementation.
type Driver string
const (
DriverVault Driver = "vault"
)
// Config represents a configured key manager driver with arbitrary settings.
type Config = model.DriverConfig[Driver]

View File

@@ -0,0 +1,23 @@
package keymanager
import (
"context"
"math/big"
"github.com/ethereum/go-ethereum/core/types"
)
// ManagedWalletKey captures information returned after provisioning a managed wallet key.
type ManagedWalletKey struct {
KeyID string
Address string
PublicKey string
}
// Manager defines the contract for managing managed wallet keys.
type Manager interface {
// CreateManagedWalletKey provisions a new managed wallet key for the provided wallet reference and network.
CreateManagedWalletKey(ctx context.Context, walletRef string, network string) (*ManagedWalletKey, error)
// SignTransaction signs the provided transaction using the identified key material.
SignTransaction(ctx context.Context, keyID string, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error)
}

View File

@@ -0,0 +1,269 @@
package vault
import (
"context"
"crypto/ecdsa"
"crypto/rand"
"encoding/hex"
"math/big"
"os"
"path"
"strings"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto"
"github.com/hashicorp/vault/api"
"go.uber.org/zap"
"github.com/tech/sendico/gateway/chain/internal/keymanager"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
)
// Config describes how to connect to Vault for managed wallet keys.
type Config struct {
Address string `mapstructure:"address"`
TokenEnv string `mapstructure:"token_env"`
Namespace string `mapstructure:"namespace"`
MountPath string `mapstructure:"mount_path"`
KeyPrefix string `mapstructure:"key_prefix"`
}
// Manager implements the keymanager.Manager contract backed by HashiCorp Vault.
type Manager struct {
logger mlogger.Logger
client *api.Client
store *api.KVv2
keyPrefix string
}
// New constructs a Vault-backed key manager.
func New(logger mlogger.Logger, cfg Config) (*Manager, error) {
if logger == nil {
return nil, merrors.InvalidArgument("vault key manager: logger is required")
}
address := strings.TrimSpace(cfg.Address)
if address == "" {
logger.Error("vault address missing")
return nil, merrors.InvalidArgument("vault key manager: address is required")
}
tokenEnv := strings.TrimSpace(cfg.TokenEnv)
if tokenEnv == "" {
logger.Error("vault token env missing")
return nil, merrors.InvalidArgument("vault key manager: token_env is required")
}
token := strings.TrimSpace(os.Getenv(tokenEnv))
if token == "" {
logger.Error("vault token missing; expected Vault Agent to export token", zap.String("env", tokenEnv))
return nil, merrors.InvalidArgument("vault key manager: token env " + tokenEnv + " is not set (expected Vault Agent sink to populate it)")
}
mountPath := strings.Trim(strings.TrimSpace(cfg.MountPath), "/")
if mountPath == "" {
logger.Error("vault mount path missing")
return nil, merrors.InvalidArgument("vault key manager: mount_path is required")
}
keyPrefix := strings.Trim(strings.TrimSpace(cfg.KeyPrefix), "/")
if keyPrefix == "" {
keyPrefix = "chain/gateway/wallets"
}
clientCfg := api.DefaultConfig()
clientCfg.Address = address
client, err := api.NewClient(clientCfg)
if err != nil {
logger.Error("failed to create vault client", zap.Error(err))
return nil, merrors.Internal("vault key manager: failed to create client: " + err.Error())
}
client.SetToken(token)
if ns := strings.TrimSpace(cfg.Namespace); ns != "" {
client.SetNamespace(ns)
}
kv := client.KVv2(mountPath)
return &Manager{
logger: logger.Named("vault"),
client: client,
store: kv,
keyPrefix: keyPrefix,
}, nil
}
// CreateManagedWalletKey creates a new managed wallet key and stores it in Vault.
func (m *Manager) CreateManagedWalletKey(ctx context.Context, walletRef string, network string) (*keymanager.ManagedWalletKey, error) {
if strings.TrimSpace(walletRef) == "" {
m.logger.Warn("walletRef missing for managed key creation", zap.String("network", network))
return nil, merrors.InvalidArgument("vault key manager: walletRef is required")
}
if strings.TrimSpace(network) == "" {
m.logger.Warn("network missing for managed key creation", zap.String("wallet_ref", walletRef))
return nil, merrors.InvalidArgument("vault key manager: network is required")
}
privateKey, err := ecdsa.GenerateKey(secp256k1.S256(), rand.Reader)
if err != nil {
m.logger.Warn("failed to generate managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err))
return nil, merrors.Internal("vault key manager: failed to generate key: " + err.Error())
}
privateKeyBytes := crypto.FromECDSA(privateKey)
publicKey := privateKey.PublicKey
publicKeyBytes := crypto.FromECDSAPub(&publicKey)
publicKeyHex := hex.EncodeToString(publicKeyBytes)
address := crypto.PubkeyToAddress(publicKey).Hex()
err = m.persistKey(ctx, walletRef, network, privateKeyBytes, publicKeyBytes, address)
if err != nil {
m.logger.Warn("failed to persist managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err))
zeroBytes(privateKeyBytes)
zeroBytes(publicKeyBytes)
return nil, err
}
zeroBytes(privateKeyBytes)
zeroBytes(publicKeyBytes)
m.logger.Info("managed wallet key created",
zap.String("wallet_ref", walletRef),
zap.String("network", network),
zap.String("address", strings.ToLower(address)),
)
return &keymanager.ManagedWalletKey{
KeyID: m.buildKeyID(network, walletRef),
Address: strings.ToLower(address),
PublicKey: publicKeyHex,
}, nil
}
func (m *Manager) persistKey(ctx context.Context, walletRef, network string, privateKey, publicKey []byte, address string) error {
secretPath := m.buildKeyID(network, walletRef)
payload := map[string]interface{}{
"private_key": hex.EncodeToString(privateKey),
"public_key": hex.EncodeToString(publicKey),
"address": strings.ToLower(address),
"network": strings.ToLower(network),
}
if _, err := m.store.Put(ctx, secretPath, payload); err != nil {
return merrors.Internal("vault key manager: failed to write secret at " + secretPath + ": " + err.Error())
}
return nil
}
func (m *Manager) buildKeyID(network, walletRef string) string {
net := strings.Trim(strings.ToLower(network), "/")
return path.Join(m.keyPrefix, net, walletRef)
}
// SignTransaction loads the key material from Vault and signs the transaction.
func (m *Manager) SignTransaction(ctx context.Context, keyID string, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error) {
if strings.TrimSpace(keyID) == "" {
m.logger.Warn("signing failed: empty key id")
return nil, merrors.InvalidArgument("vault key manager: keyID is required")
}
if tx == nil {
m.logger.Warn("signing failed: nil transaction", zap.String("key_id", keyID))
return nil, merrors.InvalidArgument("vault key manager: transaction is nil")
}
if chainID == nil {
m.logger.Warn("signing failed: nil chain id", zap.String("key_id", keyID))
return nil, merrors.InvalidArgument("vault key manager: chainID is nil")
}
material, err := m.loadKey(ctx, keyID)
if err != nil {
m.logger.Warn("failed to load key material", zap.String("key_id", keyID), zap.Error(err))
return nil, err
}
keyBytes, err := hex.DecodeString(material.PrivateKey)
if err != nil {
m.logger.Warn("invalid key material", zap.String("key_id", keyID), zap.Error(err))
return nil, merrors.Internal("vault key manager: invalid key material: " + err.Error())
}
defer zeroBytes(keyBytes)
privateKey, err := crypto.ToECDSA(keyBytes)
if err != nil {
m.logger.Warn("failed to construct private key", zap.String("key_id", keyID), zap.Error(err))
return nil, merrors.Internal("vault key manager: failed to construct private key: " + err.Error())
}
signed, err := types.SignTx(tx, types.LatestSignerForChainID(chainID), privateKey)
if err != nil {
m.logger.Warn("failed to sign transaction", zap.String("key_id", keyID), zap.Error(err))
return nil, merrors.Internal("vault key manager: failed to sign transaction: " + err.Error())
}
m.logger.Info("transaction signed with managed key",
zap.String("key_id", keyID),
zap.String("network", material.Network),
zap.String("tx_hash", signed.Hash().Hex()),
)
return signed, nil
}
type keyMaterial struct {
PrivateKey string
PublicKey string
Address string
Network string
}
func (m *Manager) loadKey(ctx context.Context, keyID string) (*keyMaterial, error) {
secretPath := strings.Trim(strings.TrimPrefix(keyID, "/"), "/")
secret, err := m.store.Get(ctx, secretPath)
if err != nil {
m.logger.Warn("failed to read secret", zap.String("path", secretPath), zap.Error(err))
return nil, merrors.Internal("vault key manager: failed to read secret at " + secretPath + ": " + err.Error())
}
if secret == nil || secret.Data == nil {
m.logger.Warn("secret not found", zap.String("path", secretPath))
return nil, merrors.NoData("vault key manager: secret " + secretPath + " not found")
}
getString := func(key string) (string, error) {
val, ok := secret.Data[key]
if !ok {
m.logger.Warn("secret missing field", zap.String("path", secretPath), zap.String("field", key))
return "", merrors.Internal("vault key manager: secret " + secretPath + " missing " + key)
}
str, ok := val.(string)
if !ok || strings.TrimSpace(str) == "" {
m.logger.Warn("secret field invalid", zap.String("path", secretPath), zap.String("field", key))
return "", merrors.Internal("vault key manager: secret " + secretPath + " invalid " + key)
}
return str, nil
}
privateKey, err := getString("private_key")
if err != nil {
return nil, err
}
publicKey, err := getString("public_key")
if err != nil {
return nil, err
}
address, err := getString("address")
if err != nil {
return nil, err
}
network, err := getString("network")
if err != nil {
return nil, err
}
return &keyMaterial{
PrivateKey: privateKey,
PublicKey: publicKey,
Address: address,
Network: network,
}, nil
}
func zeroBytes(data []byte) {
for i := range data {
data[i] = 0
}
}
var _ keymanager.Manager = (*Manager)(nil)

View File

@@ -0,0 +1,260 @@
package serverimp
import (
"context"
"os"
"strings"
"time"
"github.com/mitchellh/mapstructure"
"github.com/tech/sendico/gateway/chain/internal/keymanager"
vaultmanager "github.com/tech/sendico/gateway/chain/internal/keymanager/vault"
gatewayservice "github.com/tech/sendico/gateway/chain/internal/service/gateway"
gatewayshared "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage"
gatewaymongo "github.com/tech/sendico/gateway/chain/storage/mongo"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/merrors"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/server/grpcapp"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
)
type Imp struct {
logger mlogger.Logger
file string
debug bool
config *config
app *grpcapp.App[storage.Repository]
}
type config struct {
*grpcapp.Config `yaml:",inline"`
Chains []chainConfig `yaml:"chains"`
ServiceWallet serviceWalletConfig `yaml:"service_wallet"`
KeyManagement keymanager.Config `yaml:"key_management"`
}
type chainConfig struct {
Name string `yaml:"name"`
RPCURLEnv string `yaml:"rpc_url_env"`
ChainID uint64 `yaml:"chain_id"`
NativeToken string `yaml:"native_token"`
Tokens []tokenConfig `yaml:"tokens"`
}
type serviceWalletConfig struct {
Chain string `yaml:"chain"`
Address string `yaml:"address"`
AddressEnv string `yaml:"address_env"`
PrivateKeyEnv string `yaml:"private_key_env"`
}
type tokenConfig struct {
Symbol string `yaml:"symbol"`
Contract string `yaml:"contract"`
ContractEnv string `yaml:"contract_env"`
}
// Create initialises the chain gateway server implementation.
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
return &Imp{
logger: logger.Named("server"),
file: file,
debug: debug,
}, nil
}
func (i *Imp) Shutdown() {
if i.app == nil {
return
}
timeout := 15 * time.Second
if i.config != nil && i.config.Runtime != nil {
timeout = i.config.Runtime.ShutdownTimeout()
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
i.app.Shutdown(ctx)
}
func (i *Imp) Start() error {
cfg, err := i.loadConfig()
if err != nil {
return err
}
i.config = cfg
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
return gatewaymongo.New(logger, conn)
}
cl := i.logger.Named("config")
networkConfigs := resolveNetworkConfigs(cl.Named("network"), cfg.Chains)
walletConfig := resolveServiceWallet(cl.Named("wallet"), cfg.ServiceWallet)
keyManager, err := resolveKeyManager(i.logger.Named("key_manager"), cfg.KeyManagement)
if err != nil {
return err
}
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
executor := gatewayservice.NewOnChainExecutor(logger, keyManager)
opts := []gatewayservice.Option{
gatewayservice.WithNetworks(networkConfigs),
gatewayservice.WithServiceWallet(walletConfig),
gatewayservice.WithKeyManager(keyManager),
gatewayservice.WithTransferExecutor(executor),
}
return gatewayservice.NewService(logger, repo, producer, opts...), nil
}
app, err := grpcapp.NewApp(i.logger, "chain_gateway", cfg.Config, i.debug, repoFactory, serviceFactory)
if err != nil {
return err
}
i.app = app
return i.app.Start()
}
func (i *Imp) loadConfig() (*config, error) {
data, err := os.ReadFile(i.file)
if err != nil {
i.logger.Error("could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
return nil, err
}
cfg := &config{
Config: &grpcapp.Config{},
}
if err := yaml.Unmarshal(data, cfg); err != nil {
i.logger.Error("failed to parse configuration", zap.Error(err))
return nil, err
}
if cfg.Runtime == nil {
cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: 15}
}
if cfg.GRPC == nil {
cfg.GRPC = &routers.GRPCConfig{
Network: "tcp",
Address: ":50070",
EnableReflection: true,
EnableHealth: true,
}
}
return cfg, nil
}
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")
continue
}
rpcURL := strings.TrimSpace(os.Getenv(chain.RPCURLEnv))
if rpcURL == "" {
logger.Warn("chain RPC endpoint not configured", zap.String("chain", chain.Name), zap.String("env", chain.RPCURLEnv))
}
contracts := make([]gatewayshared.TokenContract, 0, len(chain.Tokens))
for _, token := range chain.Tokens {
symbol := strings.TrimSpace(token.Symbol)
if symbol == "" {
logger.Warn("skipping token with empty symbol", zap.String("chain", chain.Name))
continue
}
addr := strings.TrimSpace(token.Contract)
env := strings.TrimSpace(token.ContractEnv)
if addr == "" && env != "" {
addr = strings.TrimSpace(os.Getenv(env))
}
if addr == "" {
if env != "" {
logger.Warn("token contract not configured", zap.String("token", symbol), zap.String("env", env), zap.String("chain", chain.Name))
} else {
logger.Warn("token contract not configured", zap.String("token", symbol), zap.String("chain", chain.Name))
}
continue
}
contracts = append(contracts, gatewayshared.TokenContract{
Symbol: symbol,
ContractAddress: addr,
})
}
result = append(result, gatewayshared.Network{
Name: chain.Name,
RPCURL: rpcURL,
ChainID: chain.ChainID,
NativeToken: chain.NativeToken,
TokenConfigs: contracts,
})
}
return result
}
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))
}
privateKey := strings.TrimSpace(os.Getenv(cfg.PrivateKeyEnv))
if address == "" {
if cfg.AddressEnv != "" {
logger.Warn("service wallet address not configured", zap.String("env", cfg.AddressEnv))
} else {
logger.Warn("service wallet address not configured", zap.String("chain", cfg.Chain))
}
}
if privateKey == "" {
logger.Warn("service wallet private key not configured", zap.String("env", cfg.PrivateKeyEnv))
}
return gatewayshared.ServiceWallet{
Network: cfg.Chain,
Address: address,
PrivateKey: privateKey,
}
}
func resolveKeyManager(logger mlogger.Logger, cfg keymanager.Config) (keymanager.Manager, error) {
driver := strings.ToLower(strings.TrimSpace(string(cfg.Driver)))
if driver == "" {
err := merrors.InvalidArgument("key management driver is not configured")
logger.Error("key management driver missing")
return nil, err
}
switch keymanager.Driver(driver) {
case keymanager.DriverVault:
settings := vaultmanager.Config{}
if len(cfg.Settings) > 0 {
if err := mapstructure.Decode(cfg.Settings, &settings); err != nil {
logger.Error("failed to decode vault key manager settings", zap.Error(err), zap.Any("settings", cfg.Settings))
return nil, merrors.InvalidArgument("invalid vault key manager settings: " + err.Error())
}
}
manager, err := vaultmanager.New(logger, settings)
if err != nil {
logger.Error("failed to initialise vault key manager", zap.Error(err))
return nil, err
}
return manager, nil
default:
err := merrors.InvalidArgument("unsupported key management driver: " + driver)
logger.Error("unsupported key management driver", zap.String("driver", driver))
return nil, err
}
}

View File

@@ -0,0 +1,12 @@
package server
import (
serverimp "github.com/tech/sendico/gateway/chain/internal/server/internal"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/server"
)
// Create constructs the chain gateway server implementation.
func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
return serverimp.Create(logger, file, debug)
}

View File

@@ -0,0 +1,44 @@
package commands
import (
"context"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/transfer"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/wallet"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
)
type Unary[TReq any, TResp any] interface {
Execute(context.Context, *TReq) gsresponse.Responder[TResp]
}
type Registry struct {
CreateManagedWallet Unary[chainv1.CreateManagedWalletRequest, chainv1.CreateManagedWalletResponse]
GetManagedWallet Unary[chainv1.GetManagedWalletRequest, chainv1.GetManagedWalletResponse]
ListManagedWallets Unary[chainv1.ListManagedWalletsRequest, chainv1.ListManagedWalletsResponse]
GetWalletBalance Unary[chainv1.GetWalletBalanceRequest, chainv1.GetWalletBalanceResponse]
SubmitTransfer Unary[chainv1.SubmitTransferRequest, chainv1.SubmitTransferResponse]
GetTransfer Unary[chainv1.GetTransferRequest, chainv1.GetTransferResponse]
ListTransfers Unary[chainv1.ListTransfersRequest, chainv1.ListTransfersResponse]
EstimateTransfer Unary[chainv1.EstimateTransferFeeRequest, chainv1.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,43 @@
package transfer
import (
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/merrors"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
)
func convertFees(fees []*chainv1.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,26 @@
package transfer
import (
"context"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/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,50 @@
package transfer
import (
"context"
"strings"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/merrors"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.uber.org/zap"
)
func resolveDestination(ctx context.Context, deps Deps, dest *chainv1.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 != "" {
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 := deps.Storage.Wallets().Get(ctx, managedRef)
if err != nil {
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) {
deps.Logger.Warn("destination wallet 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) == "" {
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 == "" {
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,26 @@
package transfer
import (
"context"
"strings"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/merrors"
)
func destinationAddress(ctx context.Context, deps Deps, dest model.TransferDestination) (string, error) {
if ref := strings.TrimSpace(dest.ManagedWalletRef); ref != "" {
wallet, err := deps.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")
}

View File

@@ -0,0 +1,248 @@
package transfer
import (
"context"
"errors"
"math/big"
"strings"
"time"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/shopspring/decimal"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/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 *chainv1.EstimateTransferFeeRequest) gsresponse.Responder[chainv1.EstimateTransferFeeResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
}
if req == nil {
c.deps.Logger.Warn("nil request")
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("request is required"))
}
sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef())
if sourceWalletRef == "" {
c.deps.Logger.Warn("source wallet ref missing")
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required"))
}
amount := req.GetAmount()
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
c.deps.Logger.Warn("amount missing or incomplete")
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("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[chainv1.EstimateTransferFeeResponse](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[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
}
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[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
}
dest, err := resolveDestination(ctx, c.deps, 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[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
}
c.deps.Logger.Warn("invalid destination", zap.Error(err))
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
}
destinationAddress, err := destinationAddress(ctx, c.deps, dest)
if err != nil {
c.deps.Logger.Warn("failed to resolve destination address", zap.Error(err))
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
}
feeMoney, err := estimateNetworkFee(ctx, c.deps.Logger, networkCfg, sourceWallet, destinationAddress, amount)
if err != nil {
c.deps.Logger.Warn("fee estimation failed", zap.Error(err))
return gsresponse.Auto[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
}
resp := &chainv1.EstimateTransferFeeResponse{
NetworkFee: feeMoney,
EstimationContext: "erc20_transfer",
}
return gsresponse.Success(resp)
}
func estimateNetworkFee(ctx context.Context, logger mlogger.Logger, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
rpcURL := strings.TrimSpace(network.RPCURL)
if rpcURL == "" {
return nil, merrors.InvalidArgument("network rpc url not configured")
}
if strings.TrimSpace(wallet.ContractAddress) == "" {
return nil, merrors.NotImplemented("native token transfers not supported")
}
if !common.IsHexAddress(wallet.ContractAddress) {
return nil, merrors.InvalidArgument("invalid token contract address")
}
if !common.IsHexAddress(wallet.DepositAddress) {
return nil, merrors.InvalidArgument("invalid source wallet address")
}
if !common.IsHexAddress(destination) {
return nil, merrors.InvalidArgument("invalid destination address")
}
client, err := ethclient.DialContext(ctx, rpcURL)
if err != nil {
return nil, merrors.Internal("failed to connect to rpc: " + err.Error())
}
defer client.Close()
timeoutCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
tokenABI, err := abi.JSON(strings.NewReader(erc20TransferABI))
if err != nil {
return nil, merrors.Internal("failed to parse erc20 abi: " + err.Error())
}
tokenAddr := common.HexToAddress(wallet.ContractAddress)
toAddr := common.HexToAddress(destination)
fromAddr := common.HexToAddress(wallet.DepositAddress)
decimals, err := erc20Decimals(timeoutCtx, client, tokenABI, tokenAddr)
if err != nil {
logger.Warn("failed to read token decimals", zap.Error(err))
return nil, err
}
amountBase, err := toBaseUnits(strings.TrimSpace(amount.GetAmount()), decimals)
if err != nil {
return nil, err
}
input, err := tokenABI.Pack("transfer", toAddr, amountBase)
if err != nil {
return nil, merrors.Internal("failed to encode transfer call: " + err.Error())
}
gasPrice, err := client.SuggestGasPrice(timeoutCtx)
if err != nil {
return nil, merrors.Internal("failed to suggest gas price: " + err.Error())
}
callMsg := ethereum.CallMsg{
From: fromAddr,
To: &tokenAddr,
GasPrice: gasPrice,
Data: input,
}
gasLimit, err := client.EstimateGas(timeoutCtx, callMsg)
if err != nil {
return nil, merrors.Internal("failed to estimate gas: " + err.Error())
}
fee := new(big.Int).Mul(gasPrice, new(big.Int).SetUint64(gasLimit))
feeDec := decimal.NewFromBigInt(fee, 0)
currency := strings.ToUpper(strings.TrimSpace(network.NativeToken))
if currency == "" {
currency = strings.ToUpper(network.Name)
}
return &moneyv1.Money{
Currency: currency,
Amount: feeDec.String(),
}, nil
}
func erc20Decimals(ctx context.Context, client *ethclient.Client, tokenABI abi.ABI, token common.Address) (uint8, error) {
callData, err := tokenABI.Pack("decimals")
if err != nil {
return 0, merrors.Internal("failed to encode decimals call: " + err.Error())
}
msg := ethereum.CallMsg{
To: &token,
Data: callData,
}
output, err := client.CallContract(ctx, msg, nil)
if err != nil {
return 0, merrors.Internal("decimals call failed: " + err.Error())
}
values, err := tokenABI.Unpack("decimals", output)
if err != nil {
return 0, merrors.Internal("failed to unpack decimals: " + err.Error())
}
if len(values) == 0 {
return 0, merrors.Internal("decimals call returned no data")
}
decimals, ok := values[0].(uint8)
if !ok {
return 0, merrors.Internal("decimals call returned unexpected type")
}
return decimals, nil
}
func toBaseUnits(amount string, decimals uint8) (*big.Int, error) {
value, err := decimal.NewFromString(strings.TrimSpace(amount))
if err != nil {
return nil, merrors.InvalidArgument("invalid amount " + amount + ": " + err.Error())
}
if value.IsNegative() {
return nil, merrors.InvalidArgument("amount must be positive")
}
multiplier := decimal.NewFromInt(1).Shift(int32(decimals))
scaled := value.Mul(multiplier)
if !scaled.Equal(scaled.Truncate(0)) {
return nil, merrors.InvalidArgument("amount " + amount + " exceeds token precision")
}
return scaled.BigInt(), nil
}
const erc20TransferABI = `
[
{
"constant": true,
"inputs": [],
"name": "decimals",
"outputs": [{ "name": "", "type": "uint8" }],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": false,
"inputs": [
{ "name": "_to", "type": "address" },
{ "name": "_value", "type": "uint256" }
],
"name": "transfer",
"outputs": [{ "name": "", "type": "bool" }],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
}
]`

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"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/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 *chainv1.GetTransferRequest) gsresponse.Responder[chainv1.GetTransferResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
}
if req == nil {
c.deps.Logger.Warn("nil request")
return gsresponse.InvalidArgument[chainv1.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[chainv1.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[chainv1.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[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
}
return gsresponse.Success(&chainv1.GetTransferResponse{Transfer: toProtoTransfer(transfer)})
}

View File

@@ -0,0 +1,58 @@
package transfer
import (
"context"
"strings"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/mservice"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/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 *chainv1.ListTransfersRequest) gsresponse.Responder[chainv1.ListTransfersResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.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[chainv1.ListTransfersResponse](c.deps.Logger, mservice.ChainGateway, err)
}
protoTransfers := make([]*chainv1.Transfer, 0, len(result.Items))
for _, transfer := range result.Items {
protoTransfers = append(protoTransfers, toProtoTransfer(transfer))
}
resp := &chainv1.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/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage/model"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"google.golang.org/protobuf/types/known/timestamppb"
)
func toProtoTransfer(transfer *model.Transfer) *chainv1.Transfer {
if transfer == nil {
return nil
}
destination := &chainv1.TransferDestination{}
if transfer.Destination.ManagedWalletRef != "" {
destination.Destination = &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: transfer.Destination.ManagedWalletRef}
} else if transfer.Destination.ExternalAddress != "" {
destination.Destination = &chainv1.TransferDestination_ExternalAddress{ExternalAddress: transfer.Destination.ExternalAddress}
}
destination.Memo = transfer.Destination.Memo
protoFees := make([]*chainv1.ServiceFeeBreakdown, 0, len(transfer.Fees))
for _, fee := range transfer.Fees {
protoFees = append(protoFees, &chainv1.ServiceFeeBreakdown{
FeeCode: fee.FeeCode,
Amount: shared.CloneMoney(fee.Amount),
Description: fee.Description,
})
}
asset := &chainv1.Asset{
Chain: shared.ChainEnumFromName(transfer.Network),
TokenSymbol: transfer.TokenSymbol,
ContractAddress: transfer.ContractAddress,
}
return &chainv1.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,148 @@
package transfer
import (
"context"
"errors"
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mservice"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/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 *chainv1.SubmitTransferRequest) gsresponse.Responder[chainv1.SubmitTransferResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
}
if req == nil {
c.deps.Logger.Warn("nil request")
return gsresponse.InvalidArgument[chainv1.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[chainv1.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[chainv1.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[chainv1.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[chainv1.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[chainv1.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[chainv1.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[chainv1.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[chainv1.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[chainv1.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[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
}
destination, err := resolveDestination(ctx, c.deps, 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[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
}
c.deps.Logger.Warn("invalid destination", zap.Error(err))
return gsresponse.InvalidArgument[chainv1.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[chainv1.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[chainv1.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[chainv1.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(&chainv1.SubmitTransferResponse{Transfer: toProtoTransfer(saved)})
}
c.deps.Logger.Warn("storage create failed", zap.Error(err), zap.String("transfer_ref", transfer.TransferRef))
return gsresponse.Auto[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
}
if c.deps.LaunchExecution != nil {
c.deps.LaunchExecution(saved.TransferRef, sourceWalletRef, networkCfg)
}
return gsresponse.Success(&chainv1.SubmitTransferResponse{Transfer: toProtoTransfer(saved)})
}

View File

@@ -0,0 +1,77 @@
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"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.uber.org/zap"
"google.golang.org/protobuf/types/known/timestamppb"
)
type getWalletBalanceCommand struct {
deps Deps
}
func NewGetWalletBalance(deps Deps) *getWalletBalanceCommand {
return &getWalletBalanceCommand{deps: deps}
}
func (c *getWalletBalanceCommand) Execute(ctx context.Context, req *chainv1.GetWalletBalanceRequest) gsresponse.Responder[chainv1.GetWalletBalanceResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
}
if req == nil {
c.deps.Logger.Warn("nil request")
return gsresponse.InvalidArgument[chainv1.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[chainv1.GetWalletBalanceResponse](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[chainv1.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[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
}
balance, chainErr := onChainWalletBalance(ctx, c.deps, wallet)
if chainErr != nil {
c.deps.Logger.Warn("on-chain balance fetch failed, falling back to stored balance", zap.Error(chainErr), zap.String("wallet_ref", walletRef))
stored, err := c.deps.Storage.Wallets().GetBalance(ctx, walletRef)
if err != nil {
if errors.Is(err, merrors.ErrNoData) {
c.deps.Logger.Warn("stored balance not found", zap.String("wallet_ref", walletRef))
return gsresponse.NotFound[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
}
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
}
return gsresponse.Success(&chainv1.GetWalletBalanceResponse{Balance: toProtoWalletBalance(stored)})
}
return gsresponse.Success(&chainv1.GetWalletBalanceResponse{Balance: onChainBalanceToProto(balance)})
}
func onChainBalanceToProto(balance *moneyv1.Money) *chainv1.WalletBalance {
if balance == nil {
return nil
}
zero := &moneyv1.Money{Currency: balance.Currency, Amount: "0"}
return &chainv1.WalletBalance{
Available: balance,
PendingInbound: zero,
PendingOutbound: zero,
CalculatedAt: timestamppb.Now(),
}
}

View File

@@ -0,0 +1,123 @@
package wallet
import (
"context"
"errors"
"strings"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mservice"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/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 *chainv1.CreateManagedWalletRequest) gsresponse.Responder[chainv1.CreateManagedWalletResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
}
if req == nil {
c.deps.Logger.Warn("nil request")
return gsresponse.InvalidArgument[chainv1.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[chainv1.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[chainv1.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[chainv1.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[chainv1.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[chainv1.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[chainv1.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[chainv1.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[chainv1.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[chainv1.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[chainv1.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[chainv1.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(&chainv1.CreateManagedWalletResponse{Wallet: toProtoManagedWallet(created)})
}
c.deps.Logger.Warn("storage create failed", zap.Error(err), zap.String("wallet_ref", walletRef))
return gsresponse.Auto[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
}
return gsresponse.Success(&chainv1.CreateManagedWalletResponse{Wallet: toProtoManagedWallet(created)})
}

View File

@@ -0,0 +1,25 @@
package wallet
import (
"context"
"github.com/tech/sendico/gateway/chain/internal/keymanager"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/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"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/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 *chainv1.GetManagedWalletRequest) gsresponse.Responder[chainv1.GetManagedWalletResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
}
if req == nil {
c.deps.Logger.Warn("nil request")
return gsresponse.InvalidArgument[chainv1.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[chainv1.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[chainv1.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[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
}
return gsresponse.Success(&chainv1.GetManagedWalletResponse{Wallet: toProtoManagedWallet(wallet)})
}

View File

@@ -0,0 +1,59 @@
package wallet
import (
"context"
"strings"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/mservice"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/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 *chainv1.ListManagedWalletsRequest) gsresponse.Responder[chainv1.ListManagedWalletsResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.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[chainv1.ListManagedWalletsResponse](c.deps.Logger, mservice.ChainGateway, err)
}
protoWallets := make([]*chainv1.ManagedWallet, 0, len(result.Items))
for _, wallet := range result.Items {
protoWallets = append(protoWallets, toProtoManagedWallet(wallet))
}
resp := &chainv1.ListManagedWalletsResponse{
Wallets: protoWallets,
Page: &paginationv1.CursorPageResponse{NextCursor: result.NextCursor},
}
return gsresponse.Success(resp)
}

View File

@@ -0,0 +1,124 @@
package wallet
import (
"context"
"math/big"
"strings"
"time"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/shopspring/decimal"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
func onChainWalletBalance(ctx context.Context, deps Deps, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
network := deps.Networks[strings.ToLower(strings.TrimSpace(wallet.Network))]
rpcURL := strings.TrimSpace(network.RPCURL)
if rpcURL == "" {
return nil, merrors.Internal("network rpc url is not configured")
}
contract := strings.TrimSpace(wallet.ContractAddress)
if contract == "" || !common.IsHexAddress(contract) {
return nil, merrors.InvalidArgument("invalid contract address")
}
if wallet.DepositAddress == "" || !common.IsHexAddress(wallet.DepositAddress) {
return nil, merrors.InvalidArgument("invalid wallet address")
}
client, err := ethclient.DialContext(ctx, rpcURL)
if err != nil {
return nil, merrors.Internal("failed to connect rpc: " + err.Error())
}
defer client.Close()
timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
tokenABI, err := abi.JSON(strings.NewReader(erc20ABIJSON))
if err != nil {
return nil, merrors.Internal("failed to parse erc20 abi: " + err.Error())
}
tokenAddr := common.HexToAddress(contract)
walletAddr := common.HexToAddress(wallet.DepositAddress)
decimals, err := readDecimals(timeoutCtx, client, tokenABI, tokenAddr)
if err != nil {
return nil, err
}
bal, err := readBalanceOf(timeoutCtx, client, tokenABI, tokenAddr, walletAddr)
if err != nil {
return nil, err
}
dec := decimal.NewFromBigInt(bal, 0).Shift(-int32(decimals))
return &moneyv1.Money{Currency: wallet.TokenSymbol, Amount: dec.String()}, nil
}
func readDecimals(ctx context.Context, client *ethclient.Client, tokenABI abi.ABI, token common.Address) (uint8, error) {
data, err := tokenABI.Pack("decimals")
if err != nil {
return 0, merrors.Internal("failed to encode decimals call: " + err.Error())
}
msg := ethereum.CallMsg{To: &token, Data: data}
out, err := client.CallContract(ctx, msg, nil)
if err != nil {
return 0, merrors.Internal("decimals call failed: " + err.Error())
}
values, err := tokenABI.Unpack("decimals", out)
if err != nil || len(values) == 0 {
return 0, merrors.Internal("failed to unpack decimals")
}
if val, ok := values[0].(uint8); ok {
return val, nil
}
return 0, merrors.Internal("decimals returned unexpected type")
}
func readBalanceOf(ctx context.Context, client *ethclient.Client, tokenABI abi.ABI, token common.Address, wallet common.Address) (*big.Int, error) {
data, err := tokenABI.Pack("balanceOf", wallet)
if err != nil {
return nil, merrors.Internal("failed to encode balanceOf: " + err.Error())
}
msg := ethereum.CallMsg{To: &token, Data: data}
out, err := client.CallContract(ctx, msg, nil)
if err != nil {
return nil, merrors.Internal("balanceOf call failed: " + err.Error())
}
values, err := tokenABI.Unpack("balanceOf", out)
if err != nil || len(values) == 0 {
return nil, merrors.Internal("failed to unpack balanceOf")
}
raw, ok := values[0].(*big.Int)
if !ok {
return nil, merrors.Internal("balanceOf returned unexpected type")
}
return decimal.NewFromBigInt(raw, 0).BigInt(), nil
}
const erc20ABIJSON = `
[
{
"constant": true,
"inputs": [],
"name": "decimals",
"outputs": [{ "name": "", "type": "uint8" }],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [{ "name": "_owner", "type": "address" }],
"name": "balanceOf",
"outputs": [{ "name": "balance", "type": "uint256" }],
"payable": false,
"stateMutability": "view",
"type": "function"
}
]`

View File

@@ -0,0 +1,42 @@
package wallet
import (
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage/model"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"google.golang.org/protobuf/types/known/timestamppb"
)
func toProtoManagedWallet(wallet *model.ManagedWallet) *chainv1.ManagedWallet {
if wallet == nil {
return nil
}
asset := &chainv1.Asset{
Chain: shared.ChainEnumFromName(wallet.Network),
TokenSymbol: wallet.TokenSymbol,
ContractAddress: wallet.ContractAddress,
}
return &chainv1.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) *chainv1.WalletBalance {
if balance == nil {
return nil
}
return &chainv1.WalletBalance{
Available: shared.CloneMoney(balance.Available),
PendingInbound: shared.CloneMoney(balance.PendingInbound),
PendingOutbound: shared.CloneMoney(balance.PendingOutbound),
CalculatedAt: timestamppb.New(balance.CalculatedAt.UTC()),
}
}

View File

@@ -0,0 +1,386 @@
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"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"go.uber.org/zap"
"github.com/tech/sendico/gateway/chain/internal/keymanager"
"github.com/tech/sendico/gateway/chain/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 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.
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 shared.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 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")
}
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)
}

View 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"
}
}

View File

@@ -0,0 +1,69 @@
package gateway
import (
"strings"
"github.com/tech/sendico/gateway/chain/internal/keymanager"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
clockpkg "github.com/tech/sendico/pkg/clock"
)
// Option configures the Service.
type Option func(*Service)
// 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 []shared.Network) Option {
return func(s *Service) {
if len(networks) == 0 {
return
}
if s.networks == nil {
s.networks = make(map[string]shared.Network, len(networks))
}
for _, network := range networks {
if network.Name == "" {
continue
}
clone := network
if clone.TokenConfigs == nil {
clone.TokenConfigs = []shared.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 shared.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
}
}
}

View File

@@ -0,0 +1,153 @@
package gateway
import (
"context"
"github.com/tech/sendico/gateway/chain/internal/keymanager"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/transfer"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/wallet"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage"
"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"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"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]shared.Network
serviceWallet shared.ServiceWallet
keyManager keymanager.Manager
executor TransferExecutor
commands commands.Registry
chainv1.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("service"),
storage: repo,
producer: producer,
clock: clockpkg.System{},
networks: map[string]shared.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]shared.Network{}
}
svc.commands = commands.NewRegistry(commands.RegistryDeps{
Wallet: commandsWalletDeps(svc),
Transfer: commandsTransferDeps(svc),
})
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) {
chainv1.RegisterChainGatewayServiceServer(reg, s)
})
}
func (s *Service) CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error) {
return executeUnary(ctx, s, "CreateManagedWallet", s.commands.CreateManagedWallet.Execute, req)
}
func (s *Service) GetManagedWallet(ctx context.Context, req *chainv1.GetManagedWalletRequest) (*chainv1.GetManagedWalletResponse, error) {
return executeUnary(ctx, s, "GetManagedWallet", s.commands.GetManagedWallet.Execute, req)
}
func (s *Service) ListManagedWallets(ctx context.Context, req *chainv1.ListManagedWalletsRequest) (*chainv1.ListManagedWalletsResponse, error) {
return executeUnary(ctx, s, "ListManagedWallets", s.commands.ListManagedWallets.Execute, req)
}
func (s *Service) GetWalletBalance(ctx context.Context, req *chainv1.GetWalletBalanceRequest) (*chainv1.GetWalletBalanceResponse, error) {
return executeUnary(ctx, s, "GetWalletBalance", s.commands.GetWalletBalance.Execute, req)
}
func (s *Service) SubmitTransfer(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
return executeUnary(ctx, s, "SubmitTransfer", s.commands.SubmitTransfer.Execute, req)
}
func (s *Service) GetTransfer(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error) {
return executeUnary(ctx, s, "GetTransfer", s.commands.GetTransfer.Execute, req)
}
func (s *Service) ListTransfers(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error) {
return executeUnary(ctx, s, "ListTransfers", s.commands.ListTransfers.Execute, req)
}
func (s *Service) EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error) {
return executeUnary(ctx, s, "EstimateTransferFee", s.commands.EstimateTransfer.Execute, req)
}
func (s *Service) ensureRepository(ctx context.Context) error {
if s.storage == nil {
return errStorageUnavailable
}
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
}

View File

@@ -0,0 +1,557 @@
package gateway
import (
"context"
"fmt"
"math/big"
"sort"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
ichainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/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/gateway/chain/internal/keymanager"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage"
"github.com/tech/sendico/gateway/chain/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 := &ichainv1.CreateManagedWalletRequest{
IdempotencyKey: "idem-1",
OrganizationRef: "org-1",
OwnerRef: "owner-1",
Asset: &ichainv1.Asset{
Chain: ichainv1.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, &ichainv1.CreateManagedWalletRequest{
IdempotencyKey: "idem-src",
OrganizationRef: "org-1",
OwnerRef: "owner-1",
Asset: &ichainv1.Asset{
Chain: ichainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET,
TokenSymbol: "USDC",
},
})
require.NoError(t, err)
srcRef := srcResp.GetWallet().GetWalletRef()
// destination wallet
dstResp, err := svc.CreateManagedWallet(ctx, &ichainv1.CreateManagedWalletRequest{
IdempotencyKey: "idem-dst",
OrganizationRef: "org-1",
OwnerRef: "owner-2",
Asset: &ichainv1.Asset{
Chain: ichainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET,
TokenSymbol: "USDC",
},
})
require.NoError(t, err)
dstRef := dstResp.GetWallet().GetWalletRef()
transferResp, err := svc.SubmitTransfer(ctx, &ichainv1.SubmitTransferRequest{
IdempotencyKey: "transfer-1",
OrganizationRef: "org-1",
SourceWalletRef: srcRef,
Destination: &ichainv1.TransferDestination{
Destination: &ichainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: dstRef},
},
Amount: &moneyv1.Money{Currency: "USDC", Amount: "100"},
Fees: []*ichainv1.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, &ichainv1.GetTransferRequest{TransferRef: stored.TransferRef})
require.NoError(t, err)
require.Equal(t, stored.TransferRef, getResp.GetTransfer().GetTransferRef())
// ListTransfers
listResp, err := svc.ListTransfers(ctx, &ichainv1.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, &ichainv1.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([]shared.Network{{
Name: "ethereum_mainnet",
TokenConfigs: []shared.TokenContract{
{Symbol: "USDC", ContractAddress: "0xusdc"},
},
}}),
WithServiceWallet(shared.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
}

View File

@@ -0,0 +1,142 @@
package shared
import (
"strings"
"github.com/tech/sendico/gateway/chain/storage/model"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/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 chainv1.ChainNetwork) (string, chainv1.ChainNetwork) {
if name, ok := chainv1.ChainNetwork_name[int32(chain)]; ok {
key := strings.ToLower(strings.TrimPrefix(name, "CHAIN_NETWORK_"))
return key, chain
}
return "", chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED
}
func ChainEnumFromName(name string) chainv1.ChainNetwork {
if name == "" {
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED
}
upper := strings.ToUpper(strings.ReplaceAll(strings.ReplaceAll(name, " ", "_"), "-", "_"))
key := "CHAIN_NETWORK_" + upper
if val, ok := chainv1.ChainNetwork_value[key]; ok {
return chainv1.ChainNetwork(val)
}
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED
}
func ManagedWalletStatusToProto(status model.ManagedWalletStatus) chainv1.ManagedWalletStatus {
switch status {
case model.ManagedWalletStatusActive:
return chainv1.ManagedWalletStatus_MANAGED_WALLET_ACTIVE
case model.ManagedWalletStatusSuspended:
return chainv1.ManagedWalletStatus_MANAGED_WALLET_SUSPENDED
case model.ManagedWalletStatusClosed:
return chainv1.ManagedWalletStatus_MANAGED_WALLET_CLOSED
default:
return chainv1.ManagedWalletStatus_MANAGED_WALLET_STATUS_UNSPECIFIED
}
}
func TransferStatusToModel(status chainv1.TransferStatus) model.TransferStatus {
switch status {
case chainv1.TransferStatus_TRANSFER_PENDING:
return model.TransferStatusPending
case chainv1.TransferStatus_TRANSFER_SIGNING:
return model.TransferStatusSigning
case chainv1.TransferStatus_TRANSFER_SUBMITTED:
return model.TransferStatusSubmitted
case chainv1.TransferStatus_TRANSFER_CONFIRMED:
return model.TransferStatusConfirmed
case chainv1.TransferStatus_TRANSFER_FAILED:
return model.TransferStatusFailed
case chainv1.TransferStatus_TRANSFER_CANCELLED:
return model.TransferStatusCancelled
default:
return ""
}
}
func TransferStatusToProto(status model.TransferStatus) chainv1.TransferStatus {
switch status {
case model.TransferStatusPending:
return chainv1.TransferStatus_TRANSFER_PENDING
case model.TransferStatusSigning:
return chainv1.TransferStatus_TRANSFER_SIGNING
case model.TransferStatusSubmitted:
return chainv1.TransferStatus_TRANSFER_SUBMITTED
case model.TransferStatusConfirmed:
return chainv1.TransferStatus_TRANSFER_CONFIRMED
case model.TransferStatusFailed:
return chainv1.TransferStatus_TRANSFER_FAILED
case model.TransferStatusCancelled:
return chainv1.TransferStatus_TRANSFER_CANCELLED
default:
return chainv1.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

@@ -0,0 +1,101 @@
package gateway
import (
"context"
"errors"
"strings"
"time"
"github.com/ethereum/go-ethereum/core/types"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/merrors"
"go.uber.org/zap"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
)
func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, network shared.Network) {
if s.executor == nil {
return
}
go func(ref, walletRef string, net shared.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 shared.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")
}