From 74dca153e05e2ae5f8406f89a72da43498a24016 Mon Sep 17 00:00:00 2001 From: jayteemoney Date: Wed, 7 Oct 2026 22:59:05 +0100 Subject: [PATCH] feat(frontend): link streams to organisations, and prepared setup links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Streams were never linked to the organisation that created them: the app never called stream-factory's track-stream, so every organisation's record (and its card on /organisations) showed 0 streams however much it streamed. - After a stream confirms, a sender with an active workspace is offered "Link to " (one extra wallet approval). Manage Streams shows the same button on every stream that is not yet linked, read via is-stream-tracked. - New setup links: /dashboard/create?token=…&recipient=…&amount=… &duration=…&unit=…&memo=… opens the form filled in, behind a banner asking the signer to check it. Invalid values are dropped rather than corrected, and a recipient on the other network is refused. "Copy setup link" on the form builds one from the current values, so a stream can be prepared with an organisation and signed by them in one step. - StreamCard gains a footer slot for the link row. --- frontend/src/app/dashboard/create/page.tsx | 107 ++++++++++++++++-- frontend/src/app/dashboard/streams/page.tsx | 22 ++++ .../components/stream/link-stream-button.tsx | 44 +++++++ .../src/components/stream/stream-card.tsx | 5 + frontend/src/hooks/use-workspace.ts | 46 ++++++++ frontend/src/lib/stacks.ts | 33 ++++++ frontend/src/lib/stream-setup-link.ts | 100 ++++++++++++++++ tests/stream-setup-link.test.ts | 88 ++++++++++++++ 8 files changed, 437 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/stream/link-stream-button.tsx create mode 100644 frontend/src/hooks/use-workspace.ts create mode 100644 frontend/src/lib/stream-setup-link.ts create mode 100644 tests/stream-setup-link.test.ts diff --git a/frontend/src/app/dashboard/create/page.tsx b/frontend/src/app/dashboard/create/page.tsx index 6c15145..c3d749b 100644 --- a/frontend/src/app/dashboard/create/page.tsx +++ b/frontend/src/app/dashboard/create/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Card, CardTitle, CardDescription } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; @@ -9,10 +9,20 @@ import { useWalletStore } from "@/stores/wallet-store"; import { useBlockHeight } from "@/hooks/use-block-height"; import { useStacksTx } from "@/hooks/use-stacks-tx"; import { useTokenBalance } from "@/hooks/use-token-balance"; -import { buildCreateStreamTx } from "@/lib/stacks"; +import { buildCreateStreamTx, getSenderStreams } from "@/lib/stacks"; +import { + PREFILL_KEYS, + buildStreamSetupLink, + hasPrefill, + parseStreamPrefill, + type StreamPrefill, +} from "@/lib/stream-setup-link"; +import { useWorkspace } from "@/hooks/use-workspace"; +import { LinkStreamButton } from "@/components/stream/link-stream-button"; import { DEFAULT_TOKEN, DURATION_UNITS, + NETWORK, EXPLORER_BASE, BLOCKS_PER_HOUR, toRawAmount, @@ -26,18 +36,43 @@ import { type TokenSelection, } from "@/components/stream/token-selector"; import { toast } from "sonner"; -import { Zap, ArrowRight, Info, Loader2, CheckCircle2 } from "lucide-react"; +import { Zap, ArrowRight, Info, Loader2, CheckCircle2, Copy } from "lucide-react"; export default function CreateStreamPage() { const { address, isConnected } = useWalletStore(); const { blockHeight } = useBlockHeight(); const { execute, isPending, isConfirming, txId, status, error, reset } = useStacksTx(); - const [recipient, setRecipient] = useState(""); - const [amount, setAmount] = useState(""); - const [durationValue, setDurationValue] = useState("30"); - const [durationUnit, setDurationUnit] = useState("days"); - const [memo, setMemo] = useState(""); + // A prepared setup link fills the form once, on first render. The wallet + // owner still reviews every field and signs; see lib/stream-setup-link.ts. + const [prefill] = useState(() => + typeof window === "undefined" ? {} : parseStreamPrefill(window.location.search, NETWORK) + ); + const fromLink = hasPrefill(prefill); + const [recipient, setRecipient] = useState(prefill.recipient ?? ""); + const [amount, setAmount] = useState(prefill.amount ?? ""); + const [durationValue, setDurationValue] = useState(prefill.durationValue ?? "30"); + const [durationUnit, setDurationUnit] = useState(prefill.durationUnit ?? "days"); + const [memo, setMemo] = useState(prefill.memo ?? ""); + const [createdStreamId, setCreatedStreamId] = useState(null); + const { workspace } = useWorkspace(); + + // Strip the setup values from the address bar once the form is showing, so + // a refresh does not refill it over edits. Waiting for a wallet means a + // refresh before connecting still keeps the link. `token` is left for the + // token selector, which applies and strips it itself. + useEffect(() => { + if (!isConnected) return; + const params = new URLSearchParams(window.location.search); + if (!PREFILL_KEYS.some((key) => params.has(key))) return; + PREFILL_KEYS.forEach((key) => params.delete(key)); + const rest = params.toString(); + window.history.replaceState( + null, + "", + rest ? `${window.location.pathname}?${rest}` : window.location.pathname + ); + }, [isConnected]); // The selection carries chain-verified metadata (assetName + decimals read // from the contract), which is what the transaction builder needs. Starting // on the curated default means the form is usable immediately; a `?token=` @@ -140,6 +175,14 @@ export default function CreateStreamPage() { if (result?.confirmed) { toast.success("Stream created and confirmed on-chain!"); + // The wallet result carries no stream id. Stream ids only increase, so + // the sender's highest id is the stream this transaction just created. + try { + const ids = await getSenderStreams(address); + setCreatedStreamId(ids.length > 0 ? Math.max(...ids) : null); + } catch { + setCreatedStreamId(null); + } setRecipient(""); setAmount(""); setDurationValue("30"); @@ -152,6 +195,23 @@ export default function CreateStreamPage() { const isSubmitting = isPending || isConfirming; + async function copySetupLink() { + const link = buildStreamSetupLink(window.location.origin, { + token: selectedToken.resolved.contractId, + recipient, + amount, + durationValue, + durationUnit, + memo, + }); + try { + await navigator.clipboard.writeText(link); + toast.success("Setup link copied. Send it to the person who will sign."); + } catch { + toast.error("Could not copy. Your browser blocked clipboard access."); + } + } + return (
@@ -162,6 +222,13 @@ export default function CreateStreamPage() {
+ {fromLink && status !== "success" && ( +
+ This form was filled in from a setup link. Check the recipient, the token and the + amount before you sign. +
+ )} + Manage Streams page.

+ {workspace?.isActive && createdStreamId !== null && ( +
+

+ Link stream #{createdStreamId} to {workspace.name} so it counts on your + organisation's public record. +

+ setCreatedStreamId(null)} + /> +
+ )} )} + {status !== "success" && ( + + )}
diff --git a/frontend/src/app/dashboard/streams/page.tsx b/frontend/src/app/dashboard/streams/page.tsx index 115e0d5..ba5f51a 100644 --- a/frontend/src/app/dashboard/streams/page.tsx +++ b/frontend/src/app/dashboard/streams/page.tsx @@ -19,6 +19,8 @@ import { import { formatTxError } from "@/lib/utils"; import type { StreamData } from "@/lib/stacks"; import { TopUpDialog } from "@/components/stream/top-up-dialog"; +import { LinkStreamButton } from "@/components/stream/link-stream-button"; +import { useWorkspace, useTrackedStreams } from "@/hooks/use-workspace"; import { STREAM_STATUS } from "@/lib/constants"; import { toast } from "sonner"; import Link from "next/link"; @@ -43,6 +45,12 @@ export default function ManageStreamsPage() { const { execute, isPending, isConfirming } = useStacksTx(); const [filter, setFilter] = useState("all"); const [topUpTarget, setTopUpTarget] = useState<{ id: number; stream: StreamData } | null>(null); + const { workspace } = useWorkspace(); + const canLink = workspace?.isActive === true; + const { tracked, refresh: refreshTracked } = useTrackedStreams( + streams.map((s) => s.id), + canLink + ); const filteredStreams = filter === "all" @@ -127,6 +135,20 @@ export default function ManageStreamsPage() { stream={stream} perspective="sender" actionLoading={isPending || isConfirming} + footer={ + canLink && tracked && !tracked.has(stream.id) ? ( +
+

+ Not yet counted on {workspace!.name}'s public record. +

+ +
+ ) : undefined + } onPause={async () => { const result = await execute(buildPauseStreamTx(stream.id)); if (result?.confirmed) { diff --git a/frontend/src/components/stream/link-stream-button.tsx b/frontend/src/components/stream/link-stream-button.tsx new file mode 100644 index 0000000..4e8f3b2 --- /dev/null +++ b/frontend/src/components/stream/link-stream-button.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { Link2 } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { useStacksTx } from "@/hooks/use-stacks-tx"; +import { buildTrackStreamTx } from "@/lib/stacks"; +import { formatTxError } from "@/lib/utils"; + +/** + * Links one stream to the sender's organisation, so it counts on the + * organisation's public record. Render it only for an unlinked stream whose + * sender has an active workspace; the contract refuses anything else. + */ +export function LinkStreamButton({ + streamId, + workspaceName, + onLinked, + size = "sm", +}: { + streamId: number; + workspaceName: string; + onLinked: () => void; + size?: "sm" | "md"; +}) { + const { execute, isWorking } = useStacksTx(); + + async function link() { + const result = await execute(buildTrackStreamTx(streamId)); + if (result?.confirmed) { + toast.success(`Stream #${streamId} now counts for ${workspaceName}`); + onLinked(); + } else if (result && !result.confirmed) { + toast.error(formatTxError("Could not link the stream", result)); + } + } + + return ( + + ); +} diff --git a/frontend/src/components/stream/stream-card.tsx b/frontend/src/components/stream/stream-card.tsx index a913630..e61e27d 100644 --- a/frontend/src/components/stream/stream-card.tsx +++ b/frontend/src/components/stream/stream-card.tsx @@ -1,5 +1,6 @@ "use client"; +import type { ReactNode } from "react"; import { Card } from "@/components/ui/card"; import { Badge, streamStatusToBadge } from "@/components/ui/badge"; import { Progress } from "@/components/ui/progress"; @@ -33,6 +34,8 @@ interface StreamCardProps { onClaimPartial?: () => void; onExpire?: () => void; actionLoading?: boolean; + /** Extra row under the actions, e.g. linking the stream to an organisation. */ + footer?: ReactNode; } export function StreamCard({ @@ -49,6 +52,7 @@ export function StreamCard({ onClaimPartial, onExpire, actionLoading, + footer, }: StreamCardProps) { const blockHeight = useAppStore((s) => s.currentBlockHeight); // Resolve the stream's own token. getTokenConfigByContractId previously @@ -245,6 +249,7 @@ export function StreamCard({ )} + {footer &&
{footer}
} ); } diff --git a/frontend/src/hooks/use-workspace.ts b/frontend/src/hooks/use-workspace.ts new file mode 100644 index 0000000..3143eef --- /dev/null +++ b/frontend/src/hooks/use-workspace.ts @@ -0,0 +1,46 @@ +"use client"; + +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; +import { getDao, isStreamTracked, type DaoData } from "@/lib/stacks"; +import { useWalletStore } from "@/stores/wallet-store"; + +/** The connected wallet's registered organisation, or null if it has none. */ +export function useWorkspace(): { workspace: DaoData | null; isLoading: boolean } { + const address = useWalletStore((s) => s.address); + const query = useQuery({ + queryKey: ["workspace", address], + queryFn: () => getDao(address!), + enabled: !!address, + staleTime: 60_000, + }); + return { workspace: query.data ?? null, isLoading: query.isLoading }; +} + +/** + * Which of `streamIds` are linked to the connected wallet's organisation. + * Only queried when the wallet has an active workspace, since nothing else can + * be linked. + */ +export function useTrackedStreams(streamIds: readonly number[], enabled: boolean) { + const address = useWalletStore((s) => s.address); + const queryClient = useQueryClient(); + const key = ["tracked-streams", address, [...streamIds].sort((a, b) => a - b).join(",")]; + + const query = useQuery({ + queryKey: key, + queryFn: async () => { + const flags = await Promise.all(streamIds.map((id) => isStreamTracked(address!, id))); + return new Set(streamIds.filter((_, i) => flags[i])); + }, + enabled: enabled && !!address && streamIds.length > 0, + staleTime: 60_000, + }); + + const refresh = useCallback(() => { + queryClient.invalidateQueries({ queryKey: ["tracked-streams", address] }); + queryClient.invalidateQueries({ queryKey: ["workspace", address] }); + }, [queryClient, address]); + + return { tracked: query.data ?? null, isLoading: query.isLoading, refresh }; +} diff --git a/frontend/src/lib/stacks.ts b/frontend/src/lib/stacks.ts index 495cfb2..b0f6800 100644 --- a/frontend/src/lib/stacks.ts +++ b/frontend/src/lib/stacks.ts @@ -275,6 +275,20 @@ export async function isRegisteredDao(admin: string): Promise { return result.value === true; } +/** + * Whether `streamId` is linked to the organisation registered by `admin`. + * Linking is what makes a stream count on the organisation's public record + * (its stream total on /organisations), so the UI offers it for unlinked ones. + */ +export async function isStreamTracked(admin: string, streamId: number): Promise { + const result = await callReadOnly( + STREAM_FACTORY_CONTRACT, + "is-stream-tracked", + [principalCV(admin), uintCV(streamId)] + ); + return result.value === true; +} + // ============================================================================ // Transaction builders (return openContractCall options) // ============================================================================ @@ -526,6 +540,25 @@ export function buildExpireStreamTx(params: { }; } +/** + * Link a stream to the sender's organisation (stream-factory `track-stream`). + * Moves no tokens, so deny mode with no post-conditions is exact. The contract + * refuses if the caller is not the stream's sender, has no active workspace, + * or already linked it. + */ +export function buildTrackStreamTx(streamId: number) { + const [factoryAddr, factoryName] = splitContract(STREAM_FACTORY_CONTRACT); + return { + contractAddress: factoryAddr, + contractName: factoryName, + functionName: "track-stream", + functionArgs: [uintCV(streamId)], + postConditionMode: PostConditionMode.Deny, + postConditions: [], + network: getNetwork(), + }; +} + export function buildRegisterDaoTx(name: string) { const [factAddr, factName] = splitContract(STREAM_FACTORY_CONTRACT); return { diff --git a/frontend/src/lib/stream-setup-link.ts b/frontend/src/lib/stream-setup-link.ts new file mode 100644 index 0000000..fa476dc --- /dev/null +++ b/frontend/src/lib/stream-setup-link.ts @@ -0,0 +1,100 @@ +/** + * Prepared stream setup links. + * + * A setup link opens the create-stream form already filled in, so an + * organisation can check a stream someone prepared with them and sign it in + * one step: + * + * /dashboard/create?token=SP….usda-token&recipient=SP…&amount=500 + * &duration=28&unit=days&memo=Milestone%201 + * + * A link only fills the form. Nothing is signed until the wallet owner reviews + * it, and the token still goes through the on-chain verification in the token + * selector. Values that fail validation are dropped rather than corrected, so + * a bad link leaves a field empty instead of putting a wrong value in front of + * someone about to sign. + */ + +import { DURATION_UNITS, type DurationUnit } from "./constants"; +import type { StacksNetworkName } from "./token-metadata"; + +export interface StreamPrefill { + recipient?: string; + amount?: string; + durationValue?: string; + durationUnit?: DurationUnit; + memo?: string; +} + +/** The query keys a setup link may carry, besides `token`. */ +export const PREFILL_KEYS = ["recipient", "amount", "duration", "unit", "memo"] as const; + +/** Matches the contract's `string-utf8 256` memo. */ +export const MAX_MEMO_LENGTH = 256; + +// A standard principal, or a contract principal (a stream can pay a contract). +const PRINCIPAL_RE = /^S[PMTN][0-9A-Z]{38,40}(\.[a-zA-Z][a-zA-Z0-9-]{0,39})?$/; +const POSITIVE_DECIMAL_RE = /^\d{1,30}(\.\d{1,38})?$/; +const CONTROL_CHARS_RE = /[\u0000-\u001f\u007f]/; + +function isOnNetwork(principal: string, network: StacksNetworkName): boolean { + const mainnet = principal[1] === "P" || principal[1] === "M"; + return network === "mainnet" ? mainnet : !mainnet; +} + +function positive(value: string | null): string | undefined { + if (!value || !POSITIVE_DECIMAL_RE.test(value)) return undefined; + return Number(value) > 0 ? value : undefined; +} + +/** Read the setup values from a query string, keeping only the valid ones. */ +export function parseStreamPrefill(search: string, network: StacksNetworkName): StreamPrefill { + const params = new URLSearchParams(search); + const prefill: StreamPrefill = {}; + + const recipient = params.get("recipient")?.trim(); + if (recipient && PRINCIPAL_RE.test(recipient) && isOnNetwork(recipient, network)) { + prefill.recipient = recipient; + } + + const amount = positive(params.get("amount")?.trim() ?? null); + if (amount) prefill.amount = amount; + + const duration = positive(params.get("duration")?.trim() ?? null); + const unit = params.get("unit")?.trim(); + const knownUnit = DURATION_UNITS.find((u) => u.value === unit)?.value; + // A duration only makes sense with its unit, so they are taken as a pair. + if (duration && knownUnit) { + prefill.durationValue = duration; + prefill.durationUnit = knownUnit; + } + + const memo = params.get("memo")?.trim(); + if (memo && memo.length <= MAX_MEMO_LENGTH && !CONTROL_CHARS_RE.test(memo)) { + prefill.memo = memo; + } + + return prefill; +} + +export function hasPrefill(prefill: StreamPrefill): boolean { + return Object.values(prefill).some((v) => v !== undefined); +} + +/** Build a setup link for the current form, to send to the person who will sign it. */ +export function buildStreamSetupLink( + origin: string, + values: StreamPrefill & { token?: string } +): string { + const params = new URLSearchParams(); + if (values.token) params.set("token", values.token); + if (values.recipient) params.set("recipient", values.recipient.trim()); + if (values.amount) params.set("amount", values.amount.trim()); + if (values.durationValue && values.durationUnit) { + params.set("duration", values.durationValue.trim()); + params.set("unit", values.durationUnit); + } + if (values.memo?.trim()) params.set("memo", values.memo.trim()); + const query = params.toString(); + return `${origin}/dashboard/create${query ? `?${query}` : ""}`; +} diff --git a/tests/stream-setup-link.test.ts b/tests/stream-setup-link.test.ts new file mode 100644 index 0000000..df625ac --- /dev/null +++ b/tests/stream-setup-link.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from "vitest"; + +import { + parseStreamPrefill, + buildStreamSetupLink, + hasPrefill, + MAX_MEMO_LENGTH, +} from "../frontend/src/lib/stream-setup-link"; + +const BUILDER = "SP1A5M0ZRSNQMF8BNPQPM8WWC5PJ6HJ4GEKFP8W4M"; +const TESTNET_BUILDER = "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM"; +const USDA = "SP2C2YFP12AJZB4MABJBAJ55XECVS7E4PMMZ89YZR.usda-token"; + +describe("parseStreamPrefill", () => { + it("reads a complete setup link", () => { + const search = `?token=${USDA}&recipient=${BUILDER}&amount=1250.5&duration=28&unit=days&memo=Milestone%201%20of%203`; + expect(parseStreamPrefill(search, "mainnet")).toEqual({ + recipient: BUILDER, + amount: "1250.5", + durationValue: "28", + durationUnit: "days", + memo: "Milestone 1 of 3", + }); + }); + + it("drops a recipient from the other network", () => { + expect(parseStreamPrefill(`?recipient=${TESTNET_BUILDER}`, "mainnet").recipient).toBeUndefined(); + expect(parseStreamPrefill(`?recipient=${TESTNET_BUILDER}`, "testnet").recipient).toBe(TESTNET_BUILDER); + }); + + it("drops malformed values instead of correcting them", () => { + const prefill = parseStreamPrefill( + "?recipient=not-an-address&amount=-5&duration=abc&unit=days", + "mainnet" + ); + expect(prefill).toEqual({}); + expect(hasPrefill(prefill)).toBe(false); + }); + + it("rejects a zero amount and amounts in scientific notation", () => { + expect(parseStreamPrefill("?amount=0", "mainnet").amount).toBeUndefined(); + expect(parseStreamPrefill("?amount=1e9", "mainnet").amount).toBeUndefined(); + }); + + it("only takes a duration together with a known unit", () => { + expect(parseStreamPrefill("?duration=4", "mainnet")).toEqual({}); + expect(parseStreamPrefill("?duration=4&unit=weeks", "mainnet")).toEqual({}); + expect(parseStreamPrefill("?duration=2&unit=months", "mainnet")).toEqual({ + durationValue: "2", + durationUnit: "months", + }); + }); + + it("refuses an over-long memo and control characters", () => { + const long = "a".repeat(MAX_MEMO_LENGTH + 1); + expect(parseStreamPrefill(`?memo=${long}`, "mainnet").memo).toBeUndefined(); + expect(parseStreamPrefill("?memo=line%0Abreak", "mainnet").memo).toBeUndefined(); + }); + + it("accepts a contract principal as recipient", () => { + const gate = `${BUILDER}.milestone-gate`; + expect(parseStreamPrefill(`?recipient=${gate}`, "mainnet").recipient).toBe(gate); + }); +}); + +describe("buildStreamSetupLink", () => { + it("round-trips through the parser", () => { + const values = { + token: USDA, + recipient: BUILDER, + amount: "500", + durationValue: "28", + durationUnit: "days" as const, + memo: "Milestone 1 of 3: design", + }; + const link = buildStreamSetupLink("https://stackstream.xyz", values); + expect(link.startsWith("https://stackstream.xyz/dashboard/create?token=")).toBe(true); + const { token, ...rest } = values; + expect(new URL(link).searchParams.get("token")).toBe(token); + expect(parseStreamPrefill(new URL(link).search, "mainnet")).toEqual(rest); + }); + + it("leaves out empty fields", () => { + expect(buildStreamSetupLink("https://stackstream.xyz", { recipient: BUILDER })).toBe( + `https://stackstream.xyz/dashboard/create?recipient=${BUILDER}` + ); + }); +});