From bba29b48f3d752f76d871359f75db1b407b7cf6a Mon Sep 17 00:00:00 2001 From: jayteemoney Date: Thu, 24 Sep 2026 14:49:42 +0100 Subject: [PATCH] fix(api): format stream amounts with the token's real decimals /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 --- docs/INTEGRATION_GUIDE.md | 9 +++++++-- frontend/src/app/api/streams/[id]/route.ts | 14 ++++++++++--- frontend/src/lib/openclaw-server.ts | 23 ++++++++++++++++++++++ 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/docs/INTEGRATION_GUIDE.md b/docs/INTEGRATION_GUIDE.md index 2b54338..a8aa600 100644 --- a/docs/INTEGRATION_GUIDE.md +++ b/docs/INTEGRATION_GUIDE.md @@ -257,7 +257,10 @@ A stream looks like this. Amounts are strings in the token's smallest unit. "remaining": "957223", "refundable": "1", "currentBlock": 9053473, - "progress": 100 + "progress": 100, + "tokenDecimals": 6, + "depositFormatted": "1.20", + "claimableFormatted": "0.957222" } ``` @@ -269,8 +272,10 @@ A stream looks like this. Amounts are strings in the token's smallest unit. | `remaining` | Still held by the contract for this stream | | `refundable` | What would return to the sender on cancel now | | `progress` | Percent of the stream's duration elapsed | +| `tokenDecimals` | The token's decimals, read from the token contract | +| `depositFormatted`, `claimableFormatted` | Human-readable amounts using `tokenDecimals`. `null` if decimals could not be read | -Convert amounts yourself using the token's decimals from the table above. Do not rely on the `…Formatted` fields. +For your own calculations, use the raw amounts with `tokenDecimals`. Errors return JSON with an `error` field: `400` for a malformed ID or address, `404` when not found, and `502` when the upstream blockchain API is unavailable. diff --git a/frontend/src/app/api/streams/[id]/route.ts b/frontend/src/app/api/streams/[id]/route.ts index c421f54..aec821f 100644 --- a/frontend/src/app/api/streams/[id]/route.ts +++ b/frontend/src/app/api/streams/[id]/route.ts @@ -5,6 +5,7 @@ import { getRemainingBalance, getRefundableAmount, getCurrentBlockHeight, + getTokenDecimals, getStreamStatusLabel, getStreamProgress, formatTokenAmount, @@ -32,13 +33,14 @@ export async function GET( return jsonResponse({ error: "Stream not found" }, 404); } - const [claimable, streamed, remaining, refundable, currentBlock] = + const [claimable, streamed, remaining, refundable, currentBlock, decimals] = await Promise.all([ getClaimableBalance(id), getStreamedAmount(id), getRemainingBalance(id), getRefundableAmount(id), getCurrentBlockHeight(), + getTokenDecimals(stream.token), ]); const progress = getStreamProgress( @@ -58,9 +60,15 @@ export async function GET( refundable, currentBlock, progress: Math.round(progress * 100) / 100, - depositFormatted: formatTokenAmount(stream.depositAmount), + tokenDecimals: decimals, + depositFormatted: + decimals !== null + ? formatTokenAmount(stream.depositAmount, decimals) + : null, claimableFormatted: - claimable !== null ? formatTokenAmount(claimable) : null, + decimals !== null && claimable !== null + ? formatTokenAmount(claimable, decimals) + : null, }); } catch (err) { return errorResponse(err); diff --git a/frontend/src/lib/openclaw-server.ts b/frontend/src/lib/openclaw-server.ts index ddc3a88..ec80666 100644 --- a/frontend/src/lib/openclaw-server.ts +++ b/frontend/src/lib/openclaw-server.ts @@ -96,6 +96,29 @@ function parseStreamData(raw: Record): StreamData { }; } +// SIP-010 decimals, read from the token contract itself so any token formats +// correctly, not only the ones the app lists. Decimals never change for a +// deployed token, so cache per warm instance. Returns null if the call fails; +// callers then omit formatted amounts rather than guess a wrong scale. +const decimalsCache = new Map(); + +export async function getTokenDecimals( + tokenContract: string +): Promise { + const hit = decimalsCache.get(tokenContract); + if (hit !== undefined) return hit; + try { + const result = await callReadOnly(tokenContract, "get-decimals"); + if (!result.success) return null; + const decimals = Number(result.value.value); + if (!Number.isInteger(decimals) || decimals < 0 || decimals > 38) return null; + decimalsCache.set(tokenContract, decimals); + return decimals; + } catch { + return null; + } +} + export async function getStream(streamId: number): Promise { const result = await callReadOnly(STREAM_MANAGER_CONTRACT, "get-stream", [ uintCV(streamId),