Skip to content

Latest commit

Β 

History

90 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Sent Wrong mark β€” a transfer arrow stopped at a red wall; the Nansen label lands and one of four routes turns green

Sent Wrong 🧭

Sent crypto to the wrong address? Paste it. Nansen tells you which of four recovery routes you're on β€” and drafts the ticket.

Sent Wrong β€” routes a misdirected transfer: paste the address you sent to, the Nansen label lands, one of four recovery routes turns green and the ticket is drafted

A cold verdict on the hero address costs 13 credits across 10 Nansen calls and returns in 3.9 s; its decision hash 3ea6cfcd752b reproduces from a second fresh clone and from the recorded fixture. npm run verify replays 13/13 verdicts offline β€” zero network, zero credits.


Live Demo For the Judge Built for Nansen Meridian Submission on X


Next.js TypeScript Nansen API License CI Release


πŸ“Έ See it in Action

Sent Wrong β€” 17 s demo: paste the address you sent to; on the right the Nansen call rail streams every call as it happens (pending ring β†’ green dot, POST endpoint, credits, ms, response hash, counters ticking); verdict: recoverable β€” a Binance deposit address β€” and the prefilled support ticket

the Nansen call rail after a live run β€” 10 green rows, 13 credits, 3.4 s input
the Nansen call rail, live run input
burn address poisoning look-alike
burn address poisoning look-alike

Screenshots: the Nansen call rail, live run Β· input Β· Binance deposit Β· burn address Β· poisoning look-alike Β· your own wallet, mobile

πŸ’‘ The Problem & Solution

The Problem

Someone sends USDC to an old deposit address from an email, a look-alike address planted in their history, or a contract. At 2 am they are Googling "can I get it back" and the first three results are recovery scams. The answer depends entirely on what the recipient address is, and only Nansen's entity labels know that for user-level addresses.

The Solution

Sent Wrong takes the recipient address (and optionally yours), runs ten Nansen profiler and transaction lookups, and returns one verdict card with exactly one route, the Nansen evidence behind it (endpoint and field, on the card), and one copy button holding the message to send. No dashboard, no accounts, no token.

The four routes

Route What Nansen showed What you get
Exchange deposit address the address itself is labelled 🏦 Binance: Deposit, or everything it receives is swept into an exchange's own wallet a prefilled support ticket for that exchange, with the evidence lines
Your own wallet the recipient is in your address's related-wallets, or you were its first funder a checklist β€” nothing is lost, here is how to reach it
Active stranger an unlabelled wallet; states: active (moves funds), dormant (never sent), fresh (no history), poisoner (only ever "receives" homoglyph tokens β€” an address-poisoning address) an on-chain memo with honest odds, or "report it, don't chase it"
Contract or burn Nansen refuses it as a burn address (HTTP 422), it is a token contract, it has a deployer; forwarder when it sweeps into a named custodian a note for your records and the warning that nobody can recover it β€” or "ask the service that issued this address"

Anything Nansen could not answer is a retry, never a verdict. Failed lookups are listed on the card, not hidden.

πŸ—οΈ Architecture & Tech Stack

One engine (packages/core), two faces (CLI and web). gather() is all the I/O β€” staged so a token contract ends the search at 0 credits and a quiet address gets its whole history; classify() is a pure decision table; text() writes the copy button. Every failure is a value, never an exception. Full module-by-module notes and the cold call trace are in ARCHITECTURE.md; the decision table is docs/SCORING.md.

Sent Wrong architecture β€” four views (web page with the live Nansen call rail, /q permalink, OG card, CLI) β†’ /api/verdict with its spend guard β†’ packages/core sentWrong(): gather() in four staged lookups β†’ classify() decision table β†’ actionFor() β†’ Verdict with provenance; six Nansen endpoints with their credit cost; 24 h read-through cache and 13 offline fixtures

Mermaid source β€” expand to see the diagram as text (renders on GitHub)
flowchart LR
  IN["recipient address<br/>(+ optional --from)"] --> S0
  subgraph gather ["gather() β€” lookups.ts, every call through the 24 h cache"]
    S0["Stage 0<br/>search/general Β· 0 cr<br/>token contract? β†’ stop"] --> S1
    S1["Stage 1 (parallel)<br/>transactions 14 d Β· related-wallets Β· first-funder"] --> S2
    S2["Stage 2<br/>all-time transactions + counterparties (quiet addresses)<br/>sender's related-wallets (--from)"] --> S3
    S3["Stage 3<br/>transaction-with-token-transfer-lookup Γ— ≀4<br/>newest sweeps Β· newest inbound Β· funding tx β†’ entity labels"]
  end
  S3 --> C["classify() β€” pure decision table<br/>burn (422) β†’ token contract β†’ Deposit label β†’ sweep pattern<br/>β†’ contract/forwarder β†’ your wallet β†’ stranger β†’ retry"]
  C --> T["text() β€” ticket / checklist / memo / note"]
  T --> V["verdict card + provenance<br/>route Β· evidence (endpoint β†’ field) Β· credits Β· decision hash"]
  V --> CLI["CLI: npm run sentwrong (--explain prints the same call rows)"]
  V --> WEB["Web: /api/verdict NDJSON stream (start β†’ call β†’ verdict)<br/>β†’ Nansen call rail (live) + route card + provenance drawer β†’ /q/[address] share page"]
Loading
Layer Choice Where
Data Nansen API β€” 7 endpoints, the only source consulted (no Etherscan, no RPC, no local address list) packages/core/src/nansen.ts, client.ts
Engine TypeScript: lookups.ts β†’ classify.ts β†’ text.ts β†’ verdict.ts; sha256 decision hash packages/core
Cache / replay read-through cache keyed by sha256(endpoint + body), 24 h TTL, NANSEN_OFFLINE=1 replay of recorded fixtures packages/core/src/cache.ts, fixtures/
CLI tsx β€” sentwrong <address> [--from] [--chain] [--json] [--explain] [--deep] [--no-cache] packages/cli
Web Next.js 15 β€” the Nansen call rail (every call live: pending β†’ live/cached/error, credits, ms, hash), verdict card, copy button, provenance drawer, share page, route-coloured OG card; key stays server-side apps/web
Tests / CI vitest (234 tests: unit, 40,000-case property, key-boundary), Playwright (6 suites, no key), offline fixture replay, 7-stage GitHub Actions pipeline (gates β†’ Vercel production deploy) + CodeQL + gitleaks + Dependabot .github/workflows/
Hosting Vercel β€” production domain tracks main https://sentwrong.edycu.dev

πŸ† Nansen Integration

Every decision term is a Nansen response field. Nothing else is consulted β€” no Etherscan, no RPC, no local address list.

Endpoint Credits Fields used What it decides
search/general 0 tokens[].address/symbol/chain token contract at this address β†’ contract route, before anything is paid for
profiler/address/transactions 1 (+1 all-time for quiet addresses) data[].method, tokens_sent[].to_address, tokens_received[].from_address/token_symbol, block_timestamp, transaction_hash, pagination.is_last_page; HTTP 422 "Burn address" in/out counts, the sweep hashes to look up, last activity, the sender's transfer, homoglyph spoof tokens (poisoning), burn
profiler/address/counterparties 5 counterparty_address, counterparty_address_label, interaction_count, volume_in_usd, volume_out_usd outflow concentration (100 % to one wallet = sweep), look-alike counterparties (poisoning), the evidence table
profiler/address/related-wallets 1 (+1 on your address) relation, address, address_label Deployed by/Created by β†’ contract; recipient ↔ sender link β†’ your own wallet
profiler/address/first-funder 1 first_funder_address, first_funder_name, transaction_hash, chain funder identity (exchange gas dripper vs you), the funding tx to look up
transaction-with-token-transfer-lookup 1 Γ— ≀4 token_transfer_array[].from_address_label, to_address_label, from_address, to_address the entity labels β€” 🏦 Binance: Deposit, 🏦 Coinbase, πŸ€– BitGo MultiSig, πŸ€– 🏦 Uniswap: V2 Router 2 β€” on the address and its counterparties
profiler/address/labels 100, --deep only label, category, kind a second opinion; the card says whether it agrees

A cold verdict costs 0–13 credits and makes 1–10 calls; a warm one (24 h cache) costs 0. The full decision table with the real numbers is in docs/SCORING.md.

Watch the calls happen. The web app's right-hand Nansen call rail streams every call as it is made β€” a pending row the moment the engine announces it, then the finished one with POST endpoint, the parameters, credits, latency and the response hash β€” with counters that tick; the rows are the very Call objects the provenance drawer and --explain print, so the rail, the drawer and the CLI always agree to the credit. On load it shows the recorded example's replayed calls, labelled 0 cr Β· replayed.

Why only Nansen

  • User-level deposit addresses are labelled. Etherscan tags "Binance 14"; Nansen's transaction-with-token-transfer-lookup labels the customer's deposit address itself β€” 🏦 Binance: Deposit [0xe46077] β€” for 1 credit. That single field is the difference between "some address" and "file a ticket with Binance".
  • Relations, not just code. related-wallets.relation says Deployed by, Created by, First Funder, Multisig Signer of β€” an RPC says "contract or not", never whose.
  • The gas dripper identifies the exchange before a deposit address has ever swept: first-funder β†’ funding tx β†’ 🏦 Binance [0x943080].
  • Aggregates per counterparty. counterparties.volume_out_usd gives the 100 %-to-one-wallet sweep signature in one row.
  • Even the refusal is a signal: 422 Burn address not allowed.

What we learned the hard way is in docs/DX-REPORT.md β€” including that the cheap profiler label fields carry wealth tags only, and that the all-time date range makes Nansen time out on routers (hence the 14-day/all-time two-stage window).

πŸ“Š Engineering Rigor

Metric Value
Tests 234 vitest tests (npm test), 9 s β€” 24 regression tests named after the defect they pin, 8 key-boundary tests, 4 property-based tests
Spend guard every live-verdict surface β€” POST /api/verdict and the /q/<address> permalink β€” capped at 6 requests/min per address (429) and 3,000 live credits/day (honest 503 past it, before any Nansen call) β€” deep costs 100 credits per click, so the ceiling bounds it β€” apps/web/lib/guard.ts, packages/core/test/guard.test.ts
Property-based verification 40,000 generated Nansen response sets through classify() (fast-check, 4 properties Γ— 10,000): exactly one of the five routes every time Β· a failed transactions lookup never yields a stranger verdict Β· the decision hash is invariant to USD prices Β· pure. All 14 route/sub-state pairs reached
E2E (Playwright) 6 suites, 58 runs (chromium + Pixel 7) against the built app with no key: home (the recorded example, "Run it live now"), the Nansen call rail (seeded rows, failed-run closure, bar/sheet on a phone), /judge, validation + honest no-key error, responsive 375/768/1440, key-never-reaches-the-client
Lighthouse (npm run lighthouse) / and /judge: performance 100 Β· accessibility 100 Β· best practices 96 Β· SEO 100 (desktop, median of 3, 2026-09-17)
Fixtures 13 real Nansen responses recorded live 2026-09-16, byte-for-byte; npm run verify β†’ 13/13 verdicts reproduced offline, same decision hash, 0 network, 0 credits
Benchmark, cold (13 addresses Γ— 3 runs, every call live) p50 3157 ms Β· p95 6477 ms Β· max 10567 ms
Benchmark, warm (same verdict from cache) p50 2 ms Β· p95 5 ms Β· max 7 ms Β· 0 credits Β· identical decision hash on every run
Credits per verdict mean 10.2 Β· min 0 Β· max 13 (39 verdicts, 309 live calls, 0 non-422 failures)
Hero verdict 13 credits Β· 10 calls Β· 3.9 s Β· hash 3ea6cfcd752b β€” stable across two --no-cache runs 45 s apart and a second fresh clone
Timed clean clone commands take 40–60 s end to end (see Runs in under 10 minutes)

Benchmark

npm run bench β€” 13 addresses Γ— 3 runs, cold (fresh cache, every call live) then warm (same verdict from cache), decision hash compared. Numbers and reproduce steps in DEMO.md.

Honest limits (12)

  1. The hero address, the poisoning look-alikes and the burn/contract examples are real addresses found on-chain the day this was built; deposit addresses were harvested from one USDT sweep block into Binance 14 and one into Coinbase 10.
  2. "Odds" on the stranger route are words, not numbers: Nansen tells us whether the wallet moves funds; nobody knows whether its owner is honest.
  3. A verdict is triage, not legal advice. Do not pay anyone who promises to recover funds β€” every route's text says so.
  4. Nansen's per-call latency varies 0.3–3 s by the minute: the hero verdict measured 6 s on one fresh clone and 13 s on an independent re-run from a second (independent review 2026-09-16). The Uniswap V2 router β€” the busiest address in the bench set β€” can still hit the 10 s transactions cap and retry (10.6 s on one run; 3.6 s and 3.4 s on the others).
  5. The 24 h on-disk cache is local only. On Vercel the cache is in-memory per function instance, so a cold start is fully live and a rehearsal does not reliably warm it β€” rehearse and record against npm run dev -w apps/web (independent review 2026-09-16).
  6. Bug found in review, fixed with a regression test: the Uniswap V3 SwapRouter was read as a forwarder "that sweeps into Uniswap custody" because its outflow lands in labelled pools. A custodian must now carry a non-contract label; a DEX router is contract-or-burn Β· contract (independent review 2026-09-16, commit 33095ea).
  7. Bug found in review, fixed with a regression test: a transactions timeout with the other lookups answering fell through to active-stranger Β· fresh β€” "Nothing on record" β€” a fabricated verdict on a slow Nansen minute. It is now a retry with TRANSACTIONS_FAILED (independent review 2026-09-16, commit 33095ea).
  8. Found in review: Nansen returns data: [] on every profiler endpoint for vitalik.eth, so the fresh headline no longer promises that waiting will help β€” Nansen may simply not index the address (independent review 2026-09-16; SCORING.md rule 7).
  9. Bug found in review, fixed: the CLI cache TTL was 30 min in code while the docs said 24 h β€” now 24 h; and /q/[address] computed the verdict twice per view (generateMetadata + page), now deduped with React cache() (independent review 2026-09-16, commit 3390a30).
  10. Found in review, fixed: the README linked a deployment-specific preview URL that would never receive fixes; it now links the production domain, which tracks main (independent review 2026-09-16, commit db45092).
  11. --deep (profiler/address/labels) costs 100 credits and is never automatic; its cost is printed before it runs and the card says whether it agrees.
  12. Found in audit, fixed with regression tests: a mistaken ETH send to a fresh address makes the sender its first funder, which read as "your own wallet β€” nothing is lost"; a first-funder-only link now also needs the recipient to have sent something since. A quiet address whose all-time transactions page failed was judged from 14 days of history ("nothing on record"); that is now a retry. And the /q permalink β€” a live verdict on every GET β€” now passes the same spend guard as the API route (audit 2026-09-23).

πŸš€ Getting Started

Prerequisites

  • Node 20+ (engines in package.json; timed on Node 22)
  • A Nansen API key from https://app.nansen.ai/api β€” free tier works (a verdict costs 0–13 credits)

Installation

One env var, npm i, one command.

git clone https://github.com/edycutjong/sentwrong && cd sentwrong
npm install
export NANSEN_API_KEY=nsn_…            # https://app.nansen.ai/api β€” free tier works (a verdict costs 0–13 credits)
npm run sentwrong -- 0xe460774c849089ee3edf0fb06da14c066caabbef
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]

Support ticket for Binance
────────────────────────────────────────────────────────────
Subject: Funds sent to a Binance deposit address by mistake β€” recovery request
…
13 credits Β· 10 calls (0 cached) Β· 3.9s Β· verdict 3ea6cfcd752b

Options: --from <your address> (own-wallet check + finds your transfer for the ticket) Β· --chain ethereum|base|arbitrum|polygon|optimism|bnb|avalanche|linea Β· --json Β· --explain (rule fired, every call with fields and timing) Β· --deep (one profiler/address/labels call, 100 credits, cost printed, shows whether it agrees) Β· --no-cache.

Web app: npm run dev -w apps/web β†’ http://localhost:3000 (same engine; the key stays on the server; rows appear as each Nansen call lands; locally it shares the CLI's on-disk 24 h cache layout under apps/web/.cache, so a rehearsed address stays warm across restarts).

Runs in under 10 minutes

Timed with date around each step on a fresh clone into an empty directory (macOS, Node 22, warm npm cache, 2026-09-16 15:05 UTC):

Step Command Measured
1 git clone https://github.com/edycutjong/sentwrong && cd sentwrong && npm install 7 s
2 export NANSEN_API_KEY=… typing
3 npm run sentwrong -- 0xe460774c849089ee3edf0fb06da14c066caabbef --explain (live, 13 credits) 6 s (5.0 s of it Nansen); an independent re-run from a second fresh clone at 23:55 UTC took 13 s β€” Nansen's per-call latency varies 0.3–3 s by the minute
4 npm run verify β€” 13 recorded verdicts replayed offline, 0 credits, 0 network < 1 s
5 npm test β€” 234 vitest tests 9 s
6 npm run dev -w apps/web, open http://localhost:3000, paste an address ~20 s (first compile)
Total, including reading this README well under 10 minutes; the commands themselves take 40–60 s (a cold npm cache adds a minute or two)

πŸ§ͺ Testing & CI

7-stage pipeline (.github/workflows/ci.yml): Quality β†’ Security β†’ Build β†’ E2E β†’ Performance β†’ Deploy Gate β†’ Production Deploy (prebuilt vercel deploy to sentwrong.edycu.dev, main only, after every gate; Vercel's git auto-deploy is off), with concurrency control and a Node 20/22/24 matrix on main.

# ── Code quality ────────────────────────────
npm run lint              # ESLint (flat config, TS + React hooks)
npm run format:check      # Prettier
npm run typecheck         # tsc --noEmit (engine, CLI, scripts, e2e) β€” CI also runs it for apps/web
npm test                  # 234 vitest tests, 9 s
npm run test:coverage     # + v8 coverage (100% statements/branches/functions/lines, engine + apps/web/lib)
npm run verify            # replay the 13 fixtures with NANSEN_OFFLINE=1 β€” 13/13 verdicts, same hash, no key, no network
npm run check:submission  # no unfilled text, section names, test / fixture / property-case counts vs reality, links
npm run ci                # all of the above

# ── Advanced testing ────────────────────────
npm run e2e               # Playwright, 6 suites β€” builds and starts the web app WITHOUT a key
npm run e2e:ui            # Playwright interactive mode
npm run lighthouse        # Lighthouse CI on / and /judge (a11y β‰₯ 0.9 is a hard gate)
npm run bench             # 13 addresses Γ— 3 runs live, cold/warm p50/p95 β€” costs credits

# ── Security ────────────────────────────────
npm run audit             # npm audit --audit-level=high
Layer Tool Status
Code quality ESLint + Prettier + TypeScript strict (engine, web app, e2e) βœ…
Unit testing vitest β€” 234 tests, 100% v8 coverage (statements/branches/functions/lines) βœ…
High-signal tests 11 defect-named regressions Β· 40,000-case property verification of classify() Β· key-boundary tests βœ…
E2E testing Playwright β€” 6 suites Γ— 2 browsers, no key βœ…
Security (SAST) CodeQL (javascript-typescript) βœ…
Security (SCA) Dependabot (4 npm manifests + actions, grouped monthly, no majors) + npm audit + license-checker βœ…
Secret scanning gitleaks over the full history + TruffleHog (verified only) in CI βœ…
Performance Lighthouse CI (/, /judge), bundle budget βœ…
Releases release.yml β€” semantic version from conventional commits, from v1.0.0 βœ…
Community Code of conduct, contributing, security policy, issue + PR templates βœ…
  • 234 vitest tests (npm test): the decision table rule by rule, with the regressions live QA produced (a wallet depositing into its own Binance: Deposit address; Nansen's 🏦 marker on Uniswap's router; an exchange wallet given as "your address"; a DEX router read as a "forwarder"; a transactions timeout read as "nothing on record"), label parsing on the exact strings Nansen returns, the action texts, the wire-level call plan, client retry/timeout, cache, and a replay of every fixture.
  • 13 fixtures (fixtures/*.json): real Nansen responses recorded live on 2026-09-16 by npm run seed, byte-for-byte, never edited, no key material. npm run verify replays them with NANSEN_OFFLINE=1 β†’ 13/13 verdicts reproduced offline, same decision hash, zero network, zero credits. They exist so CI and a judge without a key can see the engine decide; the CLI and the web app hit Nansen live by default and say "cached" when they do not.
  • 40,000-case property verification (packages/core/test/classify.property.test.ts): fast-check generates whole Nansen response sets β€” real label strings, 422 refusals, timeouts, spoof tokens, deployer relations, senders β€” and classify() must give exactly one enumerated route with at least one evidence line every time, never turn a failed transactions lookup into a stranger verdict, keep the decision hash invariant to USD prices, and stay pure.
  • Key-boundary tests (packages/core/test/boundary.test.ts, e2e/key-boundary.spec.ts): the server key never appears in the verdict JSON, the streamed provenance, an error, the fixtures, the page HTML, the JS bundles or the OG route; malformed input is rejected before any network call. This is the concrete claim in SECURITY.md.
  • E2E (e2e/, Playwright): the built app runs without a key β€” the home page, /judge (200, no cookies, claim present), the validation path (400 before any lookup), the honest "no key" banner instead of a fabricated verdict, and 375/768/1440 px layouts.
  • CI (.github/workflows/): the 7-stage pipeline above, CodeQL, gitleaks (full history), Dependabot, and release.yml β€” no Nansen key needed anywhere; the one secret is VERCEL_TOKEN for the deploy stage.

πŸ“ Project Structure

packages/core/src   the engine: client.ts (NansenClient) Β· cache.ts Β· nansen.ts (typed endpoints) Β· labels.ts
                    Β· lookups.ts (gather) Β· classify.ts (decision table) Β· text.ts Β· verdict.ts Β· fixtures.ts
packages/cli        sentwrong <address> [--from] [--chain] [--json] [--explain] [--deep] [--no-cache]
apps/web            Next.js 15: / Β· /api/verdict (NDJSON stream) Β· /q/[address] (share page) Β· /api/og
e2e/                Playwright suites (run against the built app with no key) Β· playwright.config.ts Β· lighthouserc.json
scripts             spike.ts Β· seed.ts Β· verify.ts Β· bench.ts Β· check_submission_readiness.ts
fixtures/           13 recorded live runs (real Nansen responses, byte-for-byte, no key material)
docs/               SCORING.md (the decision table) Β· DX-REPORT.md (Nansen API friction log) Β· screenshots/
.github/            ci.yml (7 stages) Β· codeql.yml Β· gitleaks.yml Β· release.yml Β· dependabot.yml Β· community files
ARCHITECTURE.md Β· DEMO.md Β· JUDGE.md (mirror of /judge) Β· LICENSE

πŸ“½οΈ Demo Materials

πŸ“„ License

MIT β€” see LICENSE.

About

🧭 Sent crypto to the wrong address? Paste it. Nansen labels decide which of four recovery routes you are on β€” and draft the ticket. Nansen Meridian Buildathon 2026.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages