11/3/20269 min read

From wallets to people: a hands-on look at the Web3.bio API

From wallets to people: a hands-on look at the Web3.bio API

We ran 300 real Uniswap traders through one identity API to see how many we could put a name, profile or X handle to.

Every onchain team ends up with the same dataset: thousands of wallet addresses and no idea who's behind them. You can see exactly what each wallet did (what it swapped, when, how much) but not who it is, or how to reach them.

The thing is, a lot of those people have already told the world who they are. They registered an ENS name, set up a Farcaster account, or added their X handle to a profile. The links exist, but they're scattered across a dozen protocols, each with its own API.

Web3.bio pulls them together. In this post we walk through what it does, show the two calls you'll use most, and share what happened when we pointed it at a random sample of Uniswap traders.

How does the tool work?

It's an identity search engine for web3. You give it something that identifies a person (a wallet address, an ENS name, a Farcaster username or a social handle) and it returns every public profile linked to it, in one consistent JSON format.

Instead of querying ENS, Farcaster, Lens and Basenames separately and stitching the results together, you make one request. Each profile in the response comes back with the same fields:

web3bio.jpg

The API works without a key at a low rate limit. A key raises the limit, which you'll want for anything beyond a handful of lookups.

Feature 1: wallet or name → every linked profile

The main endpoint is a single GET request:


GET https://api.web3.bio/profile/{address-or-name}

Here's the call in Node.js (18+), with no dependencies:


const identity = "vitalik.eth";
const headers = { Accept: "application/json" };
if (process.env.WEB3BIO_API_KEY) {
  headers["X-API-KEY"] = `Bearer ${process.env.WEB3BIO_API_KEY}`;
}

const response = await fetch(`https://api.web3.bio/profile/${identity}`, { headers });
const profiles = await response.json();

for (const profile of profiles) {
  const xHandle = profile.links?.twitter?.handle || "-";
  console.log(profile.platform, profile.identity, profile.address, "X:", xHandle);
}

For vitalik.eth, one request returns four profiles across four platforms. Trimmed to the interesting parts:


[
  {
    "platform": "ens",
    "identity": "vitalik.eth",
    "address": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
    "links": {
      "twitter": { "handle": "vitalikbuterin", "sources": ["nostr", "gmgn"] },
      "github":  { "handle": "vbuterin" },
      "website": { "handle": "vitalik.ca" }
    }
  },
  {
    "platform": "farcaster",
    "identity": "vitalik.eth",
    "displayName": "Vitalik Buterin",
    "social": { "follower": 537756, "following": 110 }
  },
  { "platform": "lens", "identity": "vitalik.lens" },
  { "platform": "basenames", "identity": "vb62831.base.eth" }
]

Feature 2: social handle → wallets

It also works in reverse. Add the platform as a suffix and it returns the profiles, and the wallets behind them, linked to that account:


GET https://api.web3.bio/profile/vitalikbuterin.twitter

This returns the same vitalik.eth ENS profile and its wallet. It's handy for partnership and community work: you have a list of X accounts (your followers, people who replied to a launch post) and want to know which of them are active onchain.

The limit is worth stating plainly: it can only find wallets that people linked publicly. Hit rates in this direction are lower than for wallet → profile.

What it covers

web3bio coverage.jpg

The experiment: how many Uniswap traders can it identify?

Demos on famous names are easy. The real question is how it does on ordinary wallets, so we tested it on a random sample of DeFi users.


WITH wallets AS (
  SELECT
    tx_from AS wallet,
    COUNT(*) AS swaps,
    SUM(amount_usd) AS volume_usd
  FROM dex.trades
  WHERE blockchain = 'ethereum'
    AND project = 'uniswap'
    AND block_time >= NOW() - INTERVAL '30' DAY
  GROUP BY 1
)
SELECT wallet, swaps, volume_usd
FROM wallets
WHERE swaps <= 100
ORDER BY xxhash64(wallet)
LIMIT 300

We took every wallet that swapped on Uniswap on Ethereum in the 30 days to date, using Dune's dex.trades table. We dropped wallets with more than 100 swaps in the period to filter out bots and MEV searchers, then took a sample of 300. The sample is ordered by a hash of the address rather than a random function, so anyone running the query gets the same 300 wallets.

We then ran each wallet through /profile/{address} at two requests per second. All 300 took about two and a half minutes. One detail matters for the count: every address returns a bare ethereum record, so we only counted a wallet as identified if it had at least one named profile (ENS, Farcaster, Lens, Basenames and so on).


// Measure how many wallets in a CSV resolve to a Web3.bio identity and an X handle.
// This is the script behind the Uniswap experiment in the blog post.
//
// Usage (Node 18+, no dependencies):
//   node web3bio_uniswap_match_rate.js uniswap_wallets.csv
//   node web3bio_uniswap_match_rate.js uniswap_wallets.csv --limit 10
//
// Input:  CSV with columns wallet,swaps,volume_usd (the Dune export).
// Output: <input>_web3bio.csv, one row per wallet, plus a summary in the terminal.
// Optional: WEB3BIO_API_KEY=... for a higher rate limit (or `node --env-file=.env ...`).

const fs = require("fs");

const BASE_URL = "https://api.web3.bio";
const REQUESTS_PER_SECOND = 2;
const MAX_RETRIES = 3;

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function fetchProfiles(address) {
  const headers = { Accept: "application/json" };
  if (process.env.WEB3BIO_API_KEY) {
    headers["X-API-KEY"] = `Bearer ${process.env.WEB3BIO_API_KEY}`;
  }

  for (let attempt = 0; ; attempt++) {
    let response;
    try {
      response = await fetch(`${BASE_URL}/profile/${address.toLowerCase()}`, {
        headers,
        signal: AbortSignal.timeout(10_000),
      });
    } catch (err) {
      if (attempt < MAX_RETRIES) {
        await sleep(2 ** attempt * 1000);
        continue;
      }
      throw err;
    }

    if ((response.status === 429 || response.status >= 500) && attempt < MAX_RETRIES) {
      const retryAfter = Number(response.headers.get("retry-after"));
      await sleep(retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 2000);
      continue;
    }
    if (response.status === 404) {
      return [];
    }
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
    const data = await response.json();
    return Array.isArray(data) ? data : [];
  }
}

function summarize(row, profiles) {
  // Every address has a bare "ethereum" record, so only named profiles count as an identity.
  const named = profiles.filter((p) => p.platform !== "ethereum");
  const xHandles = new Set(
    profiles
      .map((p) => p.links?.twitter?.handle?.replace(/^[@/]+/, "").toLowerCase())
      .filter(Boolean),
  );
  return {
    ...row,
    has_profile: named.length > 0,
    platforms: [...new Set(named.map((p) => p.platform))].sort().join(";"),
    x_handle: [...xHandles].sort().join(";"),
  };
}

function readWallets(csvPath) {
  const [header, ...lines] = fs.readFileSync(csvPath, "utf8").trim().split(/\r?
/);
  const columns = header.split(",");
  return lines.map((line) => {
    const values = line.split(",");
    const row = Object.fromEntries(columns.map((c, i) => [c, values[i]]));
    return { wallet: row.wallet, swaps: Number(row.swaps), volume_usd: Number(row.volume_usd) };
  });
}

function percent(count, total) {
  return total ? `${((100 * count) / total).toFixed(1)}%` : "-";
}

async function main() {
  const csvPath = process.argv[2];
  if (!csvPath) {
    console.error("Usage: node web3bio_uniswap_match_rate.js <wallets.csv> [--limit N]");
    process.exit(1);
  }
  const limitIndex = process.argv.indexOf("--limit");
  let rows = readWallets(csvPath);
  if (limitIndex > -1) {
    rows = rows.slice(0, Number(process.argv[limitIndex + 1]));
  }

  const results = [];
  let errors = 0;
  for (const [i, row] of rows.entries()) {
    try {
      results.push(summarize(row, await fetchProfiles(row.wallet)));
    } catch (err) {
      errors++;
      console.error(`
  error on ${row.wallet}: ${err.message}`);
    }
    process.stdout.write(`\r${i + 1}/${rows.length} checked`);
    await sleep(1000 / REQUESTS_PER_SECOND);
  }
  console.log();

  const outPath = csvPath.replace(/\.csv$/i, "") + "_web3bio.csv";
  const columns = ["wallet", "swaps", "volume_usd", "has_profile", "platforms", "x_handle"];
  fs.writeFileSync(outPath, [columns.join(","), ...results.map((r) => columns.map((c) => r[c]).join(","))].join("
") + "
");

  const total = results.length;
  const withProfile = results.filter((r) => r.has_profile).length;
  const withX = results.filter((r) => r.x_handle).length;
  const platformCounts = {};
  for (const r of results) {
    for (const p of r.platforms.split(";").filter(Boolean)) {
      platformCounts[p] = (platformCounts[p] || 0) + 1;
    }
  }

  console.log(`
Wallets checked:      ${total} (${errors} errors)`);
  console.log(`Any Web3.bio profile: ${withProfile} (${percent(withProfile, total)})`);
  console.log(`Linked X handle:      ${withX} (${percent(withX, total)})`);
  console.log("By platform:");
  for (const [platform, count] of Object.entries(platformCounts).sort((a, b) => b[1] - a[1])) {
    console.log(`  ${platform.padEnd(12)} ${String(count).padStart(4)} (${percent(count, total)})`);
  }

  const buckets = [
    ["Under $100", 0, 100],
    ["$100 - $1k", 100, 1_000],
    ["$1k - $10k", 1_000, 10_000],
    ["$10k and up", 10_000, Infinity],
  ];
  console.log("By 30-day volume:");
  for (const [label, low, high] of buckets) {
    const bucket = results.filter((r) => r.volume_usd >= low && r.volume_usd < high);
    const profiles = bucket.filter((r) => r.has_profile).length;
    const xs = bucket.filter((r) => r.x_handle).length;
    console.log(`  ${label.padEnd(12)} ${String(bucket.length).padStart(4)} wallets, profile ${profiles} (${percent(profiles, bucket.length)}), X ${xs}`);
  }
  console.log(`
Saved ${outPath}`);
}

main();

web3bio uniswap sample run .jpg

We also split the sample by how much each wallet traded in the 30 days:

30d volume uniswap.jpg

What it means

About one in twelve ordinary Uniswap traders has a public web3 identity Web3.bio can find, and one in thirty has a linked X handle. That sounds small until you compare it with the alternative: from the raw addresses alone, you can put a name to none of them. On a list of 100,000 users, that's roughly 8,000 named profiles and 3,000 reachable X accounts you didn't have before.

What you can do with it

Find your users on social. Run your contract's users through the API and you get a list of X and Farcaster accounts to engage, support or invite into your community, instead of a list of hex.

Screen airdrops and allowlists. A wallet tied to a long-lived ENS name or an active Farcaster account is less likely to be one of a thousand sybil wallets. It's not proof on its own, but it's a useful signal next to onchain checks.

Enrich your CRM. Add names, avatars and social links to your user table so growth and support teams see people rather than addresses.

Improve your UX. Show vitalik.eth and an avatar in your app instead of 0xd8dA…6045

Tips from running it at scale

  • Use the per-address endpoint when you need X handles. There's a batch endpoint, but it returns only each wallet's primary profile, so it can miss an X handle stored on a Farcaster or Lens profile.

  • Don't count the bare ethereum record as a match. Every address has one. Only named profiles tell you something.

  • Treat links as leads, not proof. Most X links are self-reported on a profile. Check the sources field, and verify before you act on a match.

Verdict

If you need to know who your onchain users are, Web3.bio is the simplest way we've found to start (Arkham is another option). One endpoint covers the main name services and social protocols, the JSON is clean and consistent, and the reverse lookup from social handle to wallet is a genuinely useful extra.

Its limit is the same as any tool in this space: it can only find identities people chose to make public. On ordinary DeFi wallets that's a minority, as our numbers show. But a minority of your users that you can actually name and reach is a lot more than the zero you start with.

API doc: https://api.web3.bio/

ShareXLinkedIn
A
Adil KhanGrowth Lab Insights