Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions docs/INTEGRATION_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
```

Expand All @@ -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.

Expand Down
16 changes: 14 additions & 2 deletions frontend/src/app/api/daos/[admin]/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {
getDao,
formatTokenAmount,
jsonResponse,
errorResponse,
STACKS_ADDRESS_RE,
Expand All @@ -24,7 +23,20 @@ export async function GET(
}
return jsonResponse({
...dao,
totalDepositedFormatted: formatTokenAmount(dao.totalDeposited),
// No `totalDepositedFormatted`.
//
// `total-deposited` is a single uint that stream-factory increments by
// the raw deposit of every tracked stream, regardless of which token that
// stream uses. Summing 1e8-scale sBTC raw units with 1e6-scale USDA raw
// units yields a number that is not an amount of anything.
//
// This route previously ran it through formatTokenAmount() with the
// default 8 decimals, publishing a confidently wrong figure. There is no
// correct single-token rendering without a per-token breakdown, and
// `daos` has no token key to derive one from — that needs a contract
// change, not a display fix. Until then, consumers must show the raw
// integer with a label that says what it is, or omit it.
totalDepositedIsCrossTokenAggregate: true,
});
} catch (err) {
return errorResponse(err);
Expand Down
22 changes: 19 additions & 3 deletions frontend/src/app/api/streams/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import {
getRemainingBalance,
getRefundableAmount,
getCurrentBlockHeight,
getTokenDecimals,
getTokenSymbol,
tokenDisplayLabel,
getStreamStatusLabel,
getStreamProgress,
formatTokenAmount,
Expand Down Expand Up @@ -32,13 +35,15 @@ export async function GET(
return jsonResponse({ error: "Stream not found" }, 404);
}

const [claimable, streamed, remaining, refundable, currentBlock] =
const [claimable, streamed, remaining, refundable, currentBlock, decimals, symbol] =
await Promise.all([
getClaimableBalance(id),
getStreamedAmount(id),
getRemainingBalance(id),
getRefundableAmount(id),
getCurrentBlockHeight(),
getTokenDecimals(stream.token),
getTokenSymbol(stream.token),
]);

const progress = getStreamProgress(
Expand All @@ -58,9 +63,20 @@ export async function GET(
refundable,
currentBlock,
progress: Math.round(progress * 100) / 100,
depositFormatted: formatTokenAmount(stream.depositAmount),
tokenDecimals: decimals,
// Always a string, and always this token's own label. A client that
// receives a null label cannot tell "unavailable" from "forgot", so the
// contract name is the honest fallback.
tokenLabel: tokenDisplayLabel(stream.token, symbol),
tokenSymbol: symbol,
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);
Expand Down
39 changes: 27 additions & 12 deletions frontend/src/app/dashboard/analytics/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import { useSenderStreams } from "@/hooks/use-streams";
import { useBlockHeight } from "@/hooks/use-block-height";
import { useWalletStore } from "@/stores/wallet-store";
import { formatTokenAmount, getStreamProgress, pickPrimaryToken } from "@/lib/utils";
import { STREAM_STATUS, BLOCKS_PER_DAY, getTokenConfigByContractId } from "@/lib/constants";
import { STREAM_STATUS, BLOCKS_PER_DAY, unresolvableTokenLabel } from "@/lib/constants";
import { useTokenMetadata, useTokensMetadata } from "@/hooks/use-token-metadata";
import { useAppStore } from "@/stores/app-store";
import { BarChart3, Zap, TrendingDown, Clock, Coins } from "lucide-react";

Expand All @@ -19,6 +20,14 @@ export default function AnalyticsPage() {
useBlockHeight();
const blockHeight = useAppStore((s) => s.currentBlockHeight);

const { primaryTokenId, primaryStreams, otherCount } = pickPrimaryToken(streams);
const { token: primaryToken } = useTokenMetadata(primaryTokenId ?? "");
// The per-stream table can span tokens, so resolve each distinct one.
const tokensById = useTokensMetadata(streams.map((s) => s.token));
const primaryDecimals = primaryToken?.decimals;
const primarySymbol =
primaryToken?.symbol ?? (primaryTokenId ? unresolvableTokenLabel(primaryTokenId) : "");

if (!isConnected) {
return (
<EmptyState
Expand All @@ -30,7 +39,6 @@ export default function AnalyticsPage() {
}

const active = streams.filter((s) => s.status === STREAM_STATUS.ACTIVE);
const { token: primaryToken, primaryStreams, otherCount } = pickPrimaryToken(streams);
const totalDeposited = primaryStreams.reduce((a, s) => a + s.depositAmount, 0n);
const totalWithdrawn = primaryStreams.reduce((a, s) => a + s.withdrawnAmount, 0n);
const totalRemaining = totalDeposited - totalWithdrawn;
Expand All @@ -41,7 +49,10 @@ export default function AnalyticsPage() {
const burnRatePerBlockRaw = primaryStreams
.filter((s) => s.status === STREAM_STATUS.ACTIVE)
.reduce((a, s) => a + Number(s.ratePerBlock) / 1e12, 0);
const burnRatePerDay = burnRatePerBlockRaw * BLOCKS_PER_DAY / Math.pow(10, primaryToken.decimals);
const burnRatePerDay =
primaryDecimals === undefined
? 0
: (burnRatePerBlockRaw * BLOCKS_PER_DAY) / Math.pow(10, primaryDecimals);

// Funds utilization
const utilization =
Expand All @@ -62,18 +73,22 @@ export default function AnalyticsPage() {
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label="Total Value Locked"
value={`${formatTokenAmount(totalRemaining, primaryToken.decimals)}`}
value={
primaryDecimals === undefined
? "—"
: formatTokenAmount(totalRemaining, primaryDecimals)
}
sub={
otherCount > 0
? `${primaryToken.symbol} (+${otherCount} in other tokens)`
: `${primaryToken.symbol} in streams`
? `${primarySymbol} (+${otherCount} in other tokens)`
: `${primarySymbol} in streams`
}
icon={<Coins className="h-4 w-4" />}
/>
<StatCard
label="Burn Rate"
value={`${burnRatePerDay.toFixed(4)}`}
sub={`${primaryToken.symbol} / day`}
value={primaryDecimals === undefined ? "—" : burnRatePerDay.toFixed(4)}
sub={`${primarySymbol} / day`}
icon={<TrendingDown className="h-4 w-4" />}
trend="down"
/>
Expand Down Expand Up @@ -131,7 +146,7 @@ export default function AnalyticsPage() {
blockHeight,
s.totalPausedDuration
);
const tokenConfig = getTokenConfigByContractId(s.token);
const rowToken = tokensById[s.token];
return (
<tr key={s.id} className="hover:bg-surface-2 transition-colors">
<td className="py-3 pr-4 font-mono text-xs text-zinc-400">
Expand All @@ -141,13 +156,13 @@ export default function AnalyticsPage() {
{s.recipient.slice(0, 8)}...
</td>
<td className="py-3 pr-4 text-xs text-zinc-400">
{tokenConfig.symbol}
{rowToken?.symbol ?? unresolvableTokenLabel(s.token)}
</td>
<td className="py-3 pr-4 text-zinc-200">
{formatTokenAmount(s.depositAmount, tokenConfig.decimals)}
{rowToken ? formatTokenAmount(s.depositAmount, rowToken.decimals) : "—"}
</td>
<td className="py-3 pr-4 text-zinc-200">
{formatTokenAmount(s.withdrawnAmount, tokenConfig.decimals)}
{rowToken ? formatTokenAmount(s.withdrawnAmount, rowToken.decimals) : "—"}
</td>
<td className="py-3 w-40">
<Progress value={progress} size="sm" />
Expand Down
44 changes: 26 additions & 18 deletions frontend/src/app/dashboard/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
DURATION_UNITS,
EXPLORER_BASE,
BLOCKS_PER_HOUR,
toRawAmount,
fromRawAmount,
type DurationUnit,
type TokenConfig,
} from "@/lib/constants";
Expand All @@ -36,10 +38,7 @@ export default function CreateStreamPage() {
const [selectedToken, setSelectedToken] = useState<TokenConfig>(DEFAULT_TOKEN);
const [errors, setErrors] = useState<Record<string, string>>({});

const { balance, isLoading: isBalanceLoading } = useTokenBalance(
selectedToken.contractId,
selectedToken.ftName,
);
const { balance, isLoading: isBalanceLoading } = useTokenBalance(selectedToken);

if (!isConnected) {
return (
Expand All @@ -53,20 +52,29 @@ export default function CreateStreamPage() {

const unitConfig = DURATION_UNITS.find((u) => u.value === durationUnit)!;
const durationBlocks = Math.max(1, Math.round(parseFloat(durationValue || "0") * unitConfig.blocksPerUnit));
// Use the selected token's decimals for raw unit conversion
const tokenMultiplier = Math.pow(10, selectedToken.decimals);
const amountRaw = Math.round(parseFloat(amount || "0") * tokenMultiplier);
const ratePerBlock = durationBlocks > 0 ? amountRaw / durationBlocks : 0;
// Exact string→bigint conversion. parseFloat(amount) * 10**decimals loses
// precision above 2^53 raw units and silently rounds, so the deposited
// amount can differ from what the user typed. Returns null on a malformed
// amount, which validate() reports rather than quietly sending 0.
const amountRaw = toRawAmount(amount || "0", selectedToken.decimals);
// ratePerBlock is display-only and intentionally stays in float, since the
// on-chain stream rate is derived integer division in the contract, not the
// raw total divided by blocks.
const ratePerBlock = durationBlocks > 0 && amountRaw ? Number(amountRaw) / durationBlocks : 0;
const displayPerBlock = BigInt(Math.max(1, Math.floor(ratePerBlock)));

function validate(): boolean {
const errs: Record<string, string> = {};
if (!recipient || !recipient.startsWith("S")) errs.recipient = "Enter a valid Stacks address";
if (recipient === address) errs.recipient = "Cannot stream to yourself";
if (!amount || parseFloat(amount) <= 0) errs.amount = "Enter a positive amount";
else if (amountRaw === null) {
errs.amount = "Enter a valid amount (digits and up to one decimal point)";
}
// Pre-flight balance check. The on-chain ft-transfer? inside create-stream
// returns (err u1) if the wallet is short — catch it here so users don't
// burn gas on a doomed tx.
else if (BigInt(amountRaw) > balance) {
else if (amountRaw > balance) {
errs.amount = `Insufficient ${selectedToken.symbol} balance. You have ${formatTokenAmount(balance, selectedToken.decimals)} ${selectedToken.symbol}.`;
}
if (!durationValue || parseFloat(durationValue) <= 0) errs.duration = "Enter a positive duration";
Expand All @@ -77,7 +85,7 @@ export default function CreateStreamPage() {

async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate() || !address) return;
if (!validate() || !address || amountRaw === null) return;

// Fetch the latest block height right before submitting to avoid stale data.
// Nakamoto Stacks blocks tick every ~5s, so a small buffer is not enough —
Expand All @@ -96,8 +104,8 @@ export default function CreateStreamPage() {
const txOptions = buildCreateStreamTx({
recipient,
tokenContract: selectedToken.contractId,
ftName: selectedToken.ftName,
depositAmount: BigInt(amountRaw),
token: selectedToken,
depositAmount: amountRaw,
startBlock: latestBlock + 120,
durationBlocks,
memo: memo || undefined,
Expand Down Expand Up @@ -169,13 +177,13 @@ export default function CreateStreamPage() {
<Input
label={`Total Amount (${selectedToken.symbol})`}
type="number"
step={`${1 / tokenMultiplier}`}
step="any"
min="0"
placeholder="1.0"
value={amount}
onChange={(e) => setAmount(e.target.value)}
error={errors.amount}
hint={amountRaw > 0 ? `${amountRaw.toLocaleString()} raw units (${selectedToken.decimals} decimals)` : undefined}
hint={amountRaw !== null && amountRaw > 0n ? `${amountRaw.toLocaleString()} raw units (${selectedToken.decimals} decimals)` : undefined}
disabled={isSubmitting}
/>
{address && (
Expand All @@ -189,7 +197,7 @@ export default function CreateStreamPage() {
{balance > 0n && !isSubmitting && (
<button
type="button"
onClick={() => setAmount((Number(balance) / tokenMultiplier).toString())}
onClick={() => setAmount(fromRawAmount(balance, selectedToken.decimals))}
className="text-brand-400 hover:text-brand-300 underline"
>
Use max
Expand Down Expand Up @@ -243,7 +251,7 @@ export default function CreateStreamPage() {
/>

{/* Preview */}
{amountRaw > 0 && durationBlocks > 0 && !isConfirming && status !== "success" && (
{amountRaw !== null && amountRaw > 0n && durationBlocks > 0 && !isConfirming && status !== "success" && (
<div className="rounded-xl border border-border bg-surface-0 p-4 space-y-2">
<p className="text-xs font-medium uppercase tracking-wider text-zinc-500 flex items-center gap-1.5">
<Info className="h-3 w-3" /> Stream Preview
Expand All @@ -253,9 +261,9 @@ export default function CreateStreamPage() {
<p className="text-zinc-500 text-xs">Rate per hour</p>
<p
className="text-zinc-200 font-mono"
title={`${ratePerBlock.toLocaleString()} raw units per block`}
title={`${displayPerBlock.toLocaleString()} raw units per block`}
>
{formatTokenAmount(ratePerBlock * BLOCKS_PER_HOUR, selectedToken.decimals)} {selectedToken.symbol}
{formatTokenAmount(displayPerBlock * BigInt(BLOCKS_PER_HOUR), selectedToken.decimals)} {selectedToken.symbol}
</p>
</div>
<div>
Expand Down
Loading
Loading