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