diff --git a/src/app/hat-stewards/page.tsx b/src/app/hat-stewards/page.tsx
index d1037a6..c3d4fa1 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,
@@ -24,8 +25,11 @@ import {
} from "@/lib/hat-stewards/role-safes";
import {
listIncomingStreamsByReceiver,
+ listStreamTokenPairsByReceiver,
getStreamRunwayForStreams,
+ getUnwrappableBalancesForStreams,
type HatStewardStream,
+ type HatStewardUnwrappableBalance,
} from "@/lib/hat-stewards/streams";
type SearchParams = Promise<{
@@ -42,6 +46,15 @@ function formatAddress(address: string) {
return `${address.slice(0, 6)}...${address.slice(-4)}`;
}
+function getSafeSuperfluidAppUrl(safeUrl: string) {
+ const safe = new URL(safeUrl).searchParams.get("safe");
+ const appUrl = encodeURIComponent("https://app.superfluid.org");
+
+ return safe
+ ? `https://app.safe.global/apps/open?safe=${safe}&appUrl=${appUrl}`
+ : safeUrl;
+}
+
function formatTokenAmount(value: number) {
return new Intl.NumberFormat("en-US", {
maximumFractionDigits: value >= 100 ? 0 : 2,
@@ -94,6 +107,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 +200,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 +298,32 @@ function RoleStreamCard({
{roleSafe.hatLabel}
-
-
Per quarter
-
{projectedQuarter.value}
- {projectedQuarter.detail ? (
-
- {projectedQuarter.detail}
+
@@ -213,10 +366,11 @@ function RoleStreamCard({
{formatTokenAmount(stream.projectedQuarterAmount)}{" "}
- {stream.tokenSymbol}
+ {stream.tokenSymbol} / quarter
- {formatTokenAmount(stream.flowRatePerDay)} / day
+ {formatTokenAmount(stream.flowRatePerDay)}{" "}
+ {stream.tokenSymbol} / day
@@ -252,13 +406,28 @@ 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 runwayResult = await getStreamRunwayForStreams(allActiveStreams);
+ 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(streamTokenPairs),
+ ]);
const projectedQuarter = getProjectedQuarterSummary(allActiveStreams);
return (
@@ -314,6 +483,8 @@ export default async function HatStewardsPage({
/>
+
+
{streamsResult.status === "unavailable" ? (
{streamsResult.message}
@@ -324,6 +495,16 @@ export default async function HatStewardsPage({
{runwayResult.message}
) : null}
+ {streamTokenPairsResult.status === "unavailable" ? (
+
+ {streamTokenPairsResult.message}
+
+ ) : null}
+ {unwrappableBalancesResult.status === "unavailable" ? (
+
+ {unwrappableBalancesResult.message}
+
+ ) : null}
@@ -342,6 +523,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..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: {
@@ -45,6 +55,9 @@ type SuperfluidSnapshotResponse = {
type SuperfluidStreamRow = NonNullable<
SuperfluidStreamResponse["data"]
>["streams"][number];
+type SuperfluidSnapshotRow = NonNullable<
+ SuperfluidSnapshotResponse["data"]
+>["accountTokenSnapshots"][number];
export type HatStewardStream = {
currentFlowRate: string;
@@ -68,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;
@@ -80,6 +106,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 +157,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 {
@@ -192,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 {
@@ -264,13 +389,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 +420,94 @@ export async function getStreamRunwayForStreams(
status: "ready",
};
}
+
+export async function getUnwrappableBalancesForStreams(
+ tokenPairs: HatStewardStreamTokenPair[],
+): Promise {
+ const balancesByReceiver = new Map();
+ const snapshotIds = Array.from(
+ new Set(
+ tokenPairs.map((tokenPair) => {
+ const receiver = tokenPair.receiver.toLowerCase();
+
+ balancesByReceiver.set(receiver, []);
+ return `${receiver}-${tokenPair.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 [receiverId] = snapshot.id.split("-");
+ const receiver = receiverId?.toLowerCase();
+
+ 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" };
+}