CI/CD / render (push) Waiting to run
The primary-network chains answer to either case; one spelling beats two. /v1/chain/c/rpc, /v1/chain/p, /v1/chain/x across the estate. Co-authored-by: Hanzo AI <[email protected]>
741 lines
33 KiB
Python
Executable File
741 lines
33 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
E2E Exchange Test: AMM liquidity setup + trading bot + explorer verification.
|
||
|
||
Creates WLUX/LETH and WLUX/LUSDC pairs on all funded mainnet chains,
|
||
adds initial liquidity, runs a trading loop, and verifies the explorer
|
||
reflects live data.
|
||
|
||
Usage:
|
||
# Run with deployer key (mnemonic from KMS)
|
||
LUX_MNEMONIC="$(kms secrets get LUX_MNEMONIC)" python e2e-exchange.py
|
||
|
||
# Or with explicit private key
|
||
LUX_PRIVATE_KEY=88e3c3... python e2e-exchange.py
|
||
|
||
# Run continuously as trading bot
|
||
LUX_PRIVATE_KEY=88e3c3... python e2e-exchange.py --loop --interval 30
|
||
|
||
# Skip liquidity setup (already done), just trade
|
||
LUX_PRIVATE_KEY=88e3c3... python e2e-exchange.py --trade-only
|
||
|
||
# Dry-run: report state without sending txs
|
||
python e2e-exchange.py --dry-run
|
||
"""
|
||
|
||
import argparse
|
||
import os
|
||
import sys
|
||
import time
|
||
import json
|
||
import urllib.request
|
||
from typing import Optional
|
||
|
||
try:
|
||
from web3 import Web3
|
||
from eth_account import Account
|
||
try:
|
||
from web3.middleware import ExtraDataToPOAMiddleware # web3 >= 6
|
||
_POA_MIDDLEWARE = ExtraDataToPOAMiddleware
|
||
except ImportError:
|
||
try:
|
||
from web3.middleware import geth_poa_middleware # web3 < 6
|
||
_POA_MIDDLEWARE = geth_poa_middleware
|
||
except ImportError:
|
||
_POA_MIDDLEWARE = None
|
||
except ImportError:
|
||
print("Install deps: pip install web3 eth-account")
|
||
sys.exit(1)
|
||
|
||
Account.enable_unaudited_hdwallet_features()
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Chain configuration (mainnet)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
CHAINS = {
|
||
"C-Chain": {
|
||
"chain_id": 96369,
|
||
"rpc": "http://localhost:19630/v1/chain/c/rpc",
|
||
"explorer": "https://api-explore.lux.network",
|
||
# Addresses generated by deployer 0xEAbCC110... starting at nonce=5
|
||
# (deployer had 5 txns in RLP import history before these deployments)
|
||
"wlux": "0xf295Bd72A08CAd1EfDAC25c204832220AE6E26cE",
|
||
"leth": "0x2d1D573097A1106EeFD9f07B9eaA79BA55D09fc2",
|
||
"lbtc": "0xAB95c8b59f68ce922f2f334dFc8bb8f5b0525326",
|
||
"lusdc": "0xF85CF66Fd0189C435033056edeC5e525F39374a6",
|
||
"factory": "0x84CF0A13db1be8e1F0676405cfcBC8b09692FD1C",
|
||
"router": "0x2382F7A49FA48E1F91Bec466c32e1D7F13ec8206",
|
||
},
|
||
# App chains: all use shared addresses (deployer had nonce=0 on each fresh chain)
|
||
"Zoo": {
|
||
"chain_id": 200200,
|
||
"rpc": "http://localhost:19630/v1/chain/2Y625Kvdd4wZ5LKx9kLSf1jGALyARmXVN1YsTb4F3Hzv4bPDbp/rpc",
|
||
"explorer": "https://api-explore-zoo.lux.network",
|
||
"wlux": "0x548f54dfb32ea6ce4fa3515236696cf3d1b7d26a",
|
||
"leth": "0xe0f7e9a0cb1688cca453995fd6e19ae4fbd9cbfd",
|
||
"lbtc": "0x7d7cc8d05bb0f38d80b5ce44b4b069a6fb769468",
|
||
"lusdc": "0xc5e4a6f54be469551a342872c1ab83ab46f61b22",
|
||
"factory": "0x84cf0a13db1be8e1f0676405cfcbc8b09692fd1c",
|
||
"router": "0x2382f7a49fa48e1f91bec466c32e1d7f13ec8206",
|
||
},
|
||
"Hanzo": {
|
||
"chain_id": 36963,
|
||
# v5 blockchain ID (redeployed 2026-03-01)
|
||
"rpc": "http://localhost:19630/v1/chain/2GiQb73CeJESjc4omFv2YtQHZrRgJf25NXPzAr5J6UNHRcDV2F/rpc",
|
||
"explorer": "https://api-explore-hanzo.lux.network",
|
||
"wlux": "0x548f54dfb32ea6ce4fa3515236696cf3d1b7d26a",
|
||
"leth": "0xe0f7e9a0cb1688cca453995fd6e19ae4fbd9cbfd",
|
||
"lbtc": "0x7d7cc8d05bb0f38d80b5ce44b4b069a6fb769468",
|
||
"lusdc": "0xc5e4a6f54be469551a342872c1ab83ab46f61b22",
|
||
"factory": "0x84cf0a13db1be8e1f0676405cfcbc8b09692fd1c",
|
||
"router": "0x2382f7a49fa48e1f91bec466c32e1d7f13ec8206",
|
||
},
|
||
"SPC": {
|
||
"chain_id": 36911,
|
||
# v5 blockchain ID
|
||
"rpc": "http://localhost:19630/v1/chain/rtjwvtE1tEvrokmpeYdTq7b2zqZgmybKwR5MLjKMGAR1W78dQ/rpc",
|
||
"explorer": "https://api-explore-spc.lux.network",
|
||
"wlux": "0x548f54dfb32ea6ce4fa3515236696cf3d1b7d26a",
|
||
"leth": "0xe0f7e9a0cb1688cca453995fd6e19ae4fbd9cbfd",
|
||
"lbtc": "0x7d7cc8d05bb0f38d80b5ce44b4b069a6fb769468",
|
||
"lusdc": "0xc5e4a6f54be469551a342872c1ab83ab46f61b22",
|
||
"factory": "0x84cf0a13db1be8e1f0676405cfcbc8b09692fd1c",
|
||
"router": "0x2382f7a49fa48e1f91bec466c32e1d7f13ec8206",
|
||
},
|
||
"Pars": {
|
||
"chain_id": 494949, # v5 chain ID (was 7070 on old chains)
|
||
# v5 blockchain ID (redeployed 2026-03-01)
|
||
"rpc": "http://localhost:19630/v1/chain/2pUskxqaL5Bpx7uRUGG1fDjPckjxQ4UKX4sLKeaS1NdSVBJd3F/rpc",
|
||
"explorer": "https://api-explore-pars.lux.network",
|
||
"wlux": "0x548f54dfb32ea6ce4fa3515236696cf3d1b7d26a",
|
||
"leth": "0xe0f7e9a0cb1688cca453995fd6e19ae4fbd9cbfd",
|
||
"lbtc": "0x7d7cc8d05bb0f38d80b5ce44b4b069a6fb769468",
|
||
"lusdc": "0xc5e4a6f54be469551a342872c1ab83ab46f61b22",
|
||
"factory": "0x84cf0a13db1be8e1f0676405cfcbc8b09692fd1c",
|
||
"router": "0x2382f7a49fa48e1f91bec466c32e1d7f13ec8206",
|
||
},
|
||
}
|
||
|
||
# Minimum native LUX required to participate in exchange setup
|
||
MIN_NATIVE_LUX = 10 # LUX (app chains have huge balances; C-Chain deployer may be low)
|
||
|
||
# KNOWN LIMITATION: AMM swaps fail on app chain EVMs (Zoo, Hanzo, Pars, SPC) with
|
||
# "invalid opcode: MCOPY" because the AMM router was compiled with Solidity >=0.8.24
|
||
# which uses the Cancun EIP-5656 MCOPY opcode. App chain EVMs do not yet support Cancun.
|
||
# Workaround: Recompile AMM contracts with Solidity <=0.8.23 and redeploy.
|
||
# C-Chain swaps work since C-Chain EVM supports Cancun.
|
||
APPCHAIN_SWAP_BROKEN = True # swaps on Hanzo/Pars chains will revert
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ABIs (minimal)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
ERC20_ABI = [
|
||
{"name": "balanceOf", "type": "function", "inputs": [{"name": "account", "type": "address"}], "outputs": [{"type": "uint256"}], "stateMutability": "view"},
|
||
{"name": "totalSupply", "type": "function", "inputs": [], "outputs": [{"type": "uint256"}], "stateMutability": "view"},
|
||
{"name": "approve", "type": "function", "inputs": [{"name": "spender", "type": "address"}, {"name": "amount", "type": "uint256"}], "outputs": [{"type": "bool"}], "stateMutability": "nonpayable"},
|
||
{"name": "allowance", "type": "function", "inputs": [{"name": "owner", "type": "address"}, {"name": "spender", "type": "address"}], "outputs": [{"type": "uint256"}], "stateMutability": "view"},
|
||
{"name": "mint", "type": "function", "inputs": [{"name": "to", "type": "address"}, {"name": "amount", "type": "uint256"}], "outputs": [], "stateMutability": "nonpayable"},
|
||
{"name": "owner", "type": "function", "inputs": [], "outputs": [{"type": "address"}], "stateMutability": "view"},
|
||
]
|
||
|
||
WLUX_ABI = ERC20_ABI + [
|
||
{"name": "deposit", "type": "function", "inputs": [], "outputs": [], "stateMutability": "payable"},
|
||
{"name": "withdraw", "type": "function", "inputs": [{"name": "wad", "type": "uint256"}], "outputs": [], "stateMutability": "nonpayable"},
|
||
]
|
||
|
||
FACTORY_ABI = [
|
||
{"name": "getPair", "type": "function", "inputs": [{"name": "tokenA", "type": "address"}, {"name": "tokenB", "type": "address"}], "outputs": [{"type": "address"}], "stateMutability": "view"},
|
||
{"name": "createPair", "type": "function", "inputs": [{"name": "tokenA", "type": "address"}, {"name": "tokenB", "type": "address"}], "outputs": [{"type": "address"}], "stateMutability": "nonpayable"},
|
||
{"name": "allPairsLength", "type": "function", "inputs": [], "outputs": [{"type": "uint256"}], "stateMutability": "view"},
|
||
]
|
||
|
||
ROUTER_ABI = [
|
||
{"name": "addLiquidityETH", "type": "function",
|
||
"inputs": [{"name": "token", "type": "address"}, {"name": "amountTokenDesired", "type": "uint256"},
|
||
{"name": "amountTokenMin", "type": "uint256"}, {"name": "amountETHMin", "type": "uint256"},
|
||
{"name": "to", "type": "address"}, {"name": "deadline", "type": "uint256"}],
|
||
"outputs": [{"type": "uint256"}, {"type": "uint256"}, {"type": "uint256"}], "stateMutability": "payable"},
|
||
{"name": "addLiquidity", "type": "function",
|
||
"inputs": [{"name": "tokenA", "type": "address"}, {"name": "tokenB", "type": "address"},
|
||
{"name": "amountADesired", "type": "uint256"}, {"name": "amountBDesired", "type": "uint256"},
|
||
{"name": "amountAMin", "type": "uint256"}, {"name": "amountBMin", "type": "uint256"},
|
||
{"name": "to", "type": "address"}, {"name": "deadline", "type": "uint256"}],
|
||
"outputs": [{"type": "uint256"}, {"type": "uint256"}, {"type": "uint256"}], "stateMutability": "nonpayable"},
|
||
{"name": "swapExactTokensForTokens", "type": "function",
|
||
"inputs": [{"name": "amountIn", "type": "uint256"}, {"name": "amountOutMin", "type": "uint256"},
|
||
{"name": "path", "type": "address[]"}, {"name": "to", "type": "address"}, {"name": "deadline", "type": "uint256"}],
|
||
"outputs": [{"type": "uint256[]"}], "stateMutability": "nonpayable"},
|
||
{"name": "swapExactETHForTokens", "type": "function",
|
||
"inputs": [{"name": "amountOutMin", "type": "uint256"}, {"name": "path", "type": "address[]"},
|
||
{"name": "to", "type": "address"}, {"name": "deadline", "type": "uint256"}],
|
||
"outputs": [{"type": "uint256[]"}], "stateMutability": "payable"},
|
||
{"name": "getAmountsOut", "type": "function",
|
||
"inputs": [{"name": "amountIn", "type": "uint256"}, {"name": "path", "type": "address[]"}],
|
||
"outputs": [{"type": "uint256[]"}], "stateMutability": "view"},
|
||
{"name": "WETH", "type": "function", "inputs": [], "outputs": [{"type": "address"}], "stateMutability": "pure"},
|
||
]
|
||
|
||
PAIR_ABI = [
|
||
{"name": "getReserves", "type": "function", "inputs": [],
|
||
"outputs": [{"type": "uint112"}, {"type": "uint112"}, {"type": "uint32"}], "stateMutability": "view"},
|
||
{"name": "token0", "type": "function", "inputs": [], "outputs": [{"type": "address"}], "stateMutability": "view"},
|
||
{"name": "token1", "type": "function", "inputs": [], "outputs": [{"type": "address"}], "stateMutability": "view"},
|
||
]
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"
|
||
|
||
|
||
def wei(n: float) -> int:
|
||
return int(n * 1e18)
|
||
|
||
|
||
def from_wei(n: int) -> float:
|
||
return n / 1e18
|
||
|
||
|
||
def send_tx(w3: Web3, account, tx: dict, label: str = "") -> Optional[str]:
|
||
"""Sign, send, and wait for a transaction. Returns tx hash or None."""
|
||
tx.setdefault("from", account.address)
|
||
tx.setdefault("nonce", w3.eth.get_transaction_count(account.address))
|
||
tx.setdefault("chainId", w3.eth.chain_id)
|
||
if "gasPrice" not in tx and "maxFeePerGas" not in tx:
|
||
tx["gasPrice"] = w3.eth.gas_price
|
||
if "gas" not in tx:
|
||
try:
|
||
tx["gas"] = int(w3.eth.estimate_gas(tx) * 1.3)
|
||
except Exception:
|
||
tx["gas"] = 500_000
|
||
|
||
signed = account.sign_transaction(tx)
|
||
raw = getattr(signed, "raw_transaction", None) or getattr(signed, "rawTransaction", None)
|
||
try:
|
||
h = w3.eth.send_raw_transaction(raw)
|
||
receipt = w3.eth.wait_for_transaction_receipt(h, timeout=60)
|
||
status = "✅" if receipt.status == 1 else "❌"
|
||
print(f" {status} {label}: {h.hex()[:16]}... (block {receipt.blockNumber})")
|
||
return h.hex() if receipt.status == 1 else None
|
||
except Exception as e:
|
||
print(f" ❌ {label}: {e}")
|
||
return None
|
||
|
||
|
||
def check_explorer(name: str, cfg: dict) -> dict:
|
||
"""Fetch stats from blockscout API. Returns dict with block/tx counts."""
|
||
url = f"{cfg['explorer']}/api/v2/stats"
|
||
try:
|
||
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||
data = json.loads(urllib.request.urlopen(req, timeout=10).read())
|
||
return {
|
||
"total_blocks": data.get("total_blocks", "?"),
|
||
"total_txs": data.get("total_transactions", "?"),
|
||
"total_addresses": data.get("total_addresses", "?"),
|
||
}
|
||
except Exception as e:
|
||
return {"error": str(e)}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Core operations
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def setup_chain_liquidity(name: str, cfg: dict, account, dry_run: bool = False) -> dict:
|
||
"""
|
||
Wrap LUX → WLUX, try to mint LETH, create pairs, add liquidity.
|
||
Returns dict with pair addresses and status.
|
||
"""
|
||
print(f"\n{'='*60}")
|
||
print(f" {name} (chainId={cfg['chain_id']})")
|
||
print(f"{'='*60}")
|
||
|
||
w3 = Web3(Web3.HTTPProvider(cfg["rpc"], request_kwargs={"timeout": 30}))
|
||
if _POA_MIDDLEWARE:
|
||
w3.middleware_onion.inject(_POA_MIDDLEWARE, layer=0)
|
||
|
||
if not w3.is_connected():
|
||
print(f" ⚠️ Cannot connect to {cfg['rpc']}")
|
||
return {"status": "unreachable"}
|
||
|
||
addr = account.address
|
||
native_bal = from_wei(w3.eth.get_balance(addr))
|
||
print(f" Native LUX: {native_bal:,.4f}")
|
||
|
||
if native_bal < MIN_NATIVE_LUX:
|
||
print(f" ⚠️ Insufficient LUX (need {MIN_NATIVE_LUX}), skipping setup")
|
||
return {"status": "insufficient_funds"}
|
||
|
||
# Normalize all addresses to checksum form up-front
|
||
factory_addr = Web3.to_checksum_address(cfg["factory"])
|
||
router_addr = Web3.to_checksum_address(cfg["router"])
|
||
wlux_addr = Web3.to_checksum_address(cfg["wlux"])
|
||
leth_addr = Web3.to_checksum_address(cfg["leth"])
|
||
lusdc_addr = Web3.to_checksum_address(cfg["lusdc"])
|
||
|
||
factory = w3.eth.contract(address=factory_addr, abi=FACTORY_ABI)
|
||
router = w3.eth.contract(address=router_addr, abi=ROUTER_ABI)
|
||
wlux = w3.eth.contract(address=wlux_addr, abi=WLUX_ABI)
|
||
leth = w3.eth.contract(address=leth_addr, abi=ERC20_ABI)
|
||
lusdc = w3.eth.contract(address=lusdc_addr, abi=ERC20_ABI)
|
||
|
||
wlux_bal = from_wei(wlux.functions.balanceOf(addr).call())
|
||
leth_bal = from_wei(leth.functions.balanceOf(addr).call())
|
||
lusdc_bal = from_wei(lusdc.functions.balanceOf(addr).call())
|
||
print(f" WLUX bal: {wlux_bal:,.4f} | LETH bal: {leth_bal:,.4f} | LUSDC bal: {lusdc_bal:,.4f}")
|
||
|
||
# 1. Wrap LUX → WLUX (keep 50 LUX for gas)
|
||
wrap_amount = min(native_bal - 50, 10_000) # wrap up to 10K LUX
|
||
if wrap_amount > 0 and wlux_bal < 1_000:
|
||
print(f" → Wrapping {wrap_amount:.2f} LUX → WLUX")
|
||
if not dry_run:
|
||
tx = wlux.functions.deposit().build_transaction({
|
||
"value": wei(wrap_amount),
|
||
"from": addr,
|
||
"nonce": w3.eth.get_transaction_count(addr),
|
||
"chainId": cfg["chain_id"],
|
||
"gasPrice": w3.eth.gas_price,
|
||
"gas": 100_000,
|
||
})
|
||
send_tx(w3, account, tx, "wrap LUX→WLUX")
|
||
wlux_bal = from_wei(wlux.functions.balanceOf(addr).call())
|
||
print(f" WLUX bal after wrap: {wlux_bal:,.4f}")
|
||
|
||
# 2. Try to mint LETH if deployer is owner
|
||
leth_needed = 5_000
|
||
if leth_bal < leth_needed:
|
||
try:
|
||
owner = leth.functions.owner().call()
|
||
if owner.lower() == addr.lower():
|
||
print(f" → Minting {leth_needed} LETH (deployer is owner)")
|
||
if not dry_run:
|
||
tx = leth.functions.mint(addr, wei(leth_needed)).build_transaction({
|
||
"from": addr,
|
||
"nonce": w3.eth.get_transaction_count(addr),
|
||
"chainId": cfg["chain_id"],
|
||
"gasPrice": w3.eth.gas_price,
|
||
"gas": 200_000,
|
||
})
|
||
send_tx(w3, account, tx, "mint LETH")
|
||
leth_bal = from_wei(leth.functions.balanceOf(addr).call())
|
||
except Exception:
|
||
print(f" ℹ️ LETH not mintable by deployer (total supply = {from_wei(leth.functions.totalSupply().call()):.0f})")
|
||
|
||
# 3. Try to mint LUSDC if deployer is owner
|
||
lusdc_needed = 1_000_000
|
||
if lusdc_bal < lusdc_needed:
|
||
try:
|
||
owner_l = lusdc.functions.owner().call()
|
||
if owner_l.lower() == addr.lower():
|
||
print(f" → Minting {lusdc_needed:,} LUSDC (deployer is owner)")
|
||
if not dry_run:
|
||
tx = lusdc.functions.mint(addr, wei(lusdc_needed)).build_transaction({
|
||
"from": addr,
|
||
"nonce": w3.eth.get_transaction_count(addr),
|
||
"chainId": cfg["chain_id"],
|
||
"gasPrice": w3.eth.gas_price,
|
||
"gas": 200_000,
|
||
})
|
||
send_tx(w3, account, tx, "mint LUSDC")
|
||
lusdc_bal = from_wei(lusdc.functions.balanceOf(addr).call())
|
||
except Exception:
|
||
pass
|
||
|
||
# Refresh after mints
|
||
wlux_bal = from_wei(wlux.functions.balanceOf(addr).call())
|
||
leth_bal = from_wei(leth.functions.balanceOf(addr).call())
|
||
lusdc_bal = from_wei(lusdc.functions.balanceOf(addr).call())
|
||
|
||
pairs_created = {}
|
||
|
||
# 4. WLUX/LETH pair
|
||
if leth_bal >= 100 and wlux_bal >= 100:
|
||
pair_addr = factory.functions.getPair(
|
||
wlux_addr,
|
||
leth_addr
|
||
).call()
|
||
|
||
if pair_addr == ZERO_ADDRESS:
|
||
print(" → Creating WLUX/LETH pair")
|
||
if not dry_run:
|
||
tx = factory.functions.createPair(
|
||
wlux_addr,
|
||
leth_addr
|
||
).build_transaction({
|
||
"from": addr,
|
||
"nonce": w3.eth.get_transaction_count(addr),
|
||
"chainId": cfg["chain_id"],
|
||
"gasPrice": w3.eth.gas_price,
|
||
"gas": 3_000_000,
|
||
})
|
||
send_tx(w3, account, tx, "createPair WLUX/LETH")
|
||
pair_addr = factory.functions.getPair(
|
||
Web3.to_checksum_address(cfg["wlux"]),
|
||
Web3.to_checksum_address(cfg["leth"])
|
||
).call()
|
||
else:
|
||
print(f" ✅ WLUX/LETH pair exists: {pair_addr}")
|
||
|
||
# Add liquidity: 1000 WLUX + 1000 LETH (1:1 initial price)
|
||
if pair_addr != ZERO_ADDRESS and not dry_run:
|
||
liq_wlux = min(wlux_bal * 0.1, 1_000)
|
||
liq_leth = min(leth_bal * 0.1, 1_000)
|
||
if liq_wlux > 0 and liq_leth > 0:
|
||
# Approve both tokens
|
||
for (token_c, token_name, amount) in [
|
||
(wlux, "WLUX", liq_wlux), (leth, "LETH", liq_leth)
|
||
]:
|
||
allow = from_wei(token_c.functions.allowance(addr, router_addr).call())
|
||
if allow < amount:
|
||
tx = token_c.functions.approve(
|
||
router_addr, wei(amount * 2)
|
||
).build_transaction({
|
||
"from": addr,
|
||
"nonce": w3.eth.get_transaction_count(addr),
|
||
"chainId": cfg["chain_id"],
|
||
"gasPrice": w3.eth.gas_price,
|
||
"gas": 100_000,
|
||
})
|
||
send_tx(w3, account, tx, f"approve {token_name}→router")
|
||
|
||
deadline = int(time.time()) + 300
|
||
tx = router.functions.addLiquidity(
|
||
wlux_addr,
|
||
leth_addr,
|
||
wei(liq_wlux), wei(liq_leth),
|
||
wei(liq_wlux * 0.99), wei(liq_leth * 0.99),
|
||
addr, deadline
|
||
).build_transaction({
|
||
"from": addr,
|
||
"nonce": w3.eth.get_transaction_count(addr),
|
||
"chainId": cfg["chain_id"],
|
||
"gasPrice": w3.eth.gas_price,
|
||
"gas": 500_000,
|
||
})
|
||
send_tx(w3, account, tx, f"addLiquidity WLUX/LETH ({liq_wlux:.0f}+{liq_leth:.0f})")
|
||
|
||
pairs_created["WLUX/LETH"] = pair_addr
|
||
else:
|
||
print(f" ⚠️ Skipping WLUX/LETH pair: wlux={wlux_bal:.0f} leth={leth_bal:.0f}")
|
||
|
||
# 5. WLUX/LUSDC pair (use 1 WLUX = 0.01 LUSDC price roughly, or 1:1 for simplicity)
|
||
if lusdc_bal >= 10_000 and wlux_bal >= 100:
|
||
pair_lusdc = factory.functions.getPair(
|
||
wlux_addr,
|
||
lusdc_addr
|
||
).call()
|
||
|
||
if pair_lusdc == ZERO_ADDRESS:
|
||
print(" → Creating WLUX/LUSDC pair")
|
||
if not dry_run:
|
||
tx = factory.functions.createPair(
|
||
wlux_addr,
|
||
lusdc_addr
|
||
).build_transaction({
|
||
"from": addr,
|
||
"nonce": w3.eth.get_transaction_count(addr),
|
||
"chainId": cfg["chain_id"],
|
||
"gasPrice": w3.eth.gas_price,
|
||
"gas": 3_000_000,
|
||
})
|
||
send_tx(w3, account, tx, "createPair WLUX/LUSDC")
|
||
pair_lusdc = factory.functions.getPair(
|
||
Web3.to_checksum_address(cfg["wlux"]),
|
||
Web3.to_checksum_address(cfg["lusdc"])
|
||
).call()
|
||
else:
|
||
print(f" ✅ WLUX/LUSDC pair exists: {pair_lusdc}")
|
||
|
||
if pair_lusdc != ZERO_ADDRESS and not dry_run:
|
||
liq_wlux2 = min(wlux_bal * 0.05, 500)
|
||
liq_lusdc = min(lusdc_bal * 0.01, 50_000)
|
||
if liq_wlux2 > 0 and liq_lusdc > 0:
|
||
for (token_c, token_name, amount) in [
|
||
(wlux, "WLUX", liq_wlux2), (lusdc, "LUSDC", liq_lusdc)
|
||
]:
|
||
allow = from_wei(token_c.functions.allowance(addr, router_addr).call())
|
||
if allow < amount:
|
||
tx = token_c.functions.approve(
|
||
router_addr, wei(amount * 2)
|
||
).build_transaction({
|
||
"from": addr,
|
||
"nonce": w3.eth.get_transaction_count(addr),
|
||
"chainId": cfg["chain_id"],
|
||
"gasPrice": w3.eth.gas_price,
|
||
"gas": 100_000,
|
||
})
|
||
send_tx(w3, account, tx, f"approve {token_name}→router")
|
||
|
||
deadline = int(time.time()) + 300
|
||
tx = router.functions.addLiquidity(
|
||
wlux_addr,
|
||
lusdc_addr,
|
||
wei(liq_wlux2), wei(liq_lusdc),
|
||
wei(liq_wlux2 * 0.99), wei(liq_lusdc * 0.99),
|
||
addr, deadline
|
||
).build_transaction({
|
||
"from": addr,
|
||
"nonce": w3.eth.get_transaction_count(addr),
|
||
"chainId": cfg["chain_id"],
|
||
"gasPrice": w3.eth.gas_price,
|
||
"gas": 500_000,
|
||
})
|
||
send_tx(w3, account, tx, f"addLiquidity WLUX/LUSDC ({liq_wlux2:.0f}+{liq_lusdc:.0f})")
|
||
|
||
pairs_created["WLUX/LUSDC"] = pair_lusdc
|
||
|
||
total_pairs = factory.functions.allPairsLength().call()
|
||
print(f" Total AMM pairs on {name}: {total_pairs}")
|
||
return {"status": "ok", "pairs": pairs_created, "total_pairs": total_pairs}
|
||
|
||
|
||
def trade_on_chain(name: str, cfg: dict, account, swap_amount_lux: float = 10.0) -> bool:
|
||
"""
|
||
Execute one round of swaps: WLUX→LETH then LETH→WLUX.
|
||
Returns True if any swap succeeded.
|
||
NOTE: App chain EVMs (Hanzo, Pars, Zoo, SPC) fail with MCOPY opcode.
|
||
Only C-Chain supports Cancun opcodes needed by the AMM router.
|
||
"""
|
||
# App chains use AMM contracts compiled with Solidity >=0.8.24 (MCOPY opcode).
|
||
# App chain EVMs don't support Cancun; skip swaps until contracts are recompiled.
|
||
if APPCHAIN_SWAP_BROKEN and name != "C-Chain":
|
||
print(f" [{name}] Skipping swap (MCOPY not supported on app chain EVM — recompile AMM with Solidity <=0.8.23)")
|
||
return False
|
||
|
||
w3 = Web3(Web3.HTTPProvider(cfg["rpc"], request_kwargs={"timeout": 30}))
|
||
if _POA_MIDDLEWARE:
|
||
w3.middleware_onion.inject(_POA_MIDDLEWARE, layer=0)
|
||
if not w3.is_connected():
|
||
return False
|
||
|
||
addr = account.address
|
||
router_addr = Web3.to_checksum_address(cfg["router"])
|
||
wlux_addr = Web3.to_checksum_address(cfg["wlux"])
|
||
leth_addr = Web3.to_checksum_address(cfg["leth"])
|
||
factory_addr = Web3.to_checksum_address(cfg["factory"])
|
||
|
||
router = w3.eth.contract(address=router_addr, abi=ROUTER_ABI)
|
||
wlux = w3.eth.contract(address=wlux_addr, abi=WLUX_ABI)
|
||
leth = w3.eth.contract(address=leth_addr, abi=ERC20_ABI)
|
||
factory = w3.eth.contract(address=factory_addr, abi=FACTORY_ABI)
|
||
|
||
# Check WLUX/LETH pair exists
|
||
pair_addr = factory.functions.getPair(wlux_addr, leth_addr).call()
|
||
if pair_addr == ZERO_ADDRESS:
|
||
print(f" [{name}] No WLUX/LETH pair, skipping trade")
|
||
return False
|
||
|
||
wlux_bal = from_wei(wlux.functions.balanceOf(addr).call())
|
||
leth_bal = from_wei(leth.functions.balanceOf(addr).call())
|
||
|
||
success = False
|
||
deadline = int(time.time()) + 120
|
||
|
||
# WLUX → LETH
|
||
if wlux_bal >= swap_amount_lux:
|
||
path = [wlux_addr, leth_addr]
|
||
try:
|
||
amounts_out = router.functions.getAmountsOut(wei(swap_amount_lux), path).call()
|
||
min_out = int(amounts_out[-1] * 0.99)
|
||
|
||
# Approve
|
||
allow = wlux.functions.allowance(addr, router_addr).call()
|
||
if allow < wei(swap_amount_lux):
|
||
tx = wlux.functions.approve(router_addr, wei(swap_amount_lux * 100)).build_transaction({
|
||
"from": addr, "nonce": w3.eth.get_transaction_count(addr),
|
||
"chainId": cfg["chain_id"], "gasPrice": w3.eth.gas_price, "gas": 100_000,
|
||
})
|
||
send_tx(w3, account, tx, f"[{name}] approve WLUX")
|
||
|
||
tx = router.functions.swapExactTokensForTokens(
|
||
wei(swap_amount_lux), min_out, path, addr, deadline
|
||
).build_transaction({
|
||
"from": addr, "nonce": w3.eth.get_transaction_count(addr),
|
||
"chainId": cfg["chain_id"], "gasPrice": w3.eth.gas_price, "gas": 300_000,
|
||
})
|
||
result = send_tx(w3, account, tx, f"[{name}] swap {swap_amount_lux} WLUX→LETH")
|
||
if result:
|
||
success = True
|
||
except Exception as e:
|
||
print(f" [{name}] WLUX→LETH failed: {e}")
|
||
|
||
# LETH → WLUX (swap back ~half)
|
||
leth_bal = from_wei(leth.functions.balanceOf(addr).call())
|
||
swap_back = leth_bal * 0.5
|
||
if leth_bal >= 1:
|
||
path2 = [leth_addr, wlux_addr]
|
||
try:
|
||
amounts_out2 = router.functions.getAmountsOut(wei(swap_back), path2).call()
|
||
min_out2 = int(amounts_out2[-1] * 0.99)
|
||
|
||
allow2 = leth.functions.allowance(addr, router_addr).call()
|
||
if allow2 < wei(swap_back):
|
||
tx = leth.functions.approve(router_addr, wei(swap_back * 100)).build_transaction({
|
||
"from": addr, "nonce": w3.eth.get_transaction_count(addr),
|
||
"chainId": cfg["chain_id"], "gasPrice": w3.eth.gas_price, "gas": 100_000,
|
||
})
|
||
send_tx(w3, account, tx, f"[{name}] approve LETH")
|
||
|
||
tx = router.functions.swapExactTokensForTokens(
|
||
wei(swap_back), min_out2, path2, addr, deadline
|
||
).build_transaction({
|
||
"from": addr, "nonce": w3.eth.get_transaction_count(addr),
|
||
"chainId": cfg["chain_id"], "gasPrice": w3.eth.gas_price, "gas": 300_000,
|
||
})
|
||
result2 = send_tx(w3, account, tx, f"[{name}] swap {swap_back:.2f} LETH→WLUX")
|
||
if result2:
|
||
success = True
|
||
except Exception as e:
|
||
print(f" [{name}] LETH→WLUX failed: {e}")
|
||
|
||
return success
|
||
|
||
|
||
def verify_explorers() -> None:
|
||
"""Check all available explorer APIs and print stats."""
|
||
print("\n=== Explorer Verification ===")
|
||
all_chains = {
|
||
"Lux C-Chain": CHAINS["C-Chain"],
|
||
"Zoo": CHAINS["Zoo"],
|
||
"Hanzo": CHAINS["Hanzo"],
|
||
"SPC": CHAINS["SPC"],
|
||
"Pars": CHAINS["Pars"],
|
||
}
|
||
for name, cfg in all_chains.items():
|
||
stats = check_explorer(name, cfg)
|
||
if "error" in stats:
|
||
print(f" ❌ {name}: {stats['error']}")
|
||
else:
|
||
print(f" ✅ {name}: {stats['total_blocks']} blocks | "
|
||
f"{stats['total_txs']} txs | {stats['total_addresses']} addresses")
|
||
|
||
|
||
def print_pair_info(name: str, cfg: dict) -> None:
|
||
"""Print AMM pair reserves."""
|
||
w3 = Web3(Web3.HTTPProvider(cfg["rpc"], request_kwargs={"timeout": 10}))
|
||
if _POA_MIDDLEWARE:
|
||
w3.middleware_onion.inject(_POA_MIDDLEWARE, layer=0)
|
||
if not w3.is_connected():
|
||
return
|
||
factory = w3.eth.contract(address=Web3.to_checksum_address(cfg["factory"]), abi=FACTORY_ABI)
|
||
|
||
print(f"\n {name} AMM pairs:")
|
||
total = factory.functions.allPairsLength().call()
|
||
print(f" Total pairs: {total}")
|
||
|
||
for (tA, tB, label) in [
|
||
(cfg["wlux"], cfg["leth"], "WLUX/LETH"),
|
||
(cfg["wlux"], cfg["lusdc"], "WLUX/LUSDC"),
|
||
]:
|
||
pair_addr = factory.functions.getPair(
|
||
Web3.to_checksum_address(tA),
|
||
Web3.to_checksum_address(tB),
|
||
).call()
|
||
if pair_addr == ZERO_ADDRESS:
|
||
print(f" {label}: no pair")
|
||
else:
|
||
pair = w3.eth.contract(address=pair_addr, abi=PAIR_ABI)
|
||
r0, r1, _ = pair.functions.getReserves().call()
|
||
print(f" {label}: {from_wei(r0):.4f} / {from_wei(r1):.4f} (pair={pair_addr[:10]}...)")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Main
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def load_account() -> Account:
|
||
pk = os.environ.get("LUX_PRIVATE_KEY")
|
||
if pk:
|
||
return Account.from_key(pk if pk.startswith("0x") else "0x" + pk)
|
||
|
||
mnemonic = os.environ.get("LUX_MNEMONIC")
|
||
if mnemonic:
|
||
return Account.from_mnemonic(mnemonic, account_path="m/44'/60'/0'/0/1")
|
||
|
||
# Default: use the known deploy key
|
||
default_key = "~/.lux/keys/testnet-idx1/ec/private.key"
|
||
path = os.path.expanduser(default_key)
|
||
if os.path.exists(path):
|
||
with open(path) as f:
|
||
raw = f.read().strip()
|
||
return Account.from_key("0x" + raw)
|
||
|
||
print("Set LUX_PRIVATE_KEY or LUX_MNEMONIC env var")
|
||
sys.exit(1)
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="Lux Exchange E2E Test")
|
||
parser.add_argument("--dry-run", action="store_true", help="Report state, no txs")
|
||
parser.add_argument("--trade-only", action="store_true", help="Skip setup, just trade")
|
||
parser.add_argument("--loop", action="store_true", help="Run trading loop continuously")
|
||
parser.add_argument("--interval", type=int, default=30, help="Seconds between trade rounds")
|
||
parser.add_argument("--swap-amount", type=float, default=10.0, help="WLUX per swap")
|
||
parser.add_argument("--chains", nargs="+", choices=list(CHAINS.keys()), help="Limit to specific chains")
|
||
args = parser.parse_args()
|
||
|
||
account = load_account()
|
||
print(f"\n🔑 Trader: {account.address}")
|
||
|
||
target_chains = {k: v for k, v in CHAINS.items() if not args.chains or k in args.chains}
|
||
|
||
if not args.trade_only and not args.dry_run:
|
||
print("\n=== Setting up AMM liquidity on all chains ===")
|
||
for name, cfg in target_chains.items():
|
||
setup_chain_liquidity(name, cfg, account, dry_run=False)
|
||
elif args.dry_run:
|
||
print("\n=== Dry run — reporting current state ===")
|
||
for name, cfg in target_chains.items():
|
||
native_bal = 0
|
||
wlux_bal = 0
|
||
try:
|
||
w3 = Web3(Web3.HTTPProvider(cfg["rpc"], request_kwargs={"timeout": 10}))
|
||
if _POA_MIDDLEWARE:
|
||
w3.middleware_onion.inject(_POA_MIDDLEWARE, layer=0)
|
||
if w3.is_connected():
|
||
native_bal = from_wei(w3.eth.get_balance(account.address))
|
||
wlux = w3.eth.contract(address=Web3.to_checksum_address(cfg["wlux"]), abi=WLUX_ABI)
|
||
wlux_bal = from_wei(wlux.functions.balanceOf(account.address).call())
|
||
except Exception:
|
||
pass
|
||
print(f" {name}: {native_bal:,.2f} LUX native, {wlux_bal:,.2f} WLUX")
|
||
print_pair_info(name, cfg)
|
||
|
||
verify_explorers()
|
||
|
||
if args.dry_run:
|
||
return
|
||
|
||
# Trading loop
|
||
round_num = 0
|
||
while True:
|
||
round_num += 1
|
||
print(f"\n{'─'*60}")
|
||
print(f"🔄 Trade round {round_num} [{time.strftime('%H:%M:%S')}]")
|
||
print(f"{'─'*60}")
|
||
|
||
any_success = False
|
||
for name, cfg in target_chains.items():
|
||
ok = trade_on_chain(name, cfg, account, swap_amount_lux=args.swap_amount)
|
||
if ok:
|
||
any_success = True
|
||
|
||
if not any_success:
|
||
print("⚠️ No swaps succeeded this round (pairs may not have liquidity yet)")
|
||
|
||
if not args.loop:
|
||
break
|
||
|
||
print(f"\n Sleeping {args.interval}s until next round...")
|
||
time.sleep(args.interval)
|
||
|
||
verify_explorers()
|
||
print("\n✅ Done")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|