new tron gateway

This commit is contained in:
Stephan D
2026-01-30 15:51:28 +01:00
parent 51f5b0804a
commit 8788ff67ec
77 changed files with 11050 additions and 0 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 TRON 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,26 @@
package keymanager
import (
"context"
"math/big"
"github.com/ethereum/go-ethereum/core/types"
"github.com/fbsobreira/gotron-sdk/pkg/proto/core"
)
// 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)
// SignTronTransaction signs a native TRON transaction using the identified key material.
SignTronTransaction(ctx context.Context, keyID string, tx *core.Transaction) (*core.Transaction, error)
}

View File

@@ -0,0 +1,341 @@
package vault
import (
"context"
stdecdsa "crypto/ecdsa"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"math/big"
"os"
"path"
"strings"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto"
troncore "github.com/fbsobreira/gotron-sdk/pkg/proto/core"
"github.com/hashicorp/vault/api"
"go.uber.org/zap"
"google.golang.org/protobuf/proto"
"github.com/tech/sendico/gateway/tron/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 = "gateway/chain/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 := stdecdsa.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
}
// SignTronTransaction signs a native TRON transaction using the identified key material.
func (m *Manager) SignTronTransaction(ctx context.Context, keyID string, tx *troncore.Transaction) (*troncore.Transaction, error) {
if strings.TrimSpace(keyID) == "" {
m.logger.Warn("TRON signing failed: empty key id")
return nil, merrors.InvalidArgument("vault key manager: keyID is required")
}
if tx == nil {
m.logger.Warn("TRON signing failed: nil transaction", zap.String("key_id", keyID))
return nil, merrors.InvalidArgument("vault key manager: transaction is nil")
}
if tx.GetRawData() == nil {
m.logger.Warn("TRON signing failed: nil raw data", zap.String("key_id", keyID))
return nil, merrors.InvalidArgument("vault key manager: transaction raw_data is nil")
}
material, err := m.loadKey(ctx, keyID)
if err != nil {
m.logger.Warn("Failed to load key material for TRON signing", 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 for TRON signing", zap.String("key_id", keyID), zap.Error(err))
return nil, merrors.Internal("vault key manager: invalid key material: " + err.Error())
}
defer zeroBytes(keyBytes)
// Marshal the raw_data to bytes for hashing
rawBytes, err := proto.Marshal(tx.GetRawData())
if err != nil {
m.logger.Warn("Failed to marshal TRON transaction raw_data", zap.String("key_id", keyID), zap.Error(err))
return nil, merrors.Internal("vault key manager: failed to marshal transaction: " + err.Error())
}
// SHA256 hash of the raw_data
hash := sha256.Sum256(rawBytes)
// Create secp256k1 private key
privKey := secp256k1.PrivKeyFromBytes(keyBytes)
// Sign using compact signature format (65 bytes: r[32] || s[32] || recovery_id[1])
signature := ecdsa.SignCompact(privKey, hash[:], false)
// TRON expects signature in format: r[32] || s[32] || v[1]
// SignCompact returns: recovery_id[1] || r[32] || s[32]
// We need to rearrange to: r[32] || s[32] || recovery_id[1]
if len(signature) != 65 {
m.logger.Warn("Unexpected signature length", zap.String("key_id", keyID), zap.Int("length", len(signature)))
return nil, merrors.Internal("vault key manager: unexpected signature length")
}
tronSig := make([]byte, 65)
copy(tronSig[0:32], signature[1:33]) // r
copy(tronSig[32:64], signature[33:65]) // s
tronSig[64] = signature[0] // recovery id (v)
// Append signature to transaction
tx.Signature = append(tx.Signature, tronSig)
m.logger.Info("TRON transaction signed with managed key",
zap.String("key_id", keyID),
zap.String("network", material.Network),
)
return tx, 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,431 @@
package serverimp
import (
"context"
"fmt"
"os"
"strings"
"time"
"github.com/mitchellh/mapstructure"
"github.com/shopspring/decimal"
"github.com/tech/sendico/gateway/tron/internal/keymanager"
vaultmanager "github.com/tech/sendico/gateway/tron/internal/keymanager/vault"
gatewayservice "github.com/tech/sendico/gateway/tron/internal/service/gateway"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/drivers"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/rpcclient"
gatewayshared "github.com/tech/sendico/gateway/tron/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/tronclient"
"github.com/tech/sendico/gateway/tron/storage"
gatewaymongo "github.com/tech/sendico/gateway/tron/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"
pmodel "github.com/tech/sendico/pkg/model"
"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]
rpcClients *rpcclient.Clients
tronClients *tronclient.Registry
service *gatewayservice.Service
}
type config struct {
*grpcapp.Config `yaml:",inline"`
Chains []chainConfig `yaml:"chains"`
ServiceWallet serviceWalletConfig `yaml:"service_wallet"`
KeyManagement keymanager.Config `yaml:"key_management"`
Settings gatewayservice.CacheSettings `yaml:"cache"`
}
type chainConfig struct {
Name string `yaml:"name"`
RPCURLEnv string `yaml:"rpc_url_env"`
GRPCURLEnv string `yaml:"grpc_url_env"` // Native TRON gRPC endpoint
GRPCTokenEnv string `yaml:"grpc_token_env"`
ChainID uint64 `yaml:"chain_id"`
NativeToken string `yaml:"native_token"`
Tokens []tokenConfig `yaml:"tokens"`
GasTopUpPolicy *gasTopUpPolicyConfig `yaml:"gas_topup_policy"`
}
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"`
}
type gasTopUpPolicyConfig struct {
gasTopUpRuleConfig `yaml:",inline"`
Native *gasTopUpRuleConfig `yaml:"native"`
Contract *gasTopUpRuleConfig `yaml:"contract"`
}
type gasTopUpRuleConfig struct {
BufferPercent float64 `yaml:"buffer_percent"`
MinNativeBalanceTRX float64 `yaml:"min_native_balance_trx"`
RoundingUnitTRX float64 `yaml:"rounding_unit_trx"`
MaxTopUpTRX float64 `yaml:"max_topup_trx"`
}
// 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()
}
if i.service != nil {
i.service.Shutdown()
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
i.app.Shutdown(ctx)
if i.rpcClients != nil {
i.rpcClients.Close()
}
if i.tronClients != nil {
i.tronClients.Close()
}
}
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, err := resolveNetworkConfigs(cl.Named("network"), cfg.Chains)
if err != nil {
i.logger.Error("Invalid chain network configuration", zap.Error(err))
return err
}
rpcClients, err := rpcclient.Prepare(context.Background(), i.logger.Named("rpc"), networkConfigs)
if err != nil {
i.logger.Error("Failed to prepare rpc clients", zap.Error(err))
return err
}
i.rpcClients = rpcClients
walletConfig := resolveServiceWallet(cl.Named("wallet"), cfg.ServiceWallet)
keyManager, err := resolveKeyManager(i.logger.Named("key_manager"), cfg.KeyManagement)
if err != nil {
return err
}
driverRegistry, err := drivers.NewRegistry(i.logger.Named("drivers"), networkConfigs)
if err != nil {
return err
}
// Prepare native TRON gRPC clients (optional - fallback to EVM if not configured)
tronClients, err := tronclient.Prepare(context.Background(), i.logger.Named("tron"), networkConfigs)
if err != nil {
i.logger.Warn("TRON gRPC clients not available, falling back to EVM", zap.Error(err))
// Continue without TRON clients - will fallback to EVM
} else {
i.tronClients = tronClients
}
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
invokeURI := ""
if cfg.GRPC != nil {
invokeURI = cfg.GRPC.DiscoveryInvokeURI()
}
opts := []gatewayservice.Option{
gatewayservice.WithDiscoveryInvokeURI(invokeURI),
gatewayservice.WithNetworks(networkConfigs),
gatewayservice.WithServiceWallet(walletConfig),
gatewayservice.WithKeyManager(keyManager),
gatewayservice.WithRPCClients(rpcClients),
gatewayservice.WithTronClients(i.tronClients),
gatewayservice.WithDriverRegistry(driverRegistry),
gatewayservice.WithSettings(cfg.Settings),
}
svc := gatewayservice.NewService(logger, repo, producer, opts...)
i.service = svc
return svc, nil
}
app, err := grpcapp.NewApp(i.logger, "chain", 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: ":50071",
EnableReflection: true,
EnableHealth: true,
}
}
return cfg, nil
}
func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) ([]gatewayshared.Network, error) {
result := make([]gatewayshared.Network, 0, len(chains))
for _, chain := range chains {
if strings.TrimSpace(chain.Name) == "" {
logger.Warn("Skipping unnamed chain configuration")
continue
}
network, ok := pmodel.ParseChainNetwork(chain.Name)
if !ok {
logger.Error("Unknown chain network", zap.String("chain", chain.Name))
return nil, merrors.InvalidArgument(fmt.Sprintf("unknown chain network: %s", chain.Name))
}
if !network.IsValid() {
logger.Error("Invalid chain network", zap.String("chain", chain.Name))
return nil, merrors.InvalidArgument(fmt.Sprintf("invalid chain network: %s", chain.Name))
}
rpcURL := strings.TrimSpace(os.Getenv(chain.RPCURLEnv))
if rpcURL == "" {
logger.Error("RPC url not configured", zap.String("chain", network.String()), zap.String("env", chain.RPCURLEnv))
return nil, merrors.InvalidArgument(fmt.Sprintf("chain RPC endpoint not configured (chain=%s env=%s)", network, 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", network.String()))
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", network.String()))
} else {
logger.Warn("Token contract not configured", zap.String("token", symbol), zap.String("chain", network.String()))
}
continue
}
contracts = append(contracts, gatewayshared.TokenContract{
Symbol: symbol,
ContractAddress: addr,
})
}
gasPolicy, err := buildGasTopUpPolicy(network.String(), chain.GasTopUpPolicy)
if err != nil {
logger.Error("Invalid gas top-up policy", zap.String("chain", network.String()), zap.Error(err))
return nil, err
}
// Resolve optional TRON gRPC URL
grpcURL := ""
if grpcEnv := strings.TrimSpace(chain.GRPCURLEnv); grpcEnv != "" {
grpcURL = strings.TrimSpace(os.Getenv(grpcEnv))
if grpcURL != "" {
logger.Info("TRON gRPC URL configured", zap.String("chain", network.String()), zap.String("env", grpcEnv))
}
}
grpcToken := ""
if grpcTokenEnv := strings.TrimSpace(chain.GRPCTokenEnv); grpcTokenEnv != "" {
grpcToken = strings.TrimSpace(os.Getenv(grpcTokenEnv))
if grpcToken != "" {
logger.Info("TRON gRPC token configured", zap.String("chain", network.String()), zap.String("env", grpcTokenEnv))
}
}
result = append(result, gatewayshared.Network{
Name: network,
RPCURL: rpcURL,
GRPCUrl: grpcURL,
GRPCToken: grpcToken,
ChainID: chain.ChainID,
NativeToken: chain.NativeToken,
TokenConfigs: contracts,
GasTopUpPolicy: gasPolicy,
})
}
return result, nil
}
func buildGasTopUpPolicy(chainName string, cfg *gasTopUpPolicyConfig) (*gatewayshared.GasTopUpPolicy, error) {
if cfg == nil {
return nil, nil
}
defaultRule, defaultSet, err := parseGasTopUpRule(chainName, "default", cfg.gasTopUpRuleConfig)
if err != nil {
return nil, err
}
if !defaultSet {
return nil, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy is required", chainName))
}
policy := &gatewayshared.GasTopUpPolicy{
Default: defaultRule,
}
if cfg.Native != nil {
rule, set, err := parseGasTopUpRule(chainName, "native", *cfg.Native)
if err != nil {
return nil, err
}
if set {
policy.Native = &rule
}
}
if cfg.Contract != nil {
rule, set, err := parseGasTopUpRule(chainName, "contract", *cfg.Contract)
if err != nil {
return nil, err
}
if set {
policy.Contract = &rule
}
}
return policy, nil
}
func parseGasTopUpRule(chainName, label string, cfg gasTopUpRuleConfig) (gatewayshared.GasTopUpRule, bool, error) {
if cfg.BufferPercent == 0 && cfg.MinNativeBalanceTRX == 0 && cfg.RoundingUnitTRX == 0 && cfg.MaxTopUpTRX == 0 {
return gatewayshared.GasTopUpRule{}, false, nil
}
if cfg.BufferPercent < 0 {
return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s buffer_percent must be >= 0", chainName, label))
}
if cfg.MinNativeBalanceTRX < 0 {
return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s min_native_balance_trx must be >= 0", chainName, label))
}
if cfg.RoundingUnitTRX <= 0 {
return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s rounding_unit_trx must be > 0", chainName, label))
}
if cfg.MaxTopUpTRX <= 0 {
return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s max_topup_trx must be > 0", chainName, label))
}
return gatewayshared.GasTopUpRule{
BufferPercent: decimal.NewFromFloat(cfg.BufferPercent),
MinNativeBalance: decimal.NewFromFloat(cfg.MinNativeBalanceTRX),
RoundingUnit: decimal.NewFromFloat(cfg.RoundingUnitTRX),
MaxTopUp: decimal.NewFromFloat(cfg.MaxTopUpTRX),
}, true, nil
}
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))
network, ok := pmodel.ParseChainNetwork(cfg.Chain)
if !ok {
logger.Warn("Unknown service wallet chain network", zap.String("chain", cfg.Chain))
}
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", network.String()))
}
}
if privateKey == "" {
logger.Warn("Service wallet private key not configured", zap.String("env", cfg.PrivateKeyEnv))
}
return gatewayshared.ServiceWallet{
Network: network,
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/tron/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,48 @@
package commands
import (
"context"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/commands/transfer"
"github.com/tech/sendico/gateway/tron/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]
ComputeGasTopUp Unary[chainv1.ComputeGasTopUpRequest, chainv1.ComputeGasTopUpResponse]
EnsureGasTopUp Unary[chainv1.EnsureGasTopUpRequest, chainv1.EnsureGasTopUpResponse]
}
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")),
ComputeGasTopUp: transfer.NewComputeGasTopUp(deps.Transfer.WithLogger("gas_topup.compute")),
EnsureGasTopUp: transfer.NewEnsureGasTopUp(deps.Transfer.WithLogger("gas_topup.ensure")),
}
}

View File

@@ -0,0 +1,43 @@
package transfer
import (
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/tron/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,35 @@
package transfer
import (
"context"
"time"
"github.com/tech/sendico/gateway/tron/internal/keymanager"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/drivers"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/rpcclient"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/tronclient"
"github.com/tech/sendico/gateway/tron/storage"
clockpkg "github.com/tech/sendico/pkg/clock"
"github.com/tech/sendico/pkg/mlogger"
)
type Deps struct {
Logger mlogger.Logger
Drivers *drivers.Registry
Networks *rpcclient.Registry
TronClients *tronclient.Registry // Native TRON gRPC clients
KeyManager keymanager.Manager
Storage storage.Repository
Clock clockpkg.Clock
RPCTimeout time.Duration
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,65 @@
package transfer
import (
"context"
"strings"
"github.com/tech/sendico/gateway/tron/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")
}
if deps.Drivers == nil {
deps.Logger.Warn("Chain drivers missing", zap.String("network", source.Network))
return model.TransferDestination{}, merrors.Internal("chain drivers not configured")
}
chainDriver, err := deps.Drivers.Driver(source.Network)
if err != nil {
deps.Logger.Warn("Unsupported chain driver", zap.String("network", source.Network), zap.Error(err))
return model.TransferDestination{}, merrors.InvalidArgument("unsupported chain for wallet")
}
normalized, err := chainDriver.NormalizeAddress(external)
if err != nil {
deps.Logger.Warn("Invalid external address", zap.Error(err))
return model.TransferDestination{}, err
}
return model.TransferDestination{
ExternalAddress: normalized,
ExternalAddressOriginal: external,
Memo: strings.TrimSpace(dest.GetMemo()),
}, nil
}

View File

@@ -0,0 +1,27 @@
package transfer
import (
"context"
"strings"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/driver"
"github.com/tech/sendico/gateway/tron/storage/model"
"github.com/tech/sendico/pkg/merrors"
)
func destinationAddress(ctx context.Context, deps Deps, chainDriver driver.Driver, 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 chainDriver.NormalizeAddress(wallet.DepositAddress)
}
if addr := strings.TrimSpace(dest.ExternalAddress); addr != "" {
return chainDriver.NormalizeAddress(addr)
}
return "", merrors.InvalidArgument("transfer destination address not resolved")
}

View File

@@ -0,0 +1,119 @@
package transfer
import (
"context"
"errors"
"strings"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/driver"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/shared"
"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 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("Empty request received")
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.Network(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"))
}
if c.deps.Drivers == nil {
c.deps.Logger.Warn("Chain drivers missing", zap.String("network", networkKey))
return gsresponse.Internal[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("chain drivers not configured"))
}
chainDriver, err := c.deps.Drivers.Driver(networkKey)
if err != nil {
c.deps.Logger.Warn("Unsupported chain driver", zap.String("network", networkKey), zap.Error(err))
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, chainDriver, 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)
}
walletForFee := sourceWallet
nativeCurrency := shared.NativeCurrency(networkCfg)
if nativeCurrency != "" && strings.EqualFold(nativeCurrency, amount.GetCurrency()) {
copyWallet := *sourceWallet
copyWallet.ContractAddress = ""
copyWallet.TokenSymbol = nativeCurrency
walletForFee = &copyWallet
}
driverDeps := driver.Deps{
Logger: c.deps.Logger,
Registry: c.deps.Networks,
TronRegistry: c.deps.TronClients,
KeyManager: c.deps.KeyManager,
RPCTimeout: c.deps.RPCTimeout,
}
feeMoney, err := chainDriver.EstimateFee(ctx, driverDeps, networkCfg, walletForFee, 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)
}
contextLabel := "erc20_transfer"
if strings.TrimSpace(walletForFee.ContractAddress) == "" {
contextLabel = "native_transfer"
}
resp := &chainv1.EstimateTransferFeeResponse{
NetworkFee: feeMoney,
EstimationContext: contextLabel,
}
return gsresponse.Success(resp)
}

View File

@@ -0,0 +1,290 @@
package transfer
import (
"context"
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/commands/wallet"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/driver/evm"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/driver/tron"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/tron/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 computeGasTopUpCommand struct {
deps Deps
}
func NewComputeGasTopUp(deps Deps) *computeGasTopUpCommand {
return &computeGasTopUpCommand{deps: deps}
}
func (c *computeGasTopUpCommand) Execute(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) gsresponse.Responder[chainv1.ComputeGasTopUpResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err)
}
if req == nil {
c.deps.Logger.Warn("Nil request")
return gsresponse.InvalidArgument[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("request is required"))
}
walletRef := strings.TrimSpace(req.GetWalletRef())
if walletRef == "" {
c.deps.Logger.Warn("Wallet ref missing")
return gsresponse.InvalidArgument[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required"))
}
estimatedFee := req.GetEstimatedTotalFee()
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
c.deps.Logger.Warn("Estimated fee missing")
return gsresponse.InvalidArgument[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("estimated_total_fee is required"))
}
topUp, capHit, decision, nativeBalance, walletModel, err := computeGasTopUp(ctx, c.deps, walletRef, estimatedFee)
if err != nil {
return gsresponse.Auto[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err)
}
logDecision(c.deps.Logger, walletRef, estimatedFee, nativeBalance, topUp, capHit, decision, walletModel)
return gsresponse.Success(&chainv1.ComputeGasTopUpResponse{
TopupAmount: topUp,
CapHit: capHit,
})
}
type ensureGasTopUpCommand struct {
deps Deps
}
func NewEnsureGasTopUp(deps Deps) *ensureGasTopUpCommand {
return &ensureGasTopUpCommand{deps: deps}
}
func (c *ensureGasTopUpCommand) Execute(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) gsresponse.Responder[chainv1.EnsureGasTopUpResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err)
}
if req == nil {
c.deps.Logger.Warn("Nil request")
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("request is required"))
}
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
if idempotencyKey == "" {
c.deps.Logger.Warn("Idempotency key missing")
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required"))
}
organizationRef := strings.TrimSpace(req.GetOrganizationRef())
if organizationRef == "" {
c.deps.Logger.Warn("Organization ref missing")
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required"))
}
sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef())
if sourceWalletRef == "" {
c.deps.Logger.Warn("Source wallet ref missing")
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required"))
}
targetWalletRef := strings.TrimSpace(req.GetTargetWalletRef())
if targetWalletRef == "" {
c.deps.Logger.Warn("Target wallet ref missing")
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("target_wallet_ref is required"))
}
estimatedFee := req.GetEstimatedTotalFee()
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
c.deps.Logger.Warn("Estimated fee missing")
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("estimated_total_fee is required"))
}
topUp, capHit, decision, nativeBalance, walletModel, err := computeGasTopUp(ctx, c.deps, targetWalletRef, estimatedFee)
if err != nil {
return gsresponse.Auto[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err)
}
logDecision(c.deps.Logger, targetWalletRef, estimatedFee, nativeBalance, topUp, capHit, decision, walletModel)
if topUp == nil || strings.TrimSpace(topUp.GetAmount()) == "" {
return gsresponse.Success(&chainv1.EnsureGasTopUpResponse{
TopupAmount: nil,
CapHit: capHit,
})
}
submitReq := &chainv1.SubmitTransferRequest{
IdempotencyKey: idempotencyKey,
OrganizationRef: organizationRef,
SourceWalletRef: sourceWalletRef,
Destination: &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: targetWalletRef},
},
Amount: topUp,
Metadata: shared.CloneMetadata(req.GetMetadata()),
ClientReference: strings.TrimSpace(req.GetClientReference()),
}
submitResponder := NewSubmitTransfer(c.deps.WithLogger("transfer.submit")).Execute(ctx, submitReq)
return func(ctx context.Context) (*chainv1.EnsureGasTopUpResponse, error) {
submitResp, err := submitResponder(ctx)
if err != nil {
return nil, err
}
return &chainv1.EnsureGasTopUpResponse{
TopupAmount: topUp,
CapHit: capHit,
Transfer: submitResp.GetTransfer(),
}, nil
}
}
func computeGasTopUp(ctx context.Context, deps Deps, walletRef string, estimatedFee *moneyv1.Money) (*moneyv1.Money, bool, *tron.GasTopUpDecision, *moneyv1.Money, *model.ManagedWallet, error) {
walletRef = strings.TrimSpace(walletRef)
estimatedFee = shared.CloneMoney(estimatedFee)
walletModel, err := deps.Storage.Wallets().Get(ctx, walletRef)
if err != nil {
return nil, false, nil, nil, nil, err
}
networkKey := strings.ToLower(strings.TrimSpace(walletModel.Network))
networkCfg, ok := deps.Networks.Network(networkKey)
if !ok {
return nil, false, nil, nil, nil, merrors.InvalidArgument("unsupported chain for wallet")
}
nativeBalance, err := nativeBalanceForWallet(ctx, deps, walletModel)
if err != nil {
return nil, false, nil, nil, nil, err
}
if strings.HasPrefix(networkKey, "tron") {
topUp, decision, err := tron.ComputeGasTopUp(networkCfg, walletModel, estimatedFee, nativeBalance)
if err != nil {
return nil, false, nil, nil, nil, err
}
return topUp, decision.CapHit, &decision, nativeBalance, walletModel, nil
}
if networkCfg.GasTopUpPolicy != nil {
topUp, capHit, err := evm.ComputeGasTopUp(networkCfg, walletModel, estimatedFee, nativeBalance)
if err != nil {
return nil, false, nil, nil, nil, err
}
return topUp, capHit, nil, nativeBalance, walletModel, nil
}
topUp, err := defaultGasTopUp(estimatedFee, nativeBalance)
if err != nil {
return nil, false, nil, nil, nil, err
}
return topUp, false, nil, nativeBalance, walletModel, nil
}
func nativeBalanceForWallet(ctx context.Context, deps Deps, walletModel *model.ManagedWallet) (*moneyv1.Money, error) {
if walletModel == nil {
return nil, merrors.InvalidArgument("wallet is required")
}
walletDeps := wallet.Deps{
Logger: deps.Logger.Named("wallet"),
Drivers: deps.Drivers,
Networks: deps.Networks,
KeyManager: nil,
Storage: deps.Storage,
Clock: deps.Clock,
BalanceCacheTTL: 0,
RPCTimeout: deps.RPCTimeout,
EnsureRepository: deps.EnsureRepository,
}
_, nativeBalance, err := wallet.OnChainWalletBalances(ctx, walletDeps, walletModel)
if err != nil {
return nil, err
}
if nativeBalance == nil || strings.TrimSpace(nativeBalance.GetAmount()) == "" || strings.TrimSpace(nativeBalance.GetCurrency()) == "" {
return nil, merrors.InvalidArgument("native balance is unavailable")
}
return nativeBalance, nil
}
func defaultGasTopUp(estimatedFee *moneyv1.Money, currentBalance *moneyv1.Money) (*moneyv1.Money, error) {
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
return nil, merrors.InvalidArgument("estimated fee is required")
}
if currentBalance == nil || strings.TrimSpace(currentBalance.GetAmount()) == "" || strings.TrimSpace(currentBalance.GetCurrency()) == "" {
return nil, merrors.InvalidArgument("native balance is required")
}
if !strings.EqualFold(estimatedFee.GetCurrency(), currentBalance.GetCurrency()) {
return nil, merrors.InvalidArgument("native balance currency mismatch")
}
estimated, err := decimal.NewFromString(strings.TrimSpace(estimatedFee.GetAmount()))
if err != nil {
return nil, err
}
current, err := decimal.NewFromString(strings.TrimSpace(currentBalance.GetAmount()))
if err != nil {
return nil, err
}
required := estimated.Sub(current)
if !required.IsPositive() {
return nil, nil
}
return &moneyv1.Money{
Currency: strings.ToUpper(strings.TrimSpace(estimatedFee.GetCurrency())),
Amount: required.String(),
}, nil
}
func logDecision(logger mlogger.Logger, walletRef string, estimatedFee *moneyv1.Money, nativeBalance *moneyv1.Money, topUp *moneyv1.Money, capHit bool, decision *tron.GasTopUpDecision, walletModel *model.ManagedWallet) {
if logger == nil {
return
}
fields := []zap.Field{
zap.String("wallet_ref", walletRef),
zap.String("estimated_total_fee", amountString(estimatedFee)),
zap.String("current_native_balance", amountString(nativeBalance)),
zap.String("topup_amount", amountString(topUp)),
zap.Bool("cap_hit", capHit),
}
if walletModel != nil {
fields = append(fields, zap.String("network", strings.TrimSpace(walletModel.Network)))
}
if decision != nil {
fields = append(fields,
zap.String("estimated_total_fee_trx", decision.EstimatedFeeTRX.String()),
zap.String("current_native_balance_trx", decision.CurrentBalanceTRX.String()),
zap.String("required_trx", decision.RequiredTRX.String()),
zap.String("buffered_required_trx", decision.BufferedRequiredTRX.String()),
zap.String("min_balance_topup_trx", decision.MinBalanceTopUpTRX.String()),
zap.String("raw_topup_trx", decision.RawTopUpTRX.String()),
zap.String("rounded_topup_trx", decision.RoundedTopUpTRX.String()),
zap.String("topup_trx", decision.TopUpTRX.String()),
zap.String("operation_type", decision.OperationType),
)
}
logger.Info("Gas top-up decision", fields...)
}
func amountString(m *moneyv1.Money) string {
if m == nil {
return ""
}
amount := strings.TrimSpace(m.GetAmount())
currency := strings.TrimSpace(m.GetCurrency())
if amount == "" && currency == "" {
return ""
}
if currency == "" {
return amount
}
if amount == "" {
return currency
}
return amount + " " + currency
}

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/tron/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/tron/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/tron/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/tron/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,156 @@
package transfer
import (
"context"
"errors"
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/tron/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.Network(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()
effectiveTokenSymbol := sourceWallet.TokenSymbol
effectiveContractAddress := sourceWallet.ContractAddress
nativeCurrency := shared.NativeCurrency(networkCfg)
if nativeCurrency != "" && strings.EqualFold(nativeCurrency, amountCurrency) {
effectiveTokenSymbol = nativeCurrency
effectiveContractAddress = ""
}
transfer := &model.Transfer{
IdempotencyKey: idempotencyKey,
TransferRef: shared.GenerateTransferRef(),
OrganizationRef: organizationRef,
SourceWalletRef: sourceWalletRef,
Destination: destination,
Network: sourceWallet.Network,
TokenSymbol: effectiveTokenSymbol,
ContractAddress: effectiveContractAddress,
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,150 @@
package wallet
import (
"context"
"errors"
"strings"
"time"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/tron/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mservice"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.uber.org/zap"
"google.golang.org/protobuf/types/known/timestamppb"
)
const fallbackBalanceCacheTTL = 2 * time.Minute
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)
}
tokenBalance, nativeBalance, chainErr := OnChainWalletBalances(ctx, c.deps, wallet)
if chainErr != nil {
c.deps.Logger.Warn("On-chain balance fetch failed, attempting cached 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("Cached balance not found", zap.String("wallet_ref", walletRef))
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, chainErr)
}
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
}
if c.isCachedBalanceStale(stored) {
c.deps.Logger.Info("Cached balance is stale",
zap.String("wallet_ref", walletRef),
zap.Time("calculated_at", stored.CalculatedAt),
zap.Duration("ttl", c.cacheTTL()),
)
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, chainErr)
}
return gsresponse.Success(&chainv1.GetWalletBalanceResponse{Balance: toProtoWalletBalance(stored)})
}
calculatedAt := c.now()
c.persistCachedBalance(ctx, walletRef, tokenBalance, nativeBalance, calculatedAt)
return gsresponse.Success(&chainv1.GetWalletBalanceResponse{
Balance: onChainBalanceToProto(tokenBalance, nativeBalance, calculatedAt),
})
}
func onChainBalanceToProto(balance *moneyv1.Money, native *moneyv1.Money, calculatedAt time.Time) *chainv1.WalletBalance {
if balance == nil && native == nil {
return nil
}
currency := ""
if balance != nil {
currency = balance.Currency
}
zero := zeroMoney(currency)
return &chainv1.WalletBalance{
Available: balance,
NativeAvailable: native,
PendingInbound: zero,
PendingOutbound: zero,
CalculatedAt: timestamppb.New(calculatedAt.UTC()),
}
}
func (c *getWalletBalanceCommand) persistCachedBalance(ctx context.Context, walletRef string, available *moneyv1.Money, nativeAvailable *moneyv1.Money, calculatedAt time.Time) {
if available == nil && nativeAvailable == nil {
return
}
record := &model.WalletBalance{
WalletRef: walletRef,
Available: shared.CloneMoney(available),
NativeAvailable: shared.CloneMoney(nativeAvailable),
CalculatedAt: calculatedAt,
}
currency := ""
if available != nil {
currency = available.Currency
}
record.PendingInbound = zeroMoney(currency)
record.PendingOutbound = zeroMoney(currency)
if err := c.deps.Storage.Wallets().SaveBalance(ctx, record); err != nil {
c.deps.Logger.Warn("Failed to cache wallet balance", zap.String("wallet_ref", walletRef), zap.Error(err))
}
}
func (c *getWalletBalanceCommand) isCachedBalanceStale(balance *model.WalletBalance) bool {
if balance == nil || balance.CalculatedAt.IsZero() {
return true
}
return c.now().After(balance.CalculatedAt.Add(c.cacheTTL()))
}
func (c *getWalletBalanceCommand) cacheTTL() time.Duration {
if c.deps.BalanceCacheTTL > 0 {
return c.deps.BalanceCacheTTL
}
// Fallback to sane default if not configured.
return fallbackBalanceCacheTTL
}
func (c *getWalletBalanceCommand) now() time.Time {
if c.deps.Clock != nil {
return c.deps.Clock.Now().UTC()
}
return time.Now().UTC()
}
func zeroMoney(currency string) *moneyv1.Money {
if strings.TrimSpace(currency) == "" {
return nil
}
return &moneyv1.Money{Currency: currency, Amount: "0"}
}

View File

@@ -0,0 +1,164 @@
package wallet
import (
"context"
"errors"
"strings"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/tron/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
pkgmodel "github.com/tech/sendico/pkg/model"
"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())
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.Network(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"))
}
if c.deps.Drivers == nil {
c.deps.Logger.Warn("Chain drivers missing", zap.String("chain", chainKey))
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("chain drivers not configured"))
}
chainDriver, err := c.deps.Drivers.Driver(chainKey)
if err != nil {
c.deps.Logger.Warn("Unsupported chain driver", zap.String("chain", chainKey), zap.Error(err))
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 == "" {
if !strings.EqualFold(tokenSymbol, networkCfg.NativeToken) {
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"))
}
depositAddress, err := chainDriver.FormatAddress(keyInfo.Address)
if err != nil {
c.deps.Logger.Warn("Invalid derived deposit address", zap.String("wallet_ref", walletRef), zap.Error(err))
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
}
metadata := shared.CloneMetadata(req.GetMetadata())
desc := req.GetDescribable()
name := strings.TrimSpace(desc.GetName())
if name == "" {
name = strings.TrimSpace(metadata["name"])
}
var description *string
if desc != nil && desc.Description != nil {
if trimmed := strings.TrimSpace(desc.GetDescription()); trimmed != "" {
description = &trimmed
}
}
if description == nil {
if trimmed := strings.TrimSpace(metadata["description"]); trimmed != "" {
description = &trimmed
}
}
if name == "" {
name = walletRef
}
wallet := &model.ManagedWallet{
Describable: pkgmodel.Describable{
Name: name,
Description: description,
},
IdempotencyKey: idempotencyKey,
WalletRef: walletRef,
OrganizationRef: organizationRef,
OwnerRef: ownerRef,
Network: chainKey,
TokenSymbol: tokenSymbol,
ContractAddress: contractAddress,
DepositAddress: depositAddress,
KeyReference: keyInfo.KeyID,
Status: model.ManagedWalletStatusActive,
Metadata: metadata,
}
if description != nil {
wallet.Describable.Description = description
}
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,35 @@
package wallet
import (
"context"
"time"
"github.com/tech/sendico/gateway/tron/internal/keymanager"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/drivers"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/rpcclient"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/tronclient"
"github.com/tech/sendico/gateway/tron/storage"
clockpkg "github.com/tech/sendico/pkg/clock"
"github.com/tech/sendico/pkg/mlogger"
)
type Deps struct {
Logger mlogger.Logger
Drivers *drivers.Registry
Networks *rpcclient.Registry
TronClients *tronclient.Registry // Native TRON gRPC clients
KeyManager keymanager.Manager
Storage storage.Repository
Clock clockpkg.Clock
BalanceCacheTTL time.Duration
RPCTimeout time.Duration
EnsureRepository func(context.Context) error
}
func (d Deps) WithLogger(name string) Deps {
if d.Logger == nil {
panic("wallet deps: logger is required")
}
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,62 @@
package wallet
import (
"context"
"strings"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/tron/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())
if req.GetOwnerRefFilter() != nil {
ownerRef := strings.TrimSpace(req.GetOwnerRefFilter().GetValue())
filter.OwnerRefFilter = &ownerRef
}
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 i := range result.Items {
protoWallets = append(protoWallets, toProtoManagedWallet(&result.Items[i]))
}
resp := &chainv1.ListManagedWalletsResponse{
Wallets: protoWallets,
Page: &paginationv1.CursorPageResponse{NextCursor: result.NextCursor},
}
return gsresponse.Success(resp)
}

View File

@@ -0,0 +1,63 @@
package wallet
import (
"context"
"fmt"
"strings"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/driver"
"github.com/tech/sendico/gateway/tron/storage/model"
"github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
"go.uber.org/zap"
)
func OnChainWalletBalances(ctx context.Context, deps Deps, wallet *model.ManagedWallet) (*moneyv1.Money, *moneyv1.Money, error) {
logger := deps.Logger
if wallet == nil {
return nil, nil, merrors.InvalidArgument("wallet is required")
}
if deps.Networks == nil {
return nil, nil, merrors.Internal("rpc clients not initialised")
}
if deps.Drivers == nil {
return nil, nil, merrors.Internal("chain drivers not configured")
}
networkKey := strings.ToLower(strings.TrimSpace(wallet.Network))
network, ok := deps.Networks.Network(networkKey)
if !ok {
logger.Warn("Requested network is not configured",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", networkKey),
)
return nil, nil, merrors.Internal(fmt.Sprintf("Requested network '%s' is not configured", networkKey))
}
chainDriver, err := deps.Drivers.Driver(networkKey)
if err != nil {
logger.Warn("Chain driver not configured",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", networkKey),
zap.Error(err),
)
return nil, nil, merrors.InvalidArgument("unsupported chain")
}
driverDeps := driver.Deps{
Logger: deps.Logger,
Registry: deps.Networks,
TronRegistry: deps.TronClients,
KeyManager: deps.KeyManager,
RPCTimeout: deps.RPCTimeout,
}
tokenBalance, err := chainDriver.Balance(ctx, driverDeps, network, wallet)
if err != nil {
return nil, nil, err
}
nativeBalance, err := chainDriver.NativeBalance(ctx, driverDeps, network, wallet)
if err != nil {
return nil, nil, err
}
return tokenBalance, nativeBalance, nil
}

View File

@@ -0,0 +1,66 @@
package wallet
import (
"strings"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/tron/storage/model"
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
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,
}
name := strings.TrimSpace(wallet.Name)
if name == "" {
name = strings.TrimSpace(wallet.Metadata["name"])
}
if name == "" {
name = wallet.WalletRef
}
description := ""
switch {
case wallet.Description != nil:
description = strings.TrimSpace(*wallet.Description)
default:
description = strings.TrimSpace(wallet.Metadata["description"])
}
desc := &describablev1.Describable{Name: name}
if description != "" {
desc.Description = &description
}
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()),
Describable: desc,
}
}
func toProtoWalletBalance(balance *model.WalletBalance) *chainv1.WalletBalance {
if balance == nil {
return nil
}
return &chainv1.WalletBalance{
Available: shared.CloneMoney(balance.Available),
NativeAvailable: shared.CloneMoney(balance.NativeAvailable),
PendingInbound: shared.CloneMoney(balance.PendingInbound),
PendingOutbound: shared.CloneMoney(balance.PendingOutbound),
CalculatedAt: timestamppb.New(balance.CalculatedAt.UTC()),
}
}

View File

@@ -0,0 +1,629 @@
package gateway
import (
"context"
"errors"
"fmt"
"strings"
"github.com/tech/sendico/gateway/tron/internal/appversion"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/shared"
chainasset "github.com/tech/sendico/pkg/chain"
"github.com/tech/sendico/pkg/connector/params"
"github.com/tech/sendico/pkg/merrors"
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.uber.org/zap"
"google.golang.org/protobuf/types/known/structpb"
)
const chainConnectorID = "chain"
func (s *Service) GetCapabilities(_ context.Context, _ *connectorv1.GetCapabilitiesRequest) (*connectorv1.GetCapabilitiesResponse, error) {
return &connectorv1.GetCapabilitiesResponse{
Capabilities: &connectorv1.ConnectorCapabilities{
ConnectorType: chainConnectorID,
Version: appversion.Create().Short(),
SupportedAccountKinds: []connectorv1.AccountKind{connectorv1.AccountKind_CHAIN_MANAGED_WALLET},
SupportedOperationTypes: []connectorv1.OperationType{
connectorv1.OperationType_TRANSFER,
connectorv1.OperationType_FEE_ESTIMATE,
connectorv1.OperationType_GAS_TOPUP,
},
OpenAccountParams: chainOpenAccountParams(),
OperationParams: chainOperationParams(),
},
}, nil
}
func (s *Service) OpenAccount(ctx context.Context, req *connectorv1.OpenAccountRequest) (*connectorv1.OpenAccountResponse, error) {
if req == nil {
return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "open_account: request is required", nil, "")}, nil
}
if req.GetKind() != connectorv1.AccountKind_CHAIN_MANAGED_WALLET {
return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_ACCOUNT_KIND, "open_account: unsupported account kind", nil, "")}, nil
}
reader := params.New(req.GetParams())
orgRef := strings.TrimSpace(reader.String("organization_ref"))
if orgRef == "" {
return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "open_account: organization_ref is required", nil, "")}, nil
}
asset, err := parseChainAsset(strings.TrimSpace(req.GetAsset()), reader)
if err != nil {
return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), nil, "")}, nil
}
resp, err := s.CreateManagedWallet(ctx, &chainv1.CreateManagedWalletRequest{
IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()),
OrganizationRef: orgRef,
OwnerRef: strings.TrimSpace(req.GetOwnerRef()),
Asset: asset,
Metadata: shared.CloneMetadata(reader.StringMap("metadata")),
Describable: describableFromLabel(req.GetLabel(), reader.String("description")),
})
if err != nil {
return &connectorv1.OpenAccountResponse{Error: connectorError(mapErrorCode(err), err.Error(), nil, "")}, nil
}
return &connectorv1.OpenAccountResponse{Account: chainWalletToAccount(resp.GetWallet())}, nil
}
func (s *Service) GetAccount(ctx context.Context, req *connectorv1.GetAccountRequest) (*connectorv1.GetAccountResponse, error) {
if req == nil || req.GetAccountRef() == nil || strings.TrimSpace(req.GetAccountRef().GetAccountId()) == "" {
return nil, merrors.InvalidArgument("get_account: account_ref.account_id is required")
}
resp, err := s.GetManagedWallet(ctx, &chainv1.GetManagedWalletRequest{WalletRef: strings.TrimSpace(req.GetAccountRef().GetAccountId())})
if err != nil {
return nil, err
}
return &connectorv1.GetAccountResponse{Account: chainWalletToAccount(resp.GetWallet())}, nil
}
func (s *Service) ListAccounts(ctx context.Context, req *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) {
if req == nil {
return nil, merrors.InvalidArgument("list_accounts: request is required")
}
asset := (*chainv1.Asset)(nil)
if assetString := strings.TrimSpace(req.GetAsset()); assetString != "" {
parsed, err := parseChainAsset(assetString, params.New(nil))
if err != nil {
s.logger.Warn("Error listing accounts", zap.Error(err))
return nil, err
}
asset = parsed
}
resp, err := s.ListManagedWallets(ctx, &chainv1.ListManagedWalletsRequest{
OrganizationRef: strings.TrimSpace(req.GetOrganizationRef()),
OwnerRefFilter: req.GetOwnerRefFilter(),
Asset: asset,
Page: req.GetPage(),
})
if err != nil {
return nil, err
}
accounts := make([]*connectorv1.Account, 0, len(resp.GetWallets()))
for _, wallet := range resp.GetWallets() {
accounts = append(accounts, chainWalletToAccount(wallet))
}
return &connectorv1.ListAccountsResponse{
Accounts: accounts,
Page: resp.GetPage(),
}, nil
}
func (s *Service) GetBalance(ctx context.Context, req *connectorv1.GetBalanceRequest) (*connectorv1.GetBalanceResponse, error) {
if req == nil || req.GetAccountRef() == nil || strings.TrimSpace(req.GetAccountRef().GetAccountId()) == "" {
return nil, merrors.InvalidArgument("get_balance: account_ref.account_id is required")
}
resp, err := s.GetWalletBalance(ctx, &chainv1.GetWalletBalanceRequest{WalletRef: strings.TrimSpace(req.GetAccountRef().GetAccountId())})
if err != nil {
return nil, err
}
bal := resp.GetBalance()
return &connectorv1.GetBalanceResponse{
Balance: &connectorv1.Balance{
AccountRef: req.GetAccountRef(),
Available: bal.GetAvailable(),
PendingInbound: bal.GetPendingInbound(),
PendingOutbound: bal.GetPendingOutbound(),
CalculatedAt: bal.GetCalculatedAt(),
},
}, nil
}
func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOperationRequest) (*connectorv1.SubmitOperationResponse, error) {
if req == nil || req.GetOperation() == nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: operation is required", nil, "")}}, nil
}
op := req.GetOperation()
if strings.TrimSpace(op.GetIdempotencyKey()) == "" {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: idempotency_key is required", op, "")}}, nil
}
reader := params.New(op.GetParams())
orgRef := strings.TrimSpace(reader.String("organization_ref"))
source := operationAccountID(op.GetFrom())
if source == "" {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "operation: from.account is required", op, "")}}, nil
}
switch op.GetType() {
case connectorv1.OperationType_TRANSFER:
dest, err := transferDestinationFromOperation(op)
if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil
}
amount := op.GetMoney()
if amount == nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "transfer: money is required", op, "")}}, nil
}
amount = normalizeMoneyForChain(amount)
if orgRef == "" {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "transfer: organization_ref is required", op, "")}}, nil
}
resp, err := s.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{
IdempotencyKey: strings.TrimSpace(op.GetIdempotencyKey()),
OrganizationRef: orgRef,
SourceWalletRef: source,
Destination: dest,
Amount: amount,
Fees: parseChainFees(reader),
Metadata: shared.CloneMetadata(reader.StringMap("metadata")),
ClientReference: strings.TrimSpace(reader.String("client_reference")),
})
if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
}
transfer := resp.GetTransfer()
return &connectorv1.SubmitOperationResponse{
Receipt: &connectorv1.OperationReceipt{
OperationId: strings.TrimSpace(transfer.GetTransferRef()),
Status: chainTransferStatusToOperation(transfer.GetStatus()),
ProviderRef: strings.TrimSpace(transfer.GetTransactionHash()),
},
}, nil
case connectorv1.OperationType_FEE_ESTIMATE:
dest, err := transferDestinationFromOperation(op)
if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil
}
amount := op.GetMoney()
if amount == nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "estimate: money is required", op, "")}}, nil
}
amount = normalizeMoneyForChain(amount)
opID := strings.TrimSpace(op.GetOperationId())
if opID == "" {
opID = strings.TrimSpace(op.GetIdempotencyKey())
}
resp, err := s.EstimateTransferFee(ctx, &chainv1.EstimateTransferFeeRequest{
SourceWalletRef: source,
Destination: dest,
Amount: amount,
})
if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
}
result := feeEstimateResult(resp)
return &connectorv1.SubmitOperationResponse{
Receipt: &connectorv1.OperationReceipt{
OperationId: opID,
Status: connectorv1.OperationStatus_CONFIRMED,
Result: result,
},
}, nil
case connectorv1.OperationType_GAS_TOPUP:
fee, err := parseMoneyFromMap(reader.Map("estimated_total_fee"))
if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil
}
fee = normalizeMoneyForChain(fee)
mode := strings.ToLower(strings.TrimSpace(reader.String("mode")))
if mode == "" {
mode = "compute"
}
switch mode {
case "compute":
opID := strings.TrimSpace(op.GetOperationId())
if opID == "" {
opID = strings.TrimSpace(op.GetIdempotencyKey())
}
resp, err := s.ComputeGasTopUp(ctx, &chainv1.ComputeGasTopUpRequest{
WalletRef: source,
EstimatedTotalFee: fee,
})
if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
}
return &connectorv1.SubmitOperationResponse{
Receipt: &connectorv1.OperationReceipt{
OperationId: opID,
Status: connectorv1.OperationStatus_CONFIRMED,
Result: gasTopUpResult(resp.GetTopupAmount(), resp.GetCapHit(), ""),
},
}, nil
case "ensure":
opID := strings.TrimSpace(op.GetOperationId())
if opID == "" {
opID = strings.TrimSpace(op.GetIdempotencyKey())
}
if orgRef == "" {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "gas_topup: organization_ref is required", op, "")}}, nil
}
target := strings.TrimSpace(reader.String("target_wallet_ref"))
if target == "" {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "gas_topup: target_wallet_ref is required", op, "")}}, nil
}
resp, err := s.EnsureGasTopUp(ctx, &chainv1.EnsureGasTopUpRequest{
IdempotencyKey: strings.TrimSpace(op.GetIdempotencyKey()),
OrganizationRef: orgRef,
SourceWalletRef: source,
TargetWalletRef: target,
EstimatedTotalFee: fee,
Metadata: shared.CloneMetadata(reader.StringMap("metadata")),
ClientReference: strings.TrimSpace(reader.String("client_reference")),
})
if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
}
transferRef := ""
if transfer := resp.GetTransfer(); transfer != nil {
transferRef = strings.TrimSpace(transfer.GetTransferRef())
}
return &connectorv1.SubmitOperationResponse{
Receipt: &connectorv1.OperationReceipt{
OperationId: opID,
Status: connectorv1.OperationStatus_CONFIRMED,
Result: gasTopUpResult(resp.GetTopupAmount(), resp.GetCapHit(), transferRef),
},
}, nil
default:
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "gas_topup: invalid mode", op, "")}}, nil
}
default:
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_OPERATION, "submit_operation: unsupported operation type", op, "")}}, nil
}
}
func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperationRequest) (*connectorv1.GetOperationResponse, error) {
if req == nil || strings.TrimSpace(req.GetOperationId()) == "" {
return nil, merrors.InvalidArgument("get_operation: operation_id is required")
}
resp, err := s.GetTransfer(ctx, &chainv1.GetTransferRequest{TransferRef: strings.TrimSpace(req.GetOperationId())})
if err != nil {
return nil, err
}
return &connectorv1.GetOperationResponse{Operation: chainTransferToOperation(resp.GetTransfer())}, nil
}
func (s *Service) ListOperations(ctx context.Context, req *connectorv1.ListOperationsRequest) (*connectorv1.ListOperationsResponse, error) {
if req == nil {
return nil, merrors.InvalidArgument("list_operations: request is required")
}
source := ""
if req.GetAccountRef() != nil {
source = strings.TrimSpace(req.GetAccountRef().GetAccountId())
}
resp, err := s.ListTransfers(ctx, &chainv1.ListTransfersRequest{
SourceWalletRef: source,
Status: chainStatusFromOperation(req.GetStatus()),
Page: req.GetPage(),
})
if err != nil {
return nil, err
}
ops := make([]*connectorv1.Operation, 0, len(resp.GetTransfers()))
for _, transfer := range resp.GetTransfers() {
ops = append(ops, chainTransferToOperation(transfer))
}
return &connectorv1.ListOperationsResponse{Operations: ops, Page: resp.GetPage()}, nil
}
func chainOpenAccountParams() []*connectorv1.ParamSpec {
return []*connectorv1.ParamSpec{
{Key: "organization_ref", Type: connectorv1.ParamType_STRING, Required: true, Description: "Organization reference for the wallet."},
{Key: "network", Type: connectorv1.ParamType_STRING, Required: true, Description: "Blockchain network name."},
{Key: "token_symbol", Type: connectorv1.ParamType_STRING, Required: true, Description: "Token symbol (e.g., USDT)."},
{Key: "contract_address", Type: connectorv1.ParamType_STRING, Required: false, Description: "Token contract address override."},
{Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false, Description: "Additional metadata map."},
{Key: "description", Type: connectorv1.ParamType_STRING, Required: false, Description: "Wallet description."},
}
}
func chainOperationParams() []*connectorv1.OperationParamSpec {
return []*connectorv1.OperationParamSpec{
{OperationType: connectorv1.OperationType_TRANSFER, Params: []*connectorv1.ParamSpec{
{Key: "organization_ref", Type: connectorv1.ParamType_STRING, Required: true, Description: "Organization reference."},
{Key: "destination_memo", Type: connectorv1.ParamType_STRING, Required: false, Description: "Destination memo/tag."},
{Key: "client_reference", Type: connectorv1.ParamType_STRING, Required: false, Description: "Client reference id."},
{Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false, Description: "Transfer metadata."},
{Key: "fees", Type: connectorv1.ParamType_JSON, Required: false, Description: "Service fee breakdowns."},
}},
{OperationType: connectorv1.OperationType_FEE_ESTIMATE, Params: []*connectorv1.ParamSpec{
{Key: "organization_ref", Type: connectorv1.ParamType_STRING, Required: false, Description: "Organization reference."},
{Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false, Description: "Estimate metadata."},
}},
{OperationType: connectorv1.OperationType_GAS_TOPUP, Params: []*connectorv1.ParamSpec{
{Key: "mode", Type: connectorv1.ParamType_STRING, Required: false, Description: "compute | ensure."},
{Key: "organization_ref", Type: connectorv1.ParamType_STRING, Required: false, Description: "Organization reference (required for ensure)."},
{Key: "target_wallet_ref", Type: connectorv1.ParamType_STRING, Required: false, Description: "Target wallet ref (ensure)."},
{Key: "estimated_total_fee", Type: connectorv1.ParamType_JSON, Required: true, Description: "Estimated total fee {amount,currency}."},
{Key: "client_reference", Type: connectorv1.ParamType_STRING, Required: false, Description: "Client reference."},
{Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false, Description: "Top-up metadata."},
}},
}
}
func chainWalletToAccount(wallet *chainv1.ManagedWallet) *connectorv1.Account {
if wallet == nil {
return nil
}
details, _ := structpb.NewStruct(map[string]interface{}{
"deposit_address": wallet.GetDepositAddress(),
"organization_ref": wallet.GetOrganizationRef(),
"owner_ref": wallet.GetOwnerRef(),
"network": wallet.GetAsset().GetChain().String(),
"token_symbol": wallet.GetAsset().GetTokenSymbol(),
"contract_address": wallet.GetAsset().GetContractAddress(),
"wallet_ref": wallet.GetWalletRef(),
})
return &connectorv1.Account{
Ref: &connectorv1.AccountRef{
ConnectorId: chainConnectorID,
AccountId: strings.TrimSpace(wallet.GetWalletRef()),
},
Kind: connectorv1.AccountKind_CHAIN_MANAGED_WALLET,
Asset: chainasset.AssetString(wallet.GetAsset()),
State: chainWalletState(wallet.GetStatus()),
Label: strings.TrimSpace(wallet.GetDescribable().GetName()),
OwnerRef: strings.TrimSpace(wallet.GetOwnerRef()),
ProviderDetails: details,
CreatedAt: wallet.GetCreatedAt(),
UpdatedAt: wallet.GetUpdatedAt(),
Describable: wallet.GetDescribable(),
}
}
func chainWalletState(status chainv1.ManagedWalletStatus) connectorv1.AccountState {
switch status {
case chainv1.ManagedWalletStatus_MANAGED_WALLET_ACTIVE:
return connectorv1.AccountState_ACCOUNT_ACTIVE
case chainv1.ManagedWalletStatus_MANAGED_WALLET_SUSPENDED:
return connectorv1.AccountState_ACCOUNT_SUSPENDED
case chainv1.ManagedWalletStatus_MANAGED_WALLET_CLOSED:
return connectorv1.AccountState_ACCOUNT_CLOSED
default:
return connectorv1.AccountState_ACCOUNT_STATE_UNSPECIFIED
}
}
func transferDestinationFromOperation(op *connectorv1.Operation) (*chainv1.TransferDestination, error) {
if op == nil {
return nil, merrors.InvalidArgument("transfer: operation is required")
}
if to := op.GetTo(); to != nil {
if account := to.GetAccount(); account != nil {
return &chainv1.TransferDestination{Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: strings.TrimSpace(account.GetAccountId())}}, nil
}
if ext := to.GetExternal(); ext != nil {
return &chainv1.TransferDestination{Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(ext.GetExternalRef())}}, nil
}
}
return nil, merrors.InvalidArgument("transfer: to.account or to.external is required")
}
func normalizeMoneyForChain(m *moneyv1.Money) *moneyv1.Money {
if m == nil {
return nil
}
currency := strings.TrimSpace(m.GetCurrency())
if idx := strings.Index(currency, "-"); idx > 0 {
currency = currency[:idx]
}
return &moneyv1.Money{
Amount: strings.TrimSpace(m.GetAmount()),
Currency: currency,
}
}
func parseChainFees(reader params.Reader) []*chainv1.ServiceFeeBreakdown {
rawFees := reader.List("fees")
if len(rawFees) == 0 {
return nil
}
result := make([]*chainv1.ServiceFeeBreakdown, 0, len(rawFees))
for _, item := range rawFees {
raw, ok := item.(map[string]interface{})
if !ok {
continue
}
amount := strings.TrimSpace(fmt.Sprint(raw["amount"]))
currency := strings.TrimSpace(fmt.Sprint(raw["currency"]))
if amount == "" || currency == "" {
continue
}
result = append(result, &chainv1.ServiceFeeBreakdown{
FeeCode: strings.TrimSpace(fmt.Sprint(raw["fee_code"])),
Description: strings.TrimSpace(fmt.Sprint(raw["description"])),
Amount: &moneyv1.Money{Amount: amount, Currency: currency},
})
}
if len(result) == 0 {
return nil
}
return result
}
func parseMoneyFromMap(raw map[string]interface{}) (*moneyv1.Money, error) {
if raw == nil {
return nil, merrors.InvalidArgument("money is required")
}
amount := strings.TrimSpace(fmt.Sprint(raw["amount"]))
currency := strings.TrimSpace(fmt.Sprint(raw["currency"]))
if amount == "" || currency == "" {
return nil, merrors.InvalidArgument("money is required")
}
return &moneyv1.Money{
Amount: amount,
Currency: currency,
}, nil
}
func feeEstimateResult(resp *chainv1.EstimateTransferFeeResponse) *structpb.Struct {
if resp == nil {
return nil
}
payload := map[string]interface{}{
"estimation_context": strings.TrimSpace(resp.GetEstimationContext()),
}
if fee := resp.GetNetworkFee(); fee != nil {
payload["network_fee"] = map[string]interface{}{
"amount": strings.TrimSpace(fee.GetAmount()),
"currency": strings.TrimSpace(fee.GetCurrency()),
}
}
result, err := structpb.NewStruct(payload)
if err != nil {
return nil
}
return result
}
func gasTopUpResult(amount *moneyv1.Money, capHit bool, transferRef string) *structpb.Struct {
payload := map[string]interface{}{
"cap_hit": capHit,
}
if amount != nil {
payload["topup_amount"] = map[string]interface{}{
"amount": strings.TrimSpace(amount.GetAmount()),
"currency": strings.TrimSpace(amount.GetCurrency()),
}
}
if strings.TrimSpace(transferRef) != "" {
payload["transfer_ref"] = strings.TrimSpace(transferRef)
}
result, err := structpb.NewStruct(payload)
if err != nil {
return nil
}
return result
}
func chainTransferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation {
if transfer == nil {
return nil
}
op := &connectorv1.Operation{
OperationId: strings.TrimSpace(transfer.GetTransferRef()),
Type: connectorv1.OperationType_TRANSFER,
Status: chainTransferStatusToOperation(transfer.GetStatus()),
Money: transfer.GetRequestedAmount(),
ProviderRef: strings.TrimSpace(transfer.GetTransactionHash()),
CreatedAt: transfer.GetCreatedAt(),
UpdatedAt: transfer.GetUpdatedAt(),
From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
ConnectorId: chainConnectorID,
AccountId: strings.TrimSpace(transfer.GetSourceWalletRef()),
}}},
}
if dest := transfer.GetDestination(); dest != nil {
switch d := dest.GetDestination().(type) {
case *chainv1.TransferDestination_ManagedWalletRef:
op.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
ConnectorId: chainConnectorID,
AccountId: strings.TrimSpace(d.ManagedWalletRef),
}}}
case *chainv1.TransferDestination_ExternalAddress:
op.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_External{External: &connectorv1.ExternalRef{
ExternalRef: strings.TrimSpace(d.ExternalAddress),
}}}
}
}
return op
}
func chainTransferStatusToOperation(status chainv1.TransferStatus) connectorv1.OperationStatus {
switch status {
case chainv1.TransferStatus_TRANSFER_CONFIRMED:
return connectorv1.OperationStatus_CONFIRMED
case chainv1.TransferStatus_TRANSFER_FAILED:
return connectorv1.OperationStatus_FAILED
case chainv1.TransferStatus_TRANSFER_CANCELLED:
return connectorv1.OperationStatus_CANCELED
default:
return connectorv1.OperationStatus_PENDING
}
}
func chainStatusFromOperation(status connectorv1.OperationStatus) chainv1.TransferStatus {
switch status {
case connectorv1.OperationStatus_CONFIRMED:
return chainv1.TransferStatus_TRANSFER_CONFIRMED
case connectorv1.OperationStatus_FAILED:
return chainv1.TransferStatus_TRANSFER_FAILED
case connectorv1.OperationStatus_CANCELED:
return chainv1.TransferStatus_TRANSFER_CANCELLED
default:
return chainv1.TransferStatus_TRANSFER_STATUS_UNSPECIFIED
}
}
func parseChainAsset(assetString string, reader params.Reader) (*chainv1.Asset, error) {
return chainasset.ParseAsset(
assetString,
reader.String("network"),
reader.String("token_symbol"),
reader.String("contract_address"),
)
}
func describableFromLabel(label, desc string) *describablev1.Describable {
label = strings.TrimSpace(label)
desc = strings.TrimSpace(desc)
if label == "" && desc == "" {
return nil
}
return &describablev1.Describable{
Name: label,
Description: &desc,
}
}
func operationAccountID(party *connectorv1.OperationParty) string {
if party == nil {
return ""
}
if account := party.GetAccount(); account != nil {
return strings.TrimSpace(account.GetAccountId())
}
return ""
}
func connectorError(code connectorv1.ErrorCode, message string, op *connectorv1.Operation, accountID string) *connectorv1.ConnectorError {
err := &connectorv1.ConnectorError{
Code: code,
Message: strings.TrimSpace(message),
AccountId: strings.TrimSpace(accountID),
}
if op != nil {
err.CorrelationId = strings.TrimSpace(op.GetCorrelationId())
err.ParentIntentId = strings.TrimSpace(op.GetParentIntentId())
err.OperationId = strings.TrimSpace(op.GetOperationId())
}
return err
}
func mapErrorCode(err error) connectorv1.ErrorCode {
switch {
case errors.Is(err, merrors.ErrInvalidArg):
return connectorv1.ErrorCode_INVALID_PARAMS
case errors.Is(err, merrors.ErrNoData):
return connectorv1.ErrorCode_NOT_FOUND
case errors.Is(err, merrors.ErrNotImplemented):
return connectorv1.ErrorCode_UNSUPPORTED_OPERATION
case errors.Is(err, merrors.ErrInternal):
return connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE
default:
return connectorv1.ErrorCode_PROVIDER_ERROR
}
}

View File

@@ -0,0 +1,36 @@
package driver
import (
"context"
"time"
"github.com/ethereum/go-ethereum/core/types"
"github.com/tech/sendico/gateway/tron/internal/keymanager"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/rpcclient"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/tronclient"
"github.com/tech/sendico/gateway/tron/storage/model"
"github.com/tech/sendico/pkg/mlogger"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
// Deps bundles dependencies shared across chain drivers.
type Deps struct {
Logger mlogger.Logger
Registry *rpcclient.Registry
TronRegistry *tronclient.Registry // Native TRON gRPC clients
KeyManager keymanager.Manager
RPCTimeout time.Duration
}
// Driver defines chain-specific behavior for wallet and transfer operations.
type Driver interface {
Name() string
FormatAddress(address string) (string, error)
NormalizeAddress(address string) (string, error)
Balance(ctx context.Context, deps Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error)
NativeBalance(ctx context.Context, deps Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error)
EstimateFee(ctx context.Context, deps Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error)
SubmitTransfer(ctx context.Context, deps Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, destination string) (string, error)
AwaitConfirmation(ctx context.Context, deps Deps, network shared.Network, txHash string) (*types.Receipt, error)
}

View File

@@ -0,0 +1,31 @@
package evm
import (
"math/big"
"strings"
"testing"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common"
"github.com/stretchr/testify/require"
)
func TestTronEstimateCallUsesData(t *testing.T) {
from := common.HexToAddress("0xfa89b4d534bdeb2713d4ffd893e79d6535fb58f8")
to := common.HexToAddress("0xa614f803b6fd780986a42c78ec9c7f77e6ded13c")
callMsg := ethereum.CallMsg{
From: from,
To: &to,
GasPrice: big.NewInt(100),
Data: []byte{0xa9, 0x05, 0x9c, 0xbb},
}
call := tronEstimateCall(callMsg)
require.Equal(t, strings.ToLower(from.Hex()), call["from"])
require.Equal(t, strings.ToLower(to.Hex()), call["to"])
require.Equal(t, "0x64", call["gasPrice"])
require.Equal(t, "0xa9059cbb", call["data"])
_, hasInput := call["input"]
require.False(t, hasInput)
}

View File

@@ -0,0 +1,734 @@
package evm
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/common/hexutil"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/rpc"
"github.com/shopspring/decimal"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/driver"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/tron/storage/model"
"github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
"go.uber.org/zap"
)
var (
erc20ABI abi.ABI
)
func init() {
var err error
erc20ABI, err = abi.JSON(strings.NewReader(erc20ABIJSON))
if err != nil {
panic("evm driver: 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"
}
]`
// NormalizeAddress validates and normalizes EVM hex addresses.
func NormalizeAddress(address string) (string, error) {
trimmed := strings.TrimSpace(address)
if trimmed == "" {
return "", merrors.InvalidArgument("address is required")
}
if !common.IsHexAddress(trimmed) {
return "", merrors.InvalidArgument("invalid hex address")
}
return strings.ToLower(common.HexToAddress(trimmed).Hex()), nil
}
func nativeCurrency(network shared.Network) string {
currency := strings.ToUpper(strings.TrimSpace(network.NativeToken))
if currency == "" {
currency = strings.ToUpper(network.Name.String())
}
return currency
}
func parseBaseUnitAmount(amount string) (*big.Int, error) {
trimmed := strings.TrimSpace(amount)
if trimmed == "" {
return nil, merrors.InvalidArgument("amount is required")
}
value, ok := new(big.Int).SetString(trimmed, 10)
if !ok {
return nil, merrors.InvalidArgument("invalid amount")
}
if value.Sign() < 0 {
return nil, merrors.InvalidArgument("amount must be non-negative")
}
return value, nil
}
// Balance fetches ERC20 token balance for the provided address.
func Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, address string) (*moneyv1.Money, error) {
logger := deps.Logger.Named("evm")
registry := deps.Registry
if registry == nil {
return nil, merrors.Internal("rpc clients not initialised")
}
if wallet == nil {
return nil, merrors.InvalidArgument("wallet is required")
}
normalizedAddress, err := NormalizeAddress(address)
if err != nil {
return nil, err
}
rpcURL := strings.TrimSpace(network.RPCURL)
logFields := []zap.Field{
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name.String()),
zap.String("token_symbol", strings.ToUpper(strings.TrimSpace(wallet.TokenSymbol))),
zap.String("contract", strings.ToLower(strings.TrimSpace(wallet.ContractAddress))),
zap.String("wallet_address", normalizedAddress),
}
if rpcURL == "" {
logger.Warn("Network rpc url is not configured", logFields...)
return nil, merrors.Internal("network rpc url is not configured")
}
contract := strings.TrimSpace(wallet.ContractAddress)
if contract == "" {
logger.Debug("Native balance requested", logFields...)
return NativeBalance(ctx, deps, network, wallet, normalizedAddress)
}
if !common.IsHexAddress(contract) {
logger.Warn("Invalid contract address for balance fetch", logFields...)
return nil, merrors.InvalidArgument("invalid contract address")
}
logger.Info("Fetching on-chain wallet balance", logFields...)
rpcClient, err := registry.RPCClient(network.Name.String())
if err != nil {
logger.Warn("Failed to fetch rpc client", append(logFields, zap.Error(err))...)
return nil, err
}
timeout := deps.RPCTimeout
if timeout <= 0 {
timeout = 10 * time.Second
}
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
logger.Debug("Calling token decimals", logFields...)
decimals, err := readDecimals(timeoutCtx, rpcClient, contract)
if err != nil {
logger.Warn("Token decimals call failed", append(logFields, zap.Error(err))...)
return nil, err
}
logger.Debug("Calling token balanceOf", append(logFields, zap.Uint8("decimals", decimals))...)
bal, err := readBalanceOf(timeoutCtx, rpcClient, contract, normalizedAddress)
if err != nil {
logger.Warn("Token balanceOf call failed", append(logFields, zap.Uint8("decimals", decimals), zap.Error(err))...)
return nil, err
}
dec := decimal.NewFromBigInt(bal, 0).Shift(-int32(decimals))
logger.Info("On-chain wallet balance fetched",
append(logFields,
zap.Uint8("decimals", decimals),
zap.String("balance_raw", bal.String()),
zap.String("balance", dec.String()),
)...,
)
return &moneyv1.Money{Currency: wallet.TokenSymbol, Amount: dec.String()}, nil
}
// NativeBalance fetches native token balance for the provided address.
func NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, address string) (*moneyv1.Money, error) {
logger := deps.Logger.Named("evm")
registry := deps.Registry
if registry == nil {
return nil, merrors.Internal("rpc clients not initialised")
}
if wallet == nil {
return nil, merrors.InvalidArgument("wallet is required")
}
normalizedAddress, err := NormalizeAddress(address)
if err != nil {
return nil, err
}
rpcURL := strings.TrimSpace(network.RPCURL)
logFields := []zap.Field{
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name.String()),
zap.String("wallet_address", normalizedAddress),
}
if rpcURL == "" {
logger.Warn("Network rpc url is not configured", logFields...)
return nil, merrors.Internal("network rpc url is not configured")
}
client, err := registry.Client(network.Name.String())
if err != nil {
logger.Warn("Failed to fetch rpc client", append(logFields, zap.Error(err))...)
return nil, err
}
timeout := deps.RPCTimeout
if timeout <= 0 {
timeout = 10 * time.Second
}
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
bal, err := client.BalanceAt(timeoutCtx, common.HexToAddress(normalizedAddress), nil)
if err != nil {
logger.Warn("Native balance call failed", append(logFields, zap.Error(err))...)
return nil, err
}
logger.Debug("On-chain native balance fetched",
append(logFields,
zap.String("balance_raw", bal.String()),
)...,
)
return &moneyv1.Money{
Currency: nativeCurrency(network),
Amount: bal.String(),
}, nil
}
// EstimateFee estimates ERC20 transfer fees for the given parameters.
func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, fromAddress, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
logger := deps.Logger.Named("evm")
registry := deps.Registry
if registry == nil {
return nil, merrors.Internal("rpc clients not initialised")
}
if wallet == nil {
return nil, merrors.InvalidArgument("wallet is required")
}
if amount == nil {
return nil, merrors.InvalidArgument("amount is required")
}
rpcURL := strings.TrimSpace(network.RPCURL)
if rpcURL == "" {
return nil, merrors.InvalidArgument("network rpc url not configured")
}
if _, err := NormalizeAddress(fromAddress); err != nil {
return nil, merrors.InvalidArgument("invalid source wallet address")
}
if _, err := NormalizeAddress(destination); err != nil {
return nil, merrors.InvalidArgument("invalid destination address")
}
client, err := registry.Client(network.Name.String())
if err != nil {
logger.Warn("Failed to resolve client", zap.Error(err), zap.String("network_name", network.Name.String()))
return nil, err
}
rpcClient, err := registry.RPCClient(network.Name.String())
if err != nil {
logger.Warn("Failed to resolve RPC client", zap.Error(err), zap.String("network_name", network.Name.String()))
return nil, err
}
timeout := deps.RPCTimeout
if timeout <= 0 {
timeout = 15 * time.Second
}
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
contract := strings.TrimSpace(wallet.ContractAddress)
toAddr := common.HexToAddress(destination)
fromAddr := common.HexToAddress(fromAddress)
if contract == "" {
amountBase, err := parseBaseUnitAmount(amount.GetAmount())
if err != nil {
logger.Warn("Failed to parse base unit amount", zap.Error(err), zap.String("amount", amount.GetAmount()))
return nil, err
}
gasPrice, err := client.SuggestGasPrice(timeoutCtx)
if err != nil {
logger.Warn("Failed to suggest gas price", zap.Error(err))
return nil, merrors.Internal("failed to suggest gas price: " + err.Error())
}
callMsg := ethereum.CallMsg{
From: fromAddr,
To: &toAddr,
GasPrice: gasPrice,
Value: amountBase,
}
gasLimit, err := estimateGas(timeoutCtx, network, client, rpcClient, callMsg)
if err != nil {
logger.Warn("Failed to estimate gas", zap.Error(err), zap.Any("call_mesasge", callMsg))
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)
return &moneyv1.Money{
Currency: nativeCurrency(network),
Amount: feeDec.String(),
}, nil
}
if !common.IsHexAddress(contract) {
logger.Warn("Failed to validate contract", zap.String("contract", contract))
return nil, merrors.InvalidArgument("invalid token contract address")
}
tokenAddr := common.HexToAddress(contract)
decimals, err := erc20Decimals(timeoutCtx, rpcClient, 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 := erc20ABI.Pack("transfer", toAddr, amountBase)
if err != nil {
logger.Warn("Failed to encode transfer call", zap.Error(err))
return nil, merrors.Internal("failed to encode transfer call: " + err.Error())
}
gasPrice, err := client.SuggestGasPrice(timeoutCtx)
if err != nil {
logger.Warn("Failed to suggest gas price", zap.Error(err))
return nil, merrors.Internal("failed to suggest gas price: " + err.Error())
}
callMsg := ethereum.CallMsg{
From: fromAddr,
To: &tokenAddr,
GasPrice: gasPrice,
Data: input,
}
gasLimit, err := estimateGas(timeoutCtx, network, client, rpcClient, callMsg)
if err != nil {
logger.Warn("Failed to estimate gas", zap.Error(err), zap.Any("call_message", callMsg))
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)
return &moneyv1.Money{
Currency: nativeCurrency(network),
Amount: feeDec.String(),
}, nil
}
// SubmitTransfer submits an ERC20 transfer on an EVM-compatible chain.
func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, fromAddress, destination string) (string, error) {
logger := deps.Logger.Named("evm")
registry := deps.Registry
if deps.KeyManager == nil {
logger.Warn("Key manager not configured")
return "", executorInternal("key manager is not configured", nil)
}
if registry == nil {
return "", executorInternal("rpc clients not initialised", nil)
}
rpcURL := strings.TrimSpace(network.RPCURL)
if rpcURL == "" {
logger.Warn("Network rpc url missing", zap.String("network", network.Name.String()))
return "", executorInvalid("network rpc url is not configured")
}
if source == nil || transfer == nil {
logger.Warn("Transfer context missing")
return "", executorInvalid("transfer context missing")
}
if strings.TrimSpace(source.KeyReference) == "" {
logger.Warn("Source wallet missing key reference", zap.String("wallet_ref", source.WalletRef))
return "", executorInvalid("source wallet missing key reference")
}
if _, err := NormalizeAddress(fromAddress); err != nil {
logger.Warn("Invalid source wallet address", zap.String("wallet_ref", source.WalletRef))
return "", executorInvalid("invalid source wallet address")
}
if _, err := NormalizeAddress(destination); err != nil {
logger.Warn("Invalid destination address", zap.String("transfer_ref", transfer.TransferRef), zap.String("address", destination))
return "", executorInvalid("invalid destination address " + destination)
}
logger.Info("Submitting transfer",
zap.String("transfer_ref", transfer.TransferRef),
zap.String("source_wallet_ref", source.WalletRef),
zap.String("network", network.Name.String()),
zap.String("destination", strings.ToLower(destination)),
)
client, err := registry.Client(network.Name.String())
if err != nil {
logger.Warn("Failed to initialise RPC client", zap.Error(err), zap.String("network", network.Name.String()))
return "", err
}
rpcClient, err := registry.RPCClient(network.Name.String())
if err != nil {
logger.Warn("Failed to initialise RPC client", zap.String("network", network.Name.String()))
return "", err
}
sourceAddress := common.HexToAddress(fromAddress)
destinationAddr := common.HexToAddress(destination)
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
nonce, err := client.PendingNonceAt(ctx, sourceAddress)
if err != nil {
logger.Warn("Failed to fetch nonce", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef), zap.String("wallet_ref", source.WalletRef),
)
return "", executorInternal("failed to fetch nonce", err)
}
gasPrice, err := client.SuggestGasPrice(ctx)
if err != nil {
logger.Warn("Failed to suggest gas price", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef), zap.String("network", network.Name.String()),
)
return "", executorInternal("failed to suggest gas price", err)
}
chainID := new(big.Int).SetUint64(network.ChainID)
contract := strings.TrimSpace(transfer.ContractAddress)
amount := transfer.NetAmount
if amount == nil || strings.TrimSpace(amount.Amount) == "" {
logger.Warn("Transfer missing net amount", zap.String("transfer_ref", transfer.TransferRef))
return "", executorInvalid("transfer missing net amount")
}
var tx *types.Transaction
if contract == "" {
amountInt, err := parseBaseUnitAmount(amount.Amount)
if err != nil {
logger.Warn("Invalid native amount", zap.Error(err), zap.String("transfer_ref", transfer.TransferRef))
return "", err
}
callMsg := ethereum.CallMsg{
From: sourceAddress,
To: &destinationAddr,
GasPrice: gasPrice,
Value: amountInt,
}
gasLimit, err := estimateGas(ctx, network, client, rpcClient, callMsg)
if err != nil {
logger.Warn("Failed to estimate gas", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
)
return "", executorInternal("failed to estimate gas", err)
}
tx = types.NewTransaction(nonce, destinationAddr, amountInt, gasLimit, gasPrice, nil)
} else {
if !common.IsHexAddress(contract) {
logger.Warn("Invalid token contract address",
zap.String("transfer_ref", transfer.TransferRef),
zap.String("contract", contract),
)
return "", executorInvalid("invalid token contract address " + contract)
}
tokenAddress := common.HexToAddress(contract)
decimals, err := erc20Decimals(ctx, rpcClient, tokenAddress)
if err != nil {
logger.Warn("Failed to read token decimals", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef), zap.String("contract", contract),
)
return "", err
}
amountInt, err := toBaseUnits(amount.Amount, decimals)
if err != nil {
logger.Warn("Failed to convert amount to base units", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef), zap.String("amount", amount.Amount),
)
return "", err
}
input, err := erc20ABI.Pack("transfer", destinationAddr, amountInt)
if err != nil {
logger.Warn("Failed to encode transfer call", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
)
return "", executorInternal("failed to encode transfer call", err)
}
callMsg := ethereum.CallMsg{
From: sourceAddress,
To: &tokenAddress,
GasPrice: gasPrice,
Data: input,
}
gasLimit, err := estimateGas(ctx, network, client, rpcClient, callMsg)
if err != nil {
logger.Warn("Failed to estimate gas", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
)
return "", executorInternal("failed to estimate gas", err)
}
tx = types.NewTransaction(nonce, tokenAddress, big.NewInt(0), gasLimit, gasPrice, input)
}
signedTx, err := deps.KeyManager.SignTransaction(ctx, source.KeyReference, tx, chainID)
if err != nil {
logger.Warn("Failed to sign transaction", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef), zap.String("wallet_ref", source.WalletRef),
)
return "", err
}
if err := client.SendTransaction(ctx, signedTx); err != nil {
logger.Warn("Failed to send transaction", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
)
return "", executorInternal("failed to send transaction", err)
}
txHash := signedTx.Hash().Hex()
logger.Info("Transaction submitted", zap.String("transfer_ref", transfer.TransferRef),
zap.String("tx_hash", txHash), zap.String("network", network.Name.String()),
)
return txHash, nil
}
// AwaitConfirmation waits for the transaction receipt.
func AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Network, txHash string) (*types.Receipt, error) {
logger := deps.Logger.Named("evm")
registry := deps.Registry
if strings.TrimSpace(txHash) == "" {
logger.Warn("Missing transaction hash for confirmation", zap.String("network", network.Name.String()))
return nil, executorInvalid("tx hash is required")
}
rpcURL := strings.TrimSpace(network.RPCURL)
if rpcURL == "" {
logger.Warn("Network rpc url missing while awaiting confirmation", zap.String("tx_hash", txHash))
return nil, executorInvalid("network rpc url is not configured")
}
if registry == nil {
return nil, executorInternal("rpc clients not initialised", nil)
}
client, err := registry.Client(network.Name.String())
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:
logger.Debug("Transaction not yet mined", zap.String("tx_hash", txHash),
zap.String("network", network.Name.String()),
)
continue
case <-ctx.Done():
logger.Warn("Context cancelled while awaiting confirmation", zap.String("tx_hash", txHash),
zap.String("network", network.Name.String()),
)
return nil, ctx.Err()
}
}
logger.Warn("Failed to fetch transaction receipt", zap.Error(err),
zap.String("tx_hash", txHash), zap.String("network", network.Name.String()),
)
return nil, executorInternal("failed to fetch transaction receipt", err)
}
logger.Info("Transaction confirmed", zap.String("tx_hash", txHash),
zap.String("network", network.Name.String()), zap.Uint64("status", receipt.Status),
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
)
return receipt, nil
}
}
func readDecimals(ctx context.Context, client *rpc.Client, token string) (uint8, error) {
call := map[string]string{
"to": strings.ToLower(common.HexToAddress(token).Hex()),
"data": "0x313ce567",
}
var hexResp string
if err := client.CallContext(ctx, &hexResp, "eth_call", call, "latest"); err != nil {
return 0, merrors.Internal("decimals call failed: " + err.Error())
}
val, err := shared.DecodeHexUint8(hexResp)
if err != nil {
return 0, merrors.Internal("decimals decode failed: " + err.Error())
}
return val, nil
}
func readBalanceOf(ctx context.Context, client *rpc.Client, token string, wallet string) (*big.Int, error) {
tokenAddr := common.HexToAddress(token)
walletAddr := common.HexToAddress(wallet)
addr := strings.TrimPrefix(walletAddr.Hex(), "0x")
if len(addr) < 64 {
addr = strings.Repeat("0", 64-len(addr)) + addr
}
call := map[string]string{
"to": strings.ToLower(tokenAddr.Hex()),
"data": "0x70a08231" + addr,
}
var hexResp string
if err := client.CallContext(ctx, &hexResp, "eth_call", call, "latest"); err != nil {
return nil, merrors.Internal("balanceOf call failed: " + err.Error())
}
bigVal, err := shared.DecodeHexBig(hexResp)
if err != nil {
return nil, merrors.Internal("balanceOf decode failed: " + err.Error())
}
return bigVal, nil
}
func erc20Decimals(ctx context.Context, client *rpc.Client, token common.Address) (uint8, error) {
call := map[string]string{
"to": strings.ToLower(token.Hex()),
"data": "0x313ce567",
}
var hexResp string
if err := client.CallContext(ctx, &hexResp, "eth_call", call, "latest"); err != nil {
return 0, executorInternal("decimals call failed", err)
}
val, err := shared.DecodeHexUint8(hexResp)
if err != nil {
return 0, executorInternal("decimals decode failed", err)
}
return val, nil
}
type gasEstimator interface {
EstimateGas(ctx context.Context, msg ethereum.CallMsg) (uint64, error)
}
func estimateGas(ctx context.Context, network shared.Network, client gasEstimator, rpcClient *rpc.Client, callMsg ethereum.CallMsg) (uint64, error) {
if isTronNetwork(network) {
if rpcClient == nil {
return 0, merrors.Internal("rpc client not initialised")
}
return estimateGasTron(ctx, rpcClient, callMsg)
}
return client.EstimateGas(ctx, callMsg)
}
func estimateGasTron(ctx context.Context, rpcClient *rpc.Client, callMsg ethereum.CallMsg) (uint64, error) {
call := tronEstimateCall(callMsg)
var hexResp string
if err := rpcClient.CallContext(ctx, &hexResp, "eth_estimateGas", call); err != nil {
return 0, err
}
val, err := shared.DecodeHexBig(hexResp)
if err != nil {
return 0, err
}
if val == nil {
return 0, merrors.Internal("failed to decode gas estimate")
}
return val.Uint64(), nil
}
func tronEstimateCall(callMsg ethereum.CallMsg) map[string]string {
call := make(map[string]string)
if callMsg.From != (common.Address{}) {
call["from"] = strings.ToLower(callMsg.From.Hex())
}
if callMsg.To != nil {
call["to"] = strings.ToLower(callMsg.To.Hex())
}
if callMsg.Gas > 0 {
call["gas"] = hexutil.EncodeUint64(callMsg.Gas)
}
if callMsg.GasPrice != nil {
call["gasPrice"] = hexutil.EncodeBig(callMsg.GasPrice)
}
if callMsg.Value != nil {
call["value"] = hexutil.EncodeBig(callMsg.Value)
}
if len(callMsg.Data) > 0 {
call["data"] = hexutil.Encode(callMsg.Data)
}
return call
}
func isTronNetwork(network shared.Network) bool {
return network.Name.IsTron()
}
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
}
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,108 @@
package evm
import (
"fmt"
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/tron/storage/model"
"github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
var evmBaseUnitFactor = decimal.NewFromInt(1_000_000_000_000_000_000)
// ComputeGasTopUp applies the network policy to decide an EVM native-token top-up amount.
func ComputeGasTopUp(network shared.Network, wallet *model.ManagedWallet, estimatedFee *moneyv1.Money, currentBalance *moneyv1.Money) (*moneyv1.Money, bool, error) {
if wallet == nil {
return nil, false, merrors.InvalidArgument("wallet is required")
}
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
return nil, false, merrors.InvalidArgument("estimated fee is required")
}
if currentBalance == nil || strings.TrimSpace(currentBalance.GetAmount()) == "" || strings.TrimSpace(currentBalance.GetCurrency()) == "" {
return nil, false, merrors.InvalidArgument("current native balance is required")
}
if network.GasTopUpPolicy == nil {
return nil, false, merrors.InvalidArgument("gas top-up policy is not configured")
}
nativeCurrency := strings.TrimSpace(network.NativeToken)
if nativeCurrency == "" {
nativeCurrency = strings.ToUpper(network.Name.String())
}
if !strings.EqualFold(nativeCurrency, estimatedFee.GetCurrency()) {
return nil, false, merrors.InvalidArgument(fmt.Sprintf("estimated fee currency mismatch (expected %s)", nativeCurrency))
}
if !strings.EqualFold(nativeCurrency, currentBalance.GetCurrency()) {
return nil, false, merrors.InvalidArgument(fmt.Sprintf("native balance currency mismatch (expected %s)", nativeCurrency))
}
estimatedNative, err := evmToNative(estimatedFee)
if err != nil {
return nil, false, err
}
currentNative, err := evmToNative(currentBalance)
if err != nil {
return nil, false, err
}
isContract := strings.TrimSpace(wallet.ContractAddress) != ""
rule, ok := network.GasTopUpPolicy.Rule(isContract)
if !ok {
return nil, false, merrors.InvalidArgument("gas top-up policy is not configured")
}
if rule.RoundingUnit.LessThanOrEqual(decimal.Zero) {
return nil, false, merrors.InvalidArgument("gas top-up rounding unit must be > 0")
}
if rule.MaxTopUp.LessThanOrEqual(decimal.Zero) {
return nil, false, merrors.InvalidArgument("gas top-up max top-up must be > 0")
}
required := estimatedNative.Sub(currentNative)
if required.IsNegative() {
required = decimal.Zero
}
bufferedRequired := required.Mul(decimal.NewFromInt(1).Add(rule.BufferPercent))
minBalanceTopUp := rule.MinNativeBalance.Sub(currentNative)
if minBalanceTopUp.IsNegative() {
minBalanceTopUp = decimal.Zero
}
rawTopUp := bufferedRequired
if minBalanceTopUp.GreaterThan(rawTopUp) {
rawTopUp = minBalanceTopUp
}
roundedTopUp := decimal.Zero
if rawTopUp.IsPositive() {
roundedTopUp = rawTopUp.Div(rule.RoundingUnit).Ceil().Mul(rule.RoundingUnit)
}
topUp := roundedTopUp
capHit := false
if topUp.GreaterThan(rule.MaxTopUp) {
topUp = rule.MaxTopUp
capHit = true
}
if !topUp.IsPositive() {
return nil, capHit, nil
}
baseUnits := topUp.Mul(evmBaseUnitFactor).Ceil().Truncate(0)
return &moneyv1.Money{
Currency: strings.ToUpper(nativeCurrency),
Amount: baseUnits.StringFixed(0),
}, capHit, nil
}
func evmToNative(amount *moneyv1.Money) (decimal.Decimal, error) {
value, err := decimal.NewFromString(strings.TrimSpace(amount.GetAmount()))
if err != nil {
return decimal.Zero, err
}
return value.Div(evmBaseUnitFactor), nil
}

View File

@@ -0,0 +1,146 @@
package evm
import (
"testing"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/require"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/tron/storage/model"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
func TestComputeGasTopUp_BalanceSufficient(t *testing.T) {
network := ethNetwork(defaultPolicy())
wallet := &model.ManagedWallet{}
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("5"), ethMoney("30"))
require.NoError(t, err)
require.Nil(t, topUp)
require.False(t, capHit)
}
func TestComputeGasTopUp_BufferedRequired(t *testing.T) {
network := ethNetwork(defaultPolicy())
wallet := &model.ManagedWallet{}
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("50"), ethMoney("10"))
require.NoError(t, err)
require.NotNil(t, topUp)
require.False(t, capHit)
require.Equal(t, "46000000000000000000", topUp.GetAmount())
require.Equal(t, "ETH", topUp.GetCurrency())
}
func TestComputeGasTopUp_MinBalanceBinding(t *testing.T) {
network := ethNetwork(defaultPolicy())
wallet := &model.ManagedWallet{}
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("5"), ethMoney("1"))
require.NoError(t, err)
require.NotNil(t, topUp)
require.False(t, capHit)
require.Equal(t, "19000000000000000000", topUp.GetAmount())
}
func TestComputeGasTopUp_RoundsUp(t *testing.T) {
policy := shared.GasTopUpPolicy{
Default: shared.GasTopUpRule{
BufferPercent: decimal.NewFromFloat(0),
MinNativeBalance: decimal.NewFromFloat(0),
RoundingUnit: decimal.NewFromFloat(1),
MaxTopUp: decimal.NewFromFloat(100),
},
}
network := ethNetwork(&policy)
wallet := &model.ManagedWallet{}
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("1.1"), ethMoney("0"))
require.NoError(t, err)
require.NotNil(t, topUp)
require.False(t, capHit)
require.Equal(t, "2000000000000000000", topUp.GetAmount())
}
func TestComputeGasTopUp_CapHit(t *testing.T) {
policy := shared.GasTopUpPolicy{
Default: shared.GasTopUpRule{
BufferPercent: decimal.NewFromFloat(0),
MinNativeBalance: decimal.NewFromFloat(0),
RoundingUnit: decimal.NewFromFloat(1),
MaxTopUp: decimal.NewFromFloat(10),
},
}
network := ethNetwork(&policy)
wallet := &model.ManagedWallet{}
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("100"), ethMoney("0"))
require.NoError(t, err)
require.NotNil(t, topUp)
require.True(t, capHit)
require.Equal(t, "10000000000000000000", topUp.GetAmount())
}
func TestComputeGasTopUp_MinBalanceWhenRequiredZero(t *testing.T) {
network := ethNetwork(defaultPolicy())
wallet := &model.ManagedWallet{}
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("0"), ethMoney("5"))
require.NoError(t, err)
require.NotNil(t, topUp)
require.False(t, capHit)
require.Equal(t, "15000000000000000000", topUp.GetAmount())
}
func TestComputeGasTopUp_ContractPolicyOverride(t *testing.T) {
policy := shared.GasTopUpPolicy{
Default: shared.GasTopUpRule{
BufferPercent: decimal.NewFromFloat(0.1),
MinNativeBalance: decimal.NewFromFloat(10),
RoundingUnit: decimal.NewFromFloat(1),
MaxTopUp: decimal.NewFromFloat(100),
},
Contract: &shared.GasTopUpRule{
BufferPercent: decimal.NewFromFloat(0.5),
MinNativeBalance: decimal.NewFromFloat(5),
RoundingUnit: decimal.NewFromFloat(1),
MaxTopUp: decimal.NewFromFloat(100),
},
}
network := ethNetwork(&policy)
wallet := &model.ManagedWallet{ContractAddress: "0xcontract"}
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("10"), ethMoney("0"))
require.NoError(t, err)
require.NotNil(t, topUp)
require.False(t, capHit)
require.Equal(t, "15000000000000000000", topUp.GetAmount())
}
func defaultPolicy() *shared.GasTopUpPolicy {
return &shared.GasTopUpPolicy{
Default: shared.GasTopUpRule{
BufferPercent: decimal.NewFromFloat(0.15),
MinNativeBalance: decimal.NewFromFloat(20),
RoundingUnit: decimal.NewFromFloat(1),
MaxTopUp: decimal.NewFromFloat(500),
},
}
}
func ethNetwork(policy *shared.GasTopUpPolicy) shared.Network {
return shared.Network{
Name: "ethereum_mainnet",
NativeToken: "ETH",
GasTopUpPolicy: policy,
}
}
func ethMoney(eth string) *moneyv1.Money {
value, _ := decimal.NewFromString(eth)
baseUnits := value.Mul(evmBaseUnitFactor).Truncate(0)
return &moneyv1.Money{
Currency: "ETH",
Amount: baseUnits.StringFixed(0),
}
}

View File

@@ -0,0 +1,193 @@
package tron
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"math/big"
"strings"
"github.com/tech/sendico/pkg/merrors"
)
const tronHexPrefix = "0x"
var base58Alphabet = []byte("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz")
func normalizeAddress(address string) (string, error) {
trimmed := strings.TrimSpace(address)
if trimmed == "" {
return "", merrors.InvalidArgument("address is required")
}
if strings.HasPrefix(trimmed, tronHexPrefix) || isHexString(trimmed) {
return hexToBase58(trimmed)
}
decoded, err := base58Decode(trimmed)
if err != nil {
return "", err
}
if err := validateChecksum(decoded); err != nil {
return "", err
}
return base58Encode(decoded), nil
}
func rpcAddress(address string) (string, error) {
trimmed := strings.TrimSpace(address)
if trimmed == "" {
return "", merrors.InvalidArgument("address is required")
}
if strings.HasPrefix(trimmed, tronHexPrefix) || isHexString(trimmed) {
return normalizeHexRPC(trimmed)
}
return base58ToHex(trimmed)
}
func hexToBase58(address string) (string, error) {
bytesAddr, err := parseHexAddress(address)
if err != nil {
return "", err
}
payload := append(bytesAddr, checksum(bytesAddr)...)
return base58Encode(payload), nil
}
func base58ToHex(address string) (string, error) {
decoded, err := base58Decode(address)
if err != nil {
return "", err
}
if err := validateChecksum(decoded); err != nil {
return "", err
}
return tronHexPrefix + hex.EncodeToString(decoded[1:21]), nil
}
func parseHexAddress(address string) ([]byte, error) {
trimmed := strings.TrimPrefix(strings.TrimSpace(address), tronHexPrefix)
if trimmed == "" {
return nil, merrors.InvalidArgument("address is required")
}
if len(trimmed)%2 == 1 {
trimmed = "0" + trimmed
}
decoded, err := hex.DecodeString(trimmed)
if err != nil {
return nil, merrors.InvalidArgument("invalid hex address")
}
switch len(decoded) {
case 20:
prefixed := make([]byte, 21)
prefixed[0] = 0x41
copy(prefixed[1:], decoded)
return prefixed, nil
case 21:
if decoded[0] != 0x41 {
return nil, merrors.InvalidArgument("invalid tron address prefix")
}
return decoded, nil
default:
return nil, merrors.InvalidArgument(fmt.Sprintf("invalid tron address length %d", len(decoded)))
}
}
func normalizeHexRPC(address string) (string, error) {
decoded, err := parseHexAddress(address)
if err != nil {
return "", err
}
return tronHexPrefix + hex.EncodeToString(decoded[1:21]), nil
}
func validateChecksum(decoded []byte) error {
if len(decoded) != 25 {
return merrors.InvalidArgument("invalid tron address length")
}
payload := decoded[:21]
expected := checksum(payload)
if !bytes.Equal(expected, decoded[21:]) {
return merrors.InvalidArgument("invalid tron address checksum")
}
if payload[0] != 0x41 {
return merrors.InvalidArgument("invalid tron address prefix")
}
return nil
}
func checksum(payload []byte) []byte {
first := sha256.Sum256(payload)
second := sha256.Sum256(first[:])
return second[:4]
}
func base58Encode(input []byte) string {
if len(input) == 0 {
return ""
}
x := new(big.Int).SetBytes(input)
base := big.NewInt(58)
zero := big.NewInt(0)
mod := new(big.Int)
encoded := make([]byte, 0, len(input))
for x.Cmp(zero) > 0 {
x.DivMod(x, base, mod)
encoded = append(encoded, base58Alphabet[mod.Int64()])
}
for _, b := range input {
if b != 0 {
break
}
encoded = append(encoded, base58Alphabet[0])
}
reverse(encoded)
return string(encoded)
}
func base58Decode(input string) ([]byte, error) {
result := big.NewInt(0)
base := big.NewInt(58)
for i := 0; i < len(input); i++ {
idx := bytes.IndexByte(base58Alphabet, input[i])
if idx < 0 {
return nil, merrors.InvalidArgument("invalid base58 address")
}
result.Mul(result, base)
result.Add(result, big.NewInt(int64(idx)))
}
decoded := result.Bytes()
zeroCount := 0
for zeroCount < len(input) && input[zeroCount] == base58Alphabet[0] {
zeroCount++
}
if zeroCount > 0 {
decoded = append(make([]byte, zeroCount), decoded...)
}
return decoded, nil
}
func reverse(data []byte) {
for i, j := 0, len(data)-1; i < j; i, j = i+1, j-1 {
data[i], data[j] = data[j], data[i]
}
}
func isHexString(value string) bool {
trimmed := strings.TrimPrefix(strings.TrimSpace(value), tronHexPrefix)
if trimmed == "" {
return false
}
for _, r := range trimmed {
switch {
case r >= '0' && r <= '9':
case r >= 'a' && r <= 'f':
case r >= 'A' && r <= 'F':
default:
return false
}
}
return true
}

View File

@@ -0,0 +1,165 @@
package tron
import (
"context"
"math/big"
"strings"
"time"
"github.com/ethereum/go-ethereum/core/types"
troncore "github.com/fbsobreira/gotron-sdk/pkg/proto/core"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/tronclient"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
const (
// Default poll interval for confirmation checking
defaultPollInterval = 3 * time.Second
)
// AwaitConfirmationNative waits for transaction confirmation using native TRON gRPC.
func AwaitConfirmationNative(
ctx context.Context,
logger mlogger.Logger,
tronClient *tronclient.Client,
txHash string,
) (*types.Receipt, error) {
if tronClient == nil {
return nil, merrors.Internal("tron driver: tron client not initialized")
}
normalizedHash := normalizeTxHash(txHash)
if normalizedHash == "" {
logger.Warn("Missing transaction hash for confirmation")
return nil, merrors.InvalidArgument("tron driver: tx hash is required")
}
logger.Debug("Awaiting native TRON confirmation",
zap.String("tx_hash", normalizedHash),
)
ticker := time.NewTicker(defaultPollInterval)
defer ticker.Stop()
for {
txInfo, err := tronClient.GetTransactionInfoByID(normalizedHash)
if err != nil {
// Not found yet, continue polling
logger.Debug("Transaction not yet confirmed",
zap.String("tx_hash", normalizedHash),
zap.Error(err),
)
} else if txInfo != nil && txInfo.BlockNumber > 0 {
// Transaction is confirmed
receipt := convertTronInfoToReceipt(txInfo)
logger.Info("Native TRON transaction confirmed",
zap.String("tx_hash", normalizedHash),
zap.Int64("block_number", txInfo.BlockNumber),
zap.Uint64("status", receipt.Status),
zap.Int64("fee", txInfo.Fee),
)
return receipt, nil
}
select {
case <-ticker.C:
continue
case <-ctx.Done():
logger.Warn("Context cancelled while awaiting TRON confirmation",
zap.String("tx_hash", normalizedHash),
)
return nil, ctx.Err()
}
}
}
// convertTronInfoToReceipt converts TRON TransactionInfo to go-ethereum Receipt for compatibility.
func convertTronInfoToReceipt(txInfo *troncore.TransactionInfo) *types.Receipt {
if txInfo == nil {
return nil
}
// Determine status: TRON uses 0 = SUCCESS, 1 = FAILED
// go-ethereum uses 1 = success, 0 = failure
var status uint64
if txInfo.Result == troncore.TransactionInfo_SUCESS {
status = 1 // Success
} else {
status = 0 // Failed
}
// Create a receipt that's compatible with the existing codebase
receipt := &types.Receipt{
Status: status,
BlockNumber: big.NewInt(txInfo.BlockNumber),
// TRON fees are in SUN, but we store as-is for logging
GasUsed: uint64(txInfo.Fee),
}
// Set transaction hash from txInfo.Id
if len(txInfo.Id) == 32 {
var txHash [32]byte
copy(txHash[:], txInfo.Id)
receipt.TxHash = txHash
}
// Set contract address if available
if len(txInfo.ContractAddress) > 0 {
var contractAddr [20]byte
// TRON addresses are 21 bytes with 0x41 prefix, take last 20
if len(txInfo.ContractAddress) == 21 {
copy(contractAddr[:], txInfo.ContractAddress[1:])
} else if len(txInfo.ContractAddress) == 20 {
copy(contractAddr[:], txInfo.ContractAddress)
}
receipt.ContractAddress = contractAddr
}
return receipt
}
// GetTransactionStatus checks the current status of a TRON transaction.
// Returns:
// - 1 if confirmed and successful
// - 0 if confirmed but failed
// - -1 if not yet confirmed (pending)
func GetTransactionStatus(
ctx context.Context,
logger mlogger.Logger,
tronClient *tronclient.Client,
txHash string,
) (int, error) {
if tronClient == nil {
return -1, merrors.Internal("tron driver: tron client not initialized")
}
normalizedHash := normalizeTxHash(txHash)
if normalizedHash == "" {
return -1, merrors.InvalidArgument("tron driver: tx hash is required")
}
txInfo, err := tronClient.GetTransactionInfoByID(normalizedHash)
if err != nil {
// Transaction not found or error - treat as pending
return -1, nil
}
if txInfo == nil || txInfo.BlockNumber == 0 {
// Not yet confirmed
return -1, nil
}
// Confirmed - check result
if txInfo.Result == troncore.TransactionInfo_SUCESS {
return 1, nil
}
return 0, nil
}
// isTronNetwork checks if the network name indicates a TRON network.
func isTronNetwork(networkName string) bool {
name := strings.ToLower(strings.TrimSpace(networkName))
return strings.HasPrefix(name, "tron")
}

View File

@@ -0,0 +1,304 @@
package tron
import (
"context"
"strings"
"github.com/ethereum/go-ethereum/core/types"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/driver"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/driver/evm"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/tron/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
"go.uber.org/zap"
)
// Driver implements Tron-specific behavior, including address conversion.
type Driver struct {
logger mlogger.Logger
}
func New(logger mlogger.Logger) *Driver {
return &Driver{logger: logger.Named("tron")}
}
func (d *Driver) Name() string {
return "tron"
}
func (d *Driver) FormatAddress(address string) (string, error) {
d.logger.Debug("Format address", zap.String("address", address))
normalized, err := normalizeAddress(address)
if err != nil {
d.logger.Warn("Format address failed", zap.String("address", address), zap.Error(err))
}
return normalized, err
}
func (d *Driver) NormalizeAddress(address string) (string, error) {
d.logger.Debug("Normalize address", zap.String("address", address))
normalized, err := normalizeAddress(address)
if err != nil {
d.logger.Warn("Normalize address failed", zap.String("address", address), zap.Error(err))
}
return normalized, err
}
func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
if wallet == nil {
return nil, merrors.InvalidArgument("wallet is required")
}
d.logger.Debug("Balance request", zap.String("wallet_ref", wallet.WalletRef), zap.String("network", network.Name.String()))
rpcAddr, err := rpcAddress(wallet.DepositAddress)
if err != nil {
d.logger.Warn("Balance address conversion failed", zap.Error(err),
zap.String("wallet_ref", wallet.WalletRef),
zap.String("address", wallet.DepositAddress),
)
return nil, err
}
driverDeps := deps
driverDeps.Logger = d.logger
result, err := evm.Balance(ctx, driverDeps, network, wallet, rpcAddr)
if err != nil {
d.logger.Warn("Balance failed", zap.Error(err),
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name.String()),
)
} else if result != nil {
d.logger.Debug("Balance result",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name.String()),
zap.String("amount", result.Amount),
zap.String("currency", result.Currency),
)
}
return result, err
}
func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
if wallet == nil {
return nil, merrors.InvalidArgument("wallet is required")
}
d.logger.Debug("Native balance request", zap.String("wallet_ref", wallet.WalletRef), zap.String("network", network.Name.String()))
rpcAddr, err := rpcAddress(wallet.DepositAddress)
if err != nil {
d.logger.Warn("Native balance address conversion failed", zap.Error(err),
zap.String("wallet_ref", wallet.WalletRef),
zap.String("address", wallet.DepositAddress),
)
return nil, err
}
driverDeps := deps
driverDeps.Logger = d.logger
result, err := evm.NativeBalance(ctx, driverDeps, network, wallet, rpcAddr)
if err != nil {
d.logger.Warn("Native balance failed", zap.Error(err),
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name.String()),
)
} else if result != nil {
d.logger.Debug("Native balance result",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name.String()),
zap.String("amount", result.Amount),
zap.String("currency", result.Currency),
)
}
return result, err
}
func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
if wallet == nil {
return nil, merrors.InvalidArgument("wallet is required")
}
if amount == nil {
return nil, merrors.InvalidArgument("amount is required")
}
d.logger.Debug("Estimate fee request",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name.String()),
zap.String("destination", destination),
)
rpcFrom, err := rpcAddress(wallet.DepositAddress)
if err != nil {
d.logger.Warn("Estimate fee address conversion failed", zap.Error(err),
zap.String("wallet_ref", wallet.WalletRef),
zap.String("address", wallet.DepositAddress),
)
return nil, err
}
rpcTo, err := rpcAddress(destination)
if err != nil {
d.logger.Warn("Estimate fee destination conversion failed", zap.Error(err),
zap.String("wallet_ref", wallet.WalletRef),
zap.String("destination", destination),
)
return nil, err
}
if rpcFrom == rpcTo {
return &moneyv1.Money{
Currency: nativeCurrency(network),
Amount: "0",
}, nil
}
driverDeps := deps
driverDeps.Logger = d.logger
result, err := evm.EstimateFee(ctx, driverDeps, network, wallet, rpcFrom, rpcTo, amount)
if err != nil {
d.logger.Warn("Estimate fee failed", zap.Error(err),
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name.String()),
zap.String("from_address", wallet.DepositAddress),
zap.String("from_rpc", rpcFrom),
zap.String("to_address", destination),
zap.String("to_rpc", rpcTo),
)
} else if result != nil {
d.logger.Debug("Estimate fee result",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name.String()),
zap.String("amount", result.Amount),
zap.String("currency", result.Currency),
)
}
return result, err
}
func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, destination string) (string, error) {
if source == nil {
return "", merrors.InvalidArgument("source wallet is required")
}
d.logger.Debug("Submit transfer request",
zap.String("transfer_ref", transfer.TransferRef),
zap.String("network", network.Name.String()),
zap.String("destination", destination),
)
// Check if native TRON gRPC is available
if deps.TronRegistry != nil && deps.TronRegistry.HasClient(network.Name.String()) {
d.logger.Debug("Using native TRON gRPC for transfer",
zap.String("transfer_ref", transfer.TransferRef),
zap.String("network", network.Name.String()),
)
tronClient, err := deps.TronRegistry.Client(network.Name.String())
if err != nil {
d.logger.Warn("Failed to get TRON client, falling back to EVM", zap.Error(err))
} else {
txHash, err := SubmitTransferNative(ctx, d.logger, tronClient, deps.KeyManager, transfer, source, destination)
if err != nil {
d.logger.Warn("Native TRON transfer failed", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
zap.String("network", network.Name.String()),
)
} else {
d.logger.Debug("Native TRON transfer result",
zap.String("transfer_ref", transfer.TransferRef),
zap.String("network", network.Name.String()),
zap.String("tx_hash", txHash),
)
}
return txHash, err
}
}
// Fallback to EVM JSON-RPC
rpcFrom, err := rpcAddress(source.DepositAddress)
if err != nil {
d.logger.Warn("Submit transfer address conversion failed", zap.Error(err),
zap.String("wallet_ref", source.WalletRef),
zap.String("address", source.DepositAddress),
)
return "", err
}
rpcTo, err := rpcAddress(destination)
if err != nil {
d.logger.Warn("Submit transfer destination conversion failed", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
zap.String("destination", destination),
)
return "", err
}
driverDeps := deps
driverDeps.Logger = d.logger
txHash, err := evm.SubmitTransfer(ctx, driverDeps, network, transfer, source, rpcFrom, rpcTo)
if err != nil {
d.logger.Warn("Submit transfer failed", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
zap.String("network", network.Name.String()),
)
} else {
d.logger.Debug("Submit transfer result",
zap.String("transfer_ref", transfer.TransferRef),
zap.String("network", network.Name.String()),
zap.String("tx_hash", txHash),
)
}
return txHash, err
}
func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Network, txHash string) (*types.Receipt, error) {
d.logger.Debug("Awaiting confirmation",
zap.String("tx_hash", txHash),
zap.String("network", network.Name.String()),
)
// Check if native TRON gRPC is available
if deps.TronRegistry != nil && deps.TronRegistry.HasClient(network.Name.String()) {
d.logger.Debug("Using native TRON gRPC for confirmation",
zap.String("tx_hash", txHash),
zap.String("network", network.Name.String()),
)
tronClient, err := deps.TronRegistry.Client(network.Name.String())
if err != nil {
d.logger.Warn("Failed to get TRON client, falling back to EVM", zap.Error(err))
} else {
receipt, err := AwaitConfirmationNative(ctx, d.logger, tronClient, txHash)
if err != nil {
d.logger.Warn("Native TRON confirmation failed", zap.Error(err),
zap.String("tx_hash", txHash),
zap.String("network", network.Name.String()),
)
} else if receipt != nil {
d.logger.Debug("Native TRON confirmation result",
zap.String("tx_hash", txHash),
zap.String("network", network.Name.String()),
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
zap.Uint64("status", receipt.Status),
)
}
return receipt, err
}
}
// Fallback to EVM JSON-RPC
driverDeps := deps
driverDeps.Logger = d.logger
receipt, err := evm.AwaitConfirmation(ctx, driverDeps, network, txHash)
if err != nil {
d.logger.Warn("Awaiting of confirmation failed", zap.Error(err),
zap.String("tx_hash", txHash),
zap.String("network", network.Name.String()),
)
} else if receipt != nil {
d.logger.Debug("Await confirmation result",
zap.String("tx_hash", txHash),
zap.String("network", network.Name.String()),
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
zap.Uint64("status", receipt.Status),
)
}
return receipt, err
}
func nativeCurrency(network shared.Network) string {
currency := strings.ToUpper(strings.TrimSpace(network.NativeToken))
if currency == "" {
currency = strings.ToUpper(network.Name.String())
}
return currency
}
var _ driver.Driver = (*Driver)(nil)

View File

@@ -0,0 +1,33 @@
package tron
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/driver"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/tron/storage/model"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
"go.uber.org/zap"
)
func TestEstimateFeeSelfTransferReturnsZero(t *testing.T) {
logger := zap.NewNop()
d := New(logger)
wallet := &model.ManagedWallet{
WalletRef: "wallet_ref",
DepositAddress: "TGBDXEg9rxSqGFJDcb889zqTjDwx1bmLRF",
}
network := shared.Network{
Name: "tron_mainnet",
NativeToken: "TRX",
}
amount := &moneyv1.Money{Currency: "TRX", Amount: "1000000"}
fee, err := d.EstimateFee(context.Background(), driver.Deps{}, network, wallet, wallet.DepositAddress, amount)
require.NoError(t, err)
require.NotNil(t, fee)
require.Equal(t, "TRX", fee.GetCurrency())
require.Equal(t, "0", fee.GetAmount())
}

View File

@@ -0,0 +1,143 @@
package tron
import (
"fmt"
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/tron/storage/model"
"github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
var tronBaseUnitFactor = decimal.NewFromInt(1_000_000)
// GasTopUpDecision captures the applied policy inputs and outputs (in TRX units).
type GasTopUpDecision struct {
CurrentBalanceTRX decimal.Decimal
EstimatedFeeTRX decimal.Decimal
RequiredTRX decimal.Decimal
BufferedRequiredTRX decimal.Decimal
MinBalanceTopUpTRX decimal.Decimal
RawTopUpTRX decimal.Decimal
RoundedTopUpTRX decimal.Decimal
TopUpTRX decimal.Decimal
CapHit bool
OperationType string
}
// ComputeGasTopUp applies the network policy to decide a TRX top-up amount.
func ComputeGasTopUp(network shared.Network, wallet *model.ManagedWallet, estimatedFee *moneyv1.Money, currentBalance *moneyv1.Money) (*moneyv1.Money, GasTopUpDecision, error) {
decision := GasTopUpDecision{}
if wallet == nil {
return nil, decision, merrors.InvalidArgument("wallet is required")
}
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
return nil, decision, merrors.InvalidArgument("estimated fee is required")
}
if currentBalance == nil || strings.TrimSpace(currentBalance.GetAmount()) == "" || strings.TrimSpace(currentBalance.GetCurrency()) == "" {
return nil, decision, merrors.InvalidArgument("current native balance is required")
}
if network.GasTopUpPolicy == nil {
return nil, decision, merrors.InvalidArgument("gas top-up policy is not configured")
}
nativeCurrency := strings.TrimSpace(network.NativeToken)
if nativeCurrency == "" {
nativeCurrency = strings.ToUpper(network.Name.String())
}
if !strings.EqualFold(nativeCurrency, estimatedFee.GetCurrency()) {
return nil, decision, merrors.InvalidArgument(fmt.Sprintf("estimated fee currency mismatch (expected %s)", nativeCurrency))
}
if !strings.EqualFold(nativeCurrency, currentBalance.GetCurrency()) {
return nil, decision, merrors.InvalidArgument(fmt.Sprintf("native balance currency mismatch (expected %s)", nativeCurrency))
}
estimatedTRX, err := tronToTRX(estimatedFee)
if err != nil {
return nil, decision, err
}
currentTRX, err := tronToTRX(currentBalance)
if err != nil {
return nil, decision, err
}
isContract := strings.TrimSpace(wallet.ContractAddress) != ""
rule, ok := network.GasTopUpPolicy.Rule(isContract)
if !ok {
return nil, decision, merrors.InvalidArgument("gas top-up policy is not configured")
}
if rule.RoundingUnit.LessThanOrEqual(decimal.Zero) {
return nil, decision, merrors.InvalidArgument("gas top-up rounding unit must be > 0")
}
if rule.MaxTopUp.LessThanOrEqual(decimal.Zero) {
return nil, decision, merrors.InvalidArgument("gas top-up max top-up must be > 0")
}
required := estimatedTRX.Sub(currentTRX)
if required.IsNegative() {
required = decimal.Zero
}
bufferedRequired := required.Mul(decimal.NewFromInt(1).Add(rule.BufferPercent))
minBalanceTopUp := rule.MinNativeBalance.Sub(currentTRX)
if minBalanceTopUp.IsNegative() {
minBalanceTopUp = decimal.Zero
}
rawTopUp := bufferedRequired
if minBalanceTopUp.GreaterThan(rawTopUp) {
rawTopUp = minBalanceTopUp
}
roundedTopUp := decimal.Zero
if rawTopUp.IsPositive() {
roundedTopUp = rawTopUp.Div(rule.RoundingUnit).Ceil().Mul(rule.RoundingUnit)
}
topUp := roundedTopUp
capHit := false
if topUp.GreaterThan(rule.MaxTopUp) {
topUp = rule.MaxTopUp
capHit = true
}
decision = GasTopUpDecision{
CurrentBalanceTRX: currentTRX,
EstimatedFeeTRX: estimatedTRX,
RequiredTRX: required,
BufferedRequiredTRX: bufferedRequired,
MinBalanceTopUpTRX: minBalanceTopUp,
RawTopUpTRX: rawTopUp,
RoundedTopUpTRX: roundedTopUp,
TopUpTRX: topUp,
CapHit: capHit,
OperationType: operationType(isContract),
}
if !topUp.IsPositive() {
return nil, decision, nil
}
baseUnits := topUp.Mul(tronBaseUnitFactor).Ceil().Truncate(0)
return &moneyv1.Money{
Currency: strings.ToUpper(nativeCurrency),
Amount: baseUnits.StringFixed(0),
}, decision, nil
}
func tronToTRX(amount *moneyv1.Money) (decimal.Decimal, error) {
value, err := decimal.NewFromString(strings.TrimSpace(amount.GetAmount()))
if err != nil {
return decimal.Zero, err
}
return value.Div(tronBaseUnitFactor), nil
}
func operationType(contract bool) string {
if contract {
return "trc20"
}
return "native"
}

View File

@@ -0,0 +1,147 @@
package tron
import (
"testing"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/require"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/tron/storage/model"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
func TestComputeGasTopUp_BalanceSufficient(t *testing.T) {
network := tronNetwork(defaultPolicy())
wallet := &model.ManagedWallet{}
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("5"), tronMoney("30"))
require.NoError(t, err)
require.Nil(t, topUp)
require.True(t, decision.TopUpTRX.IsZero())
}
func TestComputeGasTopUp_BufferedRequired(t *testing.T) {
network := tronNetwork(defaultPolicy())
wallet := &model.ManagedWallet{}
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("50"), tronMoney("10"))
require.NoError(t, err)
require.NotNil(t, topUp)
require.Equal(t, "46000000", topUp.GetAmount())
require.Equal(t, "TRX", topUp.GetCurrency())
require.Equal(t, "46", decision.TopUpTRX.String())
}
func TestComputeGasTopUp_MinBalanceBinding(t *testing.T) {
network := tronNetwork(defaultPolicy())
wallet := &model.ManagedWallet{}
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("5"), tronMoney("1"))
require.NoError(t, err)
require.NotNil(t, topUp)
require.Equal(t, "19000000", topUp.GetAmount())
require.Equal(t, "19", decision.TopUpTRX.String())
}
func TestComputeGasTopUp_RoundsUp(t *testing.T) {
policy := shared.GasTopUpPolicy{
Default: shared.GasTopUpRule{
BufferPercent: decimal.NewFromFloat(0),
MinNativeBalance: decimal.NewFromFloat(0),
RoundingUnit: decimal.NewFromFloat(1),
MaxTopUp: decimal.NewFromFloat(100),
},
}
network := tronNetwork(&policy)
wallet := &model.ManagedWallet{}
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("1.1"), tronMoney("0"))
require.NoError(t, err)
require.NotNil(t, topUp)
require.Equal(t, "2000000", topUp.GetAmount())
require.Equal(t, "2", decision.TopUpTRX.String())
}
func TestComputeGasTopUp_CapHit(t *testing.T) {
policy := shared.GasTopUpPolicy{
Default: shared.GasTopUpRule{
BufferPercent: decimal.NewFromFloat(0),
MinNativeBalance: decimal.NewFromFloat(0),
RoundingUnit: decimal.NewFromFloat(1),
MaxTopUp: decimal.NewFromFloat(10),
},
}
network := tronNetwork(&policy)
wallet := &model.ManagedWallet{}
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("100"), tronMoney("0"))
require.NoError(t, err)
require.NotNil(t, topUp)
require.Equal(t, "10000000", topUp.GetAmount())
require.True(t, decision.CapHit)
}
func TestComputeGasTopUp_MinBalanceWhenRequiredZero(t *testing.T) {
network := tronNetwork(defaultPolicy())
wallet := &model.ManagedWallet{}
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("0"), tronMoney("5"))
require.NoError(t, err)
require.NotNil(t, topUp)
require.Equal(t, "15000000", topUp.GetAmount())
require.Equal(t, "15", decision.TopUpTRX.String())
}
func TestComputeGasTopUp_ContractPolicyOverride(t *testing.T) {
policy := shared.GasTopUpPolicy{
Default: shared.GasTopUpRule{
BufferPercent: decimal.NewFromFloat(0.1),
MinNativeBalance: decimal.NewFromFloat(10),
RoundingUnit: decimal.NewFromFloat(1),
MaxTopUp: decimal.NewFromFloat(100),
},
Contract: &shared.GasTopUpRule{
BufferPercent: decimal.NewFromFloat(0.5),
MinNativeBalance: decimal.NewFromFloat(5),
RoundingUnit: decimal.NewFromFloat(1),
MaxTopUp: decimal.NewFromFloat(100),
},
}
network := tronNetwork(&policy)
wallet := &model.ManagedWallet{ContractAddress: "0xcontract"}
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("10"), tronMoney("0"))
require.NoError(t, err)
require.NotNil(t, topUp)
require.Equal(t, "15000000", topUp.GetAmount())
require.Equal(t, "15", decision.TopUpTRX.String())
require.Equal(t, "trc20", decision.OperationType)
}
func defaultPolicy() *shared.GasTopUpPolicy {
return &shared.GasTopUpPolicy{
Default: shared.GasTopUpRule{
BufferPercent: decimal.NewFromFloat(0.15),
MinNativeBalance: decimal.NewFromFloat(20),
RoundingUnit: decimal.NewFromFloat(1),
MaxTopUp: decimal.NewFromFloat(500),
},
}
}
func tronNetwork(policy *shared.GasTopUpPolicy) shared.Network {
return shared.Network{
Name: "tron_mainnet",
NativeToken: "TRX",
GasTopUpPolicy: policy,
}
}
func tronMoney(trx string) *moneyv1.Money {
value, _ := decimal.NewFromString(trx)
baseUnits := value.Mul(tronBaseUnitFactor).Truncate(0)
return &moneyv1.Money{
Currency: "TRX",
Amount: baseUnits.StringFixed(0),
}
}

View File

@@ -0,0 +1,308 @@
package tron
import (
"context"
"encoding/hex"
"math/big"
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/gateway/tron/internal/keymanager"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/tronclient"
"github.com/tech/sendico/gateway/tron/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
const (
// Default fee limit for TRC20 transfers (100 TRX in SUN)
defaultTRC20FeeLimit = 100_000_000
// SUN per TRX
sunPerTRX = 1_000_000
)
// SubmitTransferNative submits a transfer using native TRON gRPC.
func SubmitTransferNative(
ctx context.Context,
logger mlogger.Logger,
tronClient *tronclient.Client,
keyManager keymanager.Manager,
transfer *model.Transfer,
source *model.ManagedWallet,
destination string,
) (string, error) {
if tronClient == nil {
return "", merrors.Internal("tron driver: tron client not initialized")
}
if keyManager == nil {
return "", merrors.Internal("tron driver: key manager not configured")
}
if transfer == nil || source == nil {
return "", merrors.InvalidArgument("tron driver: transfer context missing")
}
if strings.TrimSpace(source.KeyReference) == "" {
logger.Warn("Source wallet missing key reference", zap.String("wallet_ref", source.WalletRef))
return "", merrors.InvalidArgument("tron driver: source wallet missing key reference")
}
rawFrom := strings.TrimSpace(source.DepositAddress)
rawTo := strings.TrimSpace(destination)
if rawFrom == "" || rawTo == "" {
return "", merrors.InvalidArgument("tron driver: invalid addresses")
}
fromAddr, err := normalizeAddress(rawFrom)
if err != nil {
logger.Warn("Invalid TRON source address", zap.String("wallet_ref", source.WalletRef), zap.Error(err))
return "", err
}
toAddr, err := normalizeAddress(rawTo)
if err != nil {
logger.Warn("Invalid TRON destination address", zap.String("transfer_ref", transfer.TransferRef), zap.Error(err))
return "", err
}
amount := transfer.NetAmount
if amount == nil || strings.TrimSpace(amount.Amount) == "" {
logger.Warn("Transfer missing net amount", zap.String("transfer_ref", transfer.TransferRef))
return "", merrors.InvalidArgument("tron driver: transfer missing net amount")
}
contract := strings.TrimSpace(transfer.ContractAddress)
if contract != "" {
normalizedContract, err := normalizeAddress(contract)
if err != nil {
logger.Warn("Invalid TRON contract address", zap.String("transfer_ref", transfer.TransferRef), zap.Error(err))
return "", err
}
contract = normalizedContract
}
logger.Info("Submitting native TRON transfer",
zap.String("transfer_ref", transfer.TransferRef),
zap.String("from", fromAddr),
zap.String("to", toAddr),
zap.String("contract", contract),
zap.String("amount", amount.Amount),
)
var txID string
if contract == "" {
// Native TRX transfer
txID, err = submitTRXTransfer(ctx, logger, tronClient, keyManager, source, fromAddr, toAddr, amount.Amount)
} else {
// TRC20 token transfer
txID, err = submitTRC20Transfer(ctx, logger, tronClient, keyManager, source, fromAddr, toAddr, contract, amount.Amount)
}
if err != nil {
logger.Warn("Native TRON transfer failed",
zap.String("transfer_ref", transfer.TransferRef),
zap.Error(err),
)
return "", err
}
logger.Info("Native TRON transfer submitted",
zap.String("transfer_ref", transfer.TransferRef),
zap.String("tx_id", txID),
)
return txID, nil
}
func submitTRXTransfer(
ctx context.Context,
logger mlogger.Logger,
tronClient *tronclient.Client,
keyManager keymanager.Manager,
source *model.ManagedWallet,
from, to, amountStr string,
) (string, error) {
// Parse amount (should be in base units = SUN for TRX)
amountSun, err := parseAmountToSun(amountStr)
if err != nil {
logger.Warn("Failed to parse TRX amount", zap.String("amount", amountStr), zap.Error(err))
return "", err
}
logger.Debug("Creating TRX transfer transaction",
zap.String("from", from),
zap.String("to", to),
zap.Int64("amount_sun", amountSun),
)
// Create unsigned transaction
txExt, err := tronClient.Transfer(from, to, amountSun)
if err != nil {
logger.Warn("Failed to create TRX transfer transaction", zap.Error(err))
return "", merrors.Internal("tron driver: failed to create transfer: " + err.Error())
}
if txExt == nil || txExt.Transaction == nil {
return "", merrors.Internal("tron driver: nil transaction returned")
}
// Sign transaction
signedTx, err := keyManager.SignTronTransaction(ctx, source.KeyReference, txExt.Transaction)
if err != nil {
logger.Warn("Failed to sign TRX transfer", zap.Error(err))
return "", err
}
// Broadcast transaction
result, err := tronClient.Broadcast(signedTx)
if err != nil {
logger.Warn("Failed to broadcast TRX transfer", zap.Error(err))
return "", merrors.Internal("tron driver: broadcast failed: " + err.Error())
}
if result != nil && !result.GetResult() {
msg := string(result.GetMessage())
logger.Warn("TRX transfer broadcast rejected", zap.String("message", msg))
return "", merrors.Internal("tron driver: broadcast rejected: " + msg)
}
return tronclient.TxIDFromExtention(txExt), nil
}
func submitTRC20Transfer(
ctx context.Context,
logger mlogger.Logger,
tronClient *tronclient.Client,
keyManager keymanager.Manager,
source *model.ManagedWallet,
from, to, contract, amountStr string,
) (string, error) {
decimals, err := tronClient.TRC20GetDecimals(contract)
if err != nil {
logger.Warn("Failed to read TRC20 decimals", zap.String("contract", contract), zap.Error(err))
return "", err
}
amount, err := toBaseUnits(amountStr, decimals)
if err != nil {
logger.Warn("Failed to convert TRC20 amount to base units",
zap.String("amount", amountStr),
zap.String("decimals", decimals.String()),
zap.Error(err),
)
return "", err
}
logger.Debug("Creating TRC20 transfer transaction",
zap.String("from", from),
zap.String("to", to),
zap.String("contract", contract),
zap.String("amount_raw", amountStr),
zap.String("amount_base", amount.String()),
zap.String("decimals", decimals.String()),
)
// Create unsigned transaction
txExt, err := tronClient.TRC20Send(from, to, contract, amount, defaultTRC20FeeLimit)
if err != nil {
logger.Warn("Failed to create TRC20 transfer transaction", zap.Error(err))
return "", merrors.Internal("tron driver: failed to create TRC20 transfer: " + err.Error())
}
if txExt == nil || txExt.Transaction == nil {
return "", merrors.Internal("tron driver: nil transaction returned")
}
// Sign transaction
signedTx, err := keyManager.SignTronTransaction(ctx, source.KeyReference, txExt.Transaction)
if err != nil {
logger.Warn("Failed to sign TRC20 transfer", zap.Error(err))
return "", err
}
// Broadcast transaction
result, err := tronClient.Broadcast(signedTx)
if err != nil {
logger.Warn("Failed to broadcast TRC20 transfer", zap.Error(err))
return "", merrors.Internal("tron driver: broadcast failed: " + err.Error())
}
if result != nil && !result.GetResult() {
msg := string(result.GetMessage())
logger.Warn("TRC20 transfer broadcast rejected", zap.String("message", msg))
return "", merrors.Internal("tron driver: broadcast rejected: " + msg)
}
return tronclient.TxIDFromExtention(txExt), nil
}
func parseAmountToSun(amountStr string) (int64, error) {
trimmed := strings.TrimSpace(amountStr)
if trimmed == "" {
return 0, merrors.InvalidArgument("tron driver: amount is required")
}
// Amount is already in base units (SUN)
value, ok := new(big.Int).SetString(trimmed, 10)
if !ok {
return 0, merrors.InvalidArgument("tron driver: invalid amount: " + trimmed)
}
if value.Sign() < 0 {
return 0, merrors.InvalidArgument("tron driver: amount must be non-negative")
}
if !value.IsInt64() {
return 0, merrors.InvalidArgument("tron driver: amount exceeds int64 range")
}
return value.Int64(), nil
}
func toBaseUnits(amountStr string, decimals *big.Int) (*big.Int, error) {
trimmed := strings.TrimSpace(amountStr)
if trimmed == "" {
return nil, merrors.InvalidArgument("tron driver: amount is required")
}
if decimals == nil {
return nil, merrors.InvalidArgument("tron driver: token decimals missing")
}
if decimals.Sign() < 0 {
return nil, merrors.InvalidArgument("tron driver: token decimals must be non-negative")
}
if decimals.BitLen() > 31 {
return nil, merrors.InvalidArgument("tron driver: token decimals out of range")
}
value, err := decimal.NewFromString(trimmed)
if err != nil {
return nil, merrors.InvalidArgument("tron driver: invalid amount: " + trimmed)
}
if value.IsNegative() {
return nil, merrors.InvalidArgument("tron driver: amount must be non-negative")
}
shift := int32(decimals.Int64())
multiplier := decimal.NewFromInt(1).Shift(shift)
scaled := value.Mul(multiplier)
if !scaled.Equal(scaled.Truncate(0)) {
return nil, merrors.InvalidArgument("tron driver: amount " + trimmed + " exceeds token precision")
}
return scaled.BigInt(), nil
}
// normalizeTxHash ensures consistent tx hash format (lowercase hex without 0x prefix).
func normalizeTxHash(txHash string) string {
h := strings.TrimSpace(txHash)
h = strings.TrimPrefix(h, "0x")
h = strings.TrimPrefix(h, "0X")
return strings.ToLower(h)
}
// txHashToHex converts a byte slice transaction ID to hex string.
func txHashToHex(txID []byte) string {
if len(txID) == 0 {
return ""
}
return hex.EncodeToString(txID)
}

View File

@@ -0,0 +1,67 @@
package tron
import (
"math/big"
"testing"
"github.com/stretchr/testify/require"
)
func TestToBaseUnits(t *testing.T) {
tests := []struct {
name string
amount string
decimals *big.Int
want string
expectErr bool
}{
{
name: "fractional amount",
amount: "0.7",
decimals: big.NewInt(6),
want: "700000",
},
{
name: "decimal amount",
amount: "9.3",
decimals: big.NewInt(6),
want: "9300000",
},
{
name: "integer amount",
amount: "12",
decimals: big.NewInt(6),
want: "12000000",
},
{
name: "lowest unit",
amount: "0.000001",
decimals: big.NewInt(6),
want: "1",
},
{
name: "exceeds precision",
amount: "0.0000001",
decimals: big.NewInt(6),
expectErr: true,
},
{
name: "missing decimals",
amount: "1",
decimals: nil,
expectErr: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := toBaseUnits(test.amount, test.decimals)
if test.expectErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, test.want, result.String())
})
}
}

View File

@@ -0,0 +1,68 @@
package drivers
import (
"fmt"
"strings"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/driver"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/driver/tron"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/shared"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
// Registry maps configured network keys to chain drivers.
type Registry struct {
byNetwork map[string]driver.Driver
}
// NewRegistry selects drivers for the configured networks.
func NewRegistry(logger mlogger.Logger, networks []shared.Network) (*Registry, error) {
if logger == nil {
return nil, merrors.InvalidArgument("driver registry: logger is required")
}
result := &Registry{byNetwork: map[string]driver.Driver{}}
for _, network := range networks {
if !network.Name.IsValid() {
continue
}
name := network.Name.String()
chainDriver, err := resolveDriver(logger, name)
if err != nil {
logger.Error("Unsupported chain driver", zap.String("network", name), zap.Error(err))
return nil, err
}
result.byNetwork[name] = chainDriver
}
if len(result.byNetwork) == 0 {
return nil, merrors.InvalidArgument("driver registry: no supported networks configured")
}
logger.Info("Chain drivers configured", zap.Int("count", len(result.byNetwork)))
return result, nil
}
// Driver resolves a driver for the provided network key.
func (r *Registry) Driver(network string) (driver.Driver, error) {
if r == nil || len(r.byNetwork) == 0 {
return nil, merrors.Internal("driver registry is not configured")
}
key := strings.ToLower(strings.TrimSpace(network))
if key == "" {
return nil, merrors.InvalidArgument("network is required")
}
chainDriver, ok := r.byNetwork[key]
if !ok {
return nil, merrors.InvalidArgument(fmt.Sprintf("unsupported chain network %s", key))
}
return chainDriver, nil
}
func resolveDriver(logger mlogger.Logger, network string) (driver.Driver, error) {
switch {
case strings.HasPrefix(network, "tron"):
return tron.New(logger), nil
default:
return nil, merrors.InvalidArgument("tron gateway only supports tron networks, got " + network)
}
}

View File

@@ -0,0 +1,349 @@
package gateway
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/core/types"
"github.com/ethereum/go-ethereum/rpc"
"github.com/shopspring/decimal"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/rpcclient"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/shared"
"go.uber.org/zap"
"github.com/tech/sendico/gateway/tron/internal/keymanager"
"github.com/tech/sendico/gateway/tron/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, clients *rpcclient.Clients) TransferExecutor {
return &onChainExecutor{
logger: logger.Named("executor"),
keyManager: keyManager,
clients: clients,
}
}
type onChainExecutor struct {
logger mlogger.Logger
keyManager keymanager.Manager
clients *rpcclient.Clients
}
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.Warn("Key manager not configured")
return "", executorInternal("key manager is not configured", nil)
}
rpcURL := strings.TrimSpace(network.RPCURL)
if rpcURL == "" {
o.logger.Warn("Network rpc url missing", zap.String("network", network.Name.String()))
return "", executorInvalid("network rpc url is not configured")
}
if source == nil || transfer == nil {
o.logger.Warn("Transfer context missing")
return "", executorInvalid("transfer context missing")
}
if strings.TrimSpace(source.KeyReference) == "" {
o.logger.Warn("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.Warn("Source wallet missing deposit address", zap.String("wallet_ref", source.WalletRef))
return "", executorInvalid("source wallet missing deposit address")
}
if !common.IsHexAddress(destinationAddress) {
o.logger.Warn("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.String()),
zap.String("destination", strings.ToLower(destinationAddress)),
)
client, err := o.clients.Client(network.Name.String())
if err != nil {
o.logger.Warn("Failed to initialise RPC client", zap.Error(err), zap.String("network", network.Name.String()))
return "", err
}
rpcClient, err := o.clients.RPCClient(network.Name.String())
if err != nil {
o.logger.Warn("Failed to initialise RPC client",
zap.String("network", network.Name.String()),
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.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
zap.String("wallet_ref", source.WalletRef),
)
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.String()),
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, rpcClient, tokenAddress)
if err != nil {
o.logger.Warn("Failed to read token decimals", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
zap.String("contract", transfer.ContractAddress),
)
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.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
zap.String("amount", amount.Amount),
)
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.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
zap.String("wallet_ref", source.WalletRef),
)
return "", err
}
if err := client.SendTransaction(ctx, signedTx); err != nil {
o.logger.Warn("Failed to send transaction", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
)
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.String()),
)
return txHash, 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.String()))
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.clients.Client(network.Name.String())
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.String()),
)
continue
case <-ctx.Done():
o.logger.Warn("Context cancelled while awaiting confirmation",
zap.String("tx_hash", txHash),
zap.String("network", network.Name.String()),
)
return nil, ctx.Err()
}
}
o.logger.Warn("Failed to fetch transaction receipt",
zap.String("tx_hash", txHash),
zap.String("network", network.Name.String()),
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.String()),
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 *rpc.Client, token common.Address) (uint8, error) {
call := map[string]string{
"to": strings.ToLower(token.Hex()),
"data": "0x313ce567",
}
var hexResp string
if err := client.CallContext(ctx, &hexResp, "eth_call", call, "latest"); err != nil {
return 0, executorInternal("decimals call failed", err)
}
val, err := shared.DecodeHexUint8(hexResp)
if err != nil {
return 0, executorInternal("decimals decode failed", err)
}
return val, 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,99 @@
package gateway
import (
"strings"
"github.com/tech/sendico/gateway/tron/internal/keymanager"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/drivers"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/rpcclient"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/tronclient"
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
}
}
// WithRPCClients configures pre-initialised RPC clients.
func WithRPCClients(clients *rpcclient.Clients) Option {
return func(s *Service) {
s.rpcClients = clients
}
}
// WithTronClients configures native TRON gRPC clients.
func WithTronClients(clients *tronclient.Registry) Option {
return func(s *Service) {
s.tronClients = clients
}
}
// 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.IsValid() {
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))
}
s.networks[clone.Name.String()] = clone
}
}
}
// WithServiceWallet configures the service wallet binding.
func WithServiceWallet(wallet shared.ServiceWallet) Option {
return func(s *Service) {
s.serviceWallet = wallet
}
}
// WithDriverRegistry configures the chain driver registry.
func WithDriverRegistry(registry *drivers.Registry) Option {
return func(s *Service) {
s.drivers = registry
}
}
// WithClock overrides the service clock.
func WithClock(clk clockpkg.Clock) Option {
return func(s *Service) {
if clk != nil {
s.clock = clk
}
}
}
// WithSettings applies gateway settings.
func WithSettings(settings CacheSettings) Option {
return func(s *Service) {
s.settings = settings.withDefaults()
}
}
// WithDiscoveryInvokeURI sets the invoke URI used when announcing the gateway.
func WithDiscoveryInvokeURI(invokeURI string) Option {
return func(s *Service) {
s.invokeURI = strings.TrimSpace(invokeURI)
}
}

View File

@@ -0,0 +1,204 @@
package rpcclient
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/ethereum/go-ethereum/rpc"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/shared"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
// Clients holds pre-initialised RPC clients keyed by network name.
type Clients struct {
logger mlogger.Logger
clients map[string]clientEntry
}
type clientEntry struct {
eth *ethclient.Client
rpc *rpc.Client
}
// Prepare dials all configured networks up front and returns a ready-to-use client set.
func Prepare(ctx context.Context, logger mlogger.Logger, networks []shared.Network) (*Clients, error) {
if logger == nil {
return nil, merrors.Internal("rpc clients: logger is required")
}
clientLogger := logger.Named("rpc_client")
result := &Clients{
logger: clientLogger,
clients: make(map[string]clientEntry),
}
for _, network := range networks {
name := network.Name.String()
rpcURL := strings.TrimSpace(network.RPCURL)
if !network.Name.IsValid() {
clientLogger.Warn("Skipping network with invalid name during rpc client preparation")
continue
}
if rpcURL == "" {
result.Close()
err := merrors.InvalidArgument(fmt.Sprintf("rpc url not configured for network %s", name))
clientLogger.Warn("Rpc url missing", zap.String("network", name))
return nil, err
}
fields := []zap.Field{
zap.String("network", name),
}
clientLogger.Info("Initialising rpc client", fields...)
dialCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
httpClient := &http.Client{
Transport: &loggingRoundTripper{
logger: clientLogger,
network: name,
endpoint: rpcURL,
base: http.DefaultTransport,
},
}
rpcCli, err := rpc.DialOptions(dialCtx, rpcURL, rpc.WithHTTPClient(httpClient))
cancel()
if err != nil {
result.Close()
clientLogger.Warn("Failed to dial rpc endpoint", append(fields, zap.Error(err))...)
return nil, merrors.Internal(fmt.Sprintf("rpc dial failed for %s: %s", name, err.Error()))
}
client := ethclient.NewClient(rpcCli)
result.clients[name] = clientEntry{
eth: client,
rpc: rpcCli,
}
clientLogger.Info("RPC client ready", fields...)
}
if len(result.clients) == 0 {
clientLogger.Warn("No rpc clients were initialised")
return nil, merrors.InvalidArgument("no rpc clients initialised")
} else {
clientLogger.Info("RPC clients initialised", zap.Int("count", len(result.clients)))
}
return result, nil
}
// Client returns a prepared client for the given network name.
func (c *Clients) Client(network string) (*ethclient.Client, error) {
if c == nil {
return nil, merrors.Internal("RPC clients not initialised")
}
name := strings.ToLower(strings.TrimSpace(network))
entry, ok := c.clients[name]
if !ok || entry.eth == nil {
return nil, merrors.InvalidArgument(fmt.Sprintf("RPC client not configured for network %s", name))
}
return entry.eth, nil
}
// RPCClient returns the raw RPC client for low-level calls.
func (c *Clients) RPCClient(network string) (*rpc.Client, error) {
if c == nil {
return nil, merrors.Internal("rpc clients not initialised")
}
name := strings.ToLower(strings.TrimSpace(network))
entry, ok := c.clients[name]
if !ok || entry.rpc == nil {
return nil, merrors.InvalidArgument(fmt.Sprintf("rpc client not configured for network %s", name))
}
return entry.rpc, nil
}
// Close tears down all RPC clients, logging each close.
func (c *Clients) Close() {
if c == nil {
return
}
for name, entry := range c.clients {
if entry.rpc != nil {
entry.rpc.Close()
} else if entry.eth != nil {
entry.eth.Close()
}
if c.logger != nil {
c.logger.Info("RPC client closed", zap.String("network", name))
}
}
}
type loggingRoundTripper struct {
logger mlogger.Logger
network string
endpoint string
base http.RoundTripper
}
func (l *loggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if l.base == nil {
l.base = http.DefaultTransport
}
var reqBody []byte
if req.Body != nil {
raw, _ := io.ReadAll(req.Body)
reqBody = raw
req.Body = io.NopCloser(strings.NewReader(string(raw)))
}
fields := []zap.Field{
zap.String("network", l.network),
}
if len(reqBody) > 0 {
fields = append(fields, zap.String("rpc_request", truncate(string(reqBody), 2048)))
}
l.logger.Debug("RPC request", fields...)
resp, err := l.base.RoundTrip(req)
if err != nil {
l.logger.Warn("RPC http request failed", append(fields, zap.Error(err))...)
return nil, err
}
bodyBytes, _ := io.ReadAll(resp.Body)
resp.Body.Close()
resp.Body = io.NopCloser(strings.NewReader(string(bodyBytes)))
respFields := append(fields,
zap.Int("status_code", resp.StatusCode),
)
if contentType := strings.TrimSpace(resp.Header.Get("Content-Type")); contentType != "" {
respFields = append(respFields, zap.String("content_type", contentType))
}
if len(bodyBytes) > 0 {
respFields = append(respFields, zap.String("rpc_response", truncate(string(bodyBytes), 2048)))
}
l.logger.Debug("RPC response", respFields...)
if resp.StatusCode >= 400 {
l.logger.Warn("RPC response error", respFields...)
} else if len(bodyBytes) == 0 {
l.logger.Warn("RPC response empty body", respFields...)
} else if len(bodyBytes) > 0 && !json.Valid(bodyBytes) {
l.logger.Warn("RPC response invalid JSON", respFields...)
}
return resp, nil
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
if max <= 3 {
return s[:max]
}
return s[:max-3] + "..."
}

View File

@@ -0,0 +1,54 @@
package rpcclient
import (
"strings"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/ethereum/go-ethereum/rpc"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/shared"
"github.com/tech/sendico/pkg/merrors"
)
// Registry binds static network metadata with prepared RPC clients.
type Registry struct {
networks map[string]shared.Network
clients *Clients
}
// NewRegistry constructs a registry keyed by lower-cased network name.
func NewRegistry(networks map[string]shared.Network, clients *Clients) *Registry {
return &Registry{
networks: networks,
clients: clients,
}
}
// Network fetches network metadata by key (case-insensitive).
func (r *Registry) Network(key string) (shared.Network, bool) {
if r == nil || len(r.networks) == 0 {
return shared.Network{}, false
}
n, ok := r.networks[strings.ToLower(strings.TrimSpace(key))]
return n, ok
}
// Client returns the prepared RPC client for the given network name.
func (r *Registry) Client(key string) (*ethclient.Client, error) {
if r == nil || r.clients == nil {
return nil, merrors.Internal("rpc clients not initialised")
}
return r.clients.Client(strings.ToLower(strings.TrimSpace(key)))
}
// RPCClient returns the raw RPC client for low-level calls.
func (r *Registry) RPCClient(key string) (*rpc.Client, error) {
if r == nil || r.clients == nil {
return nil, merrors.Internal("rpc clients not initialised")
}
return r.clients.RPCClient(strings.ToLower(strings.TrimSpace(key)))
}
// Networks exposes the registry map for iteration when needed.
func (r *Registry) Networks() map[string]shared.Network {
return r.networks
}

View File

@@ -0,0 +1,225 @@
package gateway
import (
"context"
"github.com/tech/sendico/gateway/tron/internal/appversion"
"github.com/tech/sendico/gateway/tron/internal/keymanager"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/commands"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/commands/transfer"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/commands/wallet"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/drivers"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/rpcclient"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/tronclient"
"github.com/tech/sendico/gateway/tron/storage"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
clockpkg "github.com/tech/sendico/pkg/clock"
"github.com/tech/sendico/pkg/discovery"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
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 ConnectorService RPC contract for chain operations.
type Service struct {
logger mlogger.Logger
storage storage.Repository
producer msg.Producer
clock clockpkg.Clock
settings CacheSettings
networks map[string]shared.Network
serviceWallet shared.ServiceWallet
keyManager keymanager.Manager
rpcClients *rpcclient.Clients
tronClients *tronclient.Registry // Native TRON gRPC clients
networkRegistry *rpcclient.Registry
drivers *drivers.Registry
commands commands.Registry
announcers []*discovery.Announcer
invokeURI string
connectorv1.UnimplementedConnectorServiceServer
}
// 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{},
settings: defaultSettings(),
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.settings = svc.settings.withDefaults()
svc.networkRegistry = rpcclient.NewRegistry(svc.networks, svc.rpcClients)
svc.commands = commands.NewRegistry(commands.RegistryDeps{
Wallet: commandsWalletDeps(svc),
Transfer: commandsTransferDeps(svc),
})
svc.startDiscoveryAnnouncers()
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) {
connectorv1.RegisterConnectorServiceServer(reg, s)
})
}
func (s *Service) Shutdown() {
if s == nil {
return
}
for _, announcer := range s.announcers {
if announcer != nil {
announcer.Stop()
}
}
}
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) ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) {
return executeUnary(ctx, s, "ComputeGasTopUp", s.commands.ComputeGasTopUp.Execute, req)
}
func (s *Service) EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) {
return executeUnary(ctx, s, "EnsureGasTopUp", s.commands.EnsureGasTopUp.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"),
Drivers: s.drivers,
Networks: s.networkRegistry,
TronClients: s.tronClients,
KeyManager: s.keyManager,
Storage: s.storage,
Clock: s.clock,
BalanceCacheTTL: s.settings.walletBalanceCacheTTL(),
RPCTimeout: s.settings.rpcTimeout(),
EnsureRepository: s.ensureRepository,
}
}
func commandsTransferDeps(s *Service) transfer.Deps {
return transfer.Deps{
Logger: s.logger.Named("transfer_cmd"),
Drivers: s.drivers,
Networks: s.networkRegistry,
TronClients: s.tronClients,
KeyManager: s.keyManager,
Storage: s.storage,
Clock: s.clock,
RPCTimeout: s.settings.rpcTimeout(),
EnsureRepository: s.ensureRepository,
LaunchExecution: s.launchTransferExecution,
}
}
func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method string, handler func(context.Context, *TReq) gsresponse.Responder[TResp], req *TReq) (*TResp, error) {
start := svc.clock.Now()
resp, err := gsresponse.Unary(svc.logger, mservice.ChainGateway, handler)(ctx, req)
observeRPC(method, err, svc.clock.Now().Sub(start))
return resp, err
}
func (s *Service) startDiscoveryAnnouncers() {
if s == nil || s.producer == nil || len(s.networks) == 0 {
return
}
version := appversion.Create().Short()
for _, network := range s.networks {
currencies := []string{shared.NativeCurrency(network)}
for _, token := range network.TokenConfigs {
if token.Symbol != "" {
currencies = append(currencies, token.Symbol)
}
}
announce := discovery.Announcement{
Service: "CRYPTO_RAIL_GATEWAY",
Rail: "CRYPTO",
Network: network.Name.String(),
Operations: []string{"balance.read", "payin.crypto", "payout.crypto", "fee.send", "observe.confirm"},
Currencies: currencies,
InvokeURI: s.invokeURI,
Version: version,
}
announcer := discovery.NewAnnouncer(s.logger, s.producer, string(mservice.ChainGateway), announce)
announcer.Start()
s.announcers = append(s.announcers, announcer)
}
}

View File

@@ -0,0 +1,664 @@
package gateway
import (
"context"
"fmt"
"math/big"
"sort"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
pmodel "github.com/tech/sendico/pkg/model"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
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/tron/internal/keymanager"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/drivers"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/tron/storage"
"github.com/tech/sendico/gateway/tron/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"
troncore "github.com/fbsobreira/gotron-sdk/pkg/proto/core"
)
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_TRON_MAINNET,
TokenSymbol: "USDT",
},
}
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 TestCreateManagedWallet_NativeTokenWithoutContract(t *testing.T) {
svc, _ := newTestService(t)
ctx := context.Background()
resp, err := svc.CreateManagedWallet(ctx, &ichainv1.CreateManagedWalletRequest{
IdempotencyKey: "idem-native",
OrganizationRef: "org-1",
OwnerRef: "owner-1",
Asset: &ichainv1.Asset{
Chain: ichainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET,
TokenSymbol: "TRX",
},
})
require.NoError(t, err)
require.NotNil(t, resp.GetWallet())
require.Equal(t, "TRX", resp.GetWallet().GetAsset().GetTokenSymbol())
require.Empty(t, resp.GetWallet().GetAsset().GetContractAddress())
}
func TestListAccounts_OrganizationRefFilters(t *testing.T) {
svc, _ := newTestService(t)
ctx := context.Background()
_, err := svc.CreateManagedWallet(ctx, &ichainv1.CreateManagedWalletRequest{
IdempotencyKey: "idem-org-1",
OrganizationRef: "org-1",
OwnerRef: "owner-1",
Asset: &ichainv1.Asset{
Chain: ichainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET,
TokenSymbol: "USDT",
},
})
require.NoError(t, err)
_, err = svc.CreateManagedWallet(ctx, &ichainv1.CreateManagedWalletRequest{
IdempotencyKey: "idem-org-2",
OrganizationRef: "org-2",
OwnerRef: "owner-2",
Asset: &ichainv1.Asset{
Chain: ichainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET,
TokenSymbol: "USDT",
},
})
require.NoError(t, err)
resp, err := svc.ListAccounts(ctx, &connectorv1.ListAccountsRequest{
OrganizationRef: "org-1",
Kind: connectorv1.AccountKind_CHAIN_MANAGED_WALLET,
})
require.NoError(t, err)
require.Len(t, resp.GetAccounts(), 1)
details := resp.GetAccounts()[0].GetProviderDetails()
require.NotNil(t, details)
orgField := details.GetFields()["organization_ref"]
require.NotNil(t, orgField)
require.Equal(t, "org-1", orgField.GetStringValue())
}
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_TRON_MAINNET,
TokenSymbol: "USDT",
},
})
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_TRON_MAINNET,
TokenSymbol: "USDT",
},
})
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: "USDT", Amount: "100"},
Fees: []*ichainv1.ServiceFeeBreakdown{
{
FeeCode: "service",
Amount: &moneyv1.Money{Currency: "USDT", 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())
}
func TestGetWalletBalance_ReturnsCachedNativeAvailable(t *testing.T) {
svc, repo := newTestService(t)
ctx := context.Background()
createResp, err := svc.CreateManagedWallet(ctx, &ichainv1.CreateManagedWalletRequest{
IdempotencyKey: "idem-balance",
OrganizationRef: "org-1",
OwnerRef: "owner-1",
Asset: &ichainv1.Asset{
Chain: ichainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET,
TokenSymbol: "USDT",
},
})
require.NoError(t, err)
walletRef := createResp.GetWallet().GetWalletRef()
err = repo.wallets.SaveBalance(ctx, &model.WalletBalance{
WalletRef: walletRef,
Available: &moneyv1.Money{Currency: "USDT", Amount: "25"},
NativeAvailable: &moneyv1.Money{Currency: "TRX", Amount: "0.5"},
CalculatedAt: time.Now().UTC(),
})
require.NoError(t, err)
resp, err := svc.GetWalletBalance(ctx, &ichainv1.GetWalletBalanceRequest{WalletRef: walletRef})
require.NoError(t, err)
require.NotNil(t, resp.GetBalance())
require.Equal(t, "0.5", resp.GetBalance().GetNativeAvailable().GetAmount())
require.Equal(t, "TRX", resp.GetBalance().GetNativeAvailable().GetCurrency())
}
// ---- 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.OwnerRefFilter != nil {
ownerRef := strings.TrimSpace(*filter.OwnerRefFilter)
if !strings.EqualFold(wallet.OwnerRef, 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(t *testing.T) (*Service, *inMemoryRepository) {
repo := newInMemoryRepository()
logger := zap.NewNop()
networks := []shared.Network{{
Name: pmodel.ChainNetworkTronMainnet,
NativeToken: "TRX",
TokenConfigs: []shared.TokenContract{
{Symbol: "USDT", ContractAddress: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"},
},
}}
driverRegistry, err := drivers.NewRegistry(logger.Named("drivers"), networks)
require.NoError(t, err)
svc := NewService(logger, repo, nil,
WithKeyManager(&fakeKeyManager{}),
WithNetworks(networks),
WithServiceWallet(shared.ServiceWallet{Network: pmodel.ChainNetworkTronMainnet, Address: "TServiceWalletAddress"}),
WithDriverRegistry(driverRegistry),
)
return svc, repo
}
type fakeKeyManager struct{}
func (f *fakeKeyManager) CreateManagedWalletKey(ctx context.Context, walletRef string, network string) (*keymanager.ManagedWalletKey, error) {
// Use a valid Tron address format for testing
return &keymanager.ManagedWalletKey{
KeyID: fmt.Sprintf("%s/%s", strings.ToLower(network), walletRef),
Address: "TGBDXEg9rxSqGFJDcb889zqTjDwx1bmLRF",
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
}
func (f *fakeKeyManager) SignTronTransaction(ctx context.Context, keyID string, tx *troncore.Transaction) (*troncore.Transaction, error) {
return tx, nil
}

View File

@@ -0,0 +1,43 @@
package gateway
import "time"
const defaultWalletBalanceCacheTTL = 120 * time.Second
const defaultRPCRequestTimeout = 15 * time.Second
// CacheSettings holds tunable gateway behaviour.
type CacheSettings struct {
WalletBalanceCacheTTLSeconds int `yaml:"wallet_balance_ttl_seconds"`
RPCRequestTimeoutSeconds int `yaml:"rpc_request_timeout_seconds"`
}
func defaultSettings() CacheSettings {
return CacheSettings{
WalletBalanceCacheTTLSeconds: int(defaultWalletBalanceCacheTTL.Seconds()),
RPCRequestTimeoutSeconds: int(defaultRPCRequestTimeout.Seconds()),
}
}
func (s CacheSettings) withDefaults() CacheSettings {
if s.WalletBalanceCacheTTLSeconds <= 0 {
s.WalletBalanceCacheTTLSeconds = int(defaultWalletBalanceCacheTTL.Seconds())
}
if s.RPCRequestTimeoutSeconds <= 0 {
s.RPCRequestTimeoutSeconds = int(defaultRPCRequestTimeout.Seconds())
}
return s
}
func (s CacheSettings) walletBalanceCacheTTL() time.Duration {
if s.WalletBalanceCacheTTLSeconds <= 0 {
return defaultWalletBalanceCacheTTL
}
return time.Duration(s.WalletBalanceCacheTTLSeconds) * time.Second
}
func (s CacheSettings) rpcTimeout() time.Duration {
if s.RPCRequestTimeoutSeconds <= 0 {
return defaultRPCRequestTimeout
}
return time.Duration(s.RPCRequestTimeoutSeconds) * time.Second
}

View File

@@ -0,0 +1,32 @@
package shared
import "github.com/shopspring/decimal"
// GasTopUpRule defines buffer, minimum, rounding, and cap behavior for native gas top-ups.
type GasTopUpRule struct {
BufferPercent decimal.Decimal
MinNativeBalance decimal.Decimal
RoundingUnit decimal.Decimal
MaxTopUp decimal.Decimal
}
// GasTopUpPolicy captures default and optional overrides for native vs contract transfers.
type GasTopUpPolicy struct {
Default GasTopUpRule
Native *GasTopUpRule
Contract *GasTopUpRule
}
// Rule selects the policy rule for the transfer type.
func (p *GasTopUpPolicy) Rule(contractTransfer bool) (GasTopUpRule, bool) {
if p == nil {
return GasTopUpRule{}, false
}
if contractTransfer && p.Contract != nil {
return *p.Contract, true
}
if !contractTransfer && p.Native != nil {
return *p.Native, true
}
return p.Default, true
}

View File

@@ -0,0 +1,148 @@
package shared
import (
"strings"
"github.com/tech/sendico/gateway/tron/storage/model"
chainasset "github.com/tech/sendico/pkg/chain"
pmodel "github.com/tech/sendico/pkg/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 {
return chainasset.NetworkFromString(name)
}
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
}
}
// NativeCurrency returns the canonical native token symbol for a network.
func NativeCurrency(network Network) string {
currency := strings.ToUpper(strings.TrimSpace(network.NativeToken))
if currency == "" {
currency = strings.ToUpper(network.Name.String())
}
return currency
}
// Network describes a supported blockchain network and known token contracts.
type Network struct {
Name pmodel.ChainNetwork
RPCURL string
GRPCUrl string // Native TRON gRPC endpoint (for transactions)
GRPCToken string // Optional auth token for TRON gRPC (x-token header)
ChainID uint64
NativeToken string
TokenConfigs []TokenContract
GasTopUpPolicy *GasTopUpPolicy
}
// 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 pmodel.ChainNetwork
Address string
PrivateKey string
}

View File

@@ -0,0 +1,50 @@
package shared
import (
"math/big"
"strings"
"github.com/tech/sendico/pkg/merrors"
)
var (
errHexEmpty = merrors.InvalidArgument("hex value is empty")
errHexInvalid = merrors.InvalidArgument("invalid hex number")
errHexOutOfRange = merrors.InvalidArgument("hex number out of range")
)
// DecodeHexBig parses a hex string that may include leading zero digits.
func DecodeHexBig(input string) (*big.Int, error) {
trimmed := strings.TrimSpace(input)
if trimmed == "" {
return nil, errHexEmpty
}
noPrefix := strings.TrimPrefix(trimmed, "0x")
if noPrefix == "" {
return nil, errHexEmpty
}
value := strings.TrimLeft(noPrefix, "0")
if value == "" {
return big.NewInt(0), nil
}
val := new(big.Int)
if _, ok := val.SetString(value, 16); !ok {
return nil, errHexInvalid
}
return val, nil
}
// DecodeHexUint8 parses a hex string into uint8, allowing leading zeros.
func DecodeHexUint8(input string) (uint8, error) {
val, err := DecodeHexBig(input)
if err != nil {
return 0, err
}
if val == nil {
return 0, errHexInvalid
}
if val.BitLen() > 8 {
return 0, errHexOutOfRange
}
return uint8(val.Uint64()), nil
}

View File

@@ -0,0 +1,16 @@
package shared
import "testing"
func TestDecodeHexUint8_LeadingZeros(t *testing.T) {
t.Parallel()
const resp = "0x0000000000000000000000000000000000000000000000000000000000000006"
val, err := DecodeHexUint8(resp)
if err != nil {
t.Fatalf("DecodeHexUint8 error: %v", err)
}
if val != 6 {
t.Fatalf("DecodeHexUint8 value = %d, want 6", val)
}
}

View File

@@ -0,0 +1,142 @@
package gateway
import (
"context"
"errors"
"strings"
"time"
"github.com/ethereum/go-ethereum/core/types"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/driver"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/tron/storage/model"
"github.com/tech/sendico/pkg/merrors"
"go.uber.org/zap"
)
func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, network shared.Network) {
if s.drivers == 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.Warn("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))
}
driverDeps := s.driverDeps()
chainDriver, err := s.driverForNetwork(network.Name.String())
if err != nil {
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
return err
}
destinationAddress, err := s.destinationAddress(ctx, chainDriver, transfer.Destination)
if err != nil {
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
return err
}
sourceAddress, err := chainDriver.NormalizeAddress(sourceWallet.DepositAddress)
if err != nil {
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
return err
}
if chainDriver.Name() == "tron" && sourceAddress == destinationAddress {
s.logger.Info("Self transfer detected; skipping submission",
zap.String("transfer_ref", transferRef),
zap.String("wallet_ref", sourceWalletRef),
zap.String("network", network.Name.String()),
)
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusConfirmed, "", ""); err != nil {
s.logger.Warn("Failed to update transfer status to confirmed", zap.String("transfer_ref", transferRef), zap.Error(err))
}
return nil
}
txHash, err := chainDriver.SubmitTransfer(ctx, driverDeps, network, transfer, sourceWallet, destinationAddress)
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 := chainDriver.AwaitConfirmation(receiptCtx, driverDeps, 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, chainDriver driver.Driver, 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 chainDriver.NormalizeAddress(wallet.DepositAddress)
}
if addr := strings.TrimSpace(dest.ExternalAddress); addr != "" {
return chainDriver.NormalizeAddress(addr)
}
return "", merrors.InvalidArgument("transfer destination address not resolved")
}
func (s *Service) driverDeps() driver.Deps {
return driver.Deps{
Logger: s.logger.Named("driver"),
Registry: s.networkRegistry,
TronRegistry: s.tronClients,
KeyManager: s.keyManager,
RPCTimeout: s.settings.rpcTimeout(),
}
}
func (s *Service) driverForNetwork(network string) (driver.Driver, error) {
if s.drivers == nil {
return nil, merrors.Internal("chain drivers not configured")
}
return s.drivers.Driver(network)
}

View File

@@ -0,0 +1,219 @@
package tronclient
import (
"context"
"crypto/tls"
"encoding/hex"
"fmt"
"math/big"
"net/url"
"strings"
"time"
"github.com/fbsobreira/gotron-sdk/pkg/client"
"github.com/fbsobreira/gotron-sdk/pkg/proto/api"
"github.com/fbsobreira/gotron-sdk/pkg/proto/core"
"github.com/tech/sendico/pkg/merrors"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
)
// Client wraps the gotron-sdk gRPC client with convenience methods.
type Client struct {
grpc *client.GrpcClient
timeout time.Duration
}
// NewClient creates a new TRON gRPC client connected to the given endpoint.
func NewClient(grpcURL string, timeout time.Duration, authToken string) (*Client, error) {
if grpcURL == "" {
return nil, merrors.InvalidArgument("tronclient: grpc url is required")
}
if timeout <= 0 {
timeout = 30 * time.Second
}
address, useTLS, err := normalizeGRPCAddress(grpcURL)
if err != nil {
return nil, err
}
grpcClient := client.NewGrpcClientWithTimeout(address, timeout)
var transportCreds grpc.DialOption
if useTLS {
transportCreds = grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS12}))
} else {
transportCreds = grpc.WithTransportCredentials(insecure.NewCredentials())
}
opts := []grpc.DialOption{transportCreds}
if token := strings.TrimSpace(authToken); token != "" {
opts = append(opts,
grpc.WithUnaryInterceptor(grpcTokenUnaryInterceptor(token)),
grpc.WithStreamInterceptor(grpcTokenStreamInterceptor(token)),
)
}
if err := grpcClient.Start(opts...); err != nil {
return nil, merrors.Internal(fmt.Sprintf("tronclient: failed to connect to %s: %v", grpcURL, err))
}
return &Client{
grpc: grpcClient,
timeout: timeout,
}, nil
}
func normalizeGRPCAddress(grpcURL string) (string, bool, error) {
target := strings.TrimSpace(grpcURL)
useTLS := false
if target == "" {
return "", false, merrors.InvalidArgument("tronclient: grpc url is required")
}
if strings.Contains(target, "://") {
u, err := url.Parse(target)
if err != nil {
return "", false, merrors.InvalidArgument("tronclient: invalid grpc url")
}
if u.Scheme == "https" || u.Scheme == "grpcs" {
useTLS = true
}
host := strings.TrimSpace(u.Host)
if host == "" {
return "", false, merrors.InvalidArgument("tronclient: grpc url missing host")
}
if useTLS && u.Port() == "" {
host = host + ":443"
}
return host, useTLS, nil
}
return target, useTLS, nil
}
func grpcTokenUnaryInterceptor(token string) grpc.UnaryClientInterceptor {
return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
ctx = metadata.AppendToOutgoingContext(ctx, "x-token", token)
return invoker(ctx, method, req, reply, cc, opts...)
}
}
func grpcTokenStreamInterceptor(token string) grpc.StreamClientInterceptor {
return func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
ctx = metadata.AppendToOutgoingContext(ctx, "x-token", token)
return streamer(ctx, desc, cc, method, opts...)
}
}
// Close closes the gRPC connection.
func (c *Client) Close() {
if c != nil && c.grpc != nil {
c.grpc.Stop()
}
}
// SetAPIKey configures the TRON-PRO-API-KEY for TronGrid requests.
func (c *Client) SetAPIKey(apiKey string) {
if c != nil && c.grpc != nil {
c.grpc.SetAPIKey(apiKey)
}
}
// Transfer creates a native TRX transfer transaction.
// Addresses should be in base58 format.
// Amount is in SUN (1 TRX = 1,000,000 SUN).
func (c *Client) Transfer(from, to string, amountSun int64) (*api.TransactionExtention, error) {
if c == nil || c.grpc == nil {
return nil, merrors.Internal("tronclient: client not initialized")
}
return c.grpc.Transfer(from, to, amountSun)
}
// TRC20Send creates a TRC20 token transfer transaction.
// Addresses should be in base58 format.
// Amount is in the token's smallest unit.
// FeeLimit is in SUN (recommended: 100_000_000 = 100 TRX).
func (c *Client) TRC20Send(from, to, contract string, amount *big.Int, feeLimit int64) (*api.TransactionExtention, error) {
if c == nil || c.grpc == nil {
return nil, merrors.Internal("tronclient: client not initialized")
}
return c.grpc.TRC20Send(from, to, contract, amount, feeLimit)
}
// Broadcast broadcasts a signed transaction to the network.
func (c *Client) Broadcast(tx *core.Transaction) (*api.Return, error) {
if c == nil || c.grpc == nil {
return nil, merrors.Internal("tronclient: client not initialized")
}
return c.grpc.Broadcast(tx)
}
// GetTransactionInfoByID retrieves transaction info by its hash.
// The txID should be a hex string (without 0x prefix).
func (c *Client) GetTransactionInfoByID(txID string) (*core.TransactionInfo, error) {
if c == nil || c.grpc == nil {
return nil, merrors.Internal("tronclient: client not initialized")
}
return c.grpc.GetTransactionInfoByID(txID)
}
// GetTransactionByID retrieves the full transaction by its hash.
func (c *Client) GetTransactionByID(txID string) (*core.Transaction, error) {
if c == nil || c.grpc == nil {
return nil, merrors.Internal("tronclient: client not initialized")
}
return c.grpc.GetTransactionByID(txID)
}
// TRC20GetDecimals returns the decimals of a TRC20 token.
func (c *Client) TRC20GetDecimals(contract string) (*big.Int, error) {
if c == nil || c.grpc == nil {
return nil, merrors.Internal("tronclient: client not initialized")
}
return c.grpc.TRC20GetDecimals(contract)
}
// TRC20ContractBalance returns the balance of an address for a TRC20 token.
func (c *Client) TRC20ContractBalance(addr, contract string) (*big.Int, error) {
if c == nil || c.grpc == nil {
return nil, merrors.Internal("tronclient: client not initialized")
}
return c.grpc.TRC20ContractBalance(addr, contract)
}
// AwaitConfirmation polls for transaction confirmation until ctx is cancelled.
func (c *Client) AwaitConfirmation(ctx context.Context, txID string, pollInterval time.Duration) (*core.TransactionInfo, error) {
if c == nil || c.grpc == nil {
return nil, merrors.Internal("tronclient: client not initialized")
}
if pollInterval <= 0 {
pollInterval = 3 * time.Second
}
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
for {
txInfo, err := c.grpc.GetTransactionInfoByID(txID)
if err == nil && txInfo != nil && txInfo.BlockNumber > 0 {
return txInfo, nil
}
select {
case <-ticker.C:
continue
case <-ctx.Done():
return nil, ctx.Err()
}
}
}
// TxIDFromExtention extracts the transaction ID hex string from a TransactionExtention.
func TxIDFromExtention(txExt *api.TransactionExtention) string {
if txExt == nil || len(txExt.Txid) == 0 {
return ""
}
return hex.EncodeToString(txExt.Txid)
}

View File

@@ -0,0 +1,127 @@
package tronclient
import (
"context"
"fmt"
"strings"
"time"
"github.com/tech/sendico/gateway/tron/internal/service/gateway/shared"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
// Registry manages TRON gRPC clients keyed by network name.
type Registry struct {
logger mlogger.Logger
clients map[string]*Client
}
// NewRegistry creates an empty registry.
func NewRegistry(logger mlogger.Logger) *Registry {
return &Registry{
logger: logger.Named("tron_registry"),
clients: make(map[string]*Client),
}
}
// Prepare initializes TRON gRPC clients for all networks with a configured GRPCUrl.
// Networks without GRPCUrl are skipped (they will fallback to EVM).
func Prepare(ctx context.Context, logger mlogger.Logger, networks []shared.Network) (*Registry, error) {
if logger == nil {
return nil, merrors.InvalidArgument("tronclient: logger is required")
}
registry := NewRegistry(logger)
timeout := 30 * time.Second
for _, network := range networks {
name := network.Name.String()
grpcURL := strings.TrimSpace(network.GRPCUrl)
grpcToken := strings.TrimSpace(network.GRPCToken)
if !network.Name.IsValid() {
continue
}
// Skip networks without TRON gRPC URL configured
if grpcURL == "" {
registry.logger.Debug("Skipping network without TRON gRPC URL",
zap.String("network", name),
)
continue
}
registry.logger.Info("Initializing TRON gRPC client",
zap.String("network", name),
zap.String("grpc_url", grpcURL),
)
client, err := NewClient(grpcURL, timeout, grpcToken)
if err != nil {
registry.Close()
registry.logger.Error("Failed to initialize TRON gRPC client",
zap.String("network", name),
zap.Error(err),
)
return nil, merrors.Internal(fmt.Sprintf("tronclient: failed to connect to %s: %v", name, err))
}
registry.clients[name] = client
registry.logger.Info("TRON gRPC client ready",
zap.String("network", name),
)
}
if len(registry.clients) > 0 {
registry.logger.Info("TRON gRPC clients initialized",
zap.Int("count", len(registry.clients)),
)
} else {
registry.logger.Debug("No TRON gRPC clients were initialized (no networks with grpc_url_env)")
}
return registry, nil
}
// Client returns the TRON gRPC client for the given network.
func (r *Registry) Client(networkName string) (*Client, error) {
if r == nil {
return nil, merrors.Internal("tronclient: registry not initialized")
}
name := strings.ToLower(strings.TrimSpace(networkName))
client, ok := r.clients[name]
if !ok || client == nil {
return nil, merrors.InvalidArgument(fmt.Sprintf("tronclient: no client for network %s", name))
}
return client, nil
}
// HasClient checks if a TRON gRPC client is available for the given network.
func (r *Registry) HasClient(networkName string) bool {
if r == nil || len(r.clients) == 0 {
return false
}
name := strings.ToLower(strings.TrimSpace(networkName))
client, ok := r.clients[name]
return ok && client != nil
}
// Close closes all TRON gRPC connections.
func (r *Registry) Close() {
if r == nil {
return
}
for name, client := range r.clients {
if client != nil {
client.Close()
if r.logger != nil {
r.logger.Info("TRON gRPC client closed", zap.String("network", name))
}
}
}
r.clients = make(map[string]*Client)
}