Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 199 additions & 13 deletions src/app/hat-stewards/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
BadgeDollarSign,
ExternalLink,
ListChecks,
ShieldCheck,
Wallet,
type LucideIcon,
Expand All @@ -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<{
Expand All @@ -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,
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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 (
<section className="rounded-lg border border-border bg-card p-5 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="max-w-5xl">
<p className="type-label-sm text-muted-foreground">Claiming</p>
<h2 className="mt-1 text-xl font-semibold">
How stewards claim streams
</h2>
<p className="mt-2 text-sm text-muted-foreground">
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.
</p>
</div>
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<ListChecks className="size-5" aria-hidden="true" />
</span>
</div>

<ol className="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{steps.map((step, index) => (
<li
key={step.title}
className="rounded-lg border border-border bg-background p-4"
>
<div className="flex items-center gap-2">
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-primary/10 text-sm font-semibold text-primary">
{index + 1}
</span>
<p className="text-sm font-semibold">{step.title}</p>
</div>
<p className="mt-3 text-sm text-muted-foreground">{step.detail}</p>
</li>
))}
</ol>

<div className="mt-4 flex items-start gap-3 rounded-lg border border-border bg-background p-4 text-sm text-muted-foreground">
<ShieldCheck
className="mt-0.5 size-4 shrink-0 text-primary"
aria-hidden="true"
/>
<p>
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.
</p>
</div>
</section>
);
}

function RoleStreamCard({
unwrappableBalances,
roleSafe,
streams,
}: {
unwrappableBalances: HatStewardUnwrappableBalance[];
roleSafe: HatStewardRoleSafe;
streams: HatStewardStream[];
}) {
const projectedQuarter = getProjectedQuarterSummary(streams);
const unwrappableBalance = getUnwrappableBalanceSummary({
balances: unwrappableBalances,
streams,
});

return (
<article className="rounded-lg border border-border bg-card p-5 shadow-sm">
Expand All @@ -163,14 +298,32 @@ function RoleStreamCard({
</p>
<h2 className="mt-1 text-xl font-semibold">{roleSafe.hatLabel}</h2>
</div>
<div className="rounded-md border border-border bg-background px-3 py-2 text-right">
<p className="type-label-sm text-muted-foreground">Per quarter</p>
<p className="mt-1 text-lg font-semibold">{projectedQuarter.value}</p>
{projectedQuarter.detail ? (
<p className="mt-1 text-xs text-muted-foreground">
{projectedQuarter.detail}
<div className="grid w-full gap-2 text-left sm:w-auto sm:min-w-64">
<a
href={getSafeSuperfluidAppUrl(roleSafe.safeUrl)}
target="_blank"
rel="noreferrer"
className="block rounded-md border border-moloch-300 bg-moloch-100/40 px-3 py-2.5 shadow-sm shadow-moloch-200/30 transition-all hover:border-moloch-400 hover:bg-moloch-100/70 focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`Open Superfluid in Safe for ${roleSafe.hatLabel}`}
>
<div className="flex items-baseline justify-between gap-3">
<p className="type-label-sm text-muted-foreground">
Claimable now
</p>
<ExternalLink
className="size-3.5 text-moloch-500"
aria-hidden="true"
/>
</div>
<p className="mt-1 text-xl font-semibold leading-tight text-foreground">
{unwrappableBalance.value}
</p>
) : null}
{unwrappableBalance.detail ? (
<p className="mt-1 text-xs text-muted-foreground">
{unwrappableBalance.detail}
</p>
) : null}
</a>
</div>
</div>

Expand Down Expand Up @@ -213,10 +366,11 @@ function RoleStreamCard({
<div className="text-left md:text-right">
<p className="text-sm font-semibold">
{formatTokenAmount(stream.projectedQuarterAmount)}{" "}
{stream.tokenSymbol}
{stream.tokenSymbol} / quarter
</p>
<p className="mt-1 text-xs text-muted-foreground">
{formatTokenAmount(stream.flowRatePerDay)} / day
{formatTokenAmount(stream.flowRatePerDay)}{" "}
{stream.tokenSymbol} / day
</p>
Comment thread
ECWireless marked this conversation as resolved.
</div>
</div>
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -314,6 +483,8 @@ export default async function HatStewardsPage({
/>
</div>

<ClaimingStreamGuide />

{streamsResult.status === "unavailable" ? (
<div className="rounded-lg border border-border bg-card p-4 text-sm text-muted-foreground">
{streamsResult.message}
Expand All @@ -324,6 +495,16 @@ export default async function HatStewardsPage({
{runwayResult.message}
</div>
) : null}
{streamTokenPairsResult.status === "unavailable" ? (
<div className="rounded-lg border border-border bg-card p-4 text-sm text-muted-foreground">
{streamTokenPairsResult.message}
</div>
) : null}
{unwrappableBalancesResult.status === "unavailable" ? (
<div className="rounded-lg border border-border bg-card p-4 text-sm text-muted-foreground">
{unwrappableBalancesResult.message}
</div>
) : null}

<section className="grid gap-4">
<div>
Expand All @@ -342,6 +523,11 @@ export default async function HatStewardsPage({
roleSafe.safeAddress.toLowerCase(),
) ?? []
}
unwrappableBalances={
unwrappableBalancesResult.balancesByReceiver.get(
roleSafe.safeAddress.toLowerCase(),
) ?? []
}
/>
))}
</div>
Expand Down
Loading