From 6fe1a581343eb047606b90cbf573d5883622cd9c Mon Sep 17 00:00:00 2001 From: ECWireless Date: Tue, 7 Jul 2026 17:01:55 +0000 Subject: [PATCH 1/2] Add Hat Steward claim guidance --- src/app/hat-stewards/page.tsx | 196 ++++++++++++++++++++++++++++++-- src/lib/hat-stewards/streams.ts | 131 ++++++++++++++++++++- 2 files changed, 310 insertions(+), 17 deletions(-) diff --git a/src/app/hat-stewards/page.tsx b/src/app/hat-stewards/page.tsx index d1037a6..29ce080 100644 --- a/src/app/hat-stewards/page.tsx +++ b/src/app/hat-stewards/page.tsx @@ -1,6 +1,7 @@ import { BadgeDollarSign, ExternalLink, + ListChecks, ShieldCheck, Wallet, type LucideIcon, @@ -25,7 +26,9 @@ import { import { listIncomingStreamsByReceiver, getStreamRunwayForStreams, + getUnwrappableBalancesForStreams, type HatStewardStream, + type HatStewardUnwrappableBalance, } from "@/lib/hat-stewards/streams"; type SearchParams = Promise<{ @@ -42,6 +45,12 @@ function formatAddress(address: string) { return `${address.slice(0, 6)}...${address.slice(-4)}`; } +function getSafeSuperfluidAppUrl(safeAddress: string) { + const appUrl = encodeURIComponent("https://app.superfluid.org"); + + return `https://app.safe.global/apps/open?safe=gno:${safeAddress}&appUrl=${appUrl}`; +} + function formatTokenAmount(value: number) { return new Intl.NumberFormat("en-US", { maximumFractionDigits: value >= 100 ? 0 : 2, @@ -49,6 +58,15 @@ function formatTokenAmount(value: number) { }).format(value); } +function formatUsdAmount(value: number) { + return new Intl.NumberFormat("en-US", { + currency: "USD", + maximumFractionDigits: value >= 100 ? 0 : 2, + minimumFractionDigits: 0, + style: "currency", + }).format(value); +} + function formatRunwayDate(value: string | null) { if (!value) { return "No projected runout"; @@ -94,6 +112,48 @@ function getProjectedQuarterSummary(streams: HatStewardStream[]) { }; } +function getUnwrappableBalanceSummary({ + balances, + streams, +}: { + balances: HatStewardUnwrappableBalance[]; + streams: HatStewardStream[]; +}) { + if (balances.length === 0) { + const streamTokenSymbols = Array.from( + new Set(streams.map((stream) => stream.tokenSymbol)), + ); + + if (streamTokenSymbols.length === 1) { + return { + detail: "No accrued balance yet", + value: `0 ${streamTokenSymbols[0]}`, + }; + } + + return { detail: undefined, value: "No balance" }; + } + + if (balances.length === 1) { + const [balance] = balances; + + return { + detail: undefined, + value: `${formatTokenAmount(balance.amount)} ${balance.tokenSymbol}`, + }; + } + + return { + detail: balances + .map( + (balance) => + `${formatTokenAmount(balance.amount)} ${balance.tokenSymbol}`, + ) + .join(", "), + value: "Mixed tokens", + }; +} + function parseHatStewardModal(value: string | string[] | undefined) { const modal = getQueryValue(value); @@ -145,14 +205,94 @@ function MetricCard({ ); } +function ClaimingStreamGuide() { + const steps = [ + { + detail: + "Open the linked role Safe below and connect with the signer wallet for that Safe.", + title: "Connect to the Safe", + }, + { + detail: + "From Safe Apps, open Superfluid so the unwrap transaction is proposed from the Safe, not a personal wallet.", + title: "Open Superfluid inside Safe", + }, + { + detail: + "Claim the available streamed balance by unwrapping the Super Token into its underlying token.", + title: "Unwrap the stream", + }, + { + detail: + "Once the unwrap is executed, send the underlying token from the Safe to your payout address.", + title: "Send the payout", + }, + ]; + + return ( +
+
+
+

Claiming

+

+ How stewards claim streams +

+

+ Streams accrue to the role Safe as Superfluid Super Tokens. To claim + your stream, open the Safe, unwrap the available balance, and pay + yourself from the correct role account. +

+
+ + +
+ +
    + {steps.map((step, index) => ( +
  1. +
    + + {index + 1} + +

    {step.title}

    +
    +

    {step.detail}

    +
  2. + ))} +
+ +
+
+
+ ); +} + function RoleStreamCard({ + unwrappableBalances, roleSafe, streams, }: { + unwrappableBalances: HatStewardUnwrappableBalance[]; roleSafe: HatStewardRoleSafe; streams: HatStewardStream[]; }) { - const projectedQuarter = getProjectedQuarterSummary(streams); + const unwrappableBalance = getUnwrappableBalanceSummary({ + balances: unwrappableBalances, + streams, + }); return (
@@ -163,14 +303,32 @@ function RoleStreamCard({

{roleSafe.hatLabel}

-
-

Per quarter

-

{projectedQuarter.value}

- {projectedQuarter.detail ? ( -

- {projectedQuarter.detail} +

+ +
+

+ Claimable now +

+
+

+ {unwrappableBalance.value}

- ) : null} + {unwrappableBalance.detail ? ( +

+ {unwrappableBalance.detail} +

+ ) : null} +
@@ -212,11 +370,10 @@ function RoleStreamCard({

- {formatTokenAmount(stream.projectedQuarterAmount)}{" "} - {stream.tokenSymbol} + {formatUsdAmount(stream.projectedQuarterAmount)} / quarter

- {formatTokenAmount(stream.flowRatePerDay)} / day + {formatUsdAmount(stream.flowRatePerDay)} / day

@@ -258,7 +415,10 @@ export default async function HatStewardsPage({ const allActiveStreams = Array.from( streamsResult.streamsByReceiver.values(), ).flat(); - const runwayResult = await getStreamRunwayForStreams(allActiveStreams); + const [runwayResult, unwrappableBalancesResult] = await Promise.all([ + getStreamRunwayForStreams(allActiveStreams), + getUnwrappableBalancesForStreams(allActiveStreams), + ]); const projectedQuarter = getProjectedQuarterSummary(allActiveStreams); return ( @@ -314,6 +474,8 @@ export default async function HatStewardsPage({ /> + + {streamsResult.status === "unavailable" ? (
{streamsResult.message} @@ -324,6 +486,11 @@ export default async function HatStewardsPage({ {runwayResult.message}
) : null} + {unwrappableBalancesResult.status === "unavailable" ? ( +
+ {unwrappableBalancesResult.message} +
+ ) : null}
@@ -342,6 +509,11 @@ export default async function HatStewardsPage({ roleSafe.safeAddress.toLowerCase(), ) ?? [] } + unwrappableBalances={ + unwrappableBalancesResult.balancesByReceiver.get( + roleSafe.safeAddress.toLowerCase(), + ) ?? [] + } /> ))}
diff --git a/src/lib/hat-stewards/streams.ts b/src/lib/hat-stewards/streams.ts index 5da312a..8451376 100644 --- a/src/lib/hat-stewards/streams.ts +++ b/src/lib/hat-stewards/streams.ts @@ -45,6 +45,9 @@ type SuperfluidSnapshotResponse = { type SuperfluidStreamRow = NonNullable< SuperfluidStreamResponse["data"] >["streams"][number]; +type SuperfluidSnapshotRow = NonNullable< + SuperfluidSnapshotResponse["data"] +>["accountTokenSnapshots"][number]; export type HatStewardStream = { currentFlowRate: string; @@ -80,6 +83,24 @@ export type HatStewardStreamRunwayResult = | { runway: HatStewardStreamRunway | null; status: "ready" } | { message: string; runway: null; status: "unavailable" }; +export type HatStewardUnwrappableBalance = { + amount: number; + tokenAddress: Address; + tokenDecimals: number; + tokenSymbol: string; +}; + +export type HatStewardUnwrappableBalancesResult = + | { + balancesByReceiver: Map; + status: "ready"; + } + | { + balancesByReceiver: Map; + message: string; + status: "unavailable"; + }; + function getSuperfluidEndpoint() { return process.env.SUPERFLUID_SUBGRAPH_URL ?? DEFAULT_SUPERFLUID_ENDPOINT; } @@ -113,6 +134,19 @@ function mapStream( }; } +function getCurrentSnapshotBalanceRaw( + snapshot: SuperfluidSnapshotRow, + nowSeconds: bigint, +) { + const updatedAt = BigInt(snapshot.updatedAtTimestamp); + const elapsed = nowSeconds > updatedAt ? nowSeconds - updatedAt : BigInt(0); + const currentBalance = + BigInt(snapshot.balanceUntilUpdatedAt) + + BigInt(snapshot.totalNetFlowRate) * elapsed; + + return currentBalance > BigInt(0) ? currentBalance : BigInt(0); +} + export async function listIncomingStreamsByReceiver( receivers: Address[], ): Promise { @@ -264,13 +298,10 @@ export async function getStreamRunwayForStreams( let depletionRateRaw = BigInt(0); for (const snapshot of snapshots) { - const updatedAt = BigInt(snapshot.updatedAtTimestamp); - const elapsed = - nowSeconds > updatedAt ? nowSeconds - updatedAt : BigInt(0); const netFlowRate = BigInt(snapshot.totalNetFlowRate); - const currentBalance = BigInt(snapshot.balanceUntilUpdatedAt) + netFlowRate * elapsed; + const currentBalance = getCurrentSnapshotBalanceRaw(snapshot, nowSeconds); - balanceRaw += currentBalance > BigInt(0) ? currentBalance : BigInt(0); + balanceRaw += currentBalance; if (netFlowRate < BigInt(0)) { depletionRateRaw += -netFlowRate; @@ -298,3 +329,93 @@ export async function getStreamRunwayForStreams( status: "ready", }; } + +export async function getUnwrappableBalancesForStreams( + streams: HatStewardStream[], +): Promise { + const balancesByReceiver = new Map(); + const snapshotIds = Array.from( + new Set( + streams.map((stream) => { + const receiver = stream.receiver.toLowerCase(); + + balancesByReceiver.set(receiver, []); + return `${receiver}-${stream.tokenAddress.toLowerCase()}`; + }), + ), + ); + + if (snapshotIds.length === 0) { + return { balancesByReceiver, status: "ready" }; + } + + const response = await fetch(getSuperfluidEndpoint(), { + body: JSON.stringify({ + query: ` + query HatStewardUnwrappableBalances($ids: [String!]!) { + accountTokenSnapshots(first: 100, where: { id_in: $ids }) { + id + balanceUntilUpdatedAt + totalNetFlowRate + updatedAtTimestamp + token { + id + symbol + decimals + } + } + } + `, + variables: { ids: snapshotIds }, + }), + headers: { "content-type": "application/json" }, + next: { revalidate: 300 }, + method: "POST", + }); + + if (!response.ok) { + return { + balancesByReceiver, + message: `Superfluid balance request failed: ${response.status}`, + status: "unavailable", + }; + } + + const body = (await response.json()) as SuperfluidSnapshotResponse; + + if (body.errors?.length) { + return { + balancesByReceiver, + message: body.errors.map((error) => error.message).join("; "), + status: "unavailable", + }; + } + + const nowSeconds = BigInt(Math.floor(Date.now() / 1000)); + + for (const snapshot of body.data?.accountTokenSnapshots ?? []) { + const [receiver] = snapshot.id.split("-"); + + if (!receiver) { + continue; + } + + const amountRaw = getCurrentSnapshotBalanceRaw(snapshot, nowSeconds); + + if (amountRaw === BigInt(0)) { + continue; + } + + const receiverBalances = balancesByReceiver.get(receiver) ?? []; + + receiverBalances.push({ + amount: getTokenAmount(amountRaw, snapshot.token.decimals), + tokenAddress: getAddress(snapshot.token.id), + tokenDecimals: snapshot.token.decimals, + tokenSymbol: snapshot.token.symbol, + }); + balancesByReceiver.set(receiver, receiverBalances); + } + + return { balancesByReceiver, status: "ready" }; +} From 208fd337287a6d050ecdac436b8dd222a7599117 Mon Sep 17 00:00:00 2001 From: ECWireless Date: Tue, 7 Jul 2026 18:51:14 +0000 Subject: [PATCH 2/2] Address Hat Steward claim review --- src/app/hat-stewards/page.tsx | 48 +++++++++------ src/lib/hat-stewards/streams.ts | 102 ++++++++++++++++++++++++++++++-- 2 files changed, 128 insertions(+), 22 deletions(-) diff --git a/src/app/hat-stewards/page.tsx b/src/app/hat-stewards/page.tsx index 29ce080..c3d4fa1 100644 --- a/src/app/hat-stewards/page.tsx +++ b/src/app/hat-stewards/page.tsx @@ -25,6 +25,7 @@ import { } from "@/lib/hat-stewards/role-safes"; import { listIncomingStreamsByReceiver, + listStreamTokenPairsByReceiver, getStreamRunwayForStreams, getUnwrappableBalancesForStreams, type HatStewardStream, @@ -45,10 +46,13 @@ function formatAddress(address: string) { return `${address.slice(0, 6)}...${address.slice(-4)}`; } -function getSafeSuperfluidAppUrl(safeAddress: string) { +function getSafeSuperfluidAppUrl(safeUrl: string) { + const safe = new URL(safeUrl).searchParams.get("safe"); const appUrl = encodeURIComponent("https://app.superfluid.org"); - return `https://app.safe.global/apps/open?safe=gno:${safeAddress}&appUrl=${appUrl}`; + return safe + ? `https://app.safe.global/apps/open?safe=${safe}&appUrl=${appUrl}` + : safeUrl; } function formatTokenAmount(value: number) { @@ -58,15 +62,6 @@ function formatTokenAmount(value: number) { }).format(value); } -function formatUsdAmount(value: number) { - return new Intl.NumberFormat("en-US", { - currency: "USD", - maximumFractionDigits: value >= 100 ? 0 : 2, - minimumFractionDigits: 0, - style: "currency", - }).format(value); -} - function formatRunwayDate(value: string | null) { if (!value) { return "No projected runout"; @@ -305,7 +300,7 @@ function RoleStreamCard({ @@ -409,15 +406,27 @@ export default async function HatStewardsPage({ listHatStewardRoleSafes(), ]); const activeRoleSafes = roleSafes.filter((roleSafe) => !roleSafe.archivedAt); - const streamsResult = await listIncomingStreamsByReceiver( - activeRoleSafes.map((roleSafe) => roleSafe.safeAddress), + const roleSafeAddresses = activeRoleSafes.map( + (roleSafe) => roleSafe.safeAddress, ); + const [streamsResult, streamTokenPairsResult] = await Promise.all([ + listIncomingStreamsByReceiver(roleSafeAddresses), + listStreamTokenPairsByReceiver(roleSafeAddresses), + ]); const allActiveStreams = Array.from( streamsResult.streamsByReceiver.values(), ).flat(); + const activeStreamTokenPairs = allActiveStreams.map((stream) => ({ + receiver: stream.receiver, + tokenAddress: stream.tokenAddress, + })); + const streamTokenPairs = + streamTokenPairsResult.status === "ready" + ? streamTokenPairsResult.tokenPairs + : activeStreamTokenPairs; const [runwayResult, unwrappableBalancesResult] = await Promise.all([ getStreamRunwayForStreams(allActiveStreams), - getUnwrappableBalancesForStreams(allActiveStreams), + getUnwrappableBalancesForStreams(streamTokenPairs), ]); const projectedQuarter = getProjectedQuarterSummary(allActiveStreams); @@ -486,6 +495,11 @@ export default async function HatStewardsPage({ {runwayResult.message} ) : null} + {streamTokenPairsResult.status === "unavailable" ? ( +
+ {streamTokenPairsResult.message} +
+ ) : null} {unwrappableBalancesResult.status === "unavailable" ? (
{unwrappableBalancesResult.message} diff --git a/src/lib/hat-stewards/streams.ts b/src/lib/hat-stewards/streams.ts index 8451376..1040651 100644 --- a/src/lib/hat-stewards/streams.ts +++ b/src/lib/hat-stewards/streams.ts @@ -25,6 +25,16 @@ type SuperfluidStreamResponse = { errors?: { message: string }[]; }; +type SuperfluidStreamTokenPairResponse = { + data?: { + streams: { + receiver: { id: string }; + token: { id: string }; + }[]; + }; + errors?: { message: string }[]; +}; + type SuperfluidSnapshotResponse = { data?: { accountTokenSnapshots: { @@ -71,6 +81,19 @@ export type HatStewardStreamsResult = streamsByReceiver: Map; }; +export type HatStewardStreamTokenPair = { + receiver: Address; + tokenAddress: Address; +}; + +export type HatStewardStreamTokenPairsResult = + | { status: "ready"; tokenPairs: HatStewardStreamTokenPair[] } + | { + message: string; + status: "unavailable"; + tokenPairs: HatStewardStreamTokenPair[]; + }; + export type HatStewardStreamRunway = { balanceAmount: number; depletionRatePerDay: number; @@ -226,6 +249,74 @@ export async function listIncomingStreamsByReceiver( return { status: "ready", streamsByReceiver }; } +export async function listStreamTokenPairsByReceiver( + receivers: Address[], +): Promise { + if (receivers.length === 0) { + return { status: "ready", tokenPairs: [] }; + } + + const response = await fetch(getSuperfluidEndpoint(), { + body: JSON.stringify({ + query: ` + query HatStewardStreamTokenPairs($receivers: [String!]!) { + streams( + first: 1000 + orderBy: updatedAtTimestamp + orderDirection: desc + where: { receiver_in: $receivers } + ) { + receiver { + id + } + token { + id + } + } + } + `, + variables: { + receivers: receivers.map((receiver) => receiver.toLowerCase()), + }, + }), + headers: { "content-type": "application/json" }, + next: { revalidate: 300 }, + method: "POST", + }); + + if (!response.ok) { + return { + message: `Superfluid stream-token request failed: ${response.status}`, + status: "unavailable", + tokenPairs: [], + }; + } + + const body = (await response.json()) as SuperfluidStreamTokenPairResponse; + + if (body.errors?.length) { + return { + message: body.errors.map((error) => error.message).join("; "), + status: "unavailable", + tokenPairs: [], + }; + } + + const tokenPairsById = new Map(); + + for (const stream of body.data?.streams ?? []) { + const receiver = getAddress(stream.receiver.id); + const tokenAddress = getAddress(stream.token.id); + + tokenPairsById.set(`${receiver.toLowerCase()}-${tokenAddress.toLowerCase()}`, { + receiver, + tokenAddress, + }); + } + + return { status: "ready", tokenPairs: Array.from(tokenPairsById.values()) }; +} + export async function getStreamRunwayForStreams( streams: HatStewardStream[], ): Promise { @@ -331,16 +422,16 @@ export async function getStreamRunwayForStreams( } export async function getUnwrappableBalancesForStreams( - streams: HatStewardStream[], + tokenPairs: HatStewardStreamTokenPair[], ): Promise { const balancesByReceiver = new Map(); const snapshotIds = Array.from( new Set( - streams.map((stream) => { - const receiver = stream.receiver.toLowerCase(); + tokenPairs.map((tokenPair) => { + const receiver = tokenPair.receiver.toLowerCase(); balancesByReceiver.set(receiver, []); - return `${receiver}-${stream.tokenAddress.toLowerCase()}`; + return `${receiver}-${tokenPair.tokenAddress.toLowerCase()}`; }), ), ); @@ -394,7 +485,8 @@ export async function getUnwrappableBalancesForStreams( const nowSeconds = BigInt(Math.floor(Date.now() / 1000)); for (const snapshot of body.data?.accountTokenSnapshots ?? []) { - const [receiver] = snapshot.id.split("-"); + const [receiverId] = snapshot.id.split("-"); + const receiver = receiverId?.toLowerCase(); if (!receiver) { continue;