#!/usr/bin/env python3 """ Batch mainnet cWUSDC to EI matrix wallets via canonical Multicall3 aggregate3. Each inner call is transferFrom(deployer, recipient, amount) on the token, so msg.sender is Multicall3. Requires a prior approve(deployer -> Multicall3) for at least the sum of amounts in this run (one tx before batches). Default Multicall3 (Ethereum): 0xcA11bde05977b3631167028862bE2a173976CA11 Examples: python3 scripts/lib/ei_matrix_multicall3_cwusdc_batch.py --dry-run \\ --tsv reports/status/ei-matrix-cwusdc-topup-amounts.tsv python3 scripts/lib/ei_matrix_multicall3_cwusdc_batch.py --execute \\ --tsv reports/status/ei-matrix-cwusdc-topup-amounts.tsv Env: PRIVATE_KEY (or DEPLOYER_ADDRESS for dry-run calldata only), ETHEREUM_MAINNET_RPC, CWUSDC_MAINNET (optional), MULTICALL3_MAINNET (optional), EI_MATRIX_MC_CHUNK (default 200). """ from __future__ import annotations import argparse import json import os import subprocess import sys import time from pathlib import Path MULTICALL3_MAINNET = "0xcA11bde05977b3631167028862bE2a173976CA11" DEFAULT_CWUSDC = "0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a" def _sh(cmd: list[str]) -> str: r = subprocess.run(cmd, capture_output=True, text=True, check=False) if r.returncode != 0: raise RuntimeError(f"command failed: {' '.join(cmd)}\n{(r.stderr or r.stdout).strip()}") return (r.stdout or "").strip() def _deployer(pk: str | None) -> str: if pk: return _sh(["cast", "wallet", "address", "--private-key", pk]) env = (os.environ.get("DEPLOYER_ADDRESS") or os.environ.get("DEPLOYER") or "").strip() if env: return env raise SystemExit("Set PRIVATE_KEY or DEPLOYER_ADDRESS for transferFrom(from=...)") def _cast_calldata_transfer_from(from_addr: str, to_addr: str, amount: int) -> str: out = _sh(["cast", "calldata", "transferFrom(address,address,uint256)", from_addr, to_addr, str(amount)]) return out if out.startswith("0x") else "0x" + out def _cast_calldata_aggregate3(calls_tuple_str: str) -> str: out = _sh(["cast", "calldata", "aggregate3((address,bool,bytes)[])", calls_tuple_str]) return out if out.startswith("0x") else "0x" + out def _estimate_gas(from_addr: str, multicall: str, data: str, rpc_url: str) -> int: payload = json.dumps({"from": from_addr, "to": multicall, "data": data}) raw = _sh(["cast", "rpc", "eth_estimateGas", payload, "--rpc-url", rpc_url]) return int(raw, 16) def _allowance(token: str, owner: str, spender: str, rpc_url: str) -> int: out = _sh(["cast", "call", token, "allowance(address,address)(uint256)", owner, spender, "--rpc-url", rpc_url]) return int(out.split()[0], 0) def _send_cast_send(to: str, sig: str, args: list[str], rpc_url: str, pk: str, gas_limit: str | None) -> None: cmd = ["cast", "send", to, sig, *args, "--rpc-url", rpc_url, "--private-key", pk] if gas_limit: cmd.extend(["--gas-limit", gas_limit]) print("→", " ".join(cmd[:8]), "…", file=sys.stderr) r = subprocess.run(cmd, env={**os.environ}) if r.returncode != 0: sys.exit(r.returncode) def _send_raw_calldata(to: str, data: str, rpc_url: str, pk: str, gas_limit: str) -> None: cmd = ["cast", "send", to, data, "--rpc-url", rpc_url, "--private-key", pk, "--gas-limit", gas_limit] print("→ cast send", to[:10] + "…", "--gas-limit", gas_limit, file=sys.stderr) r = subprocess.run(cmd, env={**os.environ}) if r.returncode != 0: sys.exit(r.returncode) def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--tsv", required=True, help="linearIndex TAB amountRaw") ap.add_argument("--grid", default="config/pmm-soak-wallet-grid.json") ap.add_argument("--chunk-size", type=int, default=int(os.environ.get("EI_MATRIX_MC_CHUNK", "200"))) ap.add_argument("--multicall", default=os.environ.get("MULTICALL3_MAINNET", MULTICALL3_MAINNET)) ap.add_argument("--token", default=os.environ.get("CWUSDC_MAINNET", DEFAULT_CWUSDC)) ap.add_argument("--rpc-url", default=os.environ.get("ETHEREUM_MAINNET_RPC") or os.environ.get("RPC_URL_1") or "") ap.add_argument("--dry-run", action="store_true") ap.add_argument("--execute", action="store_true") ap.add_argument("--gas-headroom-bps", type=int, default=13000) ap.add_argument("--min-gas-per-batch", type=int, default=500_000) ap.add_argument("--start-batch", type=int, default=0) ap.add_argument("--max-batches", type=int, default=0, help="0 = all remaining") ap.add_argument("--progress-file", default="reports/status/ei-matrix-multicall3-batch-progress.txt") args = ap.parse_args() if not args.rpc_url: print("Need --rpc-url or ETHEREUM_MAINNET_RPC / RPC_URL_1", file=sys.stderr) return 2 if args.dry_run == args.execute: print("Specify exactly one of --dry-run or --execute", file=sys.stderr) return 2 repo = Path(__file__).resolve().parents[2] grid_path = repo / args.grid if not os.path.isabs(args.grid) else Path(args.grid) tsv_path = repo / args.tsv if not os.path.isabs(args.tsv) else Path(args.tsv) wallets = json.loads(grid_path.read_text(encoding="utf-8"))["wallets"] rows: list[tuple[str, int]] = [] for line in tsv_path.read_text(encoding="utf-8").splitlines(): line = line.split("#", 1)[0].strip() if not line: continue parts = line.split("\t") if len(parts) < 2: parts = line.split() if len(parts) < 2: continue idx = int(parts[0]) amt = int(parts[1]) if amt <= 0: continue addr = wallets[idx]["address"] rows.append((addr, amt)) if not rows: print("No positive-amount rows in TSV.", file=sys.stderr) return 0 pk = os.environ.get("PRIVATE_KEY", "").strip() or None if args.execute and not pk: print("PRIVATE_KEY required for --execute", file=sys.stderr) return 2 deployer = _deployer(pk) mc = args.multicall token = args.token all_chunks: list[list[tuple[str, int]]] = [] for i in range(0, len(rows), args.chunk_size): all_chunks.append(rows[i : i + args.chunk_size]) start_b = max(0, args.start_batch) if args.max_batches > 0: end_b = min(len(all_chunks), start_b + args.max_batches) else: end_b = len(all_chunks) chunks = all_chunks[start_b:end_b] budget_raw = sum(amt for c in chunks for _, amt in c) if not chunks: print("No batches in range.", file=sys.stderr) return 0 print( f"batches {start_b}..{end_b - 1} of {len(all_chunks)} transfers={sum(len(c) for c in chunks)} " f"budget_raw={budget_raw}", file=sys.stderr, ) if args.dry_run: try: allow = _allowance(token, deployer, mc, args.rpc_url) except Exception: allow = 0 print(f"# allowance Multicall3: {allow} budget_this_run: {budget_raw}", file=sys.stderr) if allow < budget_raw: print( f"cast send {token} \"approve(address,uint256)\" {mc} {budget_raw} \\\n" f" --rpc-url \"$ETHEREUM_MAINNET_RPC\" --private-key \"$PRIVATE_KEY\" --gas-limit 120000", file=sys.stderr, ) chunk = chunks[0] parts = [] for addr, amt in chunk: data = _cast_calldata_transfer_from(deployer, addr, amt) parts.append(f"({token},false,{data})") tuple_str = "[" + ",".join(parts) + "]" calldata = _cast_calldata_aggregate3(tuple_str) gl = args.min_gas_per_batch + 65_000 * len(chunk) sample_hex = repo / "reports/status/ei-matrix-multicall3-dryrun-sample-batch.hex" sample_hex.write_text(calldata + "\n", encoding="utf-8") rel = os.path.relpath(str(sample_hex), str(repo)) print(f"\n# sample batch 0 n={len(chunk)} gas_limit~{gl}", file=sys.stderr) print(f"# calldata written: {rel}", file=sys.stderr) print( f"cast send {mc} $(cat {rel}) --rpc-url \"$ETHEREUM_MAINNET_RPC\" \\\n" f" --private-key \"$PRIVATE_KEY\" --gas-limit {gl}" ) print(f"\n# … {len(chunks)} batches total (chunk_size={args.chunk_size})", file=sys.stderr) return 0 assert pk is not None allow = _allowance(token, deployer, mc, args.rpc_url) if allow < budget_raw: print(f"Approving Multicall3 for {budget_raw} raw (was {allow})", file=sys.stderr) _send_cast_send(token, "approve(address,uint256)", [mc, str(budget_raw)], args.rpc_url, pk, "120000") time.sleep(2) allow2 = _allowance(token, deployer, mc, args.rpc_url) if allow2 < budget_raw: print(f"Allowance insufficient: {allow2} < {budget_raw}", file=sys.stderr) return 1 progress_path = repo / args.progress_file progress_path.parent.mkdir(parents=True, exist_ok=True) for bi, chunk in enumerate(chunks): global_batch_idx = start_b + bi parts = [] for addr, amt in chunk: data = _cast_calldata_transfer_from(deployer, addr, amt) parts.append(f"({token},false,{data})") tuple_str = "[" + ",".join(parts) + "]" calldata = _cast_calldata_aggregate3(tuple_str) gas_est = args.min_gas_per_batch try: gas_est = _estimate_gas(deployer, mc, calldata, args.rpc_url) except Exception as e: print(f"[warn] estimateGas failed, fallback: {e}", file=sys.stderr) gas_est = 70_000 * len(chunk) + 400_000 gas_with_headroom = max(args.min_gas_per_batch, (gas_est * args.gas_headroom_bps + 9999) // 10000) print(f"Batch {global_batch_idx}: n={len(chunk)} estimate={gas_est} limit={gas_with_headroom}", file=sys.stderr) _send_raw_calldata(mc, calldata, args.rpc_url, pk, str(gas_with_headroom)) progress_path.write_text(f"{global_batch_idx}\n", encoding="utf-8") time.sleep(1) print("Done.", file=sys.stderr) return 0 if __name__ == "__main__": raise SystemExit(main())