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),