QUEST LOG · September 27, 2026· UPDATED October 29, 2026

12 Seconds: My Testnet Private Key Got Swept Clean

"Can you check the on-chain history for these two? Did my private key leak?"

Thirty-some hours before the hackathon deadline, I sent 0.2 test ETH on Sepolia into my deployment wallet.

12 seconds later, the money was gone.

This is the story of that session. It burned through roughly 410 million tokens. The original task was "turn the ENS v2 and Uniswap mechanics into contracts and deploy them on-chain," but halfway through a security incident barged in, and at the very end someone force-pushed main.

This was my project for ETHTokyo 2026 in September. Spoiler first: didn't place, but I had a blast building it.


# The original design: the name tree is the family tree

First, what we were trying to build. UNMAPPED (originally called Unwritten) is an app that generates game worlds with AI. Each world is a "cartridge," and other people can fork it and remix it.

At the start I handed it a design I'd hashed out with ChatGPT and asked, "do the mechanics in here hold up?" We went back and forth on NFC physical cards (decided to hold off), Uniswap being non-negotiable, and whether there was a better EIP to mix in, and eventually boiled it all down to one sentence:

ENS decides what this world is and who owns it, Uniswap decides what it's worth and how it trades, and the hook splits the trading revenue along the family tree to the owners of the names.

More concretely:

  • Name tree = family tree: read night.mushroom.zelda.unmapped.eth from right to left and you get "zelda was remixed into mushroom, and mushroom was remixed into night"
  • CCA fair launch: each world's token is sold through Uniswap's Continuous Clearing Auction, and a remix has to bid with its upstream world's token, so every new remix that launches is buying its ancestors
  • v4 hook revenue share: once the auction graduates, a v4 pool opens automatically, and the hook splits trading revenue along the family tree to the names' owners

I said "going with ensv2 and cca feels best right, write me a lineage market first," and it did: 24 files, one commit.

All that was left was deploying.


# "Still not enough funds"

I said "sepolia eth is all sorted." It first ran three read-only checks: whether there's a private key in .env (without printing it), that address's balance, and the current gas price.

Hold off on deploying: the address for the private key in .env doesn't have enough balance.
Balance: 0.00046 Sepolia ETH

I figured the transfer just hadn't landed yet, and a few minutes later asked "is it there yet":

Not yet, still not enough funds.
The private key in .env has been swapped (the file was modified at 01:41)... The old address I checked last time, 0x0c9F…2d34, now has 0 ETH.

I definitely sent money in. Why are both addresses empty?

When you see something like that thirty-some hours before a deadline, your first thought is not "slow transfer."


# 12 seconds on-chain

I asked it to pull the on-chain history for both addresses. It first spelled out what it had done itself: every dry run so far had used a throwaway account randomly generated each time, and it had never sent a single transaction with the private key in .env. Then the verdict came back:

The old private key has definitely leaked, and it was stolen more than a year ago. This project didn't cause it.

Here's how it went down (times in Taiwan time, as it reported them):

  • 00:36:12: I sent 0.1998 ETH from the new address into the old one
  • 00:36:24, 12 seconds later: someone used this private key to sign an EIP-7702 transaction delegating the address to a sweeper contract, and in that same transaction moved out the entire 0.4664 ETH
  • 00:40:48: another 0.05 ETH came in from a faucet, and the delegated code swept it out automatically within the same transaction

The receiving address had piled up 117 ETH on Sepolia, and its last 50 incoming transfers came from 18 different addresses. This is a bot that does nothing but sweep leaked private keys.

Digging further back: on Arbitrum One mainnet, that same private key had already sent 1.86 USDC and 0.0035 ETH to the very same address on 2025-07-20.

In other words, this key leaked more than a year ago, I never knew, and I was using it as my hackathon deployment wallet. Where it originally leaked from, the chain can't tell you. The one thing I know for sure: it stopped being just my key a long time ago.


# EIP-7702 turns a "leak" into a "curse"

It used to be that when a private key leaked, the bot had to beat you to moving the funds out. It was a gas race.

After EIP-7702, things are different. The attacker delegates your EOA to a contract, and from then on any money that comes in gets moved out automatically by the contract in the same transaction. No racing, no watching. Not even the 0.05 from the faucet gets away.

The AI, word for word:

This address is dead. The 7702 delegation is still attached, so anything you send to it will be moved out immediately, including faucet funds. Please don't send anything else to it.

A leaked private key used to be a vulnerability. Now it's a permanent black hole.


# New key, and then take the wallet out of the app

The fix was straightforward: I told it "just make me a new one, I'll faucet it." It generated a new private key and wrote it into .env (which is in .gitignore, permissions 600). The private key itself was never printed anywhere; it only gave me the address. The new one on Sepolia: nonce 0, balance 0, no delegation code.

Before deploying, it also made the deploy script safe to re-run: if it fails midway, contracts that are already deployed get skipped, so no gas gets spent twice. Before committing, it double-checked that the on-chain logs contained no private key.

Then unmapped.eth got registered and three contracts went up on Sepolia, in about 4–5 minutes.

After all that, my next request was:

app side shouldnt touch wallets, try to only use the passkey thats already in there

So the player side switched to passkey accounts: PasskeyAccountFactory derives the address from the public key and only deploys on first use; gas is covered by a paymaster. Finally, it walked the first world through one full cycle inside the app:

Launch → auction → settle → open pool → buy → distribute revenue share. All operated inside the app, all actually on-chain, and the player never connected a wallet from start to finish.

The buy was 10 USDC for 536 AETHERLAND, and that world accrued 5.408 AETHERLAND in revenue share from it.


# Interlude: someone force-pushed main

Just as another session was about to commit this batch of changes in two commits, a message came through:

Remote main was rewritten and force-pushed by a Codex task (it says you asked for it). The new HEAD is 7a04adf, and every old commit hash has changed.

I replied with one line: "make sure it's fine." It didn't just take that session's plan at face value. It fetched the new main itself, checked each rewritten file for overlap with the market batch, and confirmed the working tree wouldn't quietly overwrite those changes back.

Sounds scary, but it really wasn't a big deal: there's a conflict, someone has to fix it, and it doesn't matter who. At that point the same repo had several Claude sessions plus Codex running at once: one on the market, one on phase two, one handling commits, each running its own E2E.

Multiple sessions each running their own E2E is genuinely efficient. The price is that every so often someone has to stop and check that everyone is still editing the same thing.

At that point we had about 28 hours left until the deadline (9/27 09:00 JST). The contract compiled to 20,595 bytes, still a bit of headroom under the 24,576 limit.


# What I learned

  1. A testnet wallet is still a wallet. This private key had been stolen on mainnet, and I used it to deploy anyway. Sepolia ETH is worthless, but the same key might be holding real value on another chain
  2. EIP-7702 changed what a leak means. A leaked address isn't "use with caution," it's "scrap it." Even the test coins you send in get eaten
  3. Have the AI do read-only checks first. Before sending any transaction, it checks the balance, the address the private key maps to, and gas. That habit is exactly what caught "the money's gone" before deploying instead of halfway through
  4. Players should never touch private keys. After this I'm even more convinced the app side should go all passkey, to the point that the word "wallet" never even shows up

Honestly, if I hadn't been swept this time, I'd probably have kept using that key until the day something blew up on mainnet.

12 seconds is short, but it's a lot more honest than the year-plus after the leak when I never noticed a thing.

ETHTokyo ended without a placement. But one swept-clean wallet, a flow that's genuinely on-chain from launch to revenue share, and the experience of a whole pack of sessions racing a deadline together were worth the price of admission for me.

(This post was 90% written by AI. After all, a week later a hacker drained all my JitoSOL on Solana, so I probably won't touch Solana DeFi again unless I build it myself.)

Apps I ship

Free on iOS and Android.

Solid(ar)ity

Solid(ar)ity

A privacy-first identity wallet: zero-knowledge proofs and signed cards swapped face to face, no central server.