Grok Lost $175K to a Tweet: Add a Pre-Send Tx Canary
A pre-send transaction canary blocks a bad on-chain transaction before your AI agent signs it. TxCanary runs three read-only checks — price sanity against CoinGecko, address sanity via eth_getCode, and a dry-run via eth_call — and returns BLOCK with every reason. It needs no API key, no private key, and moves zero funds.
On May 1, 2026, an X account running Grok-driven trades got talked out of about $175,000 by a tweet. The injection wasn’t even in plain text — it was hidden in Morse code inside a reply, which the model dutifully decoded and treated as an instruction. The agent moved roughly 3 billion DRB tokens to an attacker-controlled address. SlowMist’s writeup pinned it as a textbook prompt-injection: the agent trusted text it read on-chain-adjacent media as if it were a command from its operator. (Around 80% was later recovered — cold comfort if it had been your wallet.)
Here’s the part that should bother you as an engineer: the agent didn’t crash. It did exactly what it was told, confidently and wrong, and signed a transaction. No exception, no stack trace. Just a transfer to an address it had no business trusting.
You can’t out-prompt every injection. But you can refuse to send a transaction your agent never sanity-checked. This post is a ~90-line Python tool, TxCanary, that runs three read-only barriers before you sign. It needs no API key, no private key, and moves zero funds. Run it in 60 seconds.
TL;DR
- An LLM agent will read a wrong price or a hallucinated address and act on it without hesitating. The failure is silent.
- Before you reach for heavy oracles or ZK proofs, add a cheap canary: three read-only checks that run pre-send.
TxCanary.precheck()returnsPASS/BLOCK+ reasons after: (1) price sanity vs CoinGecko, (2) address sanity viaeth_getCode, (3) dry-run viaeth_call.- Stdlib +
requests. Noweb3, no keys, no signing. Hits keyless CoinGecko and a public Ethereum RPC. - This is a smoke test, not an audit. Limits are spelled out at the bottom.
This is the second piece in a small series on controlling agents before they execute, not after. The first one was a hard spend-cap for runaway agent loops; this one is about the single bad transaction.
The failure isn’t the model being dumb. It’s the model being sure.
We spend a lot of energy making agents smarter. Bigger context, better tools, sharper prompts. None of that addresses the actual on-chain failure mode, which is narrower and meaner: the agent produces a transaction that is syntactically perfect and semantically garbage, and there’s nothing between that transaction and the signer.
Three concrete ways this happens, all of which I can reproduce on mainnet read-only:
- A hallucinated address. The agent “knows” the USDC contract, or it parsed an address out of a webpage, or it got injected one. It builds a transfer to a string that isn’t a deployed contract at all — sometimes a plain wallet, sometimes a typo, sometimes a burn address. The tx is valid. The destination is wrong.
- A stale or lied-to price. The agent’s reasoning says “ETH is $1,600, this is a good buy” while the market says something else, or an injected message feeds it a fake number. It sizes a trade against a price that doesn’t exist.
- Calldata that will revert.
transferFromwith no allowance. A swap past a deadline. A method that the contract will reject the instant it’s mined. On a good day you just burn gas; on a bad day you’ve sequenced it after an irreversible step.
The expensive answer to all of this is a real transaction-simulation stack — Tenderly, a forked node, a policy engine. You should want those eventually. But they’re not where you start, and they’re not free to wire up. The canary is. It catches the dumb version of all three before the agent ever signs.
The fix: three read-only barriers, run before you sign
The whole tool is one function — precheck(tx, claimed_price_usd, token_id, token_address) — that returns a verdict and a list of reasons. Each barrier is independent and read-only. Nothing it does requires a key or touches your funds.
Barrier 1 — price sanity. Take the price the agent thinks it’s trading at and compare it to CoinGecko’s keyless /simple/price. If they disagree by more than your tolerance (2% by default), BLOCK. This is the cheapest possible guard against “the model read the wrong number,” including a number it was injected.
Barrier 2 — address sanity. Call eth_getCode on the target. A deployed contract returns bytecode. A hallucinated address, a plain wallet (EOA), or a burn address returns 0x — no code. If you’re about to call a token contract and the target has no code, that’s not a token. BLOCK. This one alone would have flagged a transfer to an address the model invented.
Barrier 3 — dry-run. Send the exact calldata through eth_call against the latest block. eth_call executes the transaction in a read-only context — same EVM, no state change, no gas spent, nothing broadcast. If it reverts, the node hands back an error, and you BLOCK with the revert reason. If your real transaction would fail, this fails first, for free.
The point of running all three and collecting every reason — instead of bailing on the first failure — is that a bad agent action often trips more than one. You want the full report, not just the first red flag.
TxCanary — the whole thing
Stdlib plus requests, deliberately. No web3, so pip install requests and you’re done — it runs on a fresh machine with nothing else. Same multi-RPC fallback as the spend-cap tool from part one: if publicnode is rate-limiting, it falls through to Cloudflare, then llamarpc.
#!/usr/bin/env python3
"""TxCanary - a pre-send sanity check for on-chain agents.
Three read-only barriers run BEFORE you sign/send a transaction:
1. price sanity - the agent's claimed price vs CoinGecko (drift > 2% -> BLOCK)
2. address sanity - eth_getCode on the target (empty code -> BLOCK, not a contract)
3. dry-run - eth_call with the calldata (revert -> BLOCK, would fail on-chain)
Stdlib + requests only. No web3, no keys, no signing, no funds moved.
Run: pip install requests && python txcanary.py
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
import requests
COINGECKO_PRICE = "https://api.coingecko.com/api/v3/simple/price"
PUBLIC_RPCS = ("https://ethereum-rpc.publicnode.com",
"https://cloudflare-eth.com",
"https://eth.llamarpc.com") # tried in order; first to answer wins
HTTP_TIMEOUT = 12
PRICE_DRIFT_TOLERANCE = 0.02 # 2% - tune to your slippage appetite
class _RpcError(Exception):
"""An RPC returned a JSON-RPC error object (e.g. execution reverted)."""
def __init__(self, err: dict):
self.err = err or {}
super().__init__(str(err))
def coingecko_price_usd(token_id: str) -> float:
r = requests.get(COINGECKO_PRICE,
params={"ids": token_id, "vs_currencies": "usd"},
timeout=HTTP_TIMEOUT)
r.raise_for_status()
data = r.json()
if token_id not in data or "usd" not in data[token_id]:
raise RuntimeError(f"CoinGecko has no USD price for id={token_id!r}")
return float(data[token_id]["usd"])
def _rpc(method: str, params: list) -> str:
"""First public node that answers wins. _RpcError = execution-level error (reverts)."""
payload = {"jsonrpc": "2.0", "id": 1, "method": method, "params": params}
transport_err = None
for url in PUBLIC_RPCS:
try:
r = requests.post(url, json=payload, timeout=HTTP_TIMEOUT)
r.raise_for_status()
data = r.json()
if "result" in data:
return data["result"]
if "error" in data:
raise _RpcError(data["error"])
except requests.RequestException as e:
transport_err = e
continue
raise RuntimeError(f"all public RPCs failed at transport level: {transport_err}")
def get_code(address: str) -> str:
return _rpc("eth_getCode", [address, "latest"])
def eth_call(tx: dict) -> str:
return _rpc("eth_call", [tx, "latest"])
@dataclass
class CanaryResult:
verdict: str = "PASS"
reasons: list = field(default_factory=list)
def block(self, reason: str) -> None:
self.verdict = "BLOCK"
self.reasons.append(reason)
def ok(self, note: str) -> None:
self.reasons.append("ok: " + note)
def precheck(tx: Optional[dict] = None,
claimed_price_usd: Optional[float] = None,
token_id: Optional[str] = None,
token_address: Optional[str] = None) -> CanaryResult:
"""Run the three barriers independently; collect every reason (no short-circuit)."""
res = CanaryResult()
# 1) PRICE SANITY
if claimed_price_usd is not None and token_id is not None:
try:
ref = coingecko_price_usd(token_id)
drift = abs(claimed_price_usd - ref) / ref
if drift > PRICE_DRIFT_TOLERANCE:
res.block(f"price drift {drift*100:.2f}% > {PRICE_DRIFT_TOLERANCE*100:.0f}% "
f"(agent claims ${claimed_price_usd:,.2f}, CoinGecko ${ref:,.2f})")
else:
res.ok(f"price within {PRICE_DRIFT_TOLERANCE*100:.0f}% "
f"(claim ${claimed_price_usd:,.2f} vs ref ${ref:,.2f}, drift {drift*100:.2f}%)")
except Exception as e:
res.block(f"price check could not complete: {e}")
# 2) ADDRESS SANITY
if token_address is not None:
try:
code = get_code(token_address)
if code in ("0x", "0x0", ""):
res.block(f"target {token_address} has NO code (EOA or hallucinated address, not a contract)")
else:
res.ok(f"target {token_address} has {(len(code)-2)//2} bytes of code")
except Exception as e:
res.block(f"address check could not complete: {e}")
# 3) DRY-RUN
if tx is not None:
try:
ret = eth_call(tx)
res.ok(f"dry-run returned {ret[:18]}{'...' if len(ret) > 18 else ''} (no revert)")
except _RpcError as e:
msg = e.err.get("message", str(e.err))
res.block(f"dry-run would REVERT: {msg}")
except Exception as e:
res.block(f"dry-run could not complete: {e}")
return res
def _print(label: str, r: CanaryResult) -> None:
print(f"[{r.verdict}] {label}")
for line in r.reasons:
print(f" ok - {line[4:]}" if line.startswith("ok: ") else f" BLOCK - {line}")
print()
if __name__ == "__main__":
print("TxCanary demo - read-only, no keys, no funds moved\n")
USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
VITALIK = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
balance_of = "0x70a08231" + "0" * 24 + VITALIK[2:].lower()
print("--- case (a): legit USDC call, honest price ---")
_print("read USDC balanceOf, agent claims $1.00",
precheck(tx={"to": USDC, "data": balance_of},
claimed_price_usd=1.00, token_id="usd-coin", token_address=USDC))
print("--- case (b): agent invented a token address ---")
_print("send to a 'token' the agent hallucinated",
precheck(token_address="0x000000000000000000000000000000000000dEaD"))
print("--- case (c): agent's price is way off reality ---")
_print("agent claims USDC = $4,200.00",
precheck(claimed_price_usd=4200.00, token_id="usd-coin"))
print("--- case (d): calldata that reverts on-chain ---")
revert_data = ("0x23b872dd" + "0" * 24 + VITALIK[2:].lower()
+ "0" * 24 + VITALIK[2:].lower() + "f" * 64)
_print("transferFrom with no allowance",
precheck(tx={"to": USDC, "data": revert_data}, token_address=USDC))
How you’d wire it in
One line, right before the signer. Build your transaction as usual, then:
result = precheck(tx, claimed_price_usd=agent_price,
token_id="ethereum", token_address=tx["to"])
if result.verdict == "BLOCK":
raise RuntimeError("TxCanary blocked: " + "; ".join(result.reasons))
# only here do you sign and broadcast
The BLOCK is your point of no return in reverse: if it raises, the signing code never runs. You pass it the four things your agent already has — the transaction, the price it reasoned with, the CoinGecko id of the asset, and the target address. It returns the full reason list so your logs show why it stopped, which matters at 3am when an injection is mid-attempt.
Run it
pip install requests
python txcanary.py
No account, no key, no faucet, no testnet. The four demo cases are deterministic on mainnet read-only: a real USDC balanceOf call (passes), a no-code burn address (blocks), a price that’s far off (blocks), and a transferFrom with no allowance that the contract rejects (blocks). The two endpoints it touches are public: CoinGecko’s keyless /simple/price and ethereum-rpc.publicnode.com for eth_getCode / eth_call, with fallback to Cloudflare and llamarpc.
Output
A real run (numbers from the live endpoints the moment it ran; the verdicts, not the exact values, are what’s deterministic):
TxCanary demo - read-only, no keys, no funds moved
--- case (a): legit USDC call, honest price ---
[PASS] read USDC balanceOf, agent claims $1.00
ok - price within 2% (claim $1.00 vs ref $1.00, drift 0.03%)
ok - target 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 has 2186 bytes of code
ok - dry-run returned 0x0000000000000000... (no revert)
--- case (b): agent invented a token address ---
[BLOCK] send to a 'token' the agent hallucinated
BLOCK - target 0x000000000000000000000000000000000000dEaD has NO code (EOA or hallucinated address, not a contract)
--- case (c): agent's price is way off reality ---
[BLOCK] agent claims USDC = $4,200.00
BLOCK - price drift 420008.81% > 2% (agent claims $4,200.00, CoinGecko $1.00)
--- case (d): calldata that reverts on-chain ---
[BLOCK] transferFrom with no allowance
ok - target 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 has 2186 bytes of code
BLOCK - dry-run would REVERT: execution reverted: ERC20: transfer amount exceeds allowance
What’s fixed across runs: case (a) passes, (b)/(c)/(d) block. CoinGecko’s exact price and the byte-count of USDC’s code can wobble — the pass/block decision can’t.
What this is not
I’d rather undersell this than have you trust it past its range. A false sense of safety is worse than no canary.
- It’s not an audit, and it’s not a real simulator.
TxCanarychecks that the target is a contract, the price is plausible, and the call won’t revert right now. It says nothing about whether that contract is malicious, whether its logic is safe, or what it does three calls deep. For that you want Tenderly, a forked-node simulation, or a proper transaction-policy engine. The canary is the layer you add first, in an afternoon — not the only one. Pair it with a pre-execution spend-cap so a runaway loop and a single bad tx are both covered. - It’s not MPC or key custody. It does nothing about how your keys are stored or who can sign. If the threat is a leaked key, this is the wrong tool entirely.
eth_calldoesn’t see MEV or slippage. A dry-run that passes at the latest block can still get sandwiched, front-run, or fill at a worse price by the time it’s mined. Treat “won’t revert” as a floor, not a guarantee about execution quality.- The price check is point-in-time and single-source. CoinGecko can lag a fast move, and one source is one source. For anything load-bearing, cross-check against a second feed and widen the tolerance to match your real slippage budget — 2% is a starting default, not gospel.
- Public RPCs are best-effort. They rate-limit and occasionally 5xx. Fine for a read-only canary; for production use your own node or a keyed provider.
None of that dents the core claim: the Grok loss was the agent acting on text it should never have trusted, with nothing between the bad decision and the send. Three read-only checks are something between.
The question I haven’t answered for myself
The price barrier is the soft one. eth_getCode and eth_call give hard yes/no answers — code or no code, reverts or doesn’t. But “is this price drift too much” depends entirely on the asset and the moment. 2% is fine for a stablecoin and absurd for a thin memecoin that moves 2% between blocks. I’ve been thinking about deriving the tolerance per-asset from recent realized volatility instead of a flat constant, but I haven’t found a clean way to do it that doesn’t pull in a price-history dependency and bloat the tool past “afternoon project.” If you’ve built a per-asset sanity band for an agent that holds up on illiquid tokens, I’d genuinely like to see how you scoped it — drop it in the comments.
Follow for the next one in this series on pre-execution control for on-chain agents.
Written with AI assistance and reviewed/edited by a human. The code in this post was run against the live CoinGecko and public Ethereum RPC endpoints before publishing; the output block above is from a real run (2026-06-08). The Grok incident figures (≈$175K, ~3B DRB, Morse-code injection, ~80% recovered) are as reported by SlowMist and crypto press on/around May 1, 2026 — verify the originals before quoting.