From 03cd2f47848b274d47b80488419d8f2493ca7a31 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Wed, 24 Dec 2025 13:20:25 +0100 Subject: [PATCH] tron refactoring --- .../internal/server/internal/serverimp.go | 8 +- .../service/gateway/commands/transfer/deps.go | 2 + .../gateway/commands/transfer/destination.go | 16 +- .../commands/transfer/destination_address.go | 7 +- .../service/gateway/commands/transfer/fee.go | 178 +----- .../service/gateway/commands/wallet/create.go | 16 +- .../service/gateway/commands/wallet/deps.go | 2 + .../commands/wallet/onchain_balance.go | 128 +--- .../service/gateway/driver/arbitrum/driver.go | 148 +++++ .../internal/service/gateway/driver/driver.go | 33 ++ .../service/gateway/driver/ethereum/driver.go | 148 +++++ .../service/gateway/driver/evm/evm.go | 554 ++++++++++++++++++ .../service/gateway/driver/tron/address.go | 193 ++++++ .../service/gateway/driver/tron/driver.go | 203 +++++++ .../service/gateway/drivers/registry.go | 74 +++ .../chain/internal/service/gateway/options.go | 15 +- .../chain/internal/service/gateway/service.go | 5 +- .../internal/service/gateway/service_test.go | 19 +- .../service/gateway/transfer_execution.go | 41 +- api/gateway/chain/storage/model/wallet.go | 30 +- api/proto/gateway/chain/v1/chain.proto | 1 - .../interface/api/srequest/payment_enums.go | 1 - .../api/srequest/payment_types_test.go | 4 +- .../internal/server/accountapiimp/service.go | 2 - .../internal/server/paymentapiimp/mapper.go | 2 - .../lib/data/mapper/payment/enums.dart | 4 - frontend/pshared/lib/l10n/en.arb | 5 - frontend/pshared/lib/l10n/ru.arb | 5 - .../lib/models/payment/chain_network.dart | 1 - frontend/pshared/lib/utils/l10n/chain.dart | 2 - 30 files changed, 1525 insertions(+), 322 deletions(-) create mode 100644 api/gateway/chain/internal/service/gateway/driver/arbitrum/driver.go create mode 100644 api/gateway/chain/internal/service/gateway/driver/driver.go create mode 100644 api/gateway/chain/internal/service/gateway/driver/ethereum/driver.go create mode 100644 api/gateway/chain/internal/service/gateway/driver/evm/evm.go create mode 100644 api/gateway/chain/internal/service/gateway/driver/tron/address.go create mode 100644 api/gateway/chain/internal/service/gateway/driver/tron/driver.go create mode 100644 api/gateway/chain/internal/service/gateway/drivers/registry.go diff --git a/api/gateway/chain/internal/server/internal/serverimp.go b/api/gateway/chain/internal/server/internal/serverimp.go index dcf876f..d1229bd 100644 --- a/api/gateway/chain/internal/server/internal/serverimp.go +++ b/api/gateway/chain/internal/server/internal/serverimp.go @@ -11,6 +11,7 @@ import ( "github.com/tech/sendico/gateway/chain/internal/keymanager" vaultmanager "github.com/tech/sendico/gateway/chain/internal/keymanager/vault" gatewayservice "github.com/tech/sendico/gateway/chain/internal/service/gateway" + "github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers" "github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient" gatewayshared "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" "github.com/tech/sendico/gateway/chain/storage" @@ -121,15 +122,18 @@ func (i *Imp) Start() error { if err != nil { return err } + driverRegistry, err := drivers.NewRegistry(i.logger.Named("drivers"), networkConfigs) + if err != nil { + return err + } serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) { - executor := gatewayservice.NewOnChainExecutor(logger, keyManager, rpcClients) opts := []gatewayservice.Option{ gatewayservice.WithNetworks(networkConfigs), gatewayservice.WithServiceWallet(walletConfig), gatewayservice.WithKeyManager(keyManager), - gatewayservice.WithTransferExecutor(executor), gatewayservice.WithRPCClients(rpcClients), + gatewayservice.WithDriverRegistry(driverRegistry), gatewayservice.WithSettings(cfg.Settings), } return gatewayservice.NewService(logger, repo, producer, opts...), nil diff --git a/api/gateway/chain/internal/service/gateway/commands/transfer/deps.go b/api/gateway/chain/internal/service/gateway/commands/transfer/deps.go index a24df33..d250d1f 100644 --- a/api/gateway/chain/internal/service/gateway/commands/transfer/deps.go +++ b/api/gateway/chain/internal/service/gateway/commands/transfer/deps.go @@ -4,6 +4,7 @@ import ( "context" "time" + "github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers" "github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient" "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" "github.com/tech/sendico/gateway/chain/storage" @@ -13,6 +14,7 @@ import ( type Deps struct { Logger mlogger.Logger + Drivers *drivers.Registry Networks *rpcclient.Registry Storage storage.Repository Clock clockpkg.Clock diff --git a/api/gateway/chain/internal/service/gateway/commands/transfer/destination.go b/api/gateway/chain/internal/service/gateway/commands/transfer/destination.go index 05c07a0..d8b3647 100644 --- a/api/gateway/chain/internal/service/gateway/commands/transfer/destination.go +++ b/api/gateway/chain/internal/service/gateway/commands/transfer/destination.go @@ -43,8 +43,22 @@ func resolveDestination(ctx context.Context, deps Deps, dest *chainv1.TransferDe 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: strings.ToLower(external), + ExternalAddress: normalized, Memo: strings.TrimSpace(dest.GetMemo()), }, nil } diff --git a/api/gateway/chain/internal/service/gateway/commands/transfer/destination_address.go b/api/gateway/chain/internal/service/gateway/commands/transfer/destination_address.go index 8efd4e7..4c0e4e3 100644 --- a/api/gateway/chain/internal/service/gateway/commands/transfer/destination_address.go +++ b/api/gateway/chain/internal/service/gateway/commands/transfer/destination_address.go @@ -4,11 +4,12 @@ import ( "context" "strings" + "github.com/tech/sendico/gateway/chain/internal/service/gateway/driver" "github.com/tech/sendico/gateway/chain/storage/model" "github.com/tech/sendico/pkg/merrors" ) -func destinationAddress(ctx context.Context, deps Deps, dest model.TransferDestination) (string, error) { +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 { @@ -17,10 +18,10 @@ func destinationAddress(ctx context.Context, deps Deps, dest model.TransferDesti if strings.TrimSpace(wallet.DepositAddress) == "" { return "", merrors.Internal("destination wallet missing deposit address") } - return wallet.DepositAddress, nil + return chainDriver.NormalizeAddress(wallet.DepositAddress) } if addr := strings.TrimSpace(dest.ExternalAddress); addr != "" { - return strings.ToLower(addr), nil + return chainDriver.NormalizeAddress(addr) } return "", merrors.InvalidArgument("transfer destination address not resolved") } diff --git a/api/gateway/chain/internal/service/gateway/commands/transfer/fee.go b/api/gateway/chain/internal/service/gateway/commands/transfer/fee.go index 295d3f4..320f55e 100644 --- a/api/gateway/chain/internal/service/gateway/commands/transfer/fee.go +++ b/api/gateway/chain/internal/service/gateway/commands/transfer/fee.go @@ -3,23 +3,12 @@ package transfer import ( "context" "errors" - "math/big" "strings" - "time" - "github.com/ethereum/go-ethereum" - "github.com/ethereum/go-ethereum/accounts/abi" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/rpc" - "github.com/shopspring/decimal" - "github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient" - "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" - "github.com/tech/sendico/gateway/chain/storage/model" + "github.com/tech/sendico/gateway/chain/internal/service/gateway/driver" "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" ) @@ -69,6 +58,15 @@ func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.E 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 { @@ -80,13 +78,18 @@ func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.E return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err) } - destinationAddress, err := destinationAddress(ctx, c.deps, dest) + 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) } - feeMoney, err := estimateNetworkFee(ctx, c.deps.Logger, c.deps.Networks, networkCfg, c.deps.RPCTimeout, sourceWallet, destinationAddress, amount) + driverDeps := driver.Deps{ + Logger: c.deps.Logger, + Registry: c.deps.Networks, + RPCTimeout: c.deps.RPCTimeout, + } + feeMoney, err := chainDriver.EstimateFee(ctx, driverDeps, networkCfg, sourceWallet, destinationAddress, amount) if err != nil { c.deps.Logger.Warn("fee estimation failed", zap.Error(err)) return gsresponse.Auto[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err) @@ -98,150 +101,3 @@ func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.E } return gsresponse.Success(resp) } - -func estimateNetworkFee(ctx context.Context, logger mlogger.Logger, registry *rpcclient.Registry, network shared.Network, timeout time.Duration, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) { - rpcURL := strings.TrimSpace(network.RPCURL) - if rpcURL == "" { - return nil, merrors.InvalidArgument("network rpc url not configured") - } - if registry == nil { - return nil, merrors.Internal("rpc clients not initialised") - } - if strings.TrimSpace(wallet.ContractAddress) == "" { - return nil, merrors.NotImplemented("native token transfers not supported") - } - if !common.IsHexAddress(wallet.ContractAddress) { - return nil, merrors.InvalidArgument("invalid token contract address") - } - if !common.IsHexAddress(wallet.DepositAddress) { - return nil, merrors.InvalidArgument("invalid source wallet address") - } - if !common.IsHexAddress(destination) { - return nil, merrors.InvalidArgument("invalid destination address") - } - - client, err := registry.Client(network.Name) - if err != nil { - return nil, err - } - rpcClient, err := registry.RPCClient(network.Name) - if err != nil { - return nil, err - } - - if timeout <= 0 { - timeout = 15 * time.Second - } - timeoutCtx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - tokenABI, err := abi.JSON(strings.NewReader(erc20TransferABI)) - if err != nil { - return nil, merrors.Internal("failed to parse erc20 abi: " + err.Error()) - } - tokenAddr := common.HexToAddress(wallet.ContractAddress) - toAddr := common.HexToAddress(destination) - fromAddr := common.HexToAddress(wallet.DepositAddress) - - decimals, err := erc20Decimals(timeoutCtx, 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 := tokenABI.Pack("transfer", toAddr, amountBase) - if err != nil { - return nil, merrors.Internal("failed to encode transfer call: " + err.Error()) - } - - gasPrice, err := client.SuggestGasPrice(timeoutCtx) - if err != nil { - return nil, merrors.Internal("failed to suggest gas price: " + err.Error()) - } - - callMsg := ethereum.CallMsg{ - From: fromAddr, - To: &tokenAddr, - GasPrice: gasPrice, - Data: input, - } - gasLimit, err := client.EstimateGas(timeoutCtx, callMsg) - if err != nil { - return nil, merrors.Internal("failed to estimate gas: " + err.Error()) - } - - fee := new(big.Int).Mul(gasPrice, new(big.Int).SetUint64(gasLimit)) - feeDec := decimal.NewFromBigInt(fee, 0) - - currency := strings.ToUpper(strings.TrimSpace(network.NativeToken)) - if currency == "" { - currency = strings.ToUpper(network.Name) - } - - return &moneyv1.Money{ - Currency: currency, - Amount: feeDec.String(), - }, nil -} - -func erc20Decimals(ctx context.Context, client *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, 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 toBaseUnits(amount string, decimals uint8) (*big.Int, error) { - value, err := decimal.NewFromString(strings.TrimSpace(amount)) - if err != nil { - return nil, merrors.InvalidArgument("invalid amount " + amount + ": " + err.Error()) - } - if value.IsNegative() { - return nil, merrors.InvalidArgument("amount must be positive") - } - multiplier := decimal.NewFromInt(1).Shift(int32(decimals)) - scaled := value.Mul(multiplier) - if !scaled.Equal(scaled.Truncate(0)) { - return nil, merrors.InvalidArgument("amount " + amount + " exceeds token precision") - } - return scaled.BigInt(), nil -} - -const erc20TransferABI = ` -[ - { - "constant": true, - "inputs": [], - "name": "decimals", - "outputs": [{ "name": "", "type": "uint8" }], - "payable": false, - "stateMutability": "view", - "type": "function" - }, - { - "constant": false, - "inputs": [ - { "name": "_to", "type": "address" }, - { "name": "_value", "type": "uint256" } - ], - "name": "transfer", - "outputs": [{ "name": "", "type": "bool" }], - "payable": false, - "stateMutability": "nonpayable", - "type": "function" - } -]` diff --git a/api/gateway/chain/internal/service/gateway/commands/wallet/create.go b/api/gateway/chain/internal/service/gateway/commands/wallet/create.go index aaac415..7d7c252 100644 --- a/api/gateway/chain/internal/service/gateway/commands/wallet/create.go +++ b/api/gateway/chain/internal/service/gateway/commands/wallet/create.go @@ -65,6 +65,15 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C 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 == "" { @@ -95,6 +104,11 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C 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() @@ -128,7 +142,7 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C Network: chainKey, TokenSymbol: tokenSymbol, ContractAddress: contractAddress, - DepositAddress: strings.ToLower(keyInfo.Address), + DepositAddress: depositAddress, KeyReference: keyInfo.KeyID, Status: model.ManagedWalletStatusActive, Metadata: metadata, diff --git a/api/gateway/chain/internal/service/gateway/commands/wallet/deps.go b/api/gateway/chain/internal/service/gateway/commands/wallet/deps.go index db2b22c..3a742ce 100644 --- a/api/gateway/chain/internal/service/gateway/commands/wallet/deps.go +++ b/api/gateway/chain/internal/service/gateway/commands/wallet/deps.go @@ -5,6 +5,7 @@ import ( "time" "github.com/tech/sendico/gateway/chain/internal/keymanager" + "github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers" "github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient" "github.com/tech/sendico/gateway/chain/storage" clockpkg "github.com/tech/sendico/pkg/clock" @@ -13,6 +14,7 @@ import ( type Deps struct { Logger mlogger.Logger + Drivers *drivers.Registry Networks *rpcclient.Registry KeyManager keymanager.Manager Storage storage.Repository diff --git a/api/gateway/chain/internal/service/gateway/commands/wallet/onchain_balance.go b/api/gateway/chain/internal/service/gateway/commands/wallet/onchain_balance.go index e56ee9a..6c0168d 100644 --- a/api/gateway/chain/internal/service/gateway/commands/wallet/onchain_balance.go +++ b/api/gateway/chain/internal/service/gateway/commands/wallet/onchain_balance.go @@ -3,14 +3,9 @@ package wallet import ( "context" "fmt" - "math/big" "strings" - "time" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/rpc" - "github.com/shopspring/decimal" - "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" + "github.com/tech/sendico/gateway/chain/internal/service/gateway/driver" "github.com/tech/sendico/gateway/chain/storage/model" "github.com/tech/sendico/pkg/merrors" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" @@ -19,10 +14,18 @@ import ( func onChainWalletBalance(ctx context.Context, deps Deps, wallet *model.ManagedWallet) (*moneyv1.Money, error) { logger := deps.Logger - registry := deps.Networks + if wallet == nil { + return nil, merrors.InvalidArgument("wallet is required") + } + if deps.Networks == nil { + return nil, merrors.Internal("rpc clients not initialised") + } + if deps.Drivers == nil { + return nil, merrors.Internal("chain drivers not configured") + } networkKey := strings.ToLower(strings.TrimSpace(wallet.Network)) - network, ok := registry.Network(networkKey) + network, ok := deps.Networks.Network(networkKey) if !ok { logger.Warn("Requested network is not configured", zap.String("wallet_ref", wallet.WalletRef), @@ -31,104 +34,21 @@ func onChainWalletBalance(ctx context.Context, deps Deps, wallet *model.ManagedW return nil, merrors.Internal(fmt.Sprintf("Requested network '%s' is not configured", networkKey)) } - rpcURL := strings.TrimSpace(network.RPCURL) - - logFields := []zap.Field{ - zap.String("wallet_ref", wallet.WalletRef), - zap.String("network", networkKey), - zap.String("token_symbol", strings.ToUpper(strings.TrimSpace(wallet.TokenSymbol))), - zap.String("contract", strings.ToLower(strings.TrimSpace(wallet.ContractAddress))), - zap.String("wallet_address", strings.ToLower(strings.TrimSpace(wallet.DepositAddress))), - } - - 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 == "" || !common.IsHexAddress(contract) { - logger.Warn("Invalid contract address for balance fetch", logFields...) - return nil, merrors.InvalidArgument("invalid contract address") - } - if wallet.DepositAddress == "" || !common.IsHexAddress(wallet.DepositAddress) { - logger.Warn("Invalid wallet address for balance fetch", logFields...) - return nil, merrors.InvalidArgument("invalid wallet address") - } - - logger.Info("Fetching on-chain wallet balance", logFields...) - - rpcClient, err := registry.RPCClient(networkKey) + chainDriver, err := deps.Drivers.Driver(networkKey) if err != nil { - logger.Warn("Failed to fetch rpc client", append(logFields, zap.Error(err))...) - return nil, err + logger.Warn("Chain driver not configured", + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", networkKey), + zap.Error(err), + ) + return nil, merrors.InvalidArgument("unsupported chain") } - timeout := deps.RPCTimeout - if timeout <= 0 { - timeout = 10 * time.Second + driverDeps := driver.Deps{ + Logger: deps.Logger, + Registry: deps.Networks, + KeyManager: deps.KeyManager, + RPCTimeout: deps.RPCTimeout, } - 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, wallet.DepositAddress) - 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 -} - -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 + return chainDriver.Balance(ctx, driverDeps, network, wallet) } diff --git a/api/gateway/chain/internal/service/gateway/driver/arbitrum/driver.go b/api/gateway/chain/internal/service/gateway/driver/arbitrum/driver.go new file mode 100644 index 0000000..4c44f19 --- /dev/null +++ b/api/gateway/chain/internal/service/gateway/driver/arbitrum/driver.go @@ -0,0 +1,148 @@ +package arbitrum + +import ( + "context" + + "github.com/ethereum/go-ethereum/core/types" + "github.com/tech/sendico/gateway/chain/internal/service/gateway/driver" + "github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/evm" + "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" + "github.com/tech/sendico/gateway/chain/storage/model" + "github.com/tech/sendico/pkg/mlogger" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + "go.uber.org/zap" +) + +// Driver implements Arbitrum-specific behavior using the shared EVM logic. +type Driver struct { + logger mlogger.Logger +} + +func New(logger mlogger.Logger) *Driver { + return &Driver{logger: logger.Named("arbitrum")} +} + +func (d *Driver) Name() string { + return "arbitrum" +} + +func (d *Driver) FormatAddress(address string) (string, error) { + d.logger.Debug("format address", zap.String("address", address)) + normalized, err := evm.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 := evm.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) { + d.logger.Debug("balance request", + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", network.Name), + ) + driverDeps := deps + driverDeps.Logger = d.logger + result, err := evm.Balance(ctx, driverDeps, network, wallet, wallet.DepositAddress) + if err != nil { + d.logger.Warn("balance failed", + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", network.Name), + zap.Error(err), + ) + } else if result != nil { + d.logger.Debug("balance result", + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", network.Name), + 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) { + d.logger.Debug("estimate fee request", + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", network.Name), + zap.String("destination", destination), + ) + driverDeps := deps + driverDeps.Logger = d.logger + result, err := evm.EstimateFee(ctx, driverDeps, network, wallet, wallet.DepositAddress, destination, amount) + if err != nil { + d.logger.Warn("estimate fee failed", + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", network.Name), + zap.Error(err), + ) + } else if result != nil { + d.logger.Debug("estimate fee result", + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", network.Name), + 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) { + d.logger.Debug("submit transfer request", + zap.String("transfer_ref", transfer.TransferRef), + zap.String("network", network.Name), + zap.String("destination", destination), + ) + driverDeps := deps + driverDeps.Logger = d.logger + txHash, err := evm.SubmitTransfer(ctx, driverDeps, network, transfer, source, source.DepositAddress, destination) + if err != nil { + d.logger.Warn("submit transfer failed", + zap.String("transfer_ref", transfer.TransferRef), + zap.String("network", network.Name), + zap.Error(err), + ) + } else { + d.logger.Debug("submit transfer result", + zap.String("transfer_ref", transfer.TransferRef), + zap.String("network", network.Name), + 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("await confirmation", + zap.String("tx_hash", txHash), + zap.String("network", network.Name), + ) + driverDeps := deps + driverDeps.Logger = d.logger + receipt, err := evm.AwaitConfirmation(ctx, driverDeps, network, txHash) + if err != nil { + d.logger.Warn("await confirmation failed", + zap.String("tx_hash", txHash), + zap.String("network", network.Name), + zap.Error(err), + ) + } else if receipt != nil { + d.logger.Debug("await confirmation result", + zap.String("tx_hash", txHash), + zap.String("network", network.Name), + zap.Uint64("block_number", receipt.BlockNumber.Uint64()), + zap.Uint64("status", receipt.Status), + ) + } + return receipt, err +} + +var _ driver.Driver = (*Driver)(nil) diff --git a/api/gateway/chain/internal/service/gateway/driver/driver.go b/api/gateway/chain/internal/service/gateway/driver/driver.go new file mode 100644 index 0000000..13628d3 --- /dev/null +++ b/api/gateway/chain/internal/service/gateway/driver/driver.go @@ -0,0 +1,33 @@ +package driver + +import ( + "context" + "time" + + "github.com/ethereum/go-ethereum/core/types" + "github.com/tech/sendico/gateway/chain/internal/keymanager" + "github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient" + "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" + "github.com/tech/sendico/gateway/chain/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 + 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) + 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) +} diff --git a/api/gateway/chain/internal/service/gateway/driver/ethereum/driver.go b/api/gateway/chain/internal/service/gateway/driver/ethereum/driver.go new file mode 100644 index 0000000..284916f --- /dev/null +++ b/api/gateway/chain/internal/service/gateway/driver/ethereum/driver.go @@ -0,0 +1,148 @@ +package ethereum + +import ( + "context" + + "github.com/ethereum/go-ethereum/core/types" + "github.com/tech/sendico/gateway/chain/internal/service/gateway/driver" + "github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/evm" + "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" + "github.com/tech/sendico/gateway/chain/storage/model" + "github.com/tech/sendico/pkg/mlogger" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + "go.uber.org/zap" +) + +// Driver implements Ethereum-specific behavior using the shared EVM logic. +type Driver struct { + logger mlogger.Logger +} + +func New(logger mlogger.Logger) *Driver { + return &Driver{logger: logger.Named("ethereum")} +} + +func (d *Driver) Name() string { + return "ethereum" +} + +func (d *Driver) FormatAddress(address string) (string, error) { + d.logger.Debug("format address", zap.String("address", address)) + normalized, err := evm.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 := evm.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) { + d.logger.Debug("balance request", + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", network.Name), + ) + driverDeps := deps + driverDeps.Logger = d.logger + result, err := evm.Balance(ctx, driverDeps, network, wallet, wallet.DepositAddress) + if err != nil { + d.logger.Warn("balance failed", + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", network.Name), + zap.Error(err), + ) + } else if result != nil { + d.logger.Debug("balance result", + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", network.Name), + 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) { + d.logger.Debug("estimate fee request", + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", network.Name), + zap.String("destination", destination), + ) + driverDeps := deps + driverDeps.Logger = d.logger + result, err := evm.EstimateFee(ctx, driverDeps, network, wallet, wallet.DepositAddress, destination, amount) + if err != nil { + d.logger.Warn("estimate fee failed", + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", network.Name), + zap.Error(err), + ) + } else if result != nil { + d.logger.Debug("estimate fee result", + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", network.Name), + 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) { + d.logger.Debug("submit transfer request", + zap.String("transfer_ref", transfer.TransferRef), + zap.String("network", network.Name), + zap.String("destination", destination), + ) + driverDeps := deps + driverDeps.Logger = d.logger + txHash, err := evm.SubmitTransfer(ctx, driverDeps, network, transfer, source, source.DepositAddress, destination) + if err != nil { + d.logger.Warn("submit transfer failed", + zap.String("transfer_ref", transfer.TransferRef), + zap.String("network", network.Name), + zap.Error(err), + ) + } else { + d.logger.Debug("submit transfer result", + zap.String("transfer_ref", transfer.TransferRef), + zap.String("network", network.Name), + 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("await confirmation", + zap.String("tx_hash", txHash), + zap.String("network", network.Name), + ) + driverDeps := deps + driverDeps.Logger = d.logger + receipt, err := evm.AwaitConfirmation(ctx, driverDeps, network, txHash) + if err != nil { + d.logger.Warn("await confirmation failed", + zap.String("tx_hash", txHash), + zap.String("network", network.Name), + zap.Error(err), + ) + } else if receipt != nil { + d.logger.Debug("await confirmation result", + zap.String("tx_hash", txHash), + zap.String("network", network.Name), + zap.Uint64("block_number", receipt.BlockNumber.Uint64()), + zap.Uint64("status", receipt.Status), + ) + } + return receipt, err +} + +var _ driver.Driver = (*Driver)(nil) diff --git a/api/gateway/chain/internal/service/gateway/driver/evm/evm.go b/api/gateway/chain/internal/service/gateway/driver/evm/evm.go new file mode 100644 index 0000000..841f7b9 --- /dev/null +++ b/api/gateway/chain/internal/service/gateway/driver/evm/evm.go @@ -0,0 +1,554 @@ +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/core/types" + "github.com/ethereum/go-ethereum/rpc" + "github.com/shopspring/decimal" + "github.com/tech/sendico/gateway/chain/internal/service/gateway/driver" + "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" + "github.com/tech/sendico/gateway/chain/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 +} + +// 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 + 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", strings.ToLower(strings.TrimSpace(network.Name))), + 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 == "" || !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) + 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 +} + +// 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 + 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 strings.TrimSpace(wallet.ContractAddress) == "" { + return nil, merrors.NotImplemented("native token transfers not supported") + } + if !common.IsHexAddress(wallet.ContractAddress) { + return nil, merrors.InvalidArgument("invalid token contract address") + } + if _, 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) + if err != nil { + return nil, err + } + rpcClient, err := registry.RPCClient(network.Name) + if err != nil { + return nil, err + } + + timeout := deps.RPCTimeout + if timeout <= 0 { + timeout = 15 * time.Second + } + timeoutCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + tokenAddr := common.HexToAddress(wallet.ContractAddress) + toAddr := common.HexToAddress(destination) + fromAddr := common.HexToAddress(fromAddress) + + 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 { + return nil, merrors.Internal("failed to encode transfer call: " + err.Error()) + } + + gasPrice, err := client.SuggestGasPrice(timeoutCtx) + if err != nil { + return nil, merrors.Internal("failed to suggest gas price: " + err.Error()) + } + + callMsg := ethereum.CallMsg{ + From: fromAddr, + To: &tokenAddr, + GasPrice: gasPrice, + Data: input, + } + gasLimit, err := client.EstimateGas(timeoutCtx, callMsg) + if err != nil { + return nil, merrors.Internal("failed to estimate gas: " + err.Error()) + } + + fee := new(big.Int).Mul(gasPrice, new(big.Int).SetUint64(gasLimit)) + feeDec := decimal.NewFromBigInt(fee, 0) + + currency := strings.ToUpper(strings.TrimSpace(network.NativeToken)) + if currency == "" { + currency = strings.ToUpper(network.Name) + } + + return &moneyv1.Money{ + Currency: currency, + Amount: feeDec.String(), + }, nil +} + +// 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 + 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)) + 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), + zap.String("destination", strings.ToLower(destination)), + ) + + client, err := registry.Client(network.Name) + if err != nil { + logger.Warn("Failed to initialise rpc client", zap.Error(err), zap.String("network", network.Name)) + return "", err + } + rpcClient, err := registry.RPCClient(network.Name) + if err != nil { + logger.Warn("failed to initialise rpc client", zap.String("network", network.Name)) + 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), + ) + return "", executorInternal("failed to suggest gas price", err) + } + + chainID := new(big.Int).SetUint64(network.ChainID) + + if strings.TrimSpace(transfer.ContractAddress) == "" { + 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) { + 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 { + 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) == "" { + 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 { + 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 := client.EstimateGas(ctx, 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), + ) + + 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 + registry := deps.Registry + + if strings.TrimSpace(txHash) == "" { + logger.Warn("Missing transaction hash for confirmation", zap.String("network", network.Name)) + return nil, executorInvalid("tx hash is required") + } + rpcURL := strings.TrimSpace(network.RPCURL) + if rpcURL == "" { + 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) + 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), + ) + continue + case <-ctx.Done(): + logger.Warn("Context cancelled while awaiting confirmation", + zap.String("tx_hash", txHash), + zap.String("network", network.Name), + ) + return nil, ctx.Err() + } + } + logger.Warn("Failed to fetch transaction receipt", + zap.String("tx_hash", txHash), + zap.String("network", network.Name), + zap.Error(err), + ) + return nil, executorInternal("failed to fetch transaction receipt", err) + } + logger.Info("Transaction confirmed", + zap.String("tx_hash", txHash), + zap.String("network", network.Name), + zap.Uint64("block_number", receipt.BlockNumber.Uint64()), + zap.Uint64("status", receipt.Status), + ) + return receipt, nil + } +} + +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 +} + +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) +} diff --git a/api/gateway/chain/internal/service/gateway/driver/tron/address.go b/api/gateway/chain/internal/service/gateway/driver/tron/address.go new file mode 100644 index 0000000..ff6048a --- /dev/null +++ b/api/gateway/chain/internal/service/gateway/driver/tron/address.go @@ -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 +} diff --git a/api/gateway/chain/internal/service/gateway/driver/tron/driver.go b/api/gateway/chain/internal/service/gateway/driver/tron/driver.go new file mode 100644 index 0000000..3c27f5d --- /dev/null +++ b/api/gateway/chain/internal/service/gateway/driver/tron/driver.go @@ -0,0 +1,203 @@ +package tron + +import ( + "context" + + "github.com/ethereum/go-ethereum/core/types" + "github.com/tech/sendico/gateway/chain/internal/service/gateway/driver" + "github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/evm" + "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" + "github.com/tech/sendico/gateway/chain/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), + ) + rpcAddr, err := rpcAddress(wallet.DepositAddress) + if err != nil { + d.logger.Warn("balance address conversion failed", + zap.String("wallet_ref", wallet.WalletRef), + zap.String("address", wallet.DepositAddress), + zap.Error(err), + ) + 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.String("wallet_ref", wallet.WalletRef), + zap.String("network", network.Name), + zap.Error(err), + ) + } else if result != nil { + d.logger.Debug("balance result", + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", network.Name), + 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") + } + d.logger.Debug("estimate fee request", + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", network.Name), + zap.String("destination", destination), + ) + rpcFrom, err := rpcAddress(wallet.DepositAddress) + if err != nil { + d.logger.Warn("estimate fee address conversion failed", + zap.String("wallet_ref", wallet.WalletRef), + zap.String("address", wallet.DepositAddress), + zap.Error(err), + ) + return nil, err + } + rpcTo, err := rpcAddress(destination) + if err != nil { + d.logger.Warn("estimate fee destination conversion failed", + zap.String("wallet_ref", wallet.WalletRef), + zap.String("destination", destination), + zap.Error(err), + ) + return nil, err + } + 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.String("wallet_ref", wallet.WalletRef), + zap.String("network", network.Name), + zap.Error(err), + ) + } else if result != nil { + d.logger.Debug("estimate fee result", + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", network.Name), + 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), + zap.String("destination", destination), + ) + rpcFrom, err := rpcAddress(source.DepositAddress) + if err != nil { + d.logger.Warn("submit transfer address conversion failed", + zap.String("wallet_ref", source.WalletRef), + zap.String("address", source.DepositAddress), + zap.Error(err), + ) + return "", err + } + rpcTo, err := rpcAddress(destination) + if err != nil { + d.logger.Warn("submit transfer destination conversion failed", + zap.String("transfer_ref", transfer.TransferRef), + zap.String("destination", destination), + zap.Error(err), + ) + 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.String("transfer_ref", transfer.TransferRef), + zap.String("network", network.Name), + zap.Error(err), + ) + } else { + d.logger.Debug("submit transfer result", + zap.String("transfer_ref", transfer.TransferRef), + zap.String("network", network.Name), + 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("await confirmation", + zap.String("tx_hash", txHash), + zap.String("network", network.Name), + ) + driverDeps := deps + driverDeps.Logger = d.logger + receipt, err := evm.AwaitConfirmation(ctx, driverDeps, network, txHash) + if err != nil { + d.logger.Warn("await confirmation failed", + zap.String("tx_hash", txHash), + zap.String("network", network.Name), + zap.Error(err), + ) + } else if receipt != nil { + d.logger.Debug("await confirmation result", + zap.String("tx_hash", txHash), + zap.String("network", network.Name), + zap.Uint64("block_number", receipt.BlockNumber.Uint64()), + zap.Uint64("status", receipt.Status), + ) + } + return receipt, err +} + +var _ driver.Driver = (*Driver)(nil) diff --git a/api/gateway/chain/internal/service/gateway/drivers/registry.go b/api/gateway/chain/internal/service/gateway/drivers/registry.go new file mode 100644 index 0000000..5b338b9 --- /dev/null +++ b/api/gateway/chain/internal/service/gateway/drivers/registry.go @@ -0,0 +1,74 @@ +package drivers + +import ( + "fmt" + "strings" + + "github.com/tech/sendico/gateway/chain/internal/service/gateway/driver" + "github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/arbitrum" + "github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/ethereum" + "github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/tron" + "github.com/tech/sendico/gateway/chain/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 { + name := strings.ToLower(strings.TrimSpace(network.Name)) + if name == "" { + continue + } + 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 + case strings.HasPrefix(network, "arbitrum"): + return arbitrum.New(logger), nil + case strings.HasPrefix(network, "ethereum"): + return ethereum.New(logger), nil + default: + return nil, merrors.InvalidArgument("unsupported chain network " + network) + } +} diff --git a/api/gateway/chain/internal/service/gateway/options.go b/api/gateway/chain/internal/service/gateway/options.go index 365dba4..936e804 100644 --- a/api/gateway/chain/internal/service/gateway/options.go +++ b/api/gateway/chain/internal/service/gateway/options.go @@ -4,6 +4,7 @@ import ( "strings" "github.com/tech/sendico/gateway/chain/internal/keymanager" + "github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers" "github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient" "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" clockpkg "github.com/tech/sendico/pkg/clock" @@ -19,13 +20,6 @@ func WithKeyManager(manager keymanager.Manager) Option { } } -// WithTransferExecutor configures the executor responsible for on-chain submissions. -func WithTransferExecutor(executor TransferExecutor) Option { - return func(s *Service) { - s.executor = executor - } -} - // WithRPCClients configures pre-initialised RPC clients. func WithRPCClients(clients *rpcclient.Clients) Option { return func(s *Service) { @@ -67,6 +61,13 @@ func WithServiceWallet(wallet shared.ServiceWallet) Option { } } +// 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) { diff --git a/api/gateway/chain/internal/service/gateway/service.go b/api/gateway/chain/internal/service/gateway/service.go index 2a9a524..c00f117 100644 --- a/api/gateway/chain/internal/service/gateway/service.go +++ b/api/gateway/chain/internal/service/gateway/service.go @@ -7,6 +7,7 @@ import ( "github.com/tech/sendico/gateway/chain/internal/service/gateway/commands" "github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/transfer" "github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/wallet" + "github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers" "github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient" "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" "github.com/tech/sendico/gateway/chain/storage" @@ -42,9 +43,9 @@ type Service struct { networks map[string]shared.Network serviceWallet shared.ServiceWallet keyManager keymanager.Manager - executor TransferExecutor rpcClients *rpcclient.Clients networkRegistry *rpcclient.Registry + drivers *drivers.Registry commands commands.Registry chainv1.UnimplementedChainGatewayServiceServer @@ -135,6 +136,7 @@ func (s *Service) ensureRepository(ctx context.Context) error { func commandsWalletDeps(s *Service) wallet.Deps { return wallet.Deps{ Logger: s.logger.Named("command"), + Drivers: s.drivers, Networks: s.networkRegistry, KeyManager: s.keyManager, Storage: s.storage, @@ -148,6 +150,7 @@ func commandsWalletDeps(s *Service) wallet.Deps { func commandsTransferDeps(s *Service) transfer.Deps { return transfer.Deps{ Logger: s.logger.Named("transfer_cmd"), + Drivers: s.drivers, Networks: s.networkRegistry, Storage: s.storage, Clock: s.clock, diff --git a/api/gateway/chain/internal/service/gateway/service_test.go b/api/gateway/chain/internal/service/gateway/service_test.go index df7071a..c22cc3b 100644 --- a/api/gateway/chain/internal/service/gateway/service_test.go +++ b/api/gateway/chain/internal/service/gateway/service_test.go @@ -18,6 +18,7 @@ import ( "google.golang.org/grpc/status" "github.com/tech/sendico/gateway/chain/internal/keymanager" + "github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers" "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" "github.com/tech/sendico/gateway/chain/storage" "github.com/tech/sendico/gateway/chain/storage/model" @@ -526,18 +527,22 @@ func sanitizeLimit(requested int32, def, max int64) int64 { return int64(requested) } -func newTestService(_ *testing.T) (*Service, *inMemoryRepository) { +func newTestService(t *testing.T) (*Service, *inMemoryRepository) { repo := newInMemoryRepository() logger := zap.NewNop() + networks := []shared.Network{{ + Name: "ethereum_mainnet", + TokenConfigs: []shared.TokenContract{ + {Symbol: "USDC", ContractAddress: "0xusdc"}, + }, + }} + driverRegistry, err := drivers.NewRegistry(logger.Named("drivers"), networks) + require.NoError(t, err) svc := NewService(logger, repo, nil, WithKeyManager(&fakeKeyManager{}), - WithNetworks([]shared.Network{{ - Name: "ethereum_mainnet", - TokenConfigs: []shared.TokenContract{ - {Symbol: "USDC", ContractAddress: "0xusdc"}, - }, - }}), + WithNetworks(networks), WithServiceWallet(shared.ServiceWallet{Network: "ethereum_mainnet", Address: "0xservice"}), + WithDriverRegistry(driverRegistry), ) return svc, repo } diff --git a/api/gateway/chain/internal/service/gateway/transfer_execution.go b/api/gateway/chain/internal/service/gateway/transfer_execution.go index 3d0aa9b..37e9eeb 100644 --- a/api/gateway/chain/internal/service/gateway/transfer_execution.go +++ b/api/gateway/chain/internal/service/gateway/transfer_execution.go @@ -7,15 +7,15 @@ import ( "time" "github.com/ethereum/go-ethereum/core/types" + "github.com/tech/sendico/gateway/chain/internal/service/gateway/driver" + "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" "github.com/tech/sendico/gateway/chain/storage/model" "github.com/tech/sendico/pkg/merrors" "go.uber.org/zap" - - "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" ) func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, network shared.Network) { - if s.executor == nil { + if s.drivers == nil { return } @@ -44,13 +44,20 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet s.logger.Warn("failed to update transfer status to signing", zap.String("transfer_ref", transferRef), zap.Error(err)) } - destinationAddress, err := s.destinationAddress(ctx, transfer.Destination) + driverDeps := s.driverDeps() + chainDriver, err := s.driverForNetwork(network.Name) if err != nil { _, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "") return err } - txHash, err := s.executor.SubmitTransfer(ctx, transfer, sourceWallet, destinationAddress, network) + destinationAddress, err := s.destinationAddress(ctx, chainDriver, transfer.Destination) + if err != nil { + _, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "") + return err + } + + 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 @@ -62,7 +69,7 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet receiptCtx, cancel := context.WithTimeout(ctx, 10*time.Minute) defer cancel() - receipt, err := s.executor.AwaitConfirmation(receiptCtx, network, txHash) + 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)) @@ -83,7 +90,7 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet return nil } -func (s *Service) destinationAddress(ctx context.Context, dest model.TransferDestination) (string, error) { +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 { @@ -92,10 +99,26 @@ func (s *Service) destinationAddress(ctx context.Context, dest model.TransferDes if strings.TrimSpace(wallet.DepositAddress) == "" { return "", merrors.Internal("destination wallet missing deposit address") } - return wallet.DepositAddress, nil + return chainDriver.NormalizeAddress(wallet.DepositAddress) } if addr := strings.TrimSpace(dest.ExternalAddress); addr != "" { - return strings.ToLower(addr), nil + 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, + 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) +} diff --git a/api/gateway/chain/storage/model/wallet.go b/api/gateway/chain/storage/model/wallet.go index 8edb5c1..f5236d3 100644 --- a/api/gateway/chain/storage/model/wallet.go +++ b/api/gateway/chain/storage/model/wallet.go @@ -91,7 +91,7 @@ func (m *ManagedWallet) Normalize() { m.Network = strings.TrimSpace(strings.ToLower(m.Network)) m.TokenSymbol = strings.TrimSpace(strings.ToUpper(m.TokenSymbol)) m.ContractAddress = strings.TrimSpace(strings.ToLower(m.ContractAddress)) - m.DepositAddress = strings.TrimSpace(strings.ToLower(m.DepositAddress)) + m.DepositAddress = normalizeWalletAddress(m.DepositAddress) m.KeyReference = strings.TrimSpace(m.KeyReference) } @@ -99,3 +99,31 @@ func (m *ManagedWallet) Normalize() { func (b *WalletBalance) Normalize() { b.WalletRef = strings.TrimSpace(b.WalletRef) } + +func normalizeWalletAddress(address string) string { + trimmed := strings.TrimSpace(address) + if trimmed == "" { + return "" + } + if isHexAddress(trimmed) { + return strings.ToLower(trimmed) + } + return trimmed +} + +func isHexAddress(value string) bool { + trimmed := strings.TrimPrefix(strings.TrimSpace(value), "0x") + if len(trimmed) != 40 && len(trimmed) != 42 { + 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 +} diff --git a/api/proto/gateway/chain/v1/chain.proto b/api/proto/gateway/chain/v1/chain.proto index 3fcfd85..b3880b9 100644 --- a/api/proto/gateway/chain/v1/chain.proto +++ b/api/proto/gateway/chain/v1/chain.proto @@ -14,7 +14,6 @@ enum ChainNetwork { CHAIN_NETWORK_UNSPECIFIED = 0; CHAIN_NETWORK_ETHEREUM_MAINNET = 1; CHAIN_NETWORK_ARBITRUM_ONE = 2; - CHAIN_NETWORK_OTHER_EVM = 3; CHAIN_NETWORK_TRON_MAINNET = 4; CHAIN_NETWORK_TRON_NILE = 5; } diff --git a/api/server/interface/api/srequest/payment_enums.go b/api/server/interface/api/srequest/payment_enums.go index c61b556..267bc44 100644 --- a/api/server/interface/api/srequest/payment_enums.go +++ b/api/server/interface/api/srequest/payment_enums.go @@ -36,7 +36,6 @@ const ( ChainNetworkUnspecified ChainNetwork = "unspecified" ChainNetworkEthereumMainnet ChainNetwork = "ethereum_mainnet" ChainNetworkArbitrumOne ChainNetwork = "arbitrum_one" - ChainNetworkOtherEVM ChainNetwork = "other_evm" ChainNetworkTronMainnet ChainNetwork = "tron_mainnet" ChainNetworkTronNile ChainNetwork = "tron_nile" ) diff --git a/api/server/interface/api/srequest/payment_types_test.go b/api/server/interface/api/srequest/payment_types_test.go index 0a41375..63131a3 100644 --- a/api/server/interface/api/srequest/payment_types_test.go +++ b/api/server/interface/api/srequest/payment_types_test.go @@ -65,7 +65,7 @@ func TestEndpointDTOBuildersAndDecoders(t *testing.T) { t.Run("external chain", func(t *testing.T) { payload := ExternalChainEndpoint{ Asset: &Asset{ - Chain: ChainNetworkOtherEVM, + Chain: ChainNetworkEthereumMainnet, TokenSymbol: "ETH", }, Address: "0x123", @@ -364,7 +364,7 @@ func TestPaymentIntentMinimalRoundTrip(t *testing.T) { func TestLegacyEndpointRoundTrip(t *testing.T) { legacy := &LegacyPaymentEndpoint{ ExternalChain: &ExternalChainEndpoint{ - Asset: &Asset{Chain: ChainNetworkOtherEVM, TokenSymbol: "DAI", ContractAddress: "0xdef"}, + Asset: &Asset{Chain: ChainNetworkEthereumMainnet, TokenSymbol: "DAI", ContractAddress: "0xdef"}, Address: "0x123", Memo: "memo", }, diff --git a/api/server/internal/server/accountapiimp/service.go b/api/server/internal/server/accountapiimp/service.go index d6a864e..eb88dea 100644 --- a/api/server/internal/server/accountapiimp/service.go +++ b/api/server/internal/server/accountapiimp/service.go @@ -214,8 +214,6 @@ func parseChainNetwork(value string) (chainv1.ChainNetwork, error) { return chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET, nil case "ARBITRUM_ONE", "CHAIN_NETWORK_ARBITRUM_ONE": return chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE, nil - case "OTHER_EVM", "CHAIN_NETWORK_OTHER_EVM": - return chainv1.ChainNetwork_CHAIN_NETWORK_OTHER_EVM, nil case "TRON_MAINNET", "CHAIN_NETWORK_TRON_MAINNET": return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET, nil case "TRON_NILE", "CHAIN_NETWORK_TRON_NILE": diff --git a/api/server/internal/server/paymentapiimp/mapper.go b/api/server/internal/server/paymentapiimp/mapper.go index 8019ee4..5e88325 100644 --- a/api/server/internal/server/paymentapiimp/mapper.go +++ b/api/server/internal/server/paymentapiimp/mapper.go @@ -286,8 +286,6 @@ func mapChainNetwork(chain srequest.ChainNetwork) (chainv1.ChainNetwork, error) return chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET, nil case string(srequest.ChainNetworkArbitrumOne): return chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE, nil - case string(srequest.ChainNetworkOtherEVM): - return chainv1.ChainNetwork_CHAIN_NETWORK_OTHER_EVM, nil case string(srequest.ChainNetworkTronMainnet): return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET, nil case string(srequest.ChainNetworkTronNile): diff --git a/frontend/pshared/lib/data/mapper/payment/enums.dart b/frontend/pshared/lib/data/mapper/payment/enums.dart index a87728c..fdce9a6 100644 --- a/frontend/pshared/lib/data/mapper/payment/enums.dart +++ b/frontend/pshared/lib/data/mapper/payment/enums.dart @@ -88,8 +88,6 @@ ChainNetwork chainNetworkFromValue(String? value) { return ChainNetwork.ethereumMainnet; case 'arbitrum_one': return ChainNetwork.arbitrumOne; - case 'other_evm': - return ChainNetwork.otherEvm; case 'tron_mainnet': return ChainNetwork.tronMainnet; case 'tron_nile': @@ -107,8 +105,6 @@ String chainNetworkToValue(ChainNetwork chain) { return 'ethereum_mainnet'; case ChainNetwork.arbitrumOne: return 'arbitrum_one'; - case ChainNetwork.otherEvm: - return 'other_evm'; case ChainNetwork.tronMainnet: return 'tron_mainnet'; case ChainNetwork.tronNile: diff --git a/frontend/pshared/lib/l10n/en.arb b/frontend/pshared/lib/l10n/en.arb index 818d523..c963da7 100644 --- a/frontend/pshared/lib/l10n/en.arb +++ b/frontend/pshared/lib/l10n/en.arb @@ -46,11 +46,6 @@ "description": "Label for the Arbitrum One network" }, - "chainNetworkOtherEvm": "Other EVM chain", - "@chainNetworkOtherEvm": { - "description": "Label for any other EVM-compatible network" - }, - "chainNetworkTronMainnet": "Tron Mainnet", "@chainNetworkTronMainnet": { "description": "Label for the Tron mainnet network" diff --git a/frontend/pshared/lib/l10n/ru.arb b/frontend/pshared/lib/l10n/ru.arb index 99240d9..83e9f94 100644 --- a/frontend/pshared/lib/l10n/ru.arb +++ b/frontend/pshared/lib/l10n/ru.arb @@ -46,11 +46,6 @@ "description": "Label for the Arbitrum One network" }, - "chainNetworkOtherEvm": "Другая EVM сеть", - "@chainNetworkOtherEvm": { - "description": "Label for any other EVM-compatible network" - }, - "chainNetworkTronMainnet": "Tron Mainnet", "@chainNetworkTronMainnet": { "description": "Label for the Tron mainnet network" diff --git a/frontend/pshared/lib/models/payment/chain_network.dart b/frontend/pshared/lib/models/payment/chain_network.dart index bdaa4a9..b1b0e88 100644 --- a/frontend/pshared/lib/models/payment/chain_network.dart +++ b/frontend/pshared/lib/models/payment/chain_network.dart @@ -2,7 +2,6 @@ enum ChainNetwork { unspecified, ethereumMainnet, arbitrumOne, - otherEvm, tronMainnet, tronNile } diff --git a/frontend/pshared/lib/utils/l10n/chain.dart b/frontend/pshared/lib/utils/l10n/chain.dart index 5ac174b..b7ca083 100644 --- a/frontend/pshared/lib/utils/l10n/chain.dart +++ b/frontend/pshared/lib/utils/l10n/chain.dart @@ -13,8 +13,6 @@ extension ChainNetworkL10n on ChainNetwork { return l10n.chainNetworkEthereumMainnet; case ChainNetwork.arbitrumOne: return l10n.chainNetworkArbitrumOne; - case ChainNetwork.otherEvm: - return l10n.chainNetworkOtherEvm; case ChainNetwork.tronMainnet: return l10n.chainNetworkTronMainnet; case ChainNetwork.tronNile: -- 2.49.1