Nansen called Binance 14 a 'Token Billionaire'. The name cost 1 credit
It's 2am and you just sent 316 USDT to the wrong address. Maybe it was an old deposit address from an email, maybe a look-alike planted in your history, maybe a contract. You Google "can I get it back" and the first three results are recovery scams. What happens next depends on what that address actually is. A Binance customer deposit address means a support ticket and a real chance of recovery. A burn address means it's gone, and anyone offering to recover it is lying.
I built Sent Wrong to answer that one question. You paste the address you sent to (and your own, if you like). It runs up to ten calls against Nansen's API and gives you one of four routes, the evidence behind it, and a support ticket ready to copy.
- Live: https://sentwrong.edycu.dev
- Code: https://github.com/edycutjong/sentwrong (MIT)
The label problem
The whole thing depends on the address's entity label. The obvious place to get that label turned out to be the wrong one.
Nansen has cheap profiler endpoints that return a label per address: counterparties (5 credits), first-funder and related-wallets (1 credit each). So the first plan was to profile the address, read its label, and be done.
On day one I pointed them at Binance 14, probably the best-known labelled hot wallet on Ethereum. The label that came back was: ["Token Billionaire"]. Coinbase 10 came back as ["High Activity"]. Those are wealth and activity tags. They tell you the address is rich or busy. They don't tell you it belongs to an exchange, and they say nothing about a customer deposit address, which is the case that actually gets people their money back.
The entity name does exist in profiler/address/labels, which costs 100 credits per call. That's too much to spend by default on every panicked paste.
Where the name actually was
It turned up, unexpectedly, in transaction-with-token-transfer-lookup. It takes a transaction hash and returns its token transfers, each with a label for both sides. For 1 credit, on the hero address:
- The sending side read:
๐ฆ Binance: Deposit [0xe46077]- that names the exact customer deposit address, not just the exchange. - The receiving side read:
๐ฆ Binance 14 [0x28c6c0]- the sweep into the exchange's own wallet.
Nothing in the docs says this endpoint carries entity labels. The repo's decision-table doc says it plainly: the whole engine hangs on that field.
That changed the design. I stopped asking "what is this address?" and started asking "which transactions should I look at to find out what this address is?"
Pick transactions, not profiles
Labels live on transactions, so pick transactions. The engine gathers in stages: a free search/general call (a token contract stops here), then 14 days of transactions plus the cheap identity lookups, full history for quiet addresses, and finally the choice of which hashes to look up.
Abridged from packages/core/src/lookups.ts, with the sender-matching branch removed:
// Which transaction hashes to look up: the newest 2 outbound (the sweeps),
// the newest inbound, the sender's transfer if we can see it, and the funding
// transaction. These carry the entity labels the profiler rows do not.
const wanted: Array<{ hash: string; role: TxLookup["role"] }> = [];
if (transactions.ok) {
const { outbound, inbound } = splitDirection(transactions.data.data, a);
for (const r of outbound.slice(0, 2))
wanted.push({ hash: r.transaction_hash, role: "outbound" });
/* … the sender's own transfer, if --from was given … */
if (inbound[0])
wanted.push({ hash: inbound[0].transaction_hash, role: "inbound" });
}
if (firstFunder.ok && firstFunder.data.data[0] && lc(firstFunder.data.data[0].chain) === chain)
wanted.push({ hash: firstFunder.data.data[0].transaction_hash, role: "funding" });
Each hash does its own job:
- Outbound transactions show where the address sweeps to, and the address's own label.
- The newest inbound transaction carries the address's own label as a recipient, even if it has never sent anything.
- The funding transaction shows who paid the address's first gas.
Exchanges fund their own deposit addresses, so first-funder → funding tx → ๐ฆ Binance [0x943080] can name the exchange before the address has swept anything. That's up to four lookups at 1 credit each.
The hero verdict costs 13 credits across 10 calls, against 100 for the labels endpoint, which is still available behind --deep (cost printed first; the card says whether it agrees).
Parsing a label is its own small trap
Labels are free text with emoji and an address stub: ๐ฆ Binance: Deposit [0xe46077], ๐ค ๐ฆ Coinbase [0xa9d1e0], Token Billionaire, sofaking.eth*.
The rule behind the most common good-news verdict is short. From packages/core/src/labels.ts, verbatim:
/** "<Entity>: Deposit" - Nansen's label for a user-level exchange deposit address. */
export function isDepositLabel(p: ParsedLabel | undefined): p is ParsedLabel & { entity: string } {
return !!p && !!p.entity && /^Deposit$/i.test(p.role ?? "");
}
Getting there took two regression tests:
๐ฆdoesn't mean "exchange." Nansen puts the same bank emoji on DEX routers:๐ค ๐ฆ Uniswap: V2 Router 2. A router's outflow looks exactly like a sweep, so any label that names a contract (Router, Pool, Proxy, …) is now kept out of the sweep rule.- Direction matters. A
: Depositlabel on a destination means a person is depositing into an exchange. That makes the sender an ordinary user wallet, not a deposit address. One of the recorded live runs hit exactly this, and it's now a named regression test: "a user wallet sending to its own ': Deposit' address is NOT a deposit address."
The refusal is the answer
On burn addresses, transactions and counterparties return HTTP 422: Burn address not allowed. It reads like an error. It's actually the most certain answer the API gives, so it's rule 1 in the decision table:
export function isBurnRejection(r: LookupResult<unknown>): boolean {
return !r.ok && r.status === 422 && /burn address/i.test(r.error);
}
Paste 0x…dEaD and you get a red CONTRACT OR BURN card with the evidence line profiler/address/transactions → HTTP 422 = Burn address not allowed and the sentence "Do not pay anyone who promises recovery."
This check runs before any transaction lookups, so a burn verdict costs 2 credits. The flip side: a 422 is an answer about the input, so it's cached and replayed; a timeout or 5xx is not, so a failed transactions lookup yields a retry, never a verdict. An early build turned a slow minute into "nothing on record", a made-up verdict. A property test now checks, over 10,000 generated response sets, that this can't happen.
What it looks like end to end
Real output from --explain --no-cache, from the repo's DEMO.md (abridged to the verdict and totals):
EXCHANGE-DEPOSIT (high · direct-label)
This is a Binance deposit address. Recoverable through Binance support.
- โ Nansen has this exact address labelled as a Binance customer deposit address.
transaction-with-token-transfer-lookup → token_transfer_array[].from_address_label = ๐ฆ Binance: Deposit [0xe46077] - โ Everything it receives is swept into Binance's own wallet.
transaction-with-token-transfer-lookup → token_transfer_array[].to_address_label = ๐ฆ Binance 14 [0x28c6c0] - โ All outflow ($2,953 at today's prices) goes to one counterparty: the sweep pattern of a deposit address.
profiler/address/counterparties → volume_out_usd = 100% to 0x28c6…1d60 - โ Binance paid this address's first gas - exchanges do that for their deposit addresses.
profiler/address/first-funder → first_funder_address (looked up) = ๐ฆ Binance [0x943080]
… 13 credits · 10 calls (0 cached) · 3.9s · verdict 3ea6cfcd752b
The numbers
A benchmark of 13 real addresses × 3 runs with every call live gave a cold p50 of 3,157 ms and p95 of 6,477 ms, a mean of 10.2 credits per verdict (0 to 13), 309 live calls and 0 failures other than the expected 422s. Warm from the 24 h cache it's p50 2 ms at 0 credits, with the same decision hash every run.
There are 234 vitest tests. 13 recorded fixtures replay offline with identical hashes (npm run verify, no key, no network).
Honest limits
- Latency is Nansen's. Per-call latency varies from 0.3 to 3 s depending on the minute, so a cold verdict takes 4-13 s. On Vercel the cache is in-memory per function instance, so a cold start is fully live.
- "Nothing on record" is ambiguous. It can't tell a brand-new address from one Nansen doesn't index.
vitalik.ethreturns empty profiler pages and comes back as a low-confidence fresh stranger. The card says so instead of promising that waiting will help. - Ethereum is the deeply tested chain. Seven other EVM chains go through the same endpoints, but only Base has been checked live. Odds on the stranger route are words, not numbers.
- Nansen can tell whether a wallet moves funds. Nobody can tell whether its owner is honest. It's triage, not legal advice. Every route's text tells you not to pay recovery services.
The takeaway
If an API returns labels in more than one place, don't assume the cheapest field is a cheaper copy of the expensive one. Here they were different kinds of label: wealth tags on the profiler rows, identity on the transfer rows. Test the field on an address whose answer you already know. Binance 14 took one call to show the problem.
Try it at https://sentwrong.edycu.dev, or clone https://github.com/edycutjong/sentwrong and run npm run verify without a key. The 30-second demo is on X. If you've been sent here after a 2am mistake: paste the address before you reply to anyone offering help.
Comments
No comments yet. Start the discussion.