onchain-forensics
Recognizes Calendly as a legitimate scheduling platform to reduce false positives in luring detection.
Correctly identifies Google Meet as a legitimate service, avoiding false impersonation flags in domain checks.
Interprets Optimism chain IDs in transaction calldata for blockchain forensic tracing across networks.
Detects impersonation of WeChat domains to identify phishing attempts in communication analysis.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@onchain-forensicsCheck rug pull risk on 0xabc123def456"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
onchain-forensics
Twelve checks you run before you pay, and after you've been robbed. Exposed as an MCP server so an agent can call them, and as plain modules so you can call them yourself.
No API keys. No accounts. Every source is a public endpoint. Read-only throughout — nothing here can move funds, and none of it will ever ask you for one.
Why this exists
It was written in one sitting, while tracing a real theft. Someone's wallet was drained on Base; the trail went through a DEX, a bridge, and out to TRON. Each tool here answers a question that came up during that trace, and each one was kept only after it reproduced a fact already established by hand.
That origin matters more than the feature list. Where a check turned out to be wrong, the fix and the reason it was wrong are written into the module. Those comments are the useful part of this repository — they are the difference between a scanner that sounds confident and one you can act on.
Three examples, all real, all caught by testing against known answers:
The bridge exit carried
0x2b6653dc, which reads as a perfectly plausible token amount. It is chain id 728126428 — TRON mainnet. Reading it as a quantity produced a confident wrong answer, which is the worst output a forensic tool can give.A one-byte chain id matched inside calldata padding and reported Optimism on a transaction that went to TRON. Ids under two bytes are now returned separately as unusable rather than as evidence.
The curated security index reports
holder_count: 0on a freshly indexed token, meaning "not computed yet" rather than "no holders". Read literally, it stamps a warning on every new launch — which is exactly the noise that makes people stop reading warnings.
Related MCP server: Onchain MCP Server
The tools
Tool | The question |
| Which contract is the real one behind this ticker? |
| What powers does the deployer still hold over my money? |
| Real Base-native asset, or an ERC-20 wearing its address prefix? |
| Who paid for this launch, and what else did they pay for? |
| Where did the stolen funds go? |
| Is this offer to get my money back the second theft? |
| Is this inbound opportunity a lure? |
| Which doors into my wallet are still open? |
| What changed around this wallet since we last looked? |
| Is this agent safe to connect to, and safe to pay? |
| Is a recovery phrase sitting in cleartext on this machine? |
| What key material is on this disk, and what still holds an old copy of it? |
The two ideas worth stealing from this repo
A dangerous capability only counts if someone can still fire it. is_mintable with ownership renounced
is inert; the same flag with a live owner is an armed rug. Scanners that list flags without that distinction
produce noise, and noise gets ignored, and ignored warnings are the same as no warnings. Proven on a live
token: BRETT reports a modifiable tax and unlocked LP, and is not a rug, because nobody can fire either.
An event is not a transaction. ERC-20 Transfer logs are strings the emitting contract chooses. Spam
contracts routinely emit transfers naming addresses that never signed anything — during the trace this came
from, a bot forged a fake transfer four minutes after the real theft, using a token named EṬH (a Unicode
homoglyph) and an address engineered to look like the victim's. Any tool that reads "transfers where
from = target" straight from an indexer will report movements that never happened. Check the signer.
A correction, kept because it is the most useful thing here
An earlier version of this README and of rugsignals.js claimed that the rugs observed while building
this all died by liquidity withdrawal, and that the unlocked-LP flag had already been printed on each
verdict. That was checked against the stored evidence and it is false. Not one of the eight rugs carried
that flag. Seven carried a single flag and it was the holder count; the eighth had no data at all. The
unlocked-pool flag appeared on tokens that survived.
The claim had been generalised from two examples that fit the story and neither of which rugged. It was plausible, mechanically sound, and wrong — which is exactly the shape of the errors this repository is meant to catch, so it stays documented rather than quietly edited away.
On vet_approach, and why it refuses to grade how convincing something is
The lure that started this repository was not phishing in any recognisable sense. It was a 35-question production dossier across six chapters, with per-chapter shot lists, citing the target's real scoring model and settlement rails, using his own catchphrase back at him, and quoting his posts verbatim.
It also asked genuinely hard questions — whether one score creates false certainty, whether the oracle profits from generating fear. That was the payload, not the praise. A flatterer never includes criticism, so including it is exactly what flips an approach from marketing into journalism in the reader's head.
The mechanism underneath is effort as a trust signal. Producing that much researched detail used to cost hours of human work, so nobody spent it to scam one person, and every reader's instinct silently priced that in. The arithmetic held for decades. It does not hold now — the same document generates in minutes from a public profile — and nobody updated their instincts.
So this tool deliberately does not score how convincing an approach is. Convincingness is manufacturable, and
grading it would hand a forgery a good mark. It grades the two things a forger cannot make harmless: where
a link actually points, and what the sender wants you to do. A brand name to the left of the
registrable domain is a free label — wechat.web09eu.com is web09eu.com.
The false-positive test earned its keep on the first run: a version keyed on brand names alone flagged
meet.google.com — Google Meet, the real one — as impersonation. A tool that warns about Google Meet is a
tool people uninstall.
Then the lure was pointed at this tool, and three things came out of it
The domain in that approach is still live months later, so it could be replayed against the module written because of it. That is the only test worth much on a tool like this, and it found more than it confirmed.
The stated rule was not the implemented rule. The output printed "a real podcast records in your
browser" while nothing in the code applied it. vetApproach took booleans — asksToInstall, asksForSeed —
so the caller had to have already decided that the message asks you to install something. That is the hard
part. It made this a checklist rather than a check: fine for a careful human, useless to an agent handed a raw
email. It now accepts message and reads the ask out of the text, and the composition is fail-closed by
construction — the scan can only ever add a flag, never clear one a caller set deliberately, because a
heuristic may accuse and must never acquit.
calendly.com was reported as "not a platform this recognises" — the meet.google.com mistake again,
wearing different clothes. And the fix is the interesting part: in the real approach, the Calendly link was
genuine. A real account on a real service. So Calendly is neither a flag nor a comfort, and it now returns
recognised_neutral with that said out loud — legitimate infrastructure is the cover, not the tell. A brand
sitting under a domain that does not own it is still impersonation; calendly.evil.com is a row in the test.
The headline was the weaker of two true findings. reason was flags[0] — whichever check happened to
push first — and on the real message that surfaced "a link points at a domain that is not a recognised
platform" while burying "they want the conversation moved into a chat app to hand you a file". Both were
reported; only one is worth acting on. Push order is not a priority, and on a security tool the first line is
what the reader acts on, so the most specific flag now leads.
Worth stating because it is the opposite of a triumphant demo: replayed on the email text alone, the verdict is
high_risk, not fraud. That is correct rather than a miss. Nothing in that message is provably
fraudulent — it is the wechat.web09eu.com link that is, and adding it flips the verdict. A tool that cried
fraud on the text would have been right by luck, and would cry fraud on real podcast invitations too.
Two of the three "defects" I first reported here were my own misuse: registrableDomain takes a hostname and
I passed it a URL, and vetApproach takes booleans and I passed prose. The code did exactly what it said. But
a public helper whose most likely misuse returns silent garbage — https://beaconlayer.co/ in, the whole URL
back out, no throw — is a defect anyway, because nobody re-reads a docstring to check an answer that looks
fine. It accepts a URL, a hostname or an email address now.
On recovery_offer
This is the only tool here that can answer with certainty rather than a score, and it is the one most likely to matter to someone reading this after a bad day.
Recovering stolen funds happens through the thief returning them, or a court, an exchange, or a token issuer freezing and reassigning them. None of those routes require anything from the victim's wallet. So a recovery that needs your signature or an upfront fee is not merely suspicious — it is structurally impossible as described. That holds no matter how credible the person sounds, and no matter how accurately they recite your loss back to you: the theft is public, and reciting it proves nothing.
The tool never returns "safe".
On open_approvals, and the all-clear it fabricated in its own first draft
An ERC-20 approval is a standing permission to move your tokens without asking again. It is the most common drain route that does not need your private key: approved once for an unlimited amount, months ago, to a contract you no longer remember. Wallets do not surface these.
The load-bearing rule is that an Approval event is not the current state. A later approval of zero
revokes an earlier one and emits its own event; reading the log gives you a list of doors that may or may not
still be open. So the log is used only to collect candidate (token, spender) pairs, and every pair is then
confirmed by calling allowance() on the chain right now.
My first draft of this counted a failed RPC call as a revoked approval. It reported forty closed doors having actually verified nine — a fabricated all-clear, on the one tool whose entire job is telling you what is still open. I had documented that exact fault in someone else's scanner an hour earlier.
The fix is four outcomes rather than two: live, confirmed-revoked, not-applicable (the call reverted,
which is a definitive answer), and could-not-check. That last state is the whole point — an unanswered call
is not a closed door, and the tool now says so and reports complete: false.
Then the unread count was driven to zero for real, by batching every allowance() into one aggregate3
call through Multicall3 — one request instead of dozens, so the rate limiting that caused the unread calls
stops happening. Verified against a live wallet: 11 unread became 0.
On watch_wallet, and why a monitor that repeats itself is worth nothing
Every other tool here answers at a point in time. This one remembers, which is what turns them into a guard.
Three unlimited approvals granted last year are a standing condition. A fourth appearing this morning is an event. Only the second deserves to interrupt anyone — a monitor that re-reports its standing conditions every hour trains its reader to close it, and a closed monitor catches nothing. So state is persisted per address and the output is a diff.
It also judges each new counterparty instead of merely announcing it, because detecting and then declining to think is half a product. And it reports its own blind spots on every run: on a wallet monitor, an empty alert list reads as "you are safe", so a check that could not run has to say so out loud.
On vet_agent, and why it will not grade a tool description
Agents now call other agents and pay them. Four dangers are checkable without trusting a word of the listing: it does not exist; its tools can move money; it asks for key material; or it is paid to an address with no past.
The discipline that made this work is that a name is marketing, the input schema is the capability. A
tool called helpful_assistant with an amount field and a to field is a payment tool. And only a
quantity field proves a payment surface — a message has a recipient exactly as a payment does, but you
cannot move value without saying how much. Keying on recipients alone flagged our own messaging tool.
Two bugs worth repeating because both produced silent passes. A word-boundary regex (\bsend\b) matched
none of nine snake_case tool names, because an underscore is a word character — wallet_transfer never
matches \btransfer\b. And an HTTP 401 was first classified as unreachable, when it means the opposite: the
agent is running and gated. That is unauditable — neither a pass nor a fail.
It deliberately does not score how good the description reads, for the same reason vet_approach does not:
a well-written tool listing is free to fabricate, and grading prose hands a forgery a good mark.
And then it flagged our own server, which is how the rule above got finished. The first version returned
high_risk on a live endpoint because one of its 43 tools takes an amount. Reading the handler showed that
tool holds no key and broadcasts nothing: it requires an EIP-712 signature the caller has to produce.
A payment surface only counts against an agent if the agent can fire it. A tool gated on a caller-supplied
signature is the agent-tool equivalent of a contract with ownership renounced — the capability is there and
nobody unattended can trigger it. That is the same rule rugsignals.js had been applying to contracts since
day one, and vet_agent was not applying it. The second use of a principle deserves as much scrutiny as
the first; I had written it down and still missed it.
The gate is narrow on purpose, because a loose reading of it would excuse every drainer with an auth header:
the field must be required, not merely accepted — an optional signature gates nothing;
it must authorize the caller. An
apiKeyauthorizes the agent: a standing credential it already holds, which is exactly the unattended case being tested for.
Those two evasions and three more are rows in test/agent-vet-gate.js, because a gate nobody tried to walk
through is not a gate. A gated surface never escalates and is never dropped either — it is reported on
every verdict including the clean ones, since "we found a payment tool and decided it was fine" is a
conclusion the reader is entitled to disagree with. What the check cannot see is whether the server actually
verifies that signature; that is off-chain code, so it is reported as a described gate and never as proof of
one.
Auditing the rest of our own endpoints then surfaced a second category: a tool that takes an amount to
record a payment that already happened. A required transaction hash is a backward reference — a hash
cannot be broadcast — where a required raw signed transaction is forward-acting and is exactly how funds
move. That line is the whole distinction, and the sharpest row in the truth table is the one asserting that
signedTx is never read as a witness merely because it contains the letters tx.
The residual risk of a witness tool is named rather than dissolved: it can claim a payment that did not happen. That is a false-record risk, not a drain risk, and a check about unattended spending has no business pretending to cover it.
Both categories were found by pointing the tool at our own servers, which is also the reason to distrust them
a little: a false-positive story is most tempting when the flagged thing is yours. The test applied to each
was whether the reasoning would be accepted for a stranger's server. Two other bugs found the same way are
recorded in the module — a hardcoded verdict line that claimed no tool named a value-moving action while the
surface field in the same response listed two that did, and a set entry (signedtx) that could never match
because tokenize splits it into signed + tx. That last one is the third time in this file that
splitting an identifier silently disabled a rule.
The danger no schema declares: browser control plus a wallet on the same machine
GitHub trending surfaced a project whose pitch is that your agent inherits your existing logins, cookies and
extensions — your real Chrome profile, with in-page tools named snapshot, fill, click, wait,
navigate, capture. Pointed at it, vet_agent reported a read-only surface. Correctly, by its own rules: a
value VERB is required in a name before the schema is examined at all, and browser automation has none by
design. fill even carries a value field, and it is never reached.
The danger is not in any tool. It is in what the browser can reach. On the machine this was tested from,
key_exposure reports eight browser wallet vaults on disk, one of them 27 MB of MetaMask state. An agent
that can navigate and click inside that profile can drive the wallet, which is a payment surface no input
schema will ever advertise.
So the check flags the combination, never browser control alone — the same tools against a clean container
are ordinary web automation, and flagging those is how a security tool gets muted. The second half is
checkable: localVaults is passed in by the caller, and comes from the vault sweep.
Getting the detector right took two wrong versions, both kept as test rows because they are the interesting
part. Keyed on VERBS, it fired on five of six honest tool sets — a trading agent (open_position +
execute_order), a file manager, a database client, a terminal, a CI runner — because open and execute are
the two most generic verbs in software. Keyed on names and schemas, it then MISSED our own Chrome MCP, whose
action tools are called computer and form_input while taking ref and coordinate: a false negative, which
on a security check is the worse direction. A DOM field decides alone now. A selector or a coordinate exists
for exactly one purpose — addressing something rendered. open_file takes a path, execute_query takes sql,
open_position takes a size, and a git checkout takes a ref that is a branch; none of them touch a DOM,
which is why the weak fields like ref only count inside a tool set that also navigates.
(This paragraph shipped once with every backticked identifier missing — "in-page tools named , , , ," — because it was written through a shell heredoc where backticks are command substitution. Fixed in the next commit. Documenting it because the same class of mistake, a tool used through a layer that silently eats part of the input, is what the rest of this file is about.)
On seed_exposure, and the false positive it produced on its first real machine
"Self custody if you know how to keep your seedphrase safe." The condition is the whole sentence, and nothing ships that checks it. An antivirus answers do you have a known virus; the question a person holding crypto actually has is is my recovery phrase readable by anything that runs here.
That question is decidable, which is the only reason this is worth building rather than guessing at. A
keyword scan drowns immediately — abandon, able, about and absent are ordinary English and all four are
BIP-39 words. But a mnemonic is not a bag of words. It is a run of 12/15/18/21/24 consecutive words drawn
from a 2048-word list, and BIP-39 puts a checksum in the last word:
word indices (11 bits each) → entropy bits ‖ checksum bits
checksum must equal the leading bits of SHA-256(entropy)So a candidate is proven by arithmetic, not scored. Measured on 1.6 MB of real English prose and source across 204 files: zero confirmed hits.
It never outputs the phrase. Not to stdout, not to a log, not in an error. It reports the file, the line and the word count. This output ends up in terminal buffers, CI logs, screenshots and pasted bug reports, none of which is a place a seed belongs — a scanner that prints the seed it found is a stealer with good intentions, and good intentions are not a security property. The location is enough to act on: go and look.
Then it was pointed at a real machine and returned exposed, wrongly. The hit was inside a paywall template
that embeds a minified wallet library, and the library embeds all 2048 BIP-39 words. A 15-word window in that
region passed the checksum by chance.
The bounded offset search was written to prevent precisely that, and it worked — on the axis I had thought about. Minified code splits the wordlist region into thirty-odd separate runs, and each run then gets its own bounded search: roughly 600 checksum tests in one file, at 1/16 each for a 12-word window. A coincidental pass there is not a risk, it is arithmetic. I had capped the multiplicity inside a run and left the number of runs unbounded — the same problem rotated ninety degrees.
The fix is that the file carries its own refutation. A note holding a seed contains 12 to 24 wordlist words; a wallet library contains hundreds. Above 200 distinct wordlist words the file is a carrier — a library, a language pack, or the list itself — and no phrase is claimed inside it. Both halves are tested: the carrier is never confirmed, and a long document that also contains a real phrase still is.
What it cannot see, said plainly, because on this question silence reads as safety. No images, no PDFs, no
password managers, no browser storage, no encrypted archives, nothing outside the paths given. nothing_found
means nothing was found in what was read — the result carries complete and a per-reason skipped count so
a partial scan can never be mistaken for a clean bill of health.
The publish gate, and a test that passed while the server was dead
npm test runs test/publishable.js, which refuses to call this repo publishable unless every relative
require resolves to a file that is here, lib/index.js exports every entry point, and the MCP server boots
over real stdio and lists all ten tools with usable schemas.
It exists because of one specific failure. lib/wallet-watch.js was copied in from the private repo it was
written in, carrying require('./screen') for a file that never came with it. The whole server then died on
load — every tool gone, not just that one. The smoke test I had run passed, because I ran it before
adding that dependency and then copied the changed file over without re-running it.
A stale test feels exactly like a passing one. That is why the correction here is a gate and not a resolution
to be more careful. The gate's rule is asymmetric on purpose: a require may resolve to nothing only if
the line says optional-require, so an absence has to be claimed in the source to be tolerated and silence
means broken. And a marker that claims optional while sitting outside a try is reported as a lie, because a
marker nobody verifies is just a comment. Both failure modes were reproduced deliberately to confirm the gate
catches them.
Install
git clone https://github.com/philpof102-svg/onchain-forensics
cd onchain-forensicsNo dependencies to install — it uses only the Node standard library. To check the clone is intact:
npm testThat boots the server and verifies all ten tools list, so a broken copy fails here rather than in your client.
As an MCP server
claude mcp add onchain-forensics -- node /absolute/path/to/onchain-forensics/bin/onchain-forensics-mcp.jsOr in any MCP client's config:
{
"mcpServers": {
"onchain-forensics": {
"command": "node",
"args": ["/absolute/path/to/onchain-forensics/bin/onchain-forensics-mcp.js"]
}
}
}As modules
const { scanRugOne } = require('./lib/rugsignals');
const { classifyB20 } = require('./lib/b20');
const { followTron } = require('./lib/trace');
const v = await scanRugOne('base', '0x...');
console.log(v.verdict, v.reason); // rug_ready | high_risk | caution | clean | unknownData sources
Blockscout (EVM chains) · TronGrid (TRON) · DexScreener (liquidity) · GoPlus (contract security) · honeypot.is (live trade simulation). All keyless, all public, all rate-limited — the code throttles and caps its own crawls, because being rude to a free endpoint is how everyone loses access to it.
What this does not do
It reports structure, never identity or intent. A shared funder proves shared control or shared infrastructure; a launchpad and a rug factory are indistinguishable from the graph alone. An amount-matched forward proves a pass-through; it says nothing about who holds the keys.
Every verdict is a pointer to the chain, not a badge. Re-verify.
Licence
MIT.
Maintenance
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/philpof102-svg/onchain-forensics'
If you have feedback or need assistance with the MCP directory API, please join our Discord server