350 lines
11 KiB
Go
350 lines
11 KiB
Go
package gateway
|
|
|
|
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/rpcclient"
|
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
|
"github.com/tech/sendico/pkg/merrors"
|
|
"github.com/tech/sendico/pkg/mlogger"
|
|
)
|
|
|
|
// TransferExecutor handles on-chain submission of transfers.
|
|
type TransferExecutor interface {
|
|
SubmitTransfer(ctx context.Context, transfer *model.Transfer, source *model.ManagedWallet, destinationAddress string, network shared.Network) (string, error)
|
|
AwaitConfirmation(ctx context.Context, network shared.Network, txHash string) (*types.Receipt, error)
|
|
}
|
|
|
|
// NewOnChainExecutor constructs a TransferExecutor that talks to an EVM-compatible chain.
|
|
func NewOnChainExecutor(logger mlogger.Logger, keyManager keymanager.Manager, clients *rpcclient.Clients) TransferExecutor {
|
|
return &onChainExecutor{
|
|
logger: logger.Named("executor"),
|
|
keyManager: keyManager,
|
|
clients: clients,
|
|
}
|
|
}
|
|
|
|
type onChainExecutor struct {
|
|
logger mlogger.Logger
|
|
keyManager keymanager.Manager
|
|
|
|
clients *rpcclient.Clients
|
|
}
|
|
|
|
func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Transfer, source *model.ManagedWallet, destinationAddress string, network shared.Network) (string, error) {
|
|
if o.keyManager == nil {
|
|
o.logger.Warn("Key manager not configured")
|
|
return "", executorInternal("key manager is not configured", nil)
|
|
}
|
|
rpcURL := strings.TrimSpace(network.RPCURL)
|
|
if rpcURL == "" {
|
|
o.logger.Warn("Network rpc url missing", zap.String("network", network.Name))
|
|
return "", executorInvalid("network rpc url is not configured")
|
|
}
|
|
if source == nil || transfer == nil {
|
|
o.logger.Warn("Transfer context missing")
|
|
return "", executorInvalid("transfer context missing")
|
|
}
|
|
if strings.TrimSpace(source.KeyReference) == "" {
|
|
o.logger.Warn("Source wallet missing key reference", zap.String("wallet_ref", source.WalletRef))
|
|
return "", executorInvalid("source wallet missing key reference")
|
|
}
|
|
if strings.TrimSpace(source.DepositAddress) == "" {
|
|
o.logger.Warn("Source wallet missing deposit address", zap.String("wallet_ref", source.WalletRef))
|
|
return "", executorInvalid("source wallet missing deposit address")
|
|
}
|
|
if !common.IsHexAddress(destinationAddress) {
|
|
o.logger.Warn("Invalid destination address", zap.String("transfer_ref", transfer.TransferRef), zap.String("address", destinationAddress))
|
|
return "", executorInvalid("invalid destination address " + destinationAddress)
|
|
}
|
|
|
|
o.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(destinationAddress)),
|
|
)
|
|
|
|
client, err := o.clients.Client(network.Name)
|
|
if err != nil {
|
|
o.logger.Warn("Failed to initialise RPC client", zap.Error(err), zap.String("network", network.Name))
|
|
return "", err
|
|
}
|
|
rpcClient, err := o.clients.RPCClient(network.Name)
|
|
if err != nil {
|
|
o.logger.Warn("Failed to initialise RPC client",
|
|
zap.String("network", network.Name),
|
|
zap.Error(err),
|
|
)
|
|
return "", err
|
|
}
|
|
|
|
sourceAddress := common.HexToAddress(source.DepositAddress)
|
|
destination := common.HexToAddress(destinationAddress)
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
|
defer cancel()
|
|
|
|
nonce, err := client.PendingNonceAt(ctx, sourceAddress)
|
|
if err != nil {
|
|
o.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 {
|
|
o.logger.Warn("Failed to suggest gas price",
|
|
zap.String("transfer_ref", transfer.TransferRef),
|
|
zap.String("network", network.Name),
|
|
zap.Error(err),
|
|
)
|
|
return "", executorInternal("failed to suggest gas price", err)
|
|
}
|
|
|
|
var tx *types.Transaction
|
|
var txHash string
|
|
|
|
chainID := new(big.Int).SetUint64(network.ChainID)
|
|
|
|
if strings.TrimSpace(transfer.ContractAddress) == "" {
|
|
o.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) {
|
|
o.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 {
|
|
o.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) == "" {
|
|
o.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 {
|
|
o.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", destination, amountInt)
|
|
if err != nil {
|
|
o.logger.Warn("Failed to encode transfer call",
|
|
zap.String("transfer_ref", transfer.TransferRef),
|
|
zap.Error(err),
|
|
)
|
|
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 {
|
|
o.logger.Warn("Failed to estimate gas",
|
|
zap.String("transfer_ref", transfer.TransferRef),
|
|
zap.Error(err),
|
|
)
|
|
return "", executorInternal("failed to estimate gas", err)
|
|
}
|
|
|
|
tx = types.NewTransaction(nonce, tokenAddress, big.NewInt(0), gasLimit, gasPrice, input)
|
|
|
|
signedTx, err := o.keyManager.SignTransaction(ctx, source.KeyReference, tx, chainID)
|
|
if err != nil {
|
|
o.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 {
|
|
o.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()
|
|
o.logger.Info("Transaction submitted",
|
|
zap.String("transfer_ref", transfer.TransferRef),
|
|
zap.String("tx_hash", txHash),
|
|
zap.String("network", network.Name),
|
|
)
|
|
|
|
return txHash, nil
|
|
}
|
|
|
|
func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network shared.Network, txHash string) (*types.Receipt, error) {
|
|
if strings.TrimSpace(txHash) == "" {
|
|
o.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 == "" {
|
|
o.logger.Warn("Network RPC url missing while awaiting confirmation", zap.String("tx_hash", txHash))
|
|
return nil, executorInvalid("network rpc url is not configured")
|
|
}
|
|
|
|
client, err := o.clients.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:
|
|
o.logger.Debug("Transaction not yet mined",
|
|
zap.String("tx_hash", txHash),
|
|
zap.String("network", network.Name),
|
|
)
|
|
continue
|
|
case <-ctx.Done():
|
|
o.logger.Warn("Context cancelled while awaiting confirmation",
|
|
zap.String("tx_hash", txHash),
|
|
zap.String("network", network.Name),
|
|
)
|
|
return nil, ctx.Err()
|
|
}
|
|
}
|
|
o.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)
|
|
}
|
|
o.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
|
|
}
|
|
}
|
|
|
|
var (
|
|
erc20ABI abi.ABI
|
|
)
|
|
|
|
func init() {
|
|
var err error
|
|
erc20ABI, err = abi.JSON(strings.NewReader(erc20ABIJSON))
|
|
if err != nil {
|
|
panic("executor: 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"
|
|
}
|
|
]`
|
|
|
|
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, executorInvalid("invalid amount " + amount + ": " + err.Error())
|
|
}
|
|
if value.IsNegative() {
|
|
return nil, executorInvalid("amount must be positive")
|
|
}
|
|
multiplier := decimal.NewFromInt(1).Shift(int32(decimals))
|
|
scaled := value.Mul(multiplier)
|
|
if !scaled.Equal(scaled.Truncate(0)) {
|
|
return nil, executorInvalid("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)
|
|
}
|