fix: resolve token metadata from chain instead of assuming sBTC - #30
Merged
Merged
Conversation
/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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What was wrong
getTokenConfigByContractIdreturnedDEFAULT_TOKEN(sBTC) on a miss. Sincestream-manager.clartakes any<sip-010-trait>, any stream outside the four-token curated list hit that fallback. Three failures followed:1e8→ 100x too small1.2 USDA, not0.012sbtc-tokenfor a USDA streamThe 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: 1200000USDA) returneddepositFormatted: "0.012"before,"1.20"now.What this changes
Resolver (
token-metadata.ts,token-metadata-client.ts). Readsdecimalsand thedefine-fungible-tokenasset name from the token itself; curated entries are a fast path, not the source of truth.assetNameis deliberately notget-name— USDA'sget-nameis"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
-lockedvariant) 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
ResolvedTokeninstead of a bareftName, so an unproven asset name is not representable.formatTokenAmount'sdecimals = 8default is gone in all three packages — a forgotten argument is now a compile error.Exact amounts.
toRawAmountreplacesparseFloat(x) * 10**decimals, which loses precision above 2^53 raw units.Aggregates.
pickPrimaryTokenreturns 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-depositedis a singleuintthat 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 plustotalDepositedIsCrossTokenAggregate: 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
tests/token-metadata.test.ts(no network, no wallet)tsc --noEmitclean; frontend and openclaw-service both build/api/streams/11against mainnet returnstokenDecimals: 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-depositedfix above needs a separate change with a migration story for existing DAOs.