DEV Community

Cover image for Flagging "pool hops" like the ones in the Binance-hack laundering, in 60 lines of JavaScript
Swapcore.Exchange
Swapcore.Exchange

Posted on

Flagging "pool hops" like the ones in the Binance-hack laundering, in 60 lines of JavaScript

The pattern

On November 9, Elliptic described how the launderers behind the Binance hack moved stolen NEAR through home-made meme coins. Between November 5 and 7 they created at least six tokens on NEAR, each costing about 25 NEAR to make, and paired each with NEAR in its own liquidity pool. One set of wallets bought the token with stolen NEAR. Another set sold it back to the pool. About 117,000 NEAR (roughly $430,000) went through before the tokens were abandoned; none has traded since November 8.

No wallet pays another wallet directly, so a naive "who sent money to whom" graph has a gap where the pool is. The trail is still all on-chain. You just have to look at the trades instead of the transfers.

From Elliptic's description, a pool hop has five signs:

  1. The token is brand new when the swaps start.
  2. Almost nobody else trades it.
  3. The wallets that buy are never the wallets that sell.
  4. What comes out is close to what went in.
  5. The buyers were funded from a wallet you already flag.

None of these alone means much. New tokens with few traders appear by the thousand every day. All five together is a different story.

The script

It takes a JSON file of swaps and token creation times, groups swaps by token and scores each one from 0 to 5.

// pool-hop.mjs
import { readFileSync } from 'node:fs';

const file = process.argv[2] ?? new URL('./sample-swaps.json', import.meta.url);
const { tokens, swaps, flagged } = JSON.parse(readFileSync(file, 'utf8'));

const HOURS = 3600e3;
const MAX_TOKEN_AGE_H = 48;
const MAX_TRADERS = 6;
const MATCH_TOLERANCE = 0.05;

const byToken = new Map();
for (const s of swaps) {
  if (!byToken.has(s.token)) byToken.set(s.token, []);
  byToken.get(s.token).push(s);
}

const report = [];
for (const [token, list] of byToken) {
  const meta = tokens.find(t => t.token === token);
  if (!meta) continue;
  const first = Math.min(...list.map(s => Date.parse(s.ts)));
  const ageH = (first - Date.parse(meta.createdAt)) / HOURS;
  const buyers = new Set(list.filter(s => s.side === 'buy').map(s => s.wallet));
  const sellers = new Set(list.filter(s => s.side === 'sell').map(s => s.wallet));
  const traders = new Set([...buyers, ...sellers]);
  const overlap = [...buyers].filter(w => sellers.has(w)).length;
  const inAmt = list.filter(s => s.side === 'buy').reduce((a, s) => a + s.amount, 0);
  const outAmt = list.filter(s => s.side === 'sell').reduce((a, s) => a + s.amount, 0);
  const taintedBuyers = [...buyers].filter(w => list.some(s => s.wallet === w && flagged.includes(s.funder)));

  const reasons = [];
  if (ageH <= MAX_TOKEN_AGE_H) reasons.push(`token ${ageH.toFixed(1)} h old at first swap`);
  if (traders.size <= MAX_TRADERS) reasons.push(`${traders.size} wallets ever traded it`);
  if (overlap === 0 && buyers.size && sellers.size) reasons.push('buyers and sellers never overlap');
  if (inAmt && Math.abs(outAmt - inAmt) / inAmt <= MATCH_TOLERANCE) reasons.push(`out ${outAmt} vs in ${inAmt} (within 5%)`);
  if (taintedBuyers.length) reasons.push(`${taintedBuyers.length} buyer(s) funded by a flagged wallet`);

  report.push({ token, score: reasons.length, reasons, sellers: [...sellers] });
}

report.sort((a, b) => b.score - a.score);
for (const r of report) {
  console.log(`${r.token.padEnd(8)} score ${r.score}/5`);
  for (const why of r.reasons) console.log(`   - ${why}`);
  if (r.score >= 4) console.log(`   => treat sells to ${r.sellers.join(', ')} as coming from the flagged source`);
}
Enter fullscreen mode Exit fullscreen mode

Running it

The sample file is made up, shaped like the pattern in the report: one throwaway token (DUCK) bought by two wallets funded from a flagged address and sold by two fresh ones, and one older token (REALDOG) traded by seven ordinary wallets. Wallet names are placeholders.

node pool-hop.mjs sample-swaps.json
Enter fullscreen mode Exit fullscreen mode
DUCK     score 5/5
   - token 0.3 h old at first swap
   - 4 wallets ever traded it
   - buyers and sellers never overlap
   - out 19700 vs in 20000 (within 5%)
   - 2 buyer(s) funded by a flagged wallet
   => treat sells to collector-1, collector-2 as coming from the flagged source
REALDOG  score 0/5
Enter fullscreen mode Exit fullscreen mode

What it won't catch

The thresholds are guesses you should tune on real data. A launderer who adds a few decoy traders, lets the token age for a week or splits the exit across many tokens will slip under a fixed cutoff. Elliptic's own description of the operation is many cheap layers stacked together, so treat a score like this as a first filter that tells you which wallets to look at by hand.

The point that holds up is Elliptic's advice to compliance teams: funds that arrive from a liquidity pool shouldn't be assumed clean, especially when a fresh token and a few wallets are involved. Follow the funding of the buyers and the gap closes.

To use this on a real chain, you'd build the swaps list from the pool contract's swap events and the funder field from each buying wallet's incoming transfers. On NEAR that means reading receipts from an indexer; on EVM chains, Swap logs plus the transfers before them.

Sources: Elliptic, "North Korea is minting meme coins to launder the Binance hack" (Nov 9, 2026); Decrypt and Cointelegraph coverage (Nov 9, 2026).

Top comments (0)