Skip to content

fix: resolve token metadata from chain instead of assuming sBTC - #30

Merged
jayteemoney merged 6 commits into
mainfrom
fix/token-metadata-resolution
Oct 2, 2026
Merged

jayteemoney merged 6 commits into
mainfrom
fix/token-metadata-resolution

Conversation

@jayteemoney

Copy link
Copy Markdown
Owner

What was wrong

getTokenConfigByContractId returned DEFAULT_TOKEN (sBTC) on a miss. Since stream-manager.clar takes any <sip-010-trait>, any stream outside the four-token curated list hit that fallback. Three failures followed:

Before After
6-decimal token amounts divided by 1e8 → 100x too small 1.2 USDA, not 0.012
Post-conditions named sbtc-token for a USDA stream named from chain
Labels any unlisted token shown as "sBTC" contract's own name

The post-condition case was the expensive one: the wallet rejects with "a post-condition was not met" and gives no hint that the asset name was wrong.

Confirmed against mainnet — stream 11 (depositAmount: 1200000 USDA) returned depositFormatted: "0.012" before, "1.20" now.

What this changes

Resolver (token-metadata.ts, token-metadata-client.ts). Reads decimals and the define-fungible-token asset name from the token itself; curated entries are a fast path, not the source of truth. assetName is deliberately not get-name — USDA's get-name is "USDA" while its asset name is "usda", and post-conditions need the latter.

Refusal over guessing. Contracts exposing several fungible tokens (sBTC and ALEX each have a -locked variant) are refused, since no read-only function disambiguates them. Claim/top-up explain the reason and disable rather than building a condition that cannot pass.

Type-level enforcement. Builders take a ResolvedToken instead of a bare ftName, so an unproven asset name is not representable. formatTokenAmount's decimals = 8 default is gone in all three packages — a forgotten argument is now a compile error.

Exact amounts. toRawAmount replaces parseFloat(x) * 10**decimals, which loses precision above 2^53 raw units.

Aggregates. pickPrimaryToken returns a contract id instead of a config, so it can't fall back. Pages reduce only same-token streams and footnote the rest — summing raw amounts across 6- and 8-decimal tokens is meaningless.

Known limitation, not hidden

daos.total-deposited is a single uint that the factory increments by the raw deposit of every tracked stream regardless of token, with no token key in the map. There is no correct rendering at any scale. Both APIs previously emitted one anyway; they now return the raw integer plus totalDepositedIsCrossTokenAggregate: true. Fixing it properly requires a contract change to key the total by token — out of scope here, and I did not want to leave a confidently-wrong number in a public API.

Verification

  • 143 tests pass, including 18 new in tests/token-metadata.test.ts (no network, no wallet)
  • tsc --noEmit clean; frontend and openclaw-service both build
  • eslint at the pre-existing 14 errors / 4 warnings baseline — no new ones
  • Live /api/streams/11 against mainnet returns tokenDecimals: 6, tokenLabel: "USDA", depositFormatted: "1.20"

Worth noting on that last point: one verification round came back all-null from a transient upstream 503 rather than a logic fault. Retried and got correct values. The resolver already treats that correctly — null means "unavailable", not "guess" — but it's why the API returns nulls rather than throwing.

Not done

PR is open, not merged. The Clarity total-deposited fix above needs a separate change with a migration story for existing DAOs.

/api/streams/:id formatted every amount as if the token had 8 decimals,
so USDA (6 decimals) showed 100x too small: stream 11 read "0.012"
instead of "1.20". Decimals are now read from the token contract's
SIP-010 get-decimals and cached per instance, so any token formats
correctly, listed or not. If the read fails the formatted fields are
null rather than a wrong number. Adds tokenDecimals to the response.

Verified on a local mainnet build: all 11 streams (USDA) format
correctly, and get-decimals parses as 8 for sBTC, ALEX and xBTC.

Claude-Session: https://claude.ai/code/session_011DndgWvvL6sUfzTgmC67Wq
getTokenConfigByContractId fell back to DEFAULT_TOKEN (sBTC) on any miss,
so every consumer silently assumed sBTC for tokens outside the four-entry
curated list. Three distinct failures followed:

- Amounts: a 6-decimal token (USDA, stSTX) divided by 1e8 instead of 1e6,
  reporting balances 100x too small.
- Post-conditions: create/top-up/claim/cancel named "sbtc-token" for a USDA
  stream, so the wallet rejected the tx with "a post-condition was not
  met" and no hint at the cause.
- Labels: any unlisted token was displayed as "sBTC".

The protocol is permissionless, so no hardcoded list is ever complete.
Adds a resolver that reads decimals and the define-fungible-token asset name
from the token itself, falling back to the curated list only when it is not
curated. assetName is not get-name: USDA's get-name is "USDA" while its
asset name is "usda".

Contracts defining several fungible tokens (sBTC and ALEX each expose a
-locked variant) are refused rather than guessed, so callers disable the
action and say why instead of building a condition that cannot pass.

Builders now take a resolved ResolvedToken rather than a bare ftName, so
it is impossible to compile a transaction with an unproven asset name.
getTokenConfigByContractId returns TokenConfig | null; all call sites
handle it. A new UnresolvableTokenError distinguishes "we refused" from
"the wallet rejected it" in the UI.

Also removes the decimals=8 default from formatTokenAmount so a forgotten
argument is a type error rather than a silently wrong number.
Transaction builders took a bare ftName string, so a caller could pass an
unverified one and get no compiler error. They now take a ResolvedToken,
making an unproven asset name unrepresentable.

getTokenBalance also took (contractId, ftName) separately; since the Hiro
balances endpoint keys on contractId::assetName, a wrong asset name reads
zero rather than erroring, which surfaced as "Insufficient balance. You
have 0.00". It now takes the same ResolvedToken and matches exactly, with
no fallback to "any asset in this contract" — a balance in
sbtc-token-locked is not a balance of sbtc-token.

pickPrimaryToken returned a TokenConfig, so it had to fall back to a
default for uncurated contracts. It now returns the dominant contract id
and callers resolve it, keeping the "never guess" property.

Amount entry uses exact string/bigint conversion instead of
parseFloat(amount) * 10**decimals, which loses precision above 2^53 raw
units (~90M at 8 decimals) and rounded silently.
Every page that rendered or transacted on a stream read decimals and symbol
from the fallback-prone config. Now each resolves the stream's own token and
renders "—" or a refusal while unresolved, so a wrong number is never shown
in place of a right one.

Claim and top-up dialogs resolved tokens via SUPPORTED_TOKENS.find(...)??
DEFAULT_TOKEN, so topping up a USDA stream built a post-condition naming
sbtc-token. Both now resolve from chain, explain an unverifiable token, and
disable the submit button rather than failing opaquely at the wallet.

Aggregate pages (earn, dashboard, analytics) reduce only streams sharing the
dominant token and label the rest as a footnote, since summing raw amounts
across 6- and 8-decimal tokens is meaningless.

The OpenClaw widget read a "formatted.deposit" field the API never returned,
so every amount fell through to an 8-decimal default and was labelled
DEFAULT_TOKEN — the same 100x error, in the assistant's answer. It now reads
the real fields and shows "—" when the API could not determine a scale.
/daos/:admin published totalDepositedFormatted at a hardcoded 8 decimals.
That value is not an amount of anything: stream-factory increments
total-deposited by the raw deposit of every tracked stream regardless of
token, and the daos map has no token key. Summing 1e8-scale sBTC raw units
with 1e6-scale USDA raw units yields a number that cannot be rendered
correctly at any scale. It now returns the raw integer plus
totalDepositedIsCrossTokenAggregate, and consumers show it unformatted.
A real fix needs a contract change to key the total by token.

/streams/:id additionally returns tokenDecimals and tokenLabel. Clients that
format amounts themselves previously had no way to learn the scale, so they
guessed 8 — which is how a 1.2 USDA deposit surfaced as 0.012 in the
assistant widget.
The standalone Express service had the same 8-decimal assumption as the
Next.js routes, so the published read API and the OpenClaw skill reported
6-decimal amounts at 1/100th of their value.

Adds getTokenDecimals/getTokenSymbol with per-token caching (both are
immutable for a deployed token), makes the decimals argument required so a
forgotten one is a compile error, and exposes tokenDecimals plus tokenLabel
so consumers can verify the scale instead of trusting it. Mirrors the
Next.js DAO change: no totalDepositedFormatted.
@vercel

vercel Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
stackstream Ready Ready Preview Oct 2, 2026 3:26am UTC

@jayteemoney
jayteemoney merged commit 5bf70cb into main Oct 2, 2026
2 of 4 checks passed

This branch was successfully deployed

1 active deployment
Preview — 84127837 Deployed Oct 2, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant