From 34420ca2fbcdcb3b841df4c2fd1016297fd489be Mon Sep 17 00:00:00 2001 From: Stephan D Date: Wed, 26 Nov 2025 23:19:29 +0100 Subject: [PATCH] +gas estimation --- .../transfer/{fees.go => convert_fees.go} | 0 .../gateway/commands/transfer/destination.go | 50 ++++ .../commands/transfer/destination_address.go | 26 ++ .../service/gateway/commands/transfer/fee.go | 223 +++++++++++++++++- .../gateway/commands/transfer/submit.go | 42 +--- 5 files changed, 292 insertions(+), 49 deletions(-) rename api/chain/gateway/internal/service/gateway/commands/transfer/{fees.go => convert_fees.go} (100%) create mode 100644 api/chain/gateway/internal/service/gateway/commands/transfer/destination.go create mode 100644 api/chain/gateway/internal/service/gateway/commands/transfer/destination_address.go diff --git a/api/chain/gateway/internal/service/gateway/commands/transfer/fees.go b/api/chain/gateway/internal/service/gateway/commands/transfer/convert_fees.go similarity index 100% rename from api/chain/gateway/internal/service/gateway/commands/transfer/fees.go rename to api/chain/gateway/internal/service/gateway/commands/transfer/convert_fees.go diff --git a/api/chain/gateway/internal/service/gateway/commands/transfer/destination.go b/api/chain/gateway/internal/service/gateway/commands/transfer/destination.go new file mode 100644 index 0000000..ad3f2d8 --- /dev/null +++ b/api/chain/gateway/internal/service/gateway/commands/transfer/destination.go @@ -0,0 +1,50 @@ +package transfer + +import ( + "context" + "strings" + + "github.com/tech/sendico/chain/gateway/storage/model" + "github.com/tech/sendico/pkg/merrors" + gatewayv1 "github.com/tech/sendico/pkg/proto/chain/gateway/v1" + "go.uber.org/zap" +) + +func resolveDestination(ctx context.Context, deps Deps, dest *gatewayv1.TransferDestination, source *model.ManagedWallet) (model.TransferDestination, error) { + if dest == nil { + return model.TransferDestination{}, merrors.InvalidArgument("destination is required") + } + managedRef := strings.TrimSpace(dest.GetManagedWalletRef()) + external := strings.TrimSpace(dest.GetExternalAddress()) + if managedRef != "" && external != "" { + deps.Logger.Warn("both managed and external destination provided") + return model.TransferDestination{}, merrors.InvalidArgument("destination must be managed_wallet_ref or external_address") + } + if managedRef != "" { + wallet, err := deps.Storage.Wallets().Get(ctx, managedRef) + if err != nil { + deps.Logger.Warn("destination wallet lookup failed", zap.Error(err), zap.String("managed_wallet_ref", managedRef)) + return model.TransferDestination{}, err + } + if !strings.EqualFold(wallet.Network, source.Network) { + deps.Logger.Warn("destination wallet network mismatch", zap.String("source_network", source.Network), zap.String("dest_network", wallet.Network)) + return model.TransferDestination{}, merrors.InvalidArgument("destination wallet network mismatch") + } + if strings.TrimSpace(wallet.DepositAddress) == "" { + deps.Logger.Warn("destination wallet missing deposit address", zap.String("managed_wallet_ref", managedRef)) + return model.TransferDestination{}, merrors.InvalidArgument("destination wallet missing deposit address") + } + return model.TransferDestination{ + ManagedWalletRef: wallet.WalletRef, + Memo: strings.TrimSpace(dest.GetMemo()), + }, nil + } + if external == "" { + deps.Logger.Warn("destination external address missing") + return model.TransferDestination{}, merrors.InvalidArgument("destination is required") + } + return model.TransferDestination{ + ExternalAddress: strings.ToLower(external), + Memo: strings.TrimSpace(dest.GetMemo()), + }, nil +} diff --git a/api/chain/gateway/internal/service/gateway/commands/transfer/destination_address.go b/api/chain/gateway/internal/service/gateway/commands/transfer/destination_address.go new file mode 100644 index 0000000..785bddf --- /dev/null +++ b/api/chain/gateway/internal/service/gateway/commands/transfer/destination_address.go @@ -0,0 +1,26 @@ +package transfer + +import ( + "context" + "strings" + + "github.com/tech/sendico/chain/gateway/storage/model" + "github.com/tech/sendico/pkg/merrors" +) + +func destinationAddress(ctx context.Context, deps Deps, dest model.TransferDestination) (string, error) { + if ref := strings.TrimSpace(dest.ManagedWalletRef); ref != "" { + wallet, err := deps.Storage.Wallets().Get(ctx, ref) + if err != nil { + return "", err + } + if strings.TrimSpace(wallet.DepositAddress) == "" { + return "", merrors.Internal("destination wallet missing deposit address") + } + return wallet.DepositAddress, nil + } + if addr := strings.TrimSpace(dest.ExternalAddress); addr != "" { + return strings.ToLower(addr), nil + } + return "", merrors.InvalidArgument("transfer destination address not resolved") +} diff --git a/api/chain/gateway/internal/service/gateway/commands/transfer/fee.go b/api/chain/gateway/internal/service/gateway/commands/transfer/fee.go index b5a2713..846ef58 100644 --- a/api/chain/gateway/internal/service/gateway/commands/transfer/fee.go +++ b/api/chain/gateway/internal/service/gateway/commands/transfer/fee.go @@ -2,9 +2,21 @@ 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" @@ -24,18 +36,213 @@ func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *gatewayv1 c.deps.Logger.Warn("repository unavailable", zap.Error(err)) return gsresponse.Unavailable[gatewayv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err) } - if req == nil || req.GetAmount() == nil { - c.deps.Logger.Warn("amount missing") + 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")) } - currency := req.GetAmount().GetCurrency() - fee := &moneyv1.Money{ - Currency: currency, - Amount: "0", + + 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: fee, - EstimationContext: "not_implemented", + 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" + } +]` diff --git a/api/chain/gateway/internal/service/gateway/commands/transfer/submit.go b/api/chain/gateway/internal/service/gateway/commands/transfer/submit.go index 759b6cd..4088601 100644 --- a/api/chain/gateway/internal/service/gateway/commands/transfer/submit.go +++ b/api/chain/gateway/internal/service/gateway/commands/transfer/submit.go @@ -84,7 +84,7 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *gatewayv1.Subm return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet")) } - destination, err := c.resolveDestination(ctx, req.GetDestination(), sourceWallet) + destination, err := resolveDestination(ctx, c.deps, req.GetDestination(), sourceWallet) if err != nil { if errors.Is(err, merrors.ErrNoData) { c.deps.Logger.Warn("destination not found", zap.String("destination_wallet_ref", req.GetDestination().GetManagedWalletRef())) @@ -146,43 +146,3 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *gatewayv1.Subm return gsresponse.Success(&gatewayv1.SubmitTransferResponse{Transfer: toProtoTransfer(saved)}) } - -func (c *submitTransferCommand) resolveDestination(ctx context.Context, dest *gatewayv1.TransferDestination, source *model.ManagedWallet) (model.TransferDestination, error) { - if dest == nil { - c.deps.Logger.Warn("destination missing") - return model.TransferDestination{}, merrors.InvalidArgument("destination is required") - } - managedRef := strings.TrimSpace(dest.GetManagedWalletRef()) - external := strings.TrimSpace(dest.GetExternalAddress()) - if managedRef != "" && external != "" { - c.deps.Logger.Warn("both managed and external destination provided") - return model.TransferDestination{}, merrors.InvalidArgument("destination must be managed_wallet_ref or external_address") - } - if managedRef != "" { - wallet, err := c.deps.Storage.Wallets().Get(ctx, managedRef) - if err != nil { - c.deps.Logger.Warn("destination wallet lookup failed", zap.Error(err), zap.String("managed_wallet_ref", managedRef)) - return model.TransferDestination{}, err - } - if !strings.EqualFold(wallet.Network, source.Network) { - c.deps.Logger.Warn("destination network mismatch", zap.String("source_network", source.Network), zap.String("dest_network", wallet.Network)) - return model.TransferDestination{}, merrors.InvalidArgument("destination wallet network mismatch") - } - if strings.TrimSpace(wallet.DepositAddress) == "" { - c.deps.Logger.Warn("destination wallet missing deposit address", zap.String("managed_wallet_ref", managedRef)) - return model.TransferDestination{}, merrors.InvalidArgument("destination wallet missing deposit address") - } - return model.TransferDestination{ - ManagedWalletRef: wallet.WalletRef, - Memo: strings.TrimSpace(dest.GetMemo()), - }, nil - } - if external == "" { - c.deps.Logger.Warn("destination external address missing") - return model.TransferDestination{}, merrors.InvalidArgument("destination is required") - } - return model.TransferDestination{ - ExternalAddress: strings.ToLower(external), - Memo: strings.TrimSpace(dest.GetMemo()), - }, nil -}