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:
| Field | Meaning for the screen |
|---|---|
contract_address | Token identity within a chain; absent on a native-coin row |
native | Identifies the native coin |
raw_balance | Integer balance as a string, in base units |
decimals | Scale used to convert base units to token units |
balance | Human-readable balance string |
symbol, name, logo | Display metadata, not evidence of legitimacy |
updated_at | Portfolio 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#
| Bucket | Rule | Display |
|---|---|---|
| Priced | Recognised identity, valid amount, fresh mapped reference price, above the dust threshold | Amount and estimated USD value |
| Unpriced | Recognised identity but no acceptable price | Amount with “Price unavailable” |
| Dust | Recognised, priced position below a chosen value threshold | Collapsed, with its value available on expansion |
| Unrecognised | Identity has not been approved by your application | Collapsed for review, not labelled “scam” |
| Invalid | Malformed balance, metadata or identity | Separate data-quality state |
| Zero | Valid zero balance | Omit 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.



