+gas estimation
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
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
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user