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) => (
+ -
+
+
+ {index + 1}
+
+
{step.title}
+
+ {step.detail}
+
+ ))}
+
+
+
+
+
+ Confirm the payout address before signing. The visible stream amount
+ is projected; the actual claimable balance is the live balance shown
+ in Superfluid at the time the Safe transaction is created.
+
+
+
+ );
+}
+
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}
+
@@ -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;