| |
| """ |
| app.py — Strategy Factory: Profitability-Gated EVM Deployer |
| ----------------------------------------------------------- |
| - Runs on Hugging Face / any container |
| - Uses RPC + keys only from env/secrets (no user input) |
| - Auto-creates a local wallet (EOA), private key never leaves disk |
| - Accepts Solidity project ZIPs |
| - Compiles with Foundry (forge) |
| - Discovers deployable artifacts |
| - Runs a profitability check on the project |
| - Only allows deployment if profitability gate passes |
| - No hardcoded RPC URL or key (HF/Secrets-safe) |
| """ |
|
|
| import os |
| import json |
| import base64 |
| import zipfile |
| import subprocess |
| import logging |
| import uuid |
| from pathlib import Path |
| from typing import Dict, Any, List, Tuple |
|
|
| from flask import Flask, render_template_string, request, abort |
| from web3 import Web3 |
| from eth_account import Account |
|
|
|
|
| |
| |
| |
|
|
| BASE_DIR = Path(__file__).resolve().parent |
|
|
| UPLOAD_DIR = BASE_DIR / "uploads" |
| BUILD_ROOT = BASE_DIR / "builds" |
| WALLET_FILE = BASE_DIR / "wallet.json" |
| LOG_FILE = BASE_DIR / "app.log" |
|
|
| UPLOAD_DIR.mkdir(exist_ok=True) |
| BUILD_ROOT.mkdir(exist_ok=True) |
|
|
| |
| |
| |
| RPC_URL = os.getenv("RPC_URL") |
| if not RPC_URL: |
| key = os.getenv("YOUR_KEY") |
| project = os.getenv("INFURA_PROJECT") |
| if not key or not project: |
| raise SystemExit( |
| "[Config] Set either RPC_URL, or YOUR_KEY + INFURA_PROJECT as secrets." |
| ) |
| |
| RPC_URL = f"https://{project}.infura.io/v3/{key}" |
|
|
| DEFAULT_GAS_LIMIT = int(os.getenv("DEPLOY_GAS_LIMIT", "5000000")) |
| DEFAULT_GAS_PRICE_GWEI = os.getenv("DEPLOY_GAS_PRICE_GWEI", "15") |
| PREFERRED_CONTRACT_NAME = os.getenv("DEPLOY_CONTRACT_NAME", "").strip() |
|
|
| |
| MIN_EXPECTED_PROFIT = float(os.getenv("MIN_EXPECTED_PROFIT", "0.0")) |
|
|
|
|
| |
| |
| |
|
|
| logger = logging.getLogger("strategy_factory") |
| logger.setLevel(logging.INFO) |
| if not logger.handlers: |
| fh = logging.FileHandler(LOG_FILE) |
| fh.setLevel(logging.INFO) |
| fmt = logging.Formatter( |
| fmt="%(asctime)s | %(levelname)s | %(message)s", |
| datefmt="%Y-%m-%d %H:%M:%S", |
| ) |
| fh.setFormatter(fmt) |
| logger.addHandler(fh) |
|
|
|
|
| def log_event(event: str, **fields: Any) -> None: |
| parts = [f"event={event}"] |
| for k, v in fields.items(): |
| parts.append(f"{k}={v}") |
| logger.info(" ".join(parts)) |
|
|
|
|
| |
| |
| |
|
|
| def load_or_create_wallet() -> Account: |
| if WALLET_FILE.exists(): |
| data = json.loads(WALLET_FILE.read_text()) |
| key = base64.b64decode(data["private_key"]) |
| acct = Account.from_key(key) |
| print(f"[Wallet] Loaded: {acct.address}") |
| log_event("wallet_loaded", address=acct.address) |
| return acct |
|
|
| acct = Account.create() |
| key_data = { |
| "address": acct.address, |
| "private_key": base64.b64encode(acct.key).decode(), |
| } |
| WALLET_FILE.write_text(json.dumps(key_data)) |
| print(f"[Wallet] Created: {acct.address}") |
| log_event("wallet_created", address=acct.address) |
| return acct |
|
|
|
|
| wallet = load_or_create_wallet() |
|
|
|
|
| |
| |
| |
|
|
| w3 = Web3(Web3.HTTPProvider(RPC_URL)) |
| if not w3.is_connected(): |
| log_event("rpc_connect_failed", rpc_url=RPC_URL) |
| raise SystemExit(f"[RPC] Cannot connect to {RPC_URL}") |
|
|
| try: |
| CHAIN_ID = w3.eth.chain_id |
| print(f"[RPC] Connected: chain_id={CHAIN_ID}") |
| log_event("rpc_connected", rpc_url=RPC_URL, chain_id=CHAIN_ID) |
| except Exception as e: |
| CHAIN_ID = None |
| log_event("rpc_chainid_error", error=str(e)) |
|
|
|
|
| |
| |
| |
|
|
| def check_forge() -> None: |
| try: |
| subprocess.run( |
| ["forge", "--version"], |
| check=True, |
| stdout=subprocess.DEVNULL, |
| stderr=subprocess.DEVNULL, |
| ) |
| log_event("forge_available") |
| except Exception as e: |
| log_event("forge_missing", error=str(e)) |
| raise SystemExit("[Setup] Foundry 'forge' binary not found in PATH.") |
|
|
|
|
| check_forge() |
|
|
|
|
| |
| |
| |
|
|
| def extract_project(zip_path: Path, build_id: str) -> Path: |
| target_dir = BUILD_ROOT / build_id |
| target_dir.mkdir(parents=True, exist_ok=True) |
| with zipfile.ZipFile(zip_path, "r") as zf: |
| zf.extractall(target_dir) |
| log_event("project_extracted", build_id=build_id, zip=str(zip_path), dest=str(target_dir)) |
| return target_dir |
|
|
|
|
| def compile_project(project_path: Path) -> None: |
| log_event("compile_start", path=str(project_path)) |
| print("[Compile] forge build ...") |
| try: |
| subprocess.run( |
| ["forge", "build", "--force"], |
| cwd=project_path, |
| check=True, |
| ) |
| except subprocess.CalledProcessError as e: |
| log_event("compile_error", path=str(project_path), error=str(e)) |
| raise RuntimeError(f"forge build failed: {e}") |
| log_event("compile_success", path=str(project_path)) |
|
|
|
|
| def discover_artifacts(project_path: Path) -> List[Dict[str, Any]]: |
| """ |
| Deployable artifacts: JSON with non-empty 'bytecode.object' and non-empty 'abi'. |
| """ |
| out_dir = project_path / "out" |
| results: List[Dict[str, Any]] = [] |
|
|
| if not out_dir.exists(): |
| log_event("artifact_out_missing", out=str(out_dir)) |
| return results |
|
|
| for json_path in out_dir.rglob("*.json"): |
| try: |
| manifest = json.loads(json_path.read_text()) |
| except Exception: |
| continue |
|
|
| bytecode_obj = (manifest.get("bytecode") or {}).get("object") or "" |
| abi = manifest.get("abi") or [] |
| contract_name = manifest.get("contractName") |
| if bytecode_obj and abi and contract_name: |
| relpath = json_path.relative_to(project_path) |
| results.append( |
| { |
| "path": json_path, |
| "relpath": relpath, |
| "contractName": contract_name, |
| "bytecodePrefix": bytecode_obj[:32], |
| "abiLength": len(abi), |
| } |
| ) |
|
|
| log_event("artifact_scan_done", path=str(project_path), count=len(results)) |
| return results |
|
|
|
|
| def select_artifact(artifacts: List[Dict[str, Any]]) -> Dict[str, Any]: |
| """ |
| Selection strategy: |
| - If PREFERRED_CONTRACT_NAME is set and present, use it. |
| - If only one artifact, use it. |
| - Else pick lexicographically smallest relpath. |
| """ |
| if not artifacts: |
| raise RuntimeError("No deployable artifacts found.") |
|
|
| if PREFERRED_CONTRACT_NAME: |
| for art in artifacts: |
| if art["contractName"] == PREFERRED_CONTRACT_NAME: |
| log_event("artifact_selected_preferred", contract=art["contractName"], relpath=str(art["relpath"])) |
| return art |
|
|
| if len(artifacts) == 1: |
| art = artifacts[0] |
| log_event("artifact_selected_single", contract=art["contractName"], relpath=str(art["relpath"])) |
| return art |
|
|
| artifacts_sorted = sorted(artifacts, key=lambda a: str(a["relpath"])) |
| art = artifacts_sorted[0] |
| log_event( |
| "artifact_selected_default", |
| contract=art["contractName"], |
| relpath=str(art["relpath"]), |
| candidates=len(artifacts), |
| ) |
| return art |
|
|
|
|
| def write_build_metadata(build_id: str, artifact_relpath: str) -> None: |
| meta = { |
| "build_id": build_id, |
| "artifact_relpath": artifact_relpath, |
| } |
| meta_path = BUILD_ROOT / build_id / "deploy_meta.json" |
| meta_path.write_text(json.dumps(meta)) |
| log_event("build_meta_written", build_id=build_id, meta=str(meta_path)) |
|
|
|
|
| def read_build_metadata(build_id: str) -> Dict[str, Any]: |
| meta_path = BUILD_ROOT / build_id / "deploy_meta.json" |
| if not meta_path.exists(): |
| log_event("build_meta_missing", build_id=build_id, path=str(meta_path)) |
| raise RuntimeError("Build metadata not found.") |
| return json.loads(meta_path.read_text()) |
|
|
|
|
| |
| |
| |
|
|
| def verify_profitability(project_path: Path) -> Tuple[bool, str]: |
| """ |
| Concrete, deterministic gate: |
| |
| - Looks for project_path / profitability.json |
| - Expects: { "expectedProfit": <float>, ... } |
| - Accepts only if expectedProfit >= MIN_EXPECTED_PROFIT |
| |
| This gives you a real, pluggable contract: |
| - Your MEV backtester / ZK verifier / simulator can emit this file. |
| - The deployer refuses to deploy projects that don't meet the threshold. |
| """ |
| metrics_path = project_path / "profitability.json" |
| if not metrics_path.exists(): |
| msg = "profitability.json not found in project root" |
| log_event("profitability_missing", path=str(metrics_path)) |
| return False, msg |
|
|
| try: |
| data = json.loads(metrics_path.read_text()) |
| except Exception as e: |
| msg = f"invalid profitability.json: {e}" |
| log_event("profitability_parse_error", path=str(metrics_path), error=str(e)) |
| return False, msg |
|
|
| expected_profit = float(data.get("expectedProfit", 0.0)) |
| log_event("profitability_read", expected_profit=expected_profit, min_required=MIN_EXPECTED_PROFIT) |
|
|
| if expected_profit < MIN_EXPECTED_PROFIT: |
| msg = f"expectedProfit {expected_profit} < required {MIN_EXPECTED_PROFIT}" |
| log_event("profitability_rejected", expected_profit=expected_profit) |
| return False, msg |
|
|
| msg = f"expectedProfit {expected_profit} >= required {MIN_EXPECTED_PROFIT}" |
| log_event("profitability_accepted", expected_profit=expected_profit) |
| return True, msg |
|
|
|
|
| |
| |
| |
|
|
| def load_manifest(project_path: Path, artifact_relpath: str) -> Dict[str, Any]: |
| manifest_path = project_path / artifact_relpath |
| if not manifest_path.exists(): |
| log_event("manifest_missing", path=str(manifest_path)) |
| raise RuntimeError("Manifest file missing on disk.") |
| return json.loads(manifest_path.read_text()) |
|
|
|
|
| def build_deploy_tx(manifest: Dict[str, Any]) -> Dict[str, Any]: |
| abi = manifest["abi"] |
| bytecode = manifest["bytecode"]["object"] |
| contract = w3.eth.contract(abi=abi, bytecode=bytecode) |
|
|
| nonce = w3.eth.get_transaction_count(wallet.address) |
| gas_price = w3.to_wei(DEFAULT_GAS_PRICE_GWEI, "gwei") |
|
|
| tx = contract.constructor().build_transaction({ |
| "from": wallet.address, |
| "nonce": nonce, |
| "gas": DEFAULT_GAS_LIMIT, |
| "gasPrice": gas_price, |
| "chainId": CHAIN_ID, |
| }) |
|
|
| log_event( |
| "tx_built", |
| wallet=wallet.address, |
| nonce=nonce, |
| gas=tx.get("gas"), |
| gas_price=gas_price, |
| ) |
| return tx |
|
|
|
|
| def deploy_manifest(project_path: Path, artifact_relpath: str) -> str: |
| log_event( |
| "deploy_start", |
| project=str(project_path), |
| artifact_relpath=artifact_relpath, |
| wallet=wallet.address, |
| ) |
|
|
| manifest = load_manifest(project_path, artifact_relpath) |
| tx = build_deploy_tx(manifest) |
| signed = wallet.sign_transaction(tx) |
|
|
| try: |
| tx_hash = w3.eth.send_raw_transaction(signed.rawTransaction) |
| log_event("tx_sent", tx_hash=tx_hash.hex()) |
| receipt = w3.eth.wait_for_transaction_receipt(tx_hash) |
| addr = receipt.contractAddress |
| status = receipt.status |
| log_event( |
| "deploy_success", |
| contract_address=addr, |
| tx_hash=tx_hash.hex(), |
| status=status, |
| ) |
| if status != 1: |
| raise RuntimeError(f"Deployment transaction reverted, status={status}") |
| return addr |
| except Exception as e: |
| log_event("deploy_error", error=str(e)) |
| raise |
|
|
|
|
| |
| |
| |
|
|
| app = Flask(__name__) |
|
|
| INDEX_HTML = """ |
| <!DOCTYPE html> |
| <html> |
| <body style="font-family:monospace;background:#0b0b0b;color:#ff8800"> |
| <h2>🧱 Strategy Factory: Profitability-Gated Deployer</h2> |
| <p>Wallet: {{ wallet }}</p> |
| <p>RPC: {{ rpc }}</p> |
| <p>Chain ID: {{ chain_id }}</p> |
| <p>Min expectedProfit: {{ min_profit }}</p> |
| <form action="/upload" method="post" enctype="multipart/form-data"> |
| <input type="file" name="zipfile" required /> |
| <button type="submit">Upload Strategy ZIP</button> |
| </form> |
| </body> |
| </html> |
| """ |
|
|
| REVIEW_HTML = """ |
| <!DOCTYPE html> |
| <html> |
| <body style="font-family:monospace;background:#0b0b0b;color:#ff8800"> |
| <h3>📦 Strategy Preview</h3> |
| <p>Build ID: {{ build_id }}</p> |
| <p>Artifact: {{ artifact_relpath }}</p> |
| <p>Profitability Gate: {{ profit_msg }}</p> |
| {% if can_deploy %} |
| <pre>{{ preview | tojson(indent=2) }}</pre> |
| <form action="/deploy" method="post"> |
| <input type="hidden" name="build_id" value="{{ build_id }}"> |
| <button type="submit">Deploy (Profitability Verified)</button> |
| </form> |
| {% else %} |
| <h3 style="color:#ff4444">Deployment blocked by profitability gate.</h3> |
| <pre>{{ preview | tojson(indent=2) }}</pre> |
| <a href="/">Back</a> |
| {% endif %} |
| </body> |
| </html> |
| """ |
|
|
| ERROR_HTML = """ |
| <!DOCTYPE html> |
| <html> |
| <body style="font-family:monospace;background:#0b0b0b;color:#ff4444"> |
| <h2>❌ Error</h2> |
| <pre>{{ message }}</pre> |
| <a href="/">Back</a> |
| </body> |
| </html> |
| """ |
|
|
|
|
| @app.route("/") |
| def index(): |
| return render_template_string( |
| INDEX_HTML, |
| wallet=wallet.address, |
| rpc=RPC_URL, |
| chain_id=CHAIN_ID, |
| min_profit=MIN_EXPECTED_PROFIT, |
| ) |
|
|
|
|
| @app.route("/health") |
| def health(): |
| return { |
| "status": "ok", |
| "chainId": CHAIN_ID, |
| "wallet": wallet.address, |
| "rpcConfigured": bool(RPC_URL), |
| } |
|
|
|
|
| @app.route("/upload", methods=["POST"]) |
| def upload(): |
| if "zipfile" not in request.files: |
| abort(400, "missing file") |
| f = request.files["zipfile"] |
| if not f.filename.lower().endswith(".zip"): |
| abort(400, "only .zip supported") |
|
|
| build_id = uuid.uuid4().hex |
| zip_path = UPLOAD_DIR / f"{build_id}.zip" |
| f.save(zip_path) |
| log_event("upload_received", filename=f.filename, saved_as=str(zip_path), build_id=build_id) |
|
|
| try: |
| project_path = extract_project(zip_path, build_id) |
| compile_project(project_path) |
| artifacts = discover_artifacts(project_path) |
| artifact = select_artifact(artifacts) |
|
|
| |
| ok, profit_msg = verify_profitability(project_path) |
|
|
| write_build_metadata(build_id, str(artifact["relpath"])) |
|
|
| preview = { |
| "contract": artifact["contractName"], |
| "artifactRelpath": str(artifact["relpath"]), |
| "bytecodePrefix": artifact["bytecodePrefix"], |
| "abiLength": artifact["abiLength"], |
| } |
|
|
| log_event( |
| "preview_ready", |
| build_id=build_id, |
| contract=preview["contract"], |
| artifact_relpath=str(artifact["artifactRelpath"]) |
| if "artifactRelpath" in preview |
| else preview["artifactRelpath"] if "artifactRelpath" in preview else "", |
| abi_len=preview["abiLength"], |
| profitability_ok=ok, |
| ) |
|
|
| return render_template_string( |
| REVIEW_HTML, |
| preview=preview, |
| build_id=build_id, |
| artifact_relpath=str(artifact["relpath"]), |
| profit_msg=profit_msg, |
| can_deploy=ok, |
| ) |
| except Exception as e: |
| log_event("upload_processing_error", build_id=build_id, error=str(e)) |
| return render_template_string(ERROR_HTML, message=str(e)), 500 |
|
|
|
|
| @app.route("/deploy", methods=["POST"]) |
| def deploy(): |
| build_id = request.form.get("build_id", "").strip() |
| if not build_id: |
| abort(400, "missing build id") |
|
|
| try: |
| meta = read_build_metadata(build_id) |
| artifact_relpath = meta["artifact_relpath"] |
| project_path = BUILD_ROOT / build_id |
|
|
| if not project_path.exists(): |
| raise RuntimeError("Build directory not found on server.") |
|
|
| |
| ok, _ = verify_profitability(project_path) |
| if not ok: |
| raise RuntimeError("Profitability gate failed at deploy time.") |
|
|
| address = deploy_manifest(project_path, artifact_relpath) |
| except Exception as e: |
| msg = f"Deployment failed: {e}" |
| log_event("deploy_exception", build_id=build_id, error=str(e)) |
| return render_template_string(ERROR_HTML, message=msg), 500 |
|
|
| return ( |
| f"<h2 style='color:lime'>✅ Deployed at: {address}</h2>" |
| f"<p>Wallet: {wallet.address}</p>" |
| f"<p><a href='/'>Back</a></p>" |
| ) |
|
|
|
|
| if __name__ == "__main__": |
| print("\n=== Strategy Factory Ready ===") |
| print(f"Wallet: {wallet.address}") |
| print(f"RPC: {RPC_URL}") |
| print(f"Chain ID: {CHAIN_ID}") |
| print(f"Min expectedProfit: {MIN_EXPECTED_PROFIT}") |
| print("Upload strategy ZIP via http://localhost:7860") |
| app.run(host="0.0.0.0", port=7860) |