sifting/io
DEX & DeFi
10 min readSiftingIO Team

Crypto token symbols are not unique: how to match an ERC-20 token to the right price

Crypto token symbols are not unique. Key ERC-20 tokens on chain plus validated contract address and map each identity to a price explicitly, never by symbol.

Crypto token symbols are not unique: how to match an ERC-20 token to the right price

Crypto token symbols are not unique, so a watchlist or portfolio app that joins a token to a price by its ticker will eventually show the wrong price with a perfectly fresh timestamp. The fix is a change of key, not a change of data source: identify an ERC-20 token by its chain plus its validated contract address, treat the symbol and name as display text only, and route every price lookup through a mapping table you have verified. If a token has no verified entry in that table, its price is unavailable. It never falls back to "whatever LINK means today."

This post covers the identity half of that problem for EVM tokens. It does not repeat how to read a wallet's balances or how to get a DEX price; those are covered in Read any wallet's token portfolio across chains without RPC calls and How to Get the Latest Price and Volume From a DEX Without the On-Chain Math. Here the question is narrower: once you hold a list of tokens, which price belongs to which one?

Why crypto token symbols are not unique#

The ERC-20 standard (EIP-20) lists name(), symbol() and decimals() as optional methods and states that interfaces and other contracts must not expect these values to be present. Nothing in the standard reserves a symbol, registers it, or checks it against other contracts. Any deployer can call a token USDC, WETH or LINK, and many do, sometimes by mistake and sometimes to catch the exact join bug this post is about.

So a symbol tells you what the contract author chose to display. It does not tell you which asset you hold. The stable identity of an ERC-20 token has exactly two parts: the chain it lives on and the contract address on that chain. Both are required. The same address can exist on several EVM chains because deployments with the same deployer and nonce, or the same CREATE2 inputs, land on the same address, and on each chain it is a separate contract with a separate supply and separate liquidity.

The table below is a synthetic fixture, not observed data. It shows the two collision shapes in one place.

RowChainContract (synthetic)symbol()What it actually isPrice mapping
Aeip155:1 (Ethereum)0x1111…1111ABCThe issuer's published contractVerified: use the reference price you chose for ABC
Beip155:1 (Ethereum)0x2222…2222ABCUnrelated contract reusing the symbolNone. Show unavailable
Ceip155:8453 (Base)0x1111…1111ABCSame address as A, different chain: a different contract, possibly none at allNone until verified on Base
Deip155:8453 (Base)0x3333…3333ABCA bridged representation of ANone until you decide it should track A

A join on symbol == "ABC" prices all four rows the same. A join on chain plus contract prices exactly one of them and leaves the rest honestly blank.

What a usable token identity looks like#

You need a single string that encodes both parts and that two teams can compare without ambiguity. The CAIP-19 asset type identifier is a reasonable template: it is written as a chain id, a slash, an asset namespace, a colon and an asset reference, and the specification calls the whole string case-sensitive. Its own example for DAI on Ethereum mainnet is:

eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f

Use that shape (or your own with the same two parts) as the primary key of your token table and of your price mapping. SiftingIO does not implement CAIP-19 and its endpoints do not accept identifiers in that form; the format is a convention for your own registry, nothing more.

Two details matter when you build the key.

First, the contract address must be validated, not copied from a symbol() lookup or a screenshot. For an EVM address that means it matches 0x plus 40 hex characters and, if it carries mixed case, that the EIP-55 checksum verifies. Then confirm it against the issuer's own published deployment list for that chain. Identity is still not proof of legitimacy: a validated address proves you are pointing at one specific contract, not that the contract is safe, audited, or the asset you think it is. Use the issuer's published list to confirm the intended deployment, not as a substitute for a security review.

Second, case handling is per chain, not global. EVM addresses are hex, so lowercasing them for comparison loses nothing except the checksum, which you have already verified. Solana addresses are base58 and case-sensitive; lowercasing one can change or invalidate the address. If your registry will ever hold non-EVM tokens, normalize inside a per-chain function rather than with a blanket .toLowerCase() on every key.

Token address, pool address, native asset, wrapped asset#

Four things get mixed into one "address" column in portfolio apps, and each needs its own handling.

A token contract address identifies the asset. A pool address identifies a liquidity venue where two assets trade. A pool has a price for one token in terms of the other, but it is not the token. SiftingIO's DEX pair symbols are already at the pair level: eth:WETH-USDC names a chain and two token legs, and the TVL snapshot for a pair returns n, the number of pools contributing to the aggregation, so one pair symbol can stand for several pools. Keep pool addresses out of your token table entirely; if you store them, store them as a separate venue record that references two token identities.

Native assets (ETH on Ethereum, ETH on Base, BNB on BSC) are outside ERC-20 scope. They have no contract address, so they cannot get an erc20: key. The wallet portfolio endpoint reflects this: the native row carries "native": true and omits contract_address, while every ERC-20 row carries one. Give native assets their own key form rather than inventing a contract for them.

Wrapped and bridged assets do not inherit the identity of the asset they represent. WETH on Ethereum is an ERC-20 contract representing native ETH, and a bridged USDC on one chain is a different contract from the issuer's native deployment on another. Whether your app should price a wrapped or bridged token by the underlying asset's reference price is a product decision. Record that decision as an explicit mapping row; never let the code infer it from a shared symbol.

Where the price join happens#

The wallet portfolio endpoint gives you the identity side for free. GET /v1/fnd/dex/wallet/:chain/:address accepts eth, base, arbitrum, bsc or polygon and a 0x address of 40 hex characters (mixed case accepted on input, returned lowercase), and returns a tokens array where each ERC-20 entry carries contract_address in lowercase alongside symbol, name, decimals, raw_balance and balance. The chain you asked for plus that contract_address is the key. The symbol and name are what you print in the row.

The price side is keyed by SiftingIO symbols, which are ticker-style: LINKUSD on the crypto venue, or a chain-prefixed pair such as base:WETH-USDC on the DEX stream. The last-trade docs define the symbol as six to twelve alphanumeric characters with no separators, case-insensitive; the DEX stream and the TVL endpoint take a TOKEN0-TOKEN1 pair, chain-prefixed on the stream and with the chain as a path segment on REST. None of them takes a contract address as input. That gap is exactly where your mapping table sits, and it is deliberate on your side: you decide that the LINK at the issuer's mainnet address is the asset priced by LINKUSD, you write that down once, and nothing else in the app is allowed to make that inference.

A minimal lookup in Node 18+ looks like this. The registry is yours; the two addresses shown are the mainnet deployment of LINK in the issuer's deployment list and WETH9 in Base's contract list. Confirm both before adopting them.

This example starts after address validation at ingestion, including EIP-55 checksum verification for mixed-case input. The helper below only checks the address shape and builds a normalized key; it is not a checksum validator.

// Node 18+. Set SIFTING_KEY in the environment before running.
const key = process.env.SIFTING_KEY;

// Verified mapping: token identity -> the SiftingIO symbol that prices it.
// Populate by hand from the issuer's published addresses. Symbol is never a key.
const registry = new Map([
  ["eip155:1/erc20:0x514910771af9ca656af840dff83e8264ecf986ca",
    { venue: "crypto", symbol: "LINKUSD" }],
  ["eip155:8453/erc20:0x4200000000000000000000000000000000000006",
    { stream: "dex", symbol: "base:WETH-USDC" }],
]);

// EVM only. Input must already be validated at ingestion, including EIP-55
// for mixed-case addresses. This helper checks shape, then normalizes the key.
// Never apply this normalization to a non-EVM chain.
function assetId(chainId, contract) {
  if (!/^0x[0-9a-fA-F]{40}$/.test(contract)) throw new Error("not an EVM address");
  return `eip155:${chainId}/erc20:${contract.toLowerCase()}`;
}

async function priceFor(chainId, contract) {
  const mapped = registry.get(assetId(chainId, contract));
  if (!mapped) return { status: "unavailable", reason: "no verified price mapping" };
  if (mapped.stream) return { status: "stream", symbol: mapped.symbol };

  const url = `https://api.sifting.io/v1/last/trade/${mapped.venue}/${mapped.symbol}`;
  const res = await fetch(url, { headers: { "X-API-Key": key } });
  if (res.status === 503) return { status: "unavailable", reason: "http_503" };
  if (!res.ok) return { status: "unavailable", reason: `http_${res.status}` };

  const tick = await res.json(); // { s, p, P, t } with p and P as strings
  return { status: "ok", symbol: tick.s, price: Number(tick.p), published_at_ms: tick.t };
}

// A LINK-named contract that is not in the registry stays unavailable.
priceFor(1, "0x2222222222222222222222222222222222222222").then(console.log);
// LINK at the mainnet address resolves to the LINKUSD reference price.
priceFor(1, "0x514910771AF9Ca656af840dff83E8264EcF986CA").then(console.log);

Three things about that code are the point. The unmapped contract returns unavailable even though its symbol() might read LINK. The entry mapped to a DEX pair is handed to the streaming path rather than priced here, because a pair symbol names two token legs rather than a single asset priced in USD, and it belongs with the WebSocket dex product described in the linked DEX post. And the t on the response is the engine's publish timestamp for a reference price aggregated across venues, not a trade time on any one venue, so it tells you how fresh the reference is and nothing about a specific fill.

The equivalent single request, if you want to see the response shape before writing code:

curl -H "X-API-Key: $SIFTING_KEY" \
     "https://api.sifting.io/v1/last/trade/crypto/LINKUSD"

A 503 with "error": "stale_snapshot" means the last tick is older than the staleness threshold; the body carries last_t and server_now so you can decide whether to retry or switch to the stream. Treat it as unavailable in the UI, the same as a missing mapping. A fresh price for the wrong token and a stale price for the right token are both wrong answers, and the app should show neither as a number.

Checklist for a token watchlist or portfolio app#

  • Key every token row on chain id plus validated contract address. Store symbol and name as display metadata that can change without breaking anything.
  • Validate EVM addresses as 0x plus 40 hex characters, verify the EIP-55 checksum when mixed case is present, then confirm the address against the issuer's published deployments for that chain.
  • Normalize case per chain. Lowercase EVM keys if you like; never lowercase a Solana address, and never run one normalizer over every chain.
  • Keep price mapping as an explicit table from token identity to SiftingIO symbol. No entry means unavailable. Symbol matching is not a fallback.
  • Treat the same address on two chains as two assets, and treat wrapped or bridged tokens as separate assets until you write a mapping row that says otherwise.
  • Keep pool addresses out of the token table. A pair symbol such as eth:WETH-USDC can aggregate several pools; it is a venue, not an asset.
  • Give native assets their own key form; they have no contract address and the wallet endpoint marks them with "native": true.
  • Show unavailable for a missing mapping and for a 503 stale_snapshot alike. Both are states the reader should see, not gaps to paper over.
  • Remember that a verified identity proves which contract you hold, not that the contract is legitimate. That check is separate and belongs to the issuer's documentation.

The wallet endpoint reference and the live snapshot reference are in the docs if you want the full parameter lists and error codes. Read the docs

Keep reading

Related posts