Skip to content

Commit dbef8b2

Browse files
authored
feat: masp reward calculator (#2033)
1 parent c2b8a73 commit dbef8b2

File tree

5 files changed

+291
-5
lines changed

5 files changed

+291
-5
lines changed

apps/namadillo/src/App/AccountOverview/AccountOverview.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
import { ConnectPanel } from "App/Common/ConnectPanel";
22
import { PageWithSidebar } from "App/Common/PageWithSidebar";
33
import { Sidebar } from "App/Layout/Sidebar";
4-
import { JoinDiscord } from "App/Sidebars/JoinDiscord";
4+
import { MaspRewardCalculator } from "App/Sidebars/MaspRewardCalculator";
55
import { ShieldAllBanner } from "App/Sidebars/ShieldAllBanner";
6+
import { applicationFeaturesAtom } from "atoms/settings";
67
import { useUserHasAccount } from "hooks/useIsAuthenticated";
8+
import { useAtomValue } from "jotai";
79
import { AssetsOverviewPanel } from "./AssetsOverviewPanel";
810
import { StakeSidebar } from "./StakeSidebar";
911
import { TotalBalanceBanner } from "./TotalBalanceBanner";
1012

1113
export const AccountOverview = (): JSX.Element => {
1214
const userHasAccount = useUserHasAccount();
15+
const features = useAtomValue(applicationFeaturesAtom);
16+
1317
if (!userHasAccount) {
1418
return (
1519
<ConnectPanel>
@@ -26,8 +30,8 @@ export const AccountOverview = (): JSX.Element => {
2630
</div>
2731
<Sidebar>
2832
<StakeSidebar />
33+
{features.shieldingRewardsEnabled && <MaspRewardCalculator />}
2934
<ShieldAllBanner />
30-
<JoinDiscord />
3135
</Sidebar>
3236
</PageWithSidebar>
3337
);
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
import { Panel } from "@namada/components";
2+
import { AssetImage } from "App/Transfer/AssetImage";
3+
import {
4+
chainAssetsMapAtom,
5+
chainParametersAtom,
6+
maspRewardsAtom,
7+
} from "atoms/chain";
8+
import { simulateShieldedRewards } from "atoms/staking/services";
9+
import BigNumber from "bignumber.js";
10+
import clsx from "clsx";
11+
import { useAtomValue } from "jotai";
12+
import { debounce } from "lodash";
13+
import { useEffect, useRef, useState } from "react";
14+
import { GoChevronDown } from "react-icons/go";
15+
import { MaspAssetRewards } from "types";
16+
import { toBaseAmount } from "utils";
17+
18+
export const MaspRewardCalculator = (): JSX.Element => {
19+
const rewards = useAtomValue(maspRewardsAtom);
20+
const [amount, setAmount] = useState<string>("");
21+
const [selectedAsset, setSelectedAsset] = useState<
22+
MaspAssetRewards | undefined
23+
>(rewards.data?.[0] || undefined);
24+
const [searchTerm, setSearchTerm] = useState<string>("");
25+
const [isDropdownOpen, setIsDropdownOpen] = useState<boolean>(false);
26+
const [calculatedRewards, setCalculatedRewards] = useState<string>("0.00");
27+
const [isCalculating, setIsCalculating] = useState<boolean>(false);
28+
const dropdownRef = useRef<HTMLDivElement>(null);
29+
const searchInputRef = useRef<HTMLInputElement>(null);
30+
const chainParameters = useAtomValue(chainParametersAtom);
31+
const chainId = chainParameters.data?.chainId;
32+
const chainAssetsMap = useAtomValue(chainAssetsMapAtom);
33+
34+
// Set initial selected asset when rewards data loads
35+
useEffect(() => {
36+
if (rewards.data && rewards.data.length > 0 && !selectedAsset) {
37+
setSelectedAsset(rewards.data[0]);
38+
}
39+
}, [rewards.data, selectedAsset]);
40+
41+
// Close on click outside
42+
useEffect(() => {
43+
const handleClickOutside = (event: MouseEvent): void => {
44+
if (
45+
dropdownRef.current &&
46+
!dropdownRef.current.contains(event.target as Node)
47+
) {
48+
setIsDropdownOpen(false);
49+
setSearchTerm("");
50+
}
51+
};
52+
53+
document.addEventListener("mousedown", handleClickOutside);
54+
return () => document.removeEventListener("mousedown", handleClickOutside);
55+
}, []);
56+
57+
useEffect(() => {
58+
const debouncedFetchRewards = debounce(async (): Promise<void> => {
59+
if (!selectedAsset || !amount || !chainId) return;
60+
61+
const assetAddress = findAssetAddress(
62+
selectedAsset.asset.symbol.toLowerCase()
63+
);
64+
if (!assetAddress) {
65+
console.error(
66+
"Could not find address for asset:",
67+
selectedAsset.asset.symbol
68+
);
69+
return;
70+
}
71+
72+
setIsCalculating(true);
73+
try {
74+
const rewardsResult = await simulateShieldedRewards(
75+
chainId,
76+
assetAddress,
77+
toBaseAmount(selectedAsset.asset, new BigNumber(amount)).toString()
78+
);
79+
setCalculatedRewards(rewardsResult);
80+
} catch (error) {
81+
console.error("Error calculating rewards:", error);
82+
setCalculatedRewards("0.00");
83+
} finally {
84+
setIsCalculating(false);
85+
}
86+
}, 300);
87+
88+
debouncedFetchRewards();
89+
90+
return () => {
91+
debouncedFetchRewards.cancel();
92+
};
93+
}, [selectedAsset, amount]);
94+
95+
// Focus search input when dropdown opens
96+
useEffect(() => {
97+
if (isDropdownOpen && searchInputRef.current) {
98+
searchInputRef.current.focus();
99+
}
100+
}, [isDropdownOpen]);
101+
102+
const handleAssetSelect = (asset: MaspAssetRewards): void => {
103+
setSelectedAsset(asset);
104+
setIsDropdownOpen(false);
105+
setSearchTerm("");
106+
};
107+
108+
// Helper function to find the address for a given asset base
109+
const findAssetAddress = (symbol: string): string | undefined => {
110+
if (!Object.keys(chainAssetsMap).length) return undefined;
111+
// Find the entry in chainAssetsMap where the asset.base matches our assetBase
112+
for (const [address, assetInfo] of Object.entries(chainAssetsMap)) {
113+
if (assetInfo?.symbol.toLowerCase() === symbol) {
114+
return address;
115+
}
116+
}
117+
return undefined;
118+
};
119+
120+
// Filter assets based on search term
121+
const filteredRewards =
122+
rewards.data?.filter((reward) =>
123+
reward.asset.symbol.toLowerCase().includes(searchTerm.toLowerCase())
124+
) || [];
125+
126+
return (
127+
<Panel className={clsx("flex flex-col pt-2 pb-2 px-2")}>
128+
<h2 className="uppercase text-[13px] text-center font-medium pb-0 pt-2">
129+
MASP REWARDS CALCULATOR
130+
</h2>
131+
<div className="mt-3 flex flex-col gap-3">
132+
{rewards.isLoading && (
133+
<i
134+
className={clsx(
135+
"absolute w-8 h-8 top-0 left-0 right-0 bottom-0 m-auto border-4",
136+
"border-transparent border-t-yellow rounded-[50%]",
137+
"animate-loadingSpinner"
138+
)}
139+
/>
140+
)}
141+
{rewards.data && (
142+
<>
143+
<div className="flex gap-0 bg-neutral-900 rounded-sm relative">
144+
<div className="flex-shrink-0" ref={dropdownRef}>
145+
<button
146+
type="button"
147+
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
148+
className={clsx(
149+
"flex items-center gap-1 px-3 py-3 text-white",
150+
"hover:bg-neutral-800 transition-colors rounded-l-sm",
151+
"min-w-[80px]"
152+
)}
153+
>
154+
<div className="w-6 h-6 flex-shrink-0">
155+
<AssetImage asset={selectedAsset?.asset} />
156+
</div>
157+
<span className="text-sm font-medium">
158+
{selectedAsset?.asset.symbol || "Select"}
159+
</span>
160+
<GoChevronDown
161+
className={clsx(
162+
"ml-1 transition-transform text-neutral-400 text-xs",
163+
isDropdownOpen && "rotate-180"
164+
)}
165+
/>
166+
</button>
167+
{isDropdownOpen && (
168+
<div
169+
className={clsx(
170+
"absolute top-full left-0 w-full z-50 mt-1",
171+
"bg-neutral-800 rounded-md",
172+
"max-h-[300px] overflow-hidden"
173+
)}
174+
>
175+
<div className="border-none">
176+
<div className="relative">
177+
<input
178+
ref={searchInputRef}
179+
type="text"
180+
value={searchTerm}
181+
onChange={(e) => setSearchTerm(e.target.value)}
182+
placeholder="Search assets..."
183+
className={clsx(
184+
"w-full pl-10 pr-3 py-2 bg-neutral-900 text-white",
185+
"border-none border-neutral-600 text-sm",
186+
"focus:outline-none focus:border-neutral-500"
187+
)}
188+
/>
189+
</div>
190+
</div>
191+
192+
<div className="max-h-[200px] overflow-y-auto border border-neutral-600 dark-scrollbar overscroll-contain">
193+
{filteredRewards.length === 0 ?
194+
<div className="p-3 text-center text-neutral-400 text-sm">
195+
No assets found
196+
</div>
197+
: filteredRewards.map((reward) => (
198+
<button
199+
key={reward.asset.base}
200+
type="button"
201+
onClick={() => handleAssetSelect(reward)}
202+
className={clsx(
203+
"w-full flex items-center py-1.5 px-1 text-left",
204+
"transition-colors"
205+
)}
206+
>
207+
<div className="px-2 py-1 gap-4 hover:bg-neutral-700 rounded-sm ml-2 flex w-full">
208+
<div className="w-8 h-8 flex-shrink-0 ">
209+
<AssetImage asset={reward.asset} />
210+
</div>
211+
<div className="flex-1 min-w-0">
212+
<div className="text-white font-medium text-sm mt-1.5">
213+
{reward.asset.symbol}
214+
</div>
215+
</div>
216+
</div>
217+
</button>
218+
))
219+
}
220+
</div>
221+
</div>
222+
)}
223+
</div>
224+
225+
<input
226+
type="number"
227+
value={amount}
228+
onChange={(e) => {
229+
const value = e.target.value;
230+
if (Number(value) === 0) setCalculatedRewards("0.00");
231+
if (value.length >= 8) return;
232+
setAmount(e.target.value);
233+
}}
234+
placeholder="Amount"
235+
className={clsx(
236+
"[&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none mr-3",
237+
"bg-transparent text-white py-3 w-full",
238+
"focus:outline-none text-right rounded-r-sm"
239+
)}
240+
/>
241+
</div>
242+
243+
<div className="flex flex-col items-center justify-center border border-neutral-500 rounded-sm py-8">
244+
<div className="text-yellow text-2xl font-bold max-w-full">
245+
{isCalculating ?
246+
<div className="flex items-center justify-center">
247+
<i
248+
className={clsx(
249+
"w-5 h-5 border-4 border-transparent border-t-yellow rounded-[50%] my-2",
250+
"animate-loadingSpinner"
251+
)}
252+
/>
253+
</div>
254+
: <>
255+
{Number(calculatedRewards).toLocaleString(undefined, {
256+
maximumFractionDigits:
257+
Number(calculatedRewards) > 1000000 ? 0 : 2,
258+
})}
259+
</>
260+
}
261+
</div>
262+
<div className="text-sm text-yellow font-normal">NAM</div>
263+
<div className="text-neutral-400 text-xs mt-1 px-4 text-center">
264+
Est. shielded rewards per 24hrs
265+
</div>
266+
</div>
267+
</>
268+
)}
269+
</div>
270+
</Panel>
271+
);
272+
};

apps/namadillo/src/App/Transactions/TransactionHistory.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ export const TransactionHistory = (): JSX.Element => {
128128
{pending.length > 0 && (
129129
<div className="mb-5 flex-none">
130130
<h2 className="text-sm mb-3 ml-4">Pending</h2>
131-
<div className="ml-4 mr-7 max-h-32 overflow-y-auto">
131+
<div className="ml-4 mr-7 max-h-32 overflow-y-auto dark-scrollbar">
132132
{pending.map((transaction) => (
133133
<PendingTransactionCard
134134
key={transaction.hash}

apps/namadillo/src/atoms/staking/services.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,3 +170,12 @@ export const clearClaimRewards = (accountAddress: string): void => {
170170
() => emptyClaimRewards
171171
);
172172
};
173+
174+
export const simulateShieldedRewards = async (
175+
chainId: string,
176+
token: string,
177+
amount: string = "0"
178+
): Promise<string> => {
179+
const sdk = await getSdkInstance();
180+
return await sdk.rpc.simulateShieldedRewards(chainId, token, amount);
181+
};

packages/shared/lib/src/sdk/mod.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1026,11 +1026,12 @@ impl Sdk {
10261026
shielded.utils.chain_id = chain_id.clone();
10271027
shielded.load().await?;
10281028

1029+
let epoch = rpc::query_masp_epoch(self.namada.client()).await?;
1030+
10291031
let (_, masp_value) = shielded
10301032
.convert_namada_amount_to_masp(
10311033
self.namada.client(),
1032-
// Masp epoch should not matter
1033-
MaspEpoch::zero(),
1034+
epoch,
10341035
&token,
10351036
amount.denom(),
10361037
amount.amount(),

0 commit comments

Comments
 (0)