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) }