#!/usr/bin/env bash # Targeted mainnet cWUSDC transfers to a subset of EI matrix wallets by linear index. # Intended for triage: feed indices from run-ei-matrix-full-readiness-audit.sh gap files. # # Usage: # ./scripts/deployment/send-cwusdc-ei-matrix-targeted.sh [--dry-run] --send-raw R \ # [--indices-file PATH] [--amounts-tsv PATH] [--resume-next] [--quiet-dry-run] [--legacy] # # --indices-file Newline-separated linear indices (default: reports/status/ei-matrix-readiness-gaps-mainnet-indices.txt). # Empty lines and # comments ignored. # --send-raw R Amount (raw, 6 decimals) per wallet when not using --amounts-tsv. # --amounts-tsv F Tab-separated: linearIndex amountRaw (must cover every index in indices file). # --resume-next Continue from reports/status/ei-matrix-cwusdc-targeted-last-idx.txt + 1 # (skips indices at or below last completed). # # Env: same as send-cwusdc-ei-matrix-wallets.sh (PRIVATE_KEY, ETHEREUM_MAINNET_RPC, CWUSDC_MAINNET, …) # Lock: reports/status/ei-matrix-cwusdc-targeted-send.lock # Progress: reports/status/ei-matrix-cwusdc-targeted-last-idx.txt # Failures: reports/status/ei-matrix-cwusdc-targeted-failures.log # set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" cd "$PROJECT_ROOT" DRY_RUN=false QUIET_DRY_RUN=false CAST_LEGACY=false RESUME_NEXT=false INDICES_FILE="${EI_MATRIX_TARGETED_INDICES_FILE:-${PROJECT_ROOT}/reports/status/ei-matrix-readiness-gaps-mainnet-indices.txt}" AMOUNTS_TSV="" SEND_RAW="" LAST_IDX_FILE="${PROJECT_ROOT}/reports/status/ei-matrix-cwusdc-targeted-last-idx.txt" while [[ $# -gt 0 ]]; do case "$1" in --dry-run) DRY_RUN=true; shift ;; --quiet-dry-run) QUIET_DRY_RUN=true; shift ;; --legacy) CAST_LEGACY=true; shift ;; --resume-next) RESUME_NEXT=true; shift ;; --indices-file) INDICES_FILE="${2:?}"; shift 2 ;; --amounts-tsv) AMOUNTS_TSV="${2:?}"; shift 2 ;; --send-raw) SEND_RAW="${2:?}"; shift 2 ;; *) echo "Unknown arg: $1" >&2; exit 1 ;; esac done if [[ -z "$SEND_RAW" && -z "$AMOUNTS_TSV" ]]; then echo "Set --send-raw R or --amounts-tsv PATH." >&2 exit 1 fi if [[ -n "$SEND_RAW" && -n "$AMOUNTS_TSV" ]]; then echo "Use only one of --send-raw or --amounts-tsv." >&2 exit 1 fi # shellcheck disable=SC1091 source "$PROJECT_ROOT/scripts/lib/load-project-env.sh" CWUSDC="${CWUSDC_MAINNET:-0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a}" PUBLIC_ETH_RPC="${ETHEREUM_MAINNET_PUBLIC_RPC:-https://ethereum-rpc.publicnode.com}" RPC="${ETHEREUM_MAINNET_RPC:-${RPC_URL_1:-${ETH_MAINNET_RPC_URL:-$PUBLIC_ETH_RPC}}}" BALANCE_RPC="${EI_MATRIX_BALANCE_RPC:-$RPC}" GRID="$PROJECT_ROOT/config/pmm-soak-wallet-grid.json" [[ -f "$INDICES_FILE" ]] || { echo "Missing indices file: $INDICES_FILE" >&2; exit 1; } [[ -f "$GRID" ]] || { echo "Missing $GRID" >&2; exit 1; } command -v cast &>/dev/null || { echo "cast required" >&2; exit 1; } command -v jq &>/dev/null || { echo "jq required" >&2; exit 1; } [[ -n "${PRIVATE_KEY:-}" ]] || { echo "PRIVATE_KEY not set" >&2; exit 1; } LOCK_FILE="${PROJECT_ROOT}/reports/status/ei-matrix-cwusdc-targeted-send.lock" mkdir -p "${PROJECT_ROOT}/reports/status" exec 200>"$LOCK_FILE" if ! flock -n 200; then echo "Another send-cwusdc-ei-matrix-targeted.sh is already running (lock: $LOCK_FILE)." >&2 exit 1 fi FROM_ADDR=$(cast wallet address --private-key "$PRIVATE_KEY") CHAIN_ID=$(cast chain-id --rpc-url "$RPC" 2>/dev/null | tr -d '[:space:]' || true) [[ -n "$CHAIN_ID" ]] || CHAIN_ID="1" if [[ "$CHAIN_ID" != "1" ]]; then echo "[WARN] chain-id=$CHAIN_ID (expected 1)." >&2 fi pending_nonce() { local resp hex resp=$(curl -sS -X POST "$RPC" -H "Content-Type: application/json" \ -d "{\"jsonrpc\":\"2.0\",\"method\":\"eth_getTransactionCount\",\"params\":[\"${FROM_ADDR}\",\"pending\"],\"id\":1}" 2>/dev/null) || return 1 hex=$(echo "$resp" | jq -r '.result // empty') [[ -n "$hex" ]] || return 1 cast to-dec "$hex" } token_decimals() { cast call "$CWUSDC" 'decimals()(uint8)' --rpc-url "$BALANCE_RPC" 2>/dev/null | awk '{print $1}' } token_balance_raw() { cast call "$CWUSDC" "balanceOf(address)(uint256)" "$FROM_ADDR" --rpc-url "$BALANCE_RPC" 2>/dev/null | awk '{print $1}' } ERR_LOG="${PROJECT_ROOT}/reports/status/ei-matrix-cwusdc-targeted-failures.log" # Sorted unique indices from file (one index per line; # comments; whitespace tolerated) IND_TMP=$(mktemp) grep -v '^[[:space:]]*$' "$INDICES_FILE" | sed 's/#.*//' \ | awk '{ gsub(/[[:space:]]/, "", $0); if ($0 ~ /^[0-9]+$/) print $0 }' \ | sort -n -u >"$IND_TMP" || true if [[ ! -s "$IND_TMP" ]]; then echo "No numeric indices in $INDICES_FILE — nothing to do." rm -f "$IND_TMP" exit 0 fi if $RESUME_NEXT; then [[ -f "$LAST_IDX_FILE" ]] || { echo "Missing $LAST_IDX_FILE for --resume-next" >&2; rm -f "$IND_TMP"; exit 1; } _last="$(tr -d '[:space:]' < "$LAST_IDX_FILE" || echo "")" [[ -n "$_last" ]] || { echo "Empty $LAST_IDX_FILE" >&2; rm -f "$IND_TMP"; exit 1; } _filtered=$(mktemp) while read -r line; do [[ "$line" =~ ^[0-9]+$ ]] || continue if [[ "$line" -gt "$_last" ]]; then echo "$line" fi done <"$IND_TMP" >"$_filtered" mv "$_filtered" "$IND_TMP" echo "Resume-next: last completed idx=$_last; remaining indices: $(wc -l <"$IND_TMP" | tr -d ' ')" fi N_IND=$(wc -l <"$IND_TMP" | tr -d ' ') if [[ "$N_IND" -eq 0 ]]; then echo "No indices to process after filters." rm -f "$IND_TMP" exit 0 fi PAIR_TMP=$(mktemp) cleanup() { [[ -f "$IND_TMP" ]] && rm -f "$IND_TMP" [[ -f "$PAIR_TMP" ]] && rm -f "$PAIR_TMP" } trap cleanup EXIT if [[ -n "$AMOUNTS_TSV" ]]; then [[ -f "$AMOUNTS_TSV" ]] || { echo "Missing amounts TSV: $AMOUNTS_TSV" >&2; exit 1; } # Build map idx->amount in Python for validation + join python3 - "$IND_TMP" "$AMOUNTS_TSV" "$PAIR_TMP" <<'PY' import sys from pathlib import Path ind_path = Path(sys.argv[1]) amt_path = Path(sys.argv[2]) out_path = Path(sys.argv[3]) wanted = [int(x) for x in ind_path.read_text().split() if x.strip().isdigit()] wanted_set = set(wanted) amap: dict[int, int] = {} for line in amt_path.read_text().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: print(f"Bad amounts line: {line!r}", file=sys.stderr) sys.exit(1) idx = int(parts[0].strip()) raw = int(parts[1].strip()) amap[idx] = raw missing = sorted(wanted_set - set(amap)) if missing: print(f"Amounts TSV missing {len(missing)} indices (first 20): {missing[:20]}", file=sys.stderr) sys.exit(1) lines = [] for idx in sorted(wanted_set): lines.append(f"{idx}\t{amap[idx]}") out_path.write_text("\n".join(lines) + "\n", encoding="utf-8") PY # PAIR_TMP now: idx \t raw per line sorted by idx BUDGET_RAW=$(awk '{s+=$2} END {print s}' "$PAIR_TMP") else while read -r idx; do echo -e "${idx}\t${SEND_RAW}" done <"$IND_TMP" | sort -n -t $'\t' -k1,1 >"$PAIR_TMP" BUDGET_RAW=$(python3 -c "print(int('$SEND_RAW') * int('$N_IND'))") fi ADDR_AMT_TMP=$(mktemp) cleanup() { [[ -f "$IND_TMP" ]] && rm -f "$IND_TMP" [[ -f "$PAIR_TMP" ]] && rm -f "$PAIR_TMP" [[ -f "$ADDR_AMT_TMP" ]] && rm -f "$ADDR_AMT_TMP" } trap cleanup EXIT while IFS=$'\t' read -r idx raw_amt; do addr=$(jq -r --argjson i "$idx" '.wallets[$i].address // empty' "$GRID") [[ -n "$addr" && "$addr" != null ]] || { echo "No address for index $idx in grid" >&2; exit 1; } echo -e "$addr\t$raw_amt\t$idx" done <"$PAIR_TMP" >"$ADDR_AMT_TMP" DECIMALS=$(token_decimals || echo "6") GAS_EST="${EI_MATRIX_SEND_GAS_EST:-70000}" HEADROOM_BPS="${EI_MATRIX_GAS_HEADROOM_BPS:-10500}" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "EI matrix cWUSDC targeted transfer (mainnet)" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "RPC: $RPC" echo "Token: $CWUSDC" echo "Signer: $FROM_ADDR" echo "Indices: $N_IND (from $INDICES_FILE)" echo "Budget: $BUDGET_RAW raw total" echo "Dry-run: $DRY_RUN" echo "" ETH_WEI=$(cast balance "$FROM_ADDR" --rpc-url "$BALANCE_RPC" 2>/dev/null | awk '{print $1}' || echo "0") TOKEN_BAL=$(token_balance_raw || echo "0") echo "Signer ETH: $ETH_WEI wei" echo "Signer cWUSDC (raw): $TOKEN_BAL" if ! $DRY_RUN && [[ "${EI_MATRIX_SKIP_BALANCE_CHECK:-}" != "1" ]]; then if ! python3 -c "import sys; sys.exit(0 if int('$TOKEN_BAL') >= int('$BUDGET_RAW') else 1)"; then echo "Insufficient cWUSDC for budget $BUDGET_RAW raw." >&2 exit 1 fi fi if ! $DRY_RUN && [[ "${EI_MATRIX_SKIP_GAS_CHECK:-}" != "1" ]]; then GAS_PRICE_WEI=$(cast gas-price --rpc-url "$RPC" 2>/dev/null | awk '{print $1}' | head -1) [[ -n "$GAS_PRICE_WEI" ]] || GAS_PRICE_WEI=0 MIN_WEI=$(python3 -c "c=int('$N_IND'); g=int('$GAS_EST'); p=int('$GAS_PRICE_WEI'); b=int('$HEADROOM_BPS'); print(c*g*p*b//10000)") if ! python3 -c "import sys; sys.exit(0 if int('$ETH_WEI') >= int('$MIN_WEI') else 1)"; then echo "Insufficient ETH for gas (need ≈ $MIN_WEI wei)." >&2 exit 1 fi fi matrix_try_transfer() { local addr="$1" raw_amt="$2" idx="$3" local dec human out tx attempt=1 dec="${DECIMALS:-6}" [[ "$raw_amt" != "0" ]] || { echo "[skip] idx=$idx zero"; return 0; } human=$(python3 -c "d=int('$dec'); a=int('$raw_amt'); print(f'{a / (10**d):.6f}')" 2>/dev/null || echo "$raw_amt") if $DRY_RUN; then if ! $QUIET_DRY_RUN; then echo "[dry-run] idx=$idx $addr raw=$raw_amt (~$human)" fi return 0 fi local cast_extra=() $CAST_LEGACY && cast_extra+=(--legacy) while [[ "$attempt" -le 2 ]]; do if out=$(cast send "$CWUSDC" "transfer(address,uint256)" "$addr" "$raw_amt" \ --rpc-url "$RPC" --private-key "$PRIVATE_KEY" \ --nonce "$NONCE" "${cast_extra[@]}" 2>&1); then tx=$(echo "$out" | tail -n1) echo "[ok] idx=$idx nonce=$NONCE $addr raw=$raw_amt tx=$tx" sent=$((sent + 1)) NONCE=$((NONCE + 1)) echo "$idx" >"$LAST_IDX_FILE" return 0 fi if [[ "$attempt" -eq 1 ]] && echo "$out" | grep -qi 'nonce too low'; then NONCE=$(pending_nonce) || true attempt=$((attempt + 1)) continue fi echo "[fail] idx=$idx $out" | tee -a "$ERR_LOG" >&2 failed=$((failed + 1)) NONCE=$(pending_nonce) || true return 0 done } sent=0 failed=0 NONCE=$(pending_nonce) || { echo "Could not read nonce" >&2; exit 1; } echo "Starting nonce: $NONCE" echo "" while IFS=$'\t' read -r addr raw_amt idx; do matrix_try_transfer "$addr" "$raw_amt" "$idx" done <"$ADDR_AMT_TMP" if $DRY_RUN; then echo "Dry-run complete ($N_IND wallets)." else echo "Done. sent=$sent failed=$failed" fi