sifting/io
DEX & DeFi
7 min readSiftingIO Team

Wallet portfolio API: handle spam, dust and unpriced tokens honestly

Build a clearer wallet balance screen with separate buckets for priced, dust, unpriced and unrecognised tokens, using a tested decimal-based classifier.

Wallet portfolio API: handle spam, dust and unpriced tokens honestly

A wallet balance screen should not give an unsolicited token the same treatment as a holding the user expects. But “not recognised,” “unpriced” and “spam” are different states. Hiding them all without explanation makes the portfolio total difficult to trust.

With the SiftingIO DEX & DeFi API, you can retrieve wallet balances and apply a display policy on your server. Get an API key to try the endpoint, or run the synthetic classifier below without a key. The goal is a clear holdings screen, not an automatic verdict that an unknown token is malicious.

Separate the API response from your display policy#

GET /v1/fnd/dex/wallet/{chain}/{address} returns a snapshot of native and ERC-20 holdings for a supported EVM chain. The documented chain slugs are eth, base, arbitrum, bsc and polygon.

The relevant response fields are:

FieldMeaning for the screen
contract_addressToken identity within a chain; absent on a native-coin row
nativeIdentifies the native coin
raw_balanceInteger balance as a string, in base units
decimalsScale used to convert base units to token units
balanceHuman-readable balance string
symbol, name, logoDisplay metadata, not evidence of legitimacy
updated_atPortfolio snapshot assembly time, in Unix seconds

The endpoint does not supply a USD valuation or a verified spam classification. Rows with unresolved token metadata can be absent, so do not label the result “every asset in this wallet, verified.” An empty response is also not proof that the wallet has never held assets.

The wallet documentation covers fetching balances. This post focuses on what the screen should do with them.

Use separate, inspectable buckets#

BucketRuleDisplay
PricedRecognised identity, valid amount, fresh mapped reference price, above the dust thresholdAmount and estimated USD value
UnpricedRecognised identity but no acceptable priceAmount with “Price unavailable”
DustRecognised, priced position below a chosen value thresholdCollapsed, with its value available on expansion
UnrecognisedIdentity has not been approved by your applicationCollapsed for review, not labelled “scam”
InvalidMalformed balance, metadata or identitySeparate data-quality state
ZeroValid zero balanceOmit from the main holdings list

Your allowlist should use chain plus contract address, not the ticker symbol. A token can copy another token’s name, symbol or logo. Native assets need a separate chain-specific entry. An allowlist is a display policy, not a security certificate: a recognised asset can still lose value or have contract risk.

Contract-to-price mapping needs its own verification. A wrapped, bridged or similarly named asset should not automatically inherit the price of the asset it resembles. See why crypto token symbols are not unique.

A testable classifier with decimal arithmetic#

The following Python code classifies already-fetched holdings and prices. It deliberately makes no network calls. Both maps are application-owned: recognised maps verified identities to a display label and an optional pricing symbol; prices contains the reference observations fetched for those symbols.

Save it as holdings.py. The decimal precision and maximum price age are explicit application settings, not API guarantees.

import re
from decimal import Decimal, InvalidOperation, localcontext

ADDRESS = re.compile(r"0x[0-9a-fA-F]{40}\Z")
CHAINS = {"eth", "base", "arbitrum", "bsc", "polygon"}

def identity(chain, token):
    if chain not in CHAINS:
        raise ValueError("Unsupported chain")
    if token.get("native") is True:
        return (chain, "native")
    address = token.get("contract_address")
    if not isinstance(address, str) or not ADDRESS.fullmatch(address):
        raise ValueError("Invalid contract address")
    return (chain, address.lower())

def amount(token):
    raw, decimals = token.get("raw_balance"), token.get("decimals")
    if not isinstance(raw, str) or not re.fullmatch(r"[0-9]{1,78}", raw):
        raise ValueError("Invalid raw balance")
    if int(raw) >= 2 ** 256:
        raise ValueError("Balance exceeds uint256")
    if type(decimals) is not int or not 0 <= decimals <= 255:
        raise ValueError("Invalid decimals")
    return Decimal(f"{raw}e-{decimals}")

def classify(chain, tokens, recognised, prices, now_ms,
             dust=Decimal("1"), max_price_age_ms=60_000):
    if chain not in CHAINS:
        raise ValueError("Unsupported chain")
    buckets = {k: [] for k in (
        "priced", "unpriced", "dust", "unrecognised", "invalid", "zero"
    )}
    with localcontext() as ctx:
        ctx.prec = 120
        for token in tokens:
            if not isinstance(token, dict):
                buckets["invalid"].append({"reason": "invalid_token_row"})
                continue
            try:
                key, units = identity(chain, token), amount(token)
            except (ValueError, InvalidOperation):
                buckets["invalid"].append({"reason": "invalid_identity_or_balance"})
                continue
            row = {"identity": key, "amount": units}
            if units == 0:
                buckets["zero"].append(row)
                continue
            mapping = recognised.get(key)
            if mapping is None:
                buckets["unrecognised"].append(row)
                continue
            row["label"] = mapping["label"]
            observation = prices.get(mapping.get("price_symbol"))
            try:
                # The REST price contract is a string. Reject a prior float cast.
                if not isinstance(observation, dict) or not isinstance(observation.get("p"), str):
                    raise ValueError("No usable price")
                price = Decimal(observation["p"])
                timestamp = observation["t"]
                if not price.is_finite() or price <= 0:
                    raise ValueError("Invalid price")
                if type(timestamp) is not int or not 0 <= now_ms - timestamp <= max_price_age_ms:
                    raise ValueError("Price not fresh")
            except (ValueError, KeyError, InvalidOperation):
                buckets["unpriced"].append(row)
                continue
            row.update(usd=units * price, price_t=timestamp)
            buckets["dust" if row["usd"] < dust else "priced"].append(row)
        buckets["displayed_value"] = sum(
            (row["usd"] for row in buckets["priced"]), Decimal("0")
        )
        return buckets

This preserves balance digits at ingestion and uses decimal arithmetic with a 120-significant-digit context for valuation. If your accepted input range or arithmetic requires a different precision, configure and test it. Round only for the final display, not before the dust comparison.

now_ms assumes a reasonably synchronized clock. A future timestamp or an observation older than the configured age goes to unpriced; it does not become a zero-dollar holding. Keep price-health and clock-health diagnostics alongside the screen.

Reproduce the result without a live wallet#

These addresses and symbols are fictional fixtures, not contracts to add to a production allowlist. Append this to the same file:

now = 1_790_769_600_000
asset = "0x" + "1" * 40
unpriced_asset = "0x" + "2" * 40
unknown_asset = "0x" + "3" * 40
recognised = {
    ("eth", "native"): {"label": "ETH", "price_symbol": "ETHUSD"},
    ("eth", asset): {"label": "TEST", "price_symbol": "TESTUSD"},
    ("eth", unpriced_asset): {"label": "UNPRICED", "price_symbol": None},
}
tokens = [
    {"native": True, "raw_balance": "2000000000000000000", "decimals": 18},
    {"contract_address": asset, "raw_balance": "1000", "decimals": 6},
    {"contract_address": unpriced_asset, "raw_balance": "5000000", "decimals": 6},
    {"contract_address": unknown_asset, "raw_balance": "100", "decimals": 0},
]
prices = {
    "ETHUSD": {"p": "2000", "t": now},
    "TESTUSD": {"p": "10", "t": now},
}
result = classify("eth", tokens, recognised, prices, now)
assert result["displayed_value"] == Decimal("4000")
assert len(result["priced"]) == 1
assert len(result["dust"]) == 1
assert result["dust"][0]["usd"] == Decimal("0.01")
assert len(result["unpriced"]) == 1
assert len(result["unrecognised"]) == 1
print(f"Displayed reference value: ${result['displayed_value']:,.2f}")

The fixture prints $4,000.00. The screen should also explain: excludes one unpriced holding, one position below $1 and one unrecognised token. The dust position is worth $0.01 in this fixture; it has not vanished from the underlying record.

Call the figure “Displayed reference value,” not “Complete wallet value.” The underlying API can omit unresolved rows, and a reference valuation is not the amount a user would necessarily receive on selling.

Connect the classifier to the API#

Fetch the wallet once for each address and chain being refreshed. Validate the response and retain its updated_at. Resolve only identities your application has reviewed, collect their distinct price symbols, then fetch each mapped price once for the refresh.

For a verified mapping to a supported crypto symbol, GET /v1/last/trade/crypto/{symbol} provides the reference price in p and its millisecond timestamp in t. Keep that timestamp separate from the portfolio’s seconds-based updated_at. Label both ages when they differ materially.

Do not silently turn a portfolio request failure into an empty wallet. Show a refresh error or the previous snapshot with its age. Likewise, a price failure should become an explicit unpriced or stale state, with rate-limit handling and a bounded retry policy, rather than a new request for every row and every viewer.

Treat token metadata as untrusted input. Render names as text, never HTML; do not turn a token name containing a URL into a clickable claim link. Apply a controlled image policy rather than loading arbitrary remote logos. A hidden token should never prompt an approval, transfer or wallet signature simply to reveal it.

Keep hidden holdings recoverable#

Offer a “Show hidden holdings” control with reasons. Let a legitimate holding be reviewed and added to the display policy without pretending every unknown contract is dangerous. A stale price should stop contributing to the current valuation, but the recognised token and its amount should remain visible.

That separation is what makes the screen useful: balances describe what the API found, pricing describes what can be valued, and the interface explains exactly what its headline total includes.

Keep reading

Related posts