Some checks failed
ci/woodpecker/push/db Pipeline is pending
ci/woodpecker/push/frontend Pipeline is pending
ci/woodpecker/push/fx_ingestor Pipeline is pending
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline failed
ci/woodpecker/push/bump_version unknown status
ci/woodpecker/push/bff Pipeline failed
249 lines
8.8 KiB
Go
249 lines
8.8 KiB
Go
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"
|
|
}
|
|
]`
|