first commit of restructured project
This commit is contained in:
74
trahn-trade-backend/internal/ethereum/abi.go
Normal file
74
trahn-trade-backend/internal/ethereum/abi.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package ethereum
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Minimal ABIs for Uniswap V2 Router02 and ERC20 — only the methods we call.
|
||||
|
||||
func mustRouterABI() io.Reader {
|
||||
return strings.NewReader(`[
|
||||
{
|
||||
"name": "swapExactTokensForETH",
|
||||
"type": "function",
|
||||
"stateMutability": "nonpayable",
|
||||
"inputs": [
|
||||
{"name": "amountIn", "type": "uint256"},
|
||||
{"name": "amountOutMin", "type": "uint256"},
|
||||
{"name": "path", "type": "address[]"},
|
||||
{"name": "to", "type": "address"},
|
||||
{"name": "deadline", "type": "uint256"}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "amounts", "type": "uint256[]"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "swapExactETHForTokens",
|
||||
"type": "function",
|
||||
"stateMutability": "payable",
|
||||
"inputs": [
|
||||
{"name": "amountOutMin", "type": "uint256"},
|
||||
{"name": "path", "type": "address[]"},
|
||||
{"name": "to", "type": "address"},
|
||||
{"name": "deadline", "type": "uint256"}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "amounts", "type": "uint256[]"}
|
||||
]
|
||||
}
|
||||
]`)
|
||||
}
|
||||
|
||||
func mustERC20ABI() io.Reader {
|
||||
return strings.NewReader(`[
|
||||
{
|
||||
"name": "balanceOf",
|
||||
"type": "function",
|
||||
"stateMutability": "view",
|
||||
"inputs": [{"name": "_owner", "type": "address"}],
|
||||
"outputs": [{"name": "balance", "type": "uint256"}]
|
||||
},
|
||||
{
|
||||
"name": "allowance",
|
||||
"type": "function",
|
||||
"stateMutability": "view",
|
||||
"inputs": [
|
||||
{"name": "_owner", "type": "address"},
|
||||
{"name": "_spender", "type": "address"}
|
||||
],
|
||||
"outputs": [{"name": "", "type": "uint256"}]
|
||||
},
|
||||
{
|
||||
"name": "approve",
|
||||
"type": "function",
|
||||
"stateMutability": "nonpayable",
|
||||
"inputs": [
|
||||
{"name": "_spender", "type": "address"},
|
||||
{"name": "_value", "type": "uint256"}
|
||||
],
|
||||
"outputs": [{"name": "", "type": "bool"}]
|
||||
}
|
||||
]`)
|
||||
}
|
||||
118
trahn-trade-backend/internal/ethereum/client.go
Normal file
118
trahn-trade-backend/internal/ethereum/client.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package ethereum
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/core/types"
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/ethereum/go-ethereum/ethclient"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
rpc *ethclient.Client
|
||||
privateKey *ecdsa.PrivateKey
|
||||
wallet common.Address
|
||||
chainID *big.Int
|
||||
gasLimit uint64
|
||||
gasMul float64
|
||||
}
|
||||
|
||||
func NewClient(rpcURL, privateKeyHex string, chainID int64, gasLimit int, gasMultiplier float64) (*Client, error) {
|
||||
rpc, err := ethclient.Dial(rpcURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial RPC: %w", err)
|
||||
}
|
||||
|
||||
pkHex := strings.TrimPrefix(privateKeyHex, "0x")
|
||||
pk, err := crypto.HexToECDSA(pkHex)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse private key: %w", err)
|
||||
}
|
||||
|
||||
addr := crypto.PubkeyToAddress(pk.PublicKey)
|
||||
|
||||
return &Client{
|
||||
rpc: rpc,
|
||||
privateKey: pk,
|
||||
wallet: addr,
|
||||
chainID: big.NewInt(chainID),
|
||||
gasLimit: uint64(gasLimit),
|
||||
gasMul: gasMultiplier,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) WalletAddress() common.Address { return c.wallet }
|
||||
func (c *Client) GasLimit() uint64 { return c.gasLimit }
|
||||
func (c *Client) Close() { c.rpc.Close() }
|
||||
|
||||
func (c *Client) ETHBalance(ctx context.Context) (*big.Int, error) {
|
||||
return c.rpc.BalanceAt(ctx, c.wallet, nil)
|
||||
}
|
||||
|
||||
func (c *Client) GasPrice(ctx context.Context) (*big.Int, error) {
|
||||
price, err := c.rpc.SuggestGasPrice(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Apply multiplier
|
||||
mul := new(big.Float).SetFloat64(c.gasMul)
|
||||
adjusted := new(big.Float).Mul(new(big.Float).SetInt(price), mul)
|
||||
result, _ := adjusted.Int(nil)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) Nonce(ctx context.Context) (uint64, error) {
|
||||
return c.rpc.PendingNonceAt(ctx, c.wallet)
|
||||
}
|
||||
|
||||
// SignAndSend signs a legacy transaction and broadcasts it, returning the tx hash.
|
||||
func (c *Client) SignAndSend(ctx context.Context, to common.Address, value *big.Int, data []byte) (string, error) {
|
||||
nonce, err := c.Nonce(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get nonce: %w", err)
|
||||
}
|
||||
gasPrice, err := c.GasPrice(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get gas price: %w", err)
|
||||
}
|
||||
|
||||
tx := types.NewTx(&types.LegacyTx{
|
||||
Nonce: nonce,
|
||||
To: &to,
|
||||
Value: value,
|
||||
Gas: c.gasLimit,
|
||||
GasPrice: gasPrice,
|
||||
Data: data,
|
||||
})
|
||||
|
||||
signer := types.NewEIP155Signer(c.chainID)
|
||||
signed, err := types.SignTx(tx, signer, c.privateKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("sign tx: %w", err)
|
||||
}
|
||||
|
||||
if err := c.rpc.SendTransaction(ctx, signed); err != nil {
|
||||
return "", fmt.Errorf("send tx: %w", err)
|
||||
}
|
||||
|
||||
return signed.Hash().Hex(), nil
|
||||
}
|
||||
|
||||
// CallContract performs a read-only eth_call and returns the raw result.
|
||||
func (c *Client) CallContract(ctx context.Context, to common.Address, data []byte) ([]byte, error) {
|
||||
msg := map[string]interface{}{
|
||||
"to": to.Hex(),
|
||||
"data": fmt.Sprintf("0x%x", data),
|
||||
}
|
||||
var result string
|
||||
err := c.rpc.Client().CallContext(ctx, &result, "eth_call", msg, "latest")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return common.FromHex(result), nil
|
||||
}
|
||||
184
trahn-trade-backend/internal/ethereum/uniswap.go
Normal file
184
trahn-trade-backend/internal/ethereum/uniswap.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package ethereum
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/big"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
)
|
||||
|
||||
const explorerTxPrefix = "https://etherscan.io/tx/"
|
||||
|
||||
// UniswapV2 wraps an Ethereum Client and provides Uniswap V2 Router swap methods.
|
||||
type UniswapV2 struct {
|
||||
client *Client
|
||||
routerAddr common.Address
|
||||
wethAddr common.Address
|
||||
quoteAddr common.Address
|
||||
quoteSymbol string
|
||||
quoteDec int
|
||||
slippagePct float64
|
||||
routerABI abi.ABI
|
||||
erc20ABI abi.ABI
|
||||
}
|
||||
|
||||
func NewUniswapV2(
|
||||
client *Client,
|
||||
routerAddr, wethAddr, quoteAddr string,
|
||||
quoteSymbol string,
|
||||
quoteDecimals int,
|
||||
slippagePct float64,
|
||||
) (*UniswapV2, error) {
|
||||
rABI, err := abi.JSON(mustRouterABI())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse router ABI: %w", err)
|
||||
}
|
||||
eABI, err := abi.JSON(mustERC20ABI())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse ERC20 ABI: %w", err)
|
||||
}
|
||||
return &UniswapV2{
|
||||
client: client,
|
||||
routerAddr: common.HexToAddress(routerAddr),
|
||||
wethAddr: common.HexToAddress(wethAddr),
|
||||
quoteAddr: common.HexToAddress(quoteAddr),
|
||||
quoteSymbol: quoteSymbol,
|
||||
quoteDec: quoteDecimals,
|
||||
slippagePct: slippagePct,
|
||||
routerABI: rABI,
|
||||
erc20ABI: eABI,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (u *UniswapV2) ExplorerURL(txHash string) string {
|
||||
return explorerTxPrefix + txHash
|
||||
}
|
||||
|
||||
// TokenBalance returns the ERC20 token balance as a human-readable float.
|
||||
func (u *UniswapV2) TokenBalance(ctx context.Context) (float64, error) {
|
||||
data, err := u.erc20ABI.Pack("balanceOf", u.client.wallet)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
result, err := u.client.CallContract(ctx, u.quoteAddr, data)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("balanceOf call: %w", err)
|
||||
}
|
||||
bal := new(big.Int).SetBytes(result)
|
||||
divisor := math.Pow10(u.quoteDec)
|
||||
f, _ := new(big.Float).Quo(new(big.Float).SetInt(bal), new(big.Float).SetFloat64(divisor)).Float64()
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// ETHBalance returns wallet ETH balance as a human-readable float.
|
||||
func (u *UniswapV2) ETHBalance(ctx context.Context) (float64, error) {
|
||||
bal, err := u.client.ETHBalance(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
f, _ := new(big.Float).Quo(new(big.Float).SetInt(bal), new(big.Float).SetFloat64(1e18)).Float64()
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// EnsureAllowance checks the router's allowance for the quote token and approves max if needed.
|
||||
func (u *UniswapV2) EnsureAllowance(ctx context.Context, requiredAmount float64) error {
|
||||
data, err := u.erc20ABI.Pack("allowance", u.client.wallet, u.routerAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := u.client.CallContract(ctx, u.quoteAddr, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("allowance call: %w", err)
|
||||
}
|
||||
current := new(big.Int).SetBytes(result)
|
||||
|
||||
requiredWei := toTokenWei(requiredAmount*2, u.quoteDec)
|
||||
if current.Cmp(requiredWei) >= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Setting %s allowance for Uniswap Router...\n", u.quoteSymbol)
|
||||
maxUint256 := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))
|
||||
approveData, err := u.erc20ABI.Pack("approve", u.routerAddr, maxUint256)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
txHash, err := u.client.SignAndSend(ctx, u.quoteAddr, big.NewInt(0), approveData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("approve tx: %w", err)
|
||||
}
|
||||
fmt.Printf("Allowance TX confirmed: %s\n", u.ExplorerURL(txHash))
|
||||
return nil
|
||||
}
|
||||
|
||||
// SwapUSDCForETH executes swapExactTokensForETH on the Uniswap V2 Router.
|
||||
// Returns the transaction hash.
|
||||
func (u *UniswapV2) SwapUSDCForETH(ctx context.Context, usdcAmount, minETHOut float64) (string, error) {
|
||||
if err := u.EnsureAllowance(ctx, usdcAmount); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
path := []common.Address{u.quoteAddr, u.wethAddr}
|
||||
deadline := big.NewInt(time.Now().Unix() + 20*60)
|
||||
amountIn := toTokenWei(usdcAmount, u.quoteDec)
|
||||
// Apply slippage to minETHOut
|
||||
minOutWei := toEthWei(minETHOut * (1 - u.slippagePct/100))
|
||||
|
||||
data, err := u.routerABI.Pack("swapExactTokensForETH",
|
||||
amountIn, minOutWei, path, u.client.wallet, deadline)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pack swapExactTokensForETH: %w", err)
|
||||
}
|
||||
|
||||
return u.client.SignAndSend(ctx, u.routerAddr, big.NewInt(0), data)
|
||||
}
|
||||
|
||||
// SwapETHForUSDC executes swapExactETHForTokens on the Uniswap V2 Router.
|
||||
// Returns the transaction hash.
|
||||
func (u *UniswapV2) SwapETHForUSDC(ctx context.Context, ethAmount float64) (string, error) {
|
||||
path := []common.Address{u.wethAddr, u.quoteAddr}
|
||||
deadline := big.NewInt(time.Now().Unix() + 20*60)
|
||||
value := toEthWei(ethAmount)
|
||||
|
||||
data, err := u.routerABI.Pack("swapExactETHForTokens",
|
||||
big.NewInt(0), path, u.client.wallet, deadline)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pack swapExactETHForTokens: %w", err)
|
||||
}
|
||||
|
||||
return u.client.SignAndSend(ctx, u.routerAddr, value, data)
|
||||
}
|
||||
|
||||
// GasCostETH estimates the gas cost for a transaction in ETH.
|
||||
func (u *UniswapV2) GasCostETH(ctx context.Context) (float64, error) {
|
||||
gasPrice, err := u.client.GasPrice(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
cost := new(big.Int).Mul(gasPrice, new(big.Int).SetUint64(u.client.GasLimit()))
|
||||
f, _ := new(big.Float).Quo(new(big.Float).SetInt(cost), new(big.Float).SetFloat64(1e18)).Float64()
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func toEthWei(eth float64) *big.Int {
|
||||
// eth * 1e18
|
||||
f := new(big.Float).Mul(new(big.Float).SetFloat64(eth), new(big.Float).SetFloat64(1e18))
|
||||
i, _ := f.Int(nil)
|
||||
return i
|
||||
}
|
||||
|
||||
func toTokenWei(amount float64, decimals int) *big.Int {
|
||||
f := new(big.Float).Mul(
|
||||
new(big.Float).SetFloat64(amount),
|
||||
new(big.Float).SetFloat64(math.Pow10(decimals)),
|
||||
)
|
||||
i, _ := f.Int(nil)
|
||||
return i
|
||||
}
|
||||
Reference in New Issue
Block a user