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) => ( +
  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 +298,32 @@ function RoleStreamCard({

{roleSafe.hatLabel}

-
-

Per quarter

-

{projectedQuarter.value}

- {projectedQuarter.detail ? ( -

- {projectedQuarter.detail} +

+ +
+

+ Claimable now +

+
+

+ {unwrappableBalance.value}

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

+ {unwrappableBalance.detail} +

+ ) : null} +
@@ -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" }; +}