DEV Community

Cover image for Build an Ethereum Wallet PnL Tracker in Node.js (20 minutes, no dependencies)
Sven Esser
Sven Esser

Posted on Originally published at sv3n.hashnode.dev

Build an Ethereum Wallet PnL Tracker in Node.js (20 minutes, no dependencies)

Want to know whether a wallet is actually profitable? Most explorers show balances, but not profit and loss per token. In this tutorial you'll build a small Node.js script that takes any Ethereum wallet address and prints:

  • net worth, realized and unrealized PnL, win rate
  • current holdings with average buy price and unrealized PnL
  • PnL for every token the wallet traded
  • the wallet's latest Uniswap buys and sells

No node, no indexer, no database. The data comes from the DexMarketCap API, which indexes every Uniswap V2, V3 and V4 pool on Ethereum. The script has zero dependencies and needs Node 18 or newer (for the built-in fetch).

1. Get an API key

  1. Open the DexMarketCap API on RapidAPI and subscribe to the free (BASIC) plan.
  2. Copy your X-RapidAPI-Key and X-RapidAPI-Host from the code snippet panel on the endpoint page.

Set them as environment variables so they never end up in your code:

export RAPIDAPI_KEY="your-key"
export RAPIDAPI_HOST="your-host-from-rapidapi"
Enter fullscreen mode Exit fullscreen mode

2. A tiny API helper

Every endpoint returns { data, pagination?, meta }. Create tracker.mjs and start with a helper that sends the RapidAPI headers, retries when the API is briefly busy (503 with a Retry-After header), and turns errors into readable messages:

const KEY = process.env.RAPIDAPI_KEY;
const HOST = process.env.RAPIDAPI_HOST;
const wallet = process.argv[2];

if (!KEY || !HOST || !wallet) {
  console.error("Usage: node tracker.mjs 0xWalletAddress");
  console.error("Set RAPIDAPI_KEY and RAPIDAPI_HOST first.");
  process.exit(1);
}

async function api(path, params = {}, retries = 2) {
  const url = new URL(`https://${HOST}${path}`);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);

  const res = await fetch(url, {
    headers: { "X-RapidAPI-Key": KEY, "X-RapidAPI-Host": HOST },
  });

  if (res.status === 503 && retries > 0) {
    const wait = Number(res.headers.get("retry-after") ?? 2);
    await new Promise((r) => setTimeout(r, wait * 1000));
    return api(path, params, retries - 1);
  }

  if (!res.ok) {
    const body = await res.json().catch(() => ({}));
    throw new Error(`${res.status} ${body?.error?.message ?? res.statusText}`);
  }
  return res.json();
}
Enter fullscreen mode Exit fullscreen mode

3. Small formatting helpers

Percentages come back as 0 to 100, USD values as numbers and timestamps as unix seconds. Token amounts are strings, to keep full precision.

const usd = (n) =>
  n == null
    ? "-"
    : n.toLocaleString("en-US", { style: "currency", currency: "USD" });

const pct = (n) => (n == null ? "-" : `${n.toFixed(1)}%`);
const when = (t) => new Date(t * 1000).toISOString().slice(0, 16).replace("T", " ");
Enter fullscreen mode Exit fullscreen mode

4. Fetch everything in parallel

Four endpoints give you the full picture. They don't depend on each other, so call them together:

Endpoint What it returns
GET /wallets/{address} Net worth, PnL, volume, win rate
GET /wallets/{address}/holdings Current balances with unrealized PnL
GET /wallets/{address}/pnl Realized and unrealized PnL per token
GET /wallets/{address}/swaps Latest buys and sells
const [overview, holdings, pnl, swaps] = await Promise.all([
  api(`/wallets/${wallet}`),
  api(`/wallets/${wallet}/holdings`, { hideDead: true, limit: 10 }),
  api(`/wallets/${wallet}/pnl`, { limit: 10 }),
  api(`/wallets/${wallet}/swaps`, { limit: 10 }),
]);
Enter fullscreen mode Exit fullscreen mode

hideDead=true leaves out worthless and dead tokens, so airdrop spam doesn't clutter the list.

5. Print the results

Start with the summary. Exchanges, routers and contracts come back with isKnownEntity: true and no portfolio, so handle that case first:

const o = overview.data;

if (o.isKnownEntity) {
  console.log(`${wallet} is a known entity (exchange, router or contract), no portfolio to show.`);
  process.exit(0);
}

console.log(`\nWallet ${wallet}`);
console.log(`Net worth:       ${usd(o.netWorthUsd)}`);
console.log(`Realized PnL:    ${usd(o.realizedPnlUsd)}`);
console.log(`Unrealized PnL:  ${usd(o.unrealizedPnlUsd)}`);
console.log(`Trades:          ${o.trades} across ${o.tokensTraded} tokens`);
console.log(`Win rate:        ${pct(o.winRatePct)} (${o.wins} wins, ${o.losses} losses)`);
Enter fullscreen mode Exit fullscreen mode

Then the three tables. console.table does the layout for us:

console.log("\nHoldings");
console.table(
  holdings.data.map((h) => ({
    token: h.token.symbol,
    value: usd(h.valueUsd),
    "avg buy": h.avgBuyPriceUsd,
    "unrealized": `${usd(h.unrealized.usd)} (${pct(h.unrealized.pct)})`,
  }))
);

console.log("\nPnL per token (sorted by realized PnL)");
console.table(
  pnl.data.map((p) => ({
    token: p.token.symbol,
    bought: usd(p.bought.usd),
    sold: usd(p.sold.usd),
    realized: `${usd(p.realized.usd)} (${pct(p.realized.pct)})`,
    unrealized: usd(p.unrealized.usd),
    "last trade": when(p.lastTx),
  }))
);

console.log("\nLatest swaps");
console.table(
  swaps.data.map((s) => ({
    time: when(s.timestamp),
    side: s.side,
    token: s.token.symbol,
    value: usd(s.valueUsd),
    tx: s.txHash.slice(0, 12) + "...",
  }))
);
Enter fullscreen mode Exit fullscreen mode

6. Run it

node tracker.mjs 0xYourWalletAddress
Enter fullscreen mode Exit fullscreen mode

You'll get the summary followed by the three tables. That's already a working wallet tracker.

Know your plan limits

The free plan is meant for trying things out. Requests above your plan's limits are clamped, not rejected, and meta.limited: true tells you when it happened:

  • BASIC: up to 10 rows for holdings and per-token PnL, and swap history for the last 24 hours.
  • Higher plans return more rows and longer history. The overview adds a biggestWins list on PRO and up, and ULTRA and MEGA add token safety flags (honeypot, rugged, dead) and the realized PnL of each sell.

If you build on this, check meta.limited and show a hint in your UI when the data is cut off.

Where to go next

  • Live updates. On PRO and above, request a stream ticket with POST /stream/ticket, connect to the WebSocket and subscribe to wallet:{address} to get the wallet's new swaps the moment they happen.
  • Pagination. Use offset on holdings and PnL, and before (with pagination.nextBefore) on swaps, to page through everything.
  • A web UI. Wrap the script in an Express route or a Next.js API route and render the tables with any charting library. Keep the API key on the server.
  • Token side. The same API has a rich list endpoint, so you can chart who holds a token. That's the topic of the next tutorial.

Try it on the free plan: DexMarketCap API on RapidAPI.

Top comments (5)

Collapse
 
cryptostrapon profile image
cryptostrapon •

One guard worth adding on the free plan, where the honeypot flags are not available: a honeypot shows a big unrealized gain, because the pool price keeps climbing while nobody can actually sell. Counting unrealized PnL as zero for tokens with no successful sells in their swap history, or with very thin pool liquidity, keeps the net worth number honest.

Collapse
 
sven_esser_94287e300ee703 profile image
Sven Esser •

Good point, but this is already handled server-side on every plan, including free: tokens with a honeypot or rug verdict are valued at 0, unrealized gains on thin pools (under 1 WETH) are capped at 0, and dust pools aren't valued at all. The free plan just doesn't return the flags field, so net worth and unrealized PnL stay honest either way.

Collapse
 
cryptostrapon profile image
cryptostrapon •

Makes sense, thanks for clarifying!

Thread Thread
 
sven_esser_94287e300ee703 profile image
Sven Esser •
Collapse
 
nullify profile image
Nullify •

Cool product running tests on it