Featured image of post Chasing 3 ETH Into a Dead Testnet: Anatomy of an Unsolvable Bounty

Chasing 3 ETH Into a Dead Testnet: Anatomy of an Unsolvable Bounty

A forensic, OSINT-heavy hunt for the keys to Johan Nygren's 2018 TeikhosBounty contracts — resurrecting two dead testnets, every code host, every forum post — and a multi-source proof that the ~3 ETH inside cannot be claimed by anyone.

TEIKHOS — proof-of-public-key bounty investigation

TL;DR

In 2018 a developer named Johan Nygren deployed a set of “proof-of-public-key” bounty contracts on Ethereum mainnet and funded them with real ETH. Submit the right public key and the contract pays you. Eight years later, three of them are still holding ~2 ETH, plus a fourth holding 1 ETH that is bricked.

I spent a long time trying to claim them. The result is not a key — it’s a proof, from about fifteen independent directions, that there is no findable key. But the interesting part isn’t the verdict; it’s the hunt: resurrecting two decommissioned testnets, a Shodan banner-search that found a node the rest of the internet forgot, a full sweep of every place this man ever published code, and the on-chain forensics tying it all together. This post is that methodology, start to finish.


Four contracts, ~3 ETH, one dead end

Everything traces back to one deployer, 0x4c5D24A7 (“bipedaljoe”), who created 18 contracts in a 16-day burst in early 2018 and then went silent. Four are the prizes:

Bounty Address Value Why it’s unrecoverable
A 0x17e5e091… 1.0 ETH 256-bit random key, never published
B 0xd7c6d542… 0.5 ETH same, two-layer “symmetric” variant
C 0x973c2178… 0.5 ETH same, hash + commit-reveal (plus a real flaw — see below)
bricked 0xaec7e8c2… 1.0 ETH key is known, but the contract has no payout opcode — pays nobody, ever

Bounty C today, still holding 0.5 ETH under a long tail of failed authenticate() attempts:

Bounty C on Etherscan — 0.5 ETH, dozens of failed authenticate() calls Bounty C (0x973c2178) on Etherscan — funded, dormant, every solve attempt reverted.

The fourth one is the saddest. Its key is actually known and verified on-chain — but the contract was compiled without a CALLER opcode in its payout path, so even the correct solver gets nothing. One ETH, frozen by a typo.


How the lock works

To claim, you submit the 64-byte public key P. The contract stores a hardcoded proof, and the proof is the key’s own signature, XORed with the key itself:

1
2
proof = signature(P) ⊕ mask(P)
claim iff  ecrecover( keccak256("\x19Ethereum Signed Message:\n64" ‖ P), v, proof ⊕ mask(P) ) == address(keccak256(P))

P appears on both sides — it is its own fixed point, P = f(P): the public key you must submit is folded into the very signature it has to produce.

The proof is a one-time pad whose solution is a self-referential fixed point

Forward (knowing P): trivial — sign, XOR, submit. Backward (knowing only the proof): no shortcut. XOR lives in a different algebra (GF(2)²⁵⁶) than the elliptic curve, so it never lifts into anything solvable. The cheapest real attack is a brute-force search for any P that hashes to the same address: 2¹⁶⁰ work — ~4.6 × 10¹⁹ years with the entire Bitcoin network’s hashpower.

The 2^160 wall and the three things that build it

And you can’t reverse it, either. The message hash e = keccak256(prefix ‖ P) sits inside the equation and depends on P, so every guess moves the target — the equation chases its own tail. That kills BSGS/meet-in-the-middle (no independent split), Pollard’s rho (no single iterated map), and Gröbner/algebraic inversion (that’s just keccak preimage resistance by another name). There is no fifth door.

Every road to reconstructing the key is a wall

So if the math is a wall, the only physically possible path is a leak — the key sitting in some log, repo, transaction, or post the author forgot about. The rest of this is the hunt for that leak.


Reading two dead testnets

The strongest lead: maybe Johan tested the bounties on a testnet in 2018 and left a key in the calldata. That meant reading Ropsten and Rinkeby — both long decommissioned, with their public RPCs shut down in 2023.

Ropsten — self-hosted archive node. I stood up an Erigon archive node on my own server to get complete Ropsten history. Erigon’s snapshots ship as pure BitTorrent (DHT + public trackers, no webseed), so this was a genuine sync — pulling and indexing segments, forcing early-segment availability. Once queryable, I scanned ~9.3 million blocks for all of Johan’s known addresses and pulled 220 authenticate calls to test against the engine. Zero hits. Ropsten: dead end, definitively.

Rinkeby — the hard one. Rinkeby is Clique proof-of-authority, which is excluded from every snapshot/era1/torrent system — so a local sync had nothing to sync from. Before the breakthrough, I exhausted the entire registered world looking for any surviving endpoint:

  • ~80 RPC endpoints across every historical provider (Infura, Alchemy, Ankr, Cloudflare gateway, and ~70 more) — all dead.
  • Source-code search for hardcoded live RPCs across GitHub (~60 endpoints in old configs/.env files, all dead), GitLab (free-tier global code search disabled — 403), and Bitbucket.
  • Data indexers — Covalent/GoldRush and Bitquery (confirmed they’d dropped Rinkeby), Crystal Intelligence (mainnet-only), and Google BigQuery across 11 EVM chains (which is what flagged one address, 0x2513CF99, as Rinkeby-only — the thread that justified the whole hunt).
  • Censys — free tier blocks the search API entirely.

Every registered path was dead. Which forced the reframe that finally worked.


The Shodan reframe — finding a node nobody remembers

Stop asking “who still serves Rinkeby?” and ask “who accidentally left a Rinkeby node exposed and running?” Shodan indexes the JSON-RPC banner of every exposed :8545 host on the internet — including its eth_chainId response. So:

1
shodan search:  "Chain Id: 0x4"   →   a single exact match (host redacted)

I then probed 3,979 candidate Geth/:8545 hosts Shodan knew of to confirm that match was unique for eth_chainId == 0x4. It was. And it was real:

1
2
3
chainId 0x4 · net_version 4 · Geth/v1.8.27-stable
genesis extraData → "Respect my authoritah ~E.Cartman"   (the iconic Rinkeby genesis)
block bodies: genesis → 5,435,344   (covers all of 2018)

A live, queryable Rinkeby archive node on no provider list anywhere — a relic someone forgot to switch off. This was the genuine technical achievement of the project, and the method generalizes: to read a dead chain, don’t look for infrastructure that preserved it — look for a node that never stopped. (I redact the host here so this writeup doesn’t point at a stranger’s misconfigured box.)

OSINT method: a Shodan banner search resurrects a forgotten archive node

The scanner. The node had no tx index, no debug/trace, and only head state — but full block bodies, which is all you need to read historical calldata. So I wrote rinkeby_scan.py:

  • Parallel batched JSON-RPC (eth_getBlockByNumber with full transactions), resumable per-range checkpoints, and a recovery loop that waits out node blips instead of dying (it was a single fragile box).
  • Two detectors per transaction: the authenticate selector 0xee0d605c (extract the 64-byte public key → live-test against A/B/C), and any mainnet proof value showing up in calldata (a “twin-hunt” for a Rinkeby copy of A/B/C).
  • A bug I caught: the first extractor read the ABI offset/length words instead of the key bytes (bytes args are selector‖offset‖length‖data) — fixed and re-ran every hit.

I scanned the full 2018 window and then the whole chain. 0 mainnet twins, 0 solving public keys.

The red herring. What the scan did surface was a cluster of real TeikhosBounty activity — resolved by CREATE-address arithmetic: a hobbyist (0x6daa5ca5…) had deployed 34 of the 41 testnet bounty contracts and self-claimed a dozen (one with an off-curve junk “key”). And 0x2513CF99 — the address that sent me to Rinkeby in the first place — is exactly CREATE(0x6daa5ca5, nonce 85): that hobbyist’s Rinkeby SHA3-512 helper, confirmed by bounty C’s own source comments. Johan’s mainnet deployer had never transacted on Rinkeby (nonce 0). The node was a triumph; the lead was a ghost.


The bulk census — BigQuery, Etherscan, and the deployment map

Two tools did the heavy on-chain forensics without running a full mainnet node:

  • Etherscan gave verified source for all 18 deployed contracts, their ABIs, the internal-transaction graph, and the deployer cluster (0x4c5d24a7 / 0x125d657d / 0x4f6816a7 / 0x80028f80). It also confirmed the bricked contract on a live transaction.
  • Google BigQuery (bigquery-public-data.crypto_ethereum, via Application Default Credentials) ran the census: every contract the deployer ever touched, transaction counts, the full deployment timeline, and the one solver transaction at block 14,513,678. For census questions this is faster and more reliable than a node. The same approach, run across six chains (ETH/ETC/BTC/LTC/DOGE/BCH), covered the cross-chain angle.

That census draws the whole operation. The only contracts Johan ever solved himself are the tutorials; the four he never touched again are exactly A, B, C, and the bricked one:

On-chain deployment map: tutorials he answered, vaults he never did All 18 contracts from one deployer in 16 days — the tutorials he answered himself, the vaults he never did, and the silent twins.


The source hunt — finding the original code he wrote

If the key ever leaked, the likeliest place was the author’s own published code. So I swept every host he ever used, in full git history.

  • GitHub (resilience-me): 51 repos and 139 gists, back to 2014. Crucially, the keygen source was found only via gist enumeration (api.github.com/users/<handle>/gists), not in any repo: g_6.sol — a fully-worked C-type demo that publishes the public key, message hash, signature, proof, and address (I verified it end-to-end against the engine) — and all_30.txt, the actual key-generation script.
  • Dependency-date pinning: all_30.txt uses [email protected][email protected]. Fetching that exact version from unpkg pinned the keygen precisely: Account.create() = keccak256 of four crypto.randomBytes(32) draws — a 256-bit OpenSSL CSPRNG key with RFC-6979 nonces. The author’s own generator rules out a weak seed.
  • GitLab (bipedaljoe): 33 repos, mirrors and experiments — no extra deployments.
  • Bitbucket (bipedaljoe): 14 repos visible via the API; the web profile 404s; the teikhos and bitpeople workspaces had been deleted.
  • priv.prv — a tantalizing filename — traces to his Vanityreum repo: it’s the .gitignore’d output of a vanity-address generator built on os.urandom, never committed. No key file exists on any disk, repo, or leak.
  • Archive recovery: Software Heritage had preserved 40 resilience-me origins — enough to confirm the keygen and contract provenance independent of live GitHub, ruling out post-hoc edits. The Wayback Machine held 0 captures of the deleted Bitbucket workspaces. That development history is gone — but it doesn’t matter, because the deployed bytecode and the npm dependency chain are fully verified on their own.

Johan Nygren’s published gists — where the original keygen and demo code actually lived The gists (resilience-me) — g_6.sol and all_30.txt live here, not in any repo.


The author’s own words — the forum posts

The last surface was his prose, read straight off the chains and threads where he published it.

  • Steemit (johan-nygren, teikhos tag), pulled via the Steem blockchain API (condenser_api.get_discussions_by_blog) rather than the web frontend. His framing is explicit: “approximate perfect security”, and — settling the weak-RNG question in his own words — “truly random keys.”
  • EIP-935, the 2018 GitHub thread where he proposed the scheme. An Ethereum developer (@3esmit) reviewed it and reached the exact conclusion I did eight years later: it’s “just one more hashing on top of address private key bruteforce” — the 2¹⁶⁰ wall, on the public record since 2018.

EIP-935: an Ethereum dev independently calls it a 2^160 wall in 2018 EIP-935 — @3esmit characterizing the security as one hash on top of address-bruteforce, with Johan (resilience-me) replying.

Here is every surface swept, with its yield:

Host / source Handle What it gave Key-relevant?
Etherscan verified source for all 18 contracts, deployer cluster, bricked-contract proof ground truth
Google BigQuery mainnet + cross-chain census, the 2022 solver tx census, no key
Ropsten Erigon archive 9.3M blocks, 220 authenticate calls 0 hits
Rinkeby node (Shodan) only surviving Rinkeby archive; full 2018 calldata 0 twins, 0 keys
GitHub resilience-me 51 repos + 139 gists; g_6.sol, all_30.txt source & keygen, no key
npm / unpkg (dependency) [email protected][email protected] keygen entropy proof
GitLab bipedaljoe 33 repos, mirrors handle linkage
Bitbucket bipedaljoe 14 repos (API only); teikhos/bitpeople deleted unrecoverable
Steemit johan-nygren “approximate perfect security”, “truly random keys” author intent
EIP-935 @3esmit: 2¹⁶⁰ wall, in 2018 third-party confirmation
Software Heritage resilience-me 40 preserved origins provenance
Wayback Machine bipedaljoe 0 captures of deleted repos unrecoverable

And the cryptographic battery that ran in parallel — every attack draining to zero:

Every attack and candidate set, all draining to zero solves


The one that was solved — and the flaw hiding in it

One sibling contract — a 0.5 ETH demo — was drained back in 2022. Here it is, balance zero:

The demo contract, drained to 0 ETH

It was solvable for one reason: its answer key was published off-chain. And how it drained exposes a genuine, previously-unreported flaw in the C-type contract — so I checked whether that flaw could be turned against the live 0.5 ETH bounty. (Spoiler: no, and the “why not” is the interesting part.)

The flaw. C picks its winner in reveal() as whoever committed the earliest signature that recovers to their own address over isSolved.msgHashnot whoever submitted the solving key in authenticate(). And isSolved.msgHash is written to public storage the instant a solve lands. So if two parties both know the key, the one who committed first wins, regardless of who did the work. On the demo, exactly that happened: address 0x83e4b2a5 (earlier committer) walked off with the ETH while 0x9c739dfa — the address that actually called authenticate() with the valid key — got nothing.

Can it touch the live bounty C? I ran six independent adversarial analyses across every attack class — reward-path reachability, the commit-race, ecrecover edge cases (zero-address tricks, malleability), the external SHA3-512 helper, and state-machine griefing. Unanimous: no. Every path that moves the 0.5 ETH out — reveal(), reward(), the selfdestruct — is gated on isSolved.timestamp != 0, written in exactly one place: inside authenticate(), inside the if that only fires when ecrecover matches address(keccak256(P)). That’s the 2¹⁶⁰ wall again. No valid key → no solve → the contract is frozen in its Commit state forever, and the front-running flaw never becomes reachable. It’s a steal-from-a-future-solver bug, not a claim-without-a-key bug — and since C can never be solved, there’s no future solver to steal from. Real flaw, permanently dormant. (One subtlety: you can’t “commit garbage now and fix it later” — reveal() reads the signature stored at commit time and then deletes it, so a winning attacker must already know the key before the commit window closes. On the live bounty that window never opens.)


Proof it was deliberate, not a mistake

A natural last hope is that the author was sloppy — a weak RNG, a reused nonce, a wrong key in the XOR. His own git history kills that idea. Cloning the C bounty’s gist surfaced six distinct proof iterations he cycled through. Testing every key I hold against all six draws a perfectly clean line:

Six proof iterations in the author’s git history: demos solved, bounties never

Three are demos he solved himself — keys public, I have them. Three were never solved and no key exists anywhere, including 7b5f8ddd…, the exact proof sitting in the live bounty C. The same hand drew both kinds and treated them oppositely: every demo got a published answer; no real bounty ever did. That’s not negligence — it’s intent.


Four investigators, one wall

By the end I wasn’t the only one who’d tried. Comparing notes, four independent efforts — mine, two other AI systems run cold, and a public bounty-hunter (@rebel0x0) still racing for the same money in 2026 — converged on the same verdict with different toolkits. The hunter, the one with the most to gain, solved only the published demo and reported “0 hits” on the real bounties after recovering 1,600+ sender pubkeys and testing 20,000+ candidates.

Four independent investigations, four toolkits, one identical result

When independent searches with different tools all return zero, the convergence stops being four failures and becomes the proof: there is no shortcut, no reconstruction, and no leak — because there is nothing to find.


Verdict

The ~3 ETH cannot be claimed by anyone. Three bounties are sealed by sound cryptography; the fourth by the author’s own bug. This was never a failure to find a key — it’s a proof, from every direction I could devise, that there is no key to find.

What the hunt produced instead:

  • A from-scratch, validated reimplementation of the whole cryptosystem, anchored to a real on-chain solve.
  • A reusable method for reading a dead chain: Shodan banner-search to resurrect a forgotten archive node, BigQuery census in place of a full node, dependency-date pinning, gist enumeration, CREATE-address arithmetic, and archival recovery via Software Heritage.
  • An on-chain proof, for about $0.05, that the fourth bounty is bricked.
  • A responsible disclosure of a real, live commit-reveal flaw (below).

Sometimes the deliverable of a treasure hunt isn’t the treasure — it’s the toolkit you built to reach it, and a proof, beyond doubt, that no one else can reach it either.


Responsible disclosure — the C-type commit-reveal flaw

For completeness: the C-type TeikhosBounty contract binds its reward to the earliest committer who can produce a self-signature over the solution’s message hash, rather than to the account that submits the solving key. Because isSolved.msgHash becomes public the moment a solve lands, any party who already knows the solution key can win the reward out from under the legitimate solver by having committed earlier. This was demonstrated in the wild on the demo contract 0x735ba26f (winner ≠ solver). It does not affect the funds in the live A/B/C bounties, which can never be solved and are therefore never exposed to it. The fix, for anyone building a similar scheme: commit to keccak256(publicKey, msg.sender) rather than a bare signature, and bind the payout to the authenticate() caller — not to a separate, front-runnable reveal.

The contract author, Johan Nygren, remains publicly reachable, and all data used here is public — no private keys, private messages, or non-public systems were accessed. Every source was a public API or banner: Etherscan, GitHub, Google BigQuery (ADC), Shodan, Software Heritage, the Steem and Ethereum chains themselves.

Built with Hugo
Theme Stack designed by Jimmy