35
Why 0 bundles were ever submitted, and six other things that failed silently (#164)
12f1e35 unverified | # scripts/wallets.sh — every signing key on this box, with balances. | |
| # | |
| # ./scripts/wallets.sh # list them | |
| # ./scripts/wallets.sh --use GrQX # make that one the active signer | |
| # ./scripts/wallets.sh --sweep FCcv # move its balance to the active signer | |
| # | |
| # ───────────────────────────────────────────────────────────────────────── | |
| # WHY | |
| # ───────────────────────────────────────────────────────────────────────── | |
| # rotate_key.sh run twice produces two new wallets, and the second run | |
| # replaces the first — including when the first is the one that was funded. | |
| # That happened here: 0.0555 SOL sitting in a keypair the bot no longer | |
| # signs with, while the active signer holds nothing. | |
| # | |
| # The instinct at that point is to rotate again, or to go looking for a | |
| # private key to paste into a wallet app. Both are worse than the actual | |
| # answer, which is usually "you already have the money, point the bot at | |
| # it". This makes that visible: every keypair recoverable from this disk, | |
| # its PUBLIC address, and its live balance, in one table. | |
| # | |
| # --use switches the signer with no new key and no transfer. Nothing moves | |
| # on-chain, nothing is exposed, and a funded wallet stops being stranded. | |
| # | |
| # --sweep exists for the case where the funds really are in the wrong | |
| # place — it signs a transfer from that wallet to the active signer. The | |
| # key is loaded in memory to sign and is never printed, never written | |
| # anywhere new, and never leaves the box. | |
| # | |
| # Never prints a private key. Addresses and balances are public by | |
| # definition; that is all you see. | |
| set -euo pipefail | |
| cd "$(dirname "$0")/.." | |
| MODE="list"; TARGET="" | |
| case "${1:-}" in | |
| --use) MODE="use"; TARGET="${2:-}" ;; | |
| --sweep) MODE="sweep"; TARGET="${2:-}" ;; | |
| "") : ;; | |
| *) echo "usage: $0 [--use PREFIX | --sweep PREFIX]" >&2; exit 64 ;; | |
| esac | |
| if [ "$MODE" != "list" ] && [ -z "$TARGET" ]; then | |
| echo "$MODE needs an address prefix, e.g. --$MODE GrQX" >&2; exit 64 | |
| fi | |
| export GA_MODE="$MODE" GA_TARGET="$TARGET" | |
| python3 - <<'PY' | |
| import glob, json, os, re, subprocess, sys, urllib.request | |
| MODE = os.environ["GA_MODE"] | |
| TARGET = os.environ["GA_TARGET"] | |
| # .env first so the RPC endpoint and the current signer are known. | |
| env = {} | |
| try: | |
| for line in open(".env", encoding="utf-8", errors="replace"): | |
| line = line.strip() | |
| if line and not line.startswith("#") and "=" in line: | |
| k, v = line.split("=", 1) | |
| env[k.strip()] = v.strip().strip('"').strip("'") | |
| except OSError: | |
| pass | |
| RPC = (env.get("SOLANA_RPC_PRIMARY") or env.get("SOLANA_RPC_URL") | |
| or "https://api.mainnet-beta.solana.com") | |
| try: | |
| import base58 | |
| from solders.keypair import Keypair | |
| except ImportError as exc: | |
| print(f" missing dependency: {exc} — run from the venv") | |
| raise SystemExit(1) | |
| PUBLIC_RPC = "https://api.mainnet-beta.solana.com" | |
| rpc_error = "" # remembered so a failure is REPORTED, not swallowed | |
| def _post(url, method, params): | |
| body = json.dumps({"jsonrpc": "2.0", "id": 1, | |
| "method": method, "params": params}).encode() | |
| req = urllib.request.Request(url, data=body, headers={ | |
| "Content-Type": "application/json", | |
| # Some providers reject a request with no User-Agent outright, which | |
| # then surfaces as an opaque HTTPError. Cheap to send, and it removes | |
| # a whole class of "why is this unknown". | |
| "User-Agent": "garden-angel-wallets/1.0", | |
| }) | |
| with urllib.request.urlopen(req, timeout=20) as r: | |
| return json.load(r) | |
| def rpc(method, params): | |
| """Configured RPC, falling back to the public one, and REMEMBERING why. | |
| The first version of this swallowed every exception and printed | |
| "unknown" in the balance column. That is the exact failure this whole | |
| session has been about: a report that says it does not know, without | |
| saying why, sends the operator hunting for a problem the tool already | |
| had the answer to. A balance lookup is also the least sensitive call | |
| there is — it takes a public address — so there is no reason to be | |
| quiet about it failing. | |
| """ | |
| global rpc_error | |
| last = None | |
| for url in ([RPC, PUBLIC_RPC] if RPC != PUBLIC_RPC else [PUBLIC_RPC]): | |
| try: | |
| out = _post(url, method, params) | |
| if "error" in out: | |
| last = f"{url.split('/')[2]}: {out['error'].get('message', out['error'])}" | |
| continue | |
| return out | |
| except Exception as exc: # noqa: BLE001 | |
| last = f"{url.split('/')[2]}: {type(exc).__name__}: {str(exc)[:90]}" | |
| if last and not rpc_error: | |
| rpc_error = last | |
| raise RuntimeError(last or "no RPC reachable") | |
| # ── collect every keypair this box can still produce ──────────────────── | |
| PATTERN = re.compile(r"""SOLANA_PRIVATE_KEY=["']?([^\s"'\x00]+)""") | |
| sources = [".env"] + sorted(glob.glob(".env.bak.*"), reverse=True) \ | |
| + sorted(glob.glob(os.path.expanduser("~/.env.bak.*")), reverse=True) \ | |
| + [os.path.expanduser("~/.bash_history")] | |
| found, seen = [], set() | |
| for path in sources: | |
| try: | |
| blob = open(path, "rb").read(8 * 1024 * 1024) | |
| except OSError: | |
| continue | |
| for m in PATTERN.finditer(blob.replace(b"\x00", b"\n").decode("utf-8", "replace")): | |
| raw = m.group(1).strip() | |
| if raw in seen: | |
| continue | |
| seen.add(raw) | |
| try: | |
| kp = Keypair.from_bytes(base58.b58decode(raw)) | |
| except Exception: | |
| continue | |
| found.append({"secret": raw, "pub": str(kp.pubkey()), | |
| "kp": kp, "where": os.path.basename(path)}) | |
| if not found: | |
| print(" no keypairs recoverable from this disk") | |
| raise SystemExit(1) | |
| active = "" | |
| cur = env.get("SOLANA_PRIVATE_KEY", "") | |
| if cur: | |
| try: | |
| active = str(Keypair.from_bytes(base58.b58decode(cur)).pubkey()) | |
| except Exception: | |
| pass | |
| for w in found: | |
| try: | |
| w["lamports"] = rpc("getBalance", [w["pub"]])["result"]["value"] | |
| except Exception: | |
| w["lamports"] = None | |
| # ── list ──────────────────────────────────────────────────────────────── | |
| print() | |
| # ONE WALLET PER BLOCK, not one per line. | |
| # | |
| # A Solana address is 44 characters. The operator's terminal is the AWS | |
| # console in mobile Safari, roughly 40 columns — so a table with address, | |
| # balance and filename on one line wrapped into three, interleaved the | |
| # columns, and produced output where "unknown" looked like it belonged to a | |
| # different wallet than it did. They asked "what is unknown s95", and "S95" | |
| # is the middle of a wrapped address: the layout invented a field that was | |
| # never there. | |
| # | |
| # Vertical blocks cost more lines and read correctly at any width. | |
| for i, w in enumerate(sorted(found, key=lambda x: -(x["lamports"] or 0)), 1): | |
| mark = " <-- SIGNING NOW" if w["pub"] == active else "" | |
| if w["lamports"] is None: | |
| bal = "balance unknown" | |
| elif w["lamports"] == 0: | |
| bal = "0 SOL (empty)" | |
| else: | |
| bal = f"{w['lamports'] / 1e9:.6f} SOL" | |
| print(f" [{i}] {bal}{mark}") | |
| print(f" {w['pub']}") | |
| print(f" from {w['where']}") | |
| print() | |
| if rpc_error: | |
| print(f" !! balances unavailable - {rpc_error}") | |
| print( " The addresses and --use still work; only the balance") | |
| print( " column needs the RPC.") | |
| print() | |
| print(" The line marked SIGNING NOW is the wallet the bot uses.") | |
| if MODE == "list": | |
| print() | |
| print(" Point the bot at whichever one holds your funds — no transfer,") | |
| print(" no new key, nothing moves on-chain:") | |
| print() | |
| print(" ./scripts/wallets.sh --use <first few characters of the address>") | |
| print() | |
| raise SystemExit(0) | |
| matches = [w for w in found if w["pub"].startswith(TARGET)] | |
| if len(matches) != 1: | |
| print(f"\n {'no' if not matches else len(matches)} wallet(s) match '{TARGET}' " | |
| f"— use more characters\n") | |
| raise SystemExit(1) | |
| w = matches[0] | |
| # ── use ───────────────────────────────────────────────────────────────── | |
| if MODE == "use": | |
| if w["pub"] == active: | |
| print(f"\n {w['pub']} is already the active signer — nothing to do\n") | |
| raise SystemExit(0) | |
| # v1.70 — --use MUST NOT SILENTLY DOWNGRADE CUSTODY. | |
| # | |
| # These three lines below write SOLANA_KEY_SOURCE=env and paste a | |
| # plaintext private key into .env. On a deployment that has already | |
| # migrated to AWS Secrets Manager that is a silent, total reversal of | |
| # the custody model: bearer authority back on disk, the vault copy | |
| # bypassed, and no output saying so. `./gat money` prints advice that | |
| # leads here (gat:164), so it is reachable by following the bot's own | |
| # instructions. | |
| # | |
| # It stays possible — there are real recovery situations where pointing | |
| # the bot at a local keypair is exactly right, and refusing outright | |
| # would strand someone locked out of AWS. But it is now a decision the | |
| # operator makes out loud instead of a side effect they discover later. | |
| # .env is the authority here, not os.environ: this script parses .env | |
| # itself (it is run by hand, so it does not inherit systemd's | |
| # EnvironmentFile) and .env is also the file --use is about to rewrite. | |
| # os.environ is consulted second so an operator who exported the var | |
| # for a one-off run is still seen. | |
| _current_source = (env.get("SOLANA_KEY_SOURCE") | |
| or os.environ.get("SOLANA_KEY_SOURCE", "")).strip().lower() | |
| if _current_source and _current_source != "env": | |
| print() | |
| print(f" ⚠️ REFUSING — this would downgrade your key custody.") | |
| print() | |
| print(f" Custody is currently : {_current_source}") | |
| print(f" --use would set it to : env (plaintext key written to .env)") | |
| print() | |
| print(" That is bearer authority over the wallet, on disk, with no") | |
| print(" revocation and no recovery — and it would be bypassing the") | |
| print(" vault copy you migrated to, without the bot ever saying so.") | |
| print() | |
| print(" If you genuinely intend that (locked out of AWS, recovering):") | |
| print(f" GA_ALLOW_CUSTODY_DOWNGRADE=1 ./scripts/wallets.sh --use {TARGET}") | |
| print() | |
| print(" To change which key the VAULT holds, rotate the secret in AWS") | |
| print(" instead — see docs/RUNBOOK.md. Nothing was changed.") | |
| print() | |
| if os.environ.get("GA_ALLOW_CUSTODY_DOWNGRADE", "") != "1": | |
| raise SystemExit(1) | |
| print(" GA_ALLOW_CUSTODY_DOWNGRADE=1 given — proceeding anyway.") | |
| print() | |
| lines = [l for l in open(".env", encoding="utf-8", errors="replace").read().splitlines() | |
| if not re.match(r"^[ \t]*(SOLANA_PRIVATE_KEY|SOLANA_WALLET|SOLANA_KEY_SOURCE)=", l)] | |
| lines += [f"SOLANA_PRIVATE_KEY={w['secret']}", | |
| f"SOLANA_WALLET={w['pub']}", | |
| "SOLANA_KEY_SOURCE=env"] | |
| open(".env", "w", encoding="utf-8").write("\n".join(lines) + "\n") | |
| os.chmod(".env", 0o600) | |
| bal = "unknown" if w["lamports"] is None else f"{w['lamports']/1e9:.6f} SOL" | |
| print(f"\n ✅ the bot will now sign with {w['pub']} ({bal})") | |
| print(f" SOLANA_WALLET set to match. Nothing moved on-chain.\n") | |
| print(f" sudo systemctl restart garden-angel-terminal\n") | |
| raise SystemExit(0) | |
| # ── sweep ─────────────────────────────────────────────────────────────── | |
| if not active: | |
| print("\n no active signer in .env to sweep INTO\n"); raise SystemExit(1) | |
| if w["pub"] == active: | |
| print("\n that IS the active signer — nothing to sweep\n"); raise SystemExit(1) | |
| if not w["lamports"]: | |
| print(f"\n {w['pub']} holds nothing\n"); raise SystemExit(1) | |
| FEE = 5_000 # one signature, plus headroom | |
| amount = w["lamports"] - FEE | |
| if amount <= 0: | |
| print(f"\n {w['pub']} holds {w['lamports']} lamports — less than the fee\n") | |
| raise SystemExit(1) | |
| print(f"\n sweep {amount/1e9:.6f} SOL") | |
| print(f" from {w['pub']}") | |
| print(f" to {active}") | |
| try: | |
| from solders.pubkey import Pubkey | |
| from solders.system_program import TransferParams, transfer | |
| from solders.message import Message | |
| from solders.transaction import Transaction | |
| except ImportError as exc: | |
| print(f"\n cannot build the transfer: {exc}\n"); raise SystemExit(1) | |
| blockhash = rpc("getLatestBlockhash", [{"commitment": "finalized"}]) | |
| from solders.hash import Hash | |
| bh = Hash.from_string(blockhash["result"]["value"]["blockhash"]) | |
| ix = transfer(TransferParams(from_pubkey=w["kp"].pubkey(), | |
| to_pubkey=Pubkey.from_string(active), | |
| lamports=amount)) | |
| msg = Message.new_with_blockhash([ix], w["kp"].pubkey(), bh) | |
| tx = Transaction([w["kp"]], msg, bh) | |
| import base64 | |
| sig = rpc("sendTransaction", | |
| [base64.b64encode(bytes(tx)).decode(), | |
| {"encoding": "base64", "skipPreflight": False}]) | |
| if "error" in sig: | |
| print(f"\n ❌ send failed: {sig['error'].get('message', sig['error'])}\n") | |
| raise SystemExit(1) | |
| print(f"\n ✅ sent: {sig['result']}") | |
| print(f" https://solscan.io/tx/{sig['result']}\n") | |
| PY | |