diff --git a/apps/namadillo/.gitignore b/apps/namadillo/.gitignore
index f263da0cc5..3bca05b5d3 100644
--- a/apps/namadillo/.gitignore
+++ b/apps/namadillo/.gitignore
@@ -10,3 +10,4 @@
/playwright/.cache/
/public/localnet-config.toml
.vercel
+.env*.local
diff --git a/apps/namadillo/package.json b/apps/namadillo/package.json
index 34940fa9f8..6f15702278 100644
--- a/apps/namadillo/package.json
+++ b/apps/namadillo/package.json
@@ -12,6 +12,7 @@
"@keplr-wallet/types": "^0.12.136",
"@namada/chain-registry": "^1.0.0",
"@namada/indexer-client": "2.4.4",
+ "@supabase/supabase-js": "^2.49.4",
"@tailwindcss/container-queries": "^0.1.1",
"@tanstack/query-core": "^5.40.0",
"@tanstack/react-query": "^5.40.0",
@@ -42,7 +43,8 @@
"traverse": "^0.6.9",
"vite-plugin-checker": "^0.8.0",
"web-vitals": "^2.1.4",
- "wonka": "^6.3.4"
+ "wonka": "^6.3.4",
+ "xlsx": "^0.18.5"
},
"scripts": {
"start:proxy": "node ./scripts/startProxies.js",
diff --git a/apps/namadillo/src/App/AppRoutes.tsx b/apps/namadillo/src/App/AppRoutes.tsx
index bec97e126a..136bb70590 100644
--- a/apps/namadillo/src/App/AppRoutes.tsx
+++ b/apps/namadillo/src/App/AppRoutes.tsx
@@ -21,6 +21,7 @@ import { SubmitVote } from "./Governance/SubmitVote";
import { ViewJson } from "./Governance/ViewJson";
import { IbcShieldAll } from "./Ibc/IbcShieldAll";
import { MoveAssets } from "./Layout/MoveAssets";
+import { Referrals } from "./Referrals/Referrals";
import { routes } from "./routes";
import { Advanced } from "./Settings/Advanced";
import { EnableFeatures } from "./Settings/EnableFeatures";
@@ -59,6 +60,9 @@ export const MainRoutes = (): JSX.Element => {
element={ }
errorElement={ }
>
+ {/* Referrals */}
+ } />
+
{/* Home */}
} />
diff --git a/apps/namadillo/src/App/Layout/AppLayout.tsx b/apps/namadillo/src/App/Layout/AppLayout.tsx
index 785b8aadbf..eeeb931de1 100644
--- a/apps/namadillo/src/App/Layout/AppLayout.tsx
+++ b/apps/namadillo/src/App/Layout/AppLayout.tsx
@@ -1,6 +1,10 @@
import { AlphaVersionTopHeader } from "App/Common/AlphaVersionTopHeader";
-import { ReactNode, useState } from "react";
+import { defaultAccountAtom } from "atoms/accounts";
+import { connectedWalletsAtom } from "atoms/integrations";
+import { useAtomValue } from "jotai";
+import { ReactNode, useEffect, useState } from "react";
import { IoMdClose } from "react-icons/io";
+import { useLocation, useNavigate } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { AppHeader } from "./AppHeader";
import { BurgerButton } from "./BurgerButton";
@@ -12,6 +16,30 @@ export const AppLayout = ({
children: ReactNode;
}): JSX.Element => {
const [displayNavigation, setDisplayNavigation] = useState(false);
+ const location = useLocation();
+ const navigate = useNavigate();
+ const connectedWallets = useAtomValue(connectedWalletsAtom);
+ const defaultAccount = useAtomValue(defaultAccountAtom);
+
+ useEffect(() => {
+ const queryParams = new URLSearchParams(location.search);
+ const referrerAddress = queryParams.get("referral");
+
+ if (referrerAddress && referrerAddress.startsWith("tnam")) {
+ const userAddress = defaultAccount.data?.address;
+ if (userAddress) {
+ // Store referral address in local storage
+ localStorage.setItem("refereeAddress", userAddress);
+ localStorage.setItem("referrerAddress", referrerAddress);
+
+ // Clear the query parameter from URL
+ queryParams.delete("referral");
+ const newSearch = queryParams.toString();
+ const newPath = location.pathname + (newSearch ? `?${newSearch}` : "");
+ navigate(newPath, { replace: true });
+ }
+ }
+ }, [location, navigate, connectedWallets, defaultAccount]);
return (
<>
diff --git a/apps/namadillo/src/App/Layout/TopNavigation.tsx b/apps/namadillo/src/App/Layout/TopNavigation.tsx
index 4e5b810d89..84b854e937 100644
--- a/apps/namadillo/src/App/Layout/TopNavigation.tsx
+++ b/apps/namadillo/src/App/Layout/TopNavigation.tsx
@@ -30,6 +30,12 @@ export const TopNavigation = (): JSX.Element => {
const defaultAccount = useAtomValue(defaultAccountAtom);
const location = useLocation();
const navigate = useNavigate();
+ const isValidityOps =
+ defaultAccount.data?.address &&
+ [
+ "tnam1q8lhvxys53dlc8wzlg7dyqf9avd0vff6wvav4amt",
+ "tnam1qr0e06vqhw9u0yqy9d5zmtq0q8ekckhe2vkqc3ky",
+ ].includes(defaultAccount.data?.address);
if (!userHasAccount) {
return (
@@ -53,7 +59,7 @@ export const TopNavigation = (): JSX.Element => {
return (
-
+
{maspEnabled && (
{
Transfer
)}
+ {isValidityOps && (
+
+ Referrals
+
+ )}
diff --git a/apps/namadillo/src/App/Referrals/ReferralSheetModal.tsx b/apps/namadillo/src/App/Referrals/ReferralSheetModal.tsx
new file mode 100644
index 0000000000..c36f5e7063
--- /dev/null
+++ b/apps/namadillo/src/App/Referrals/ReferralSheetModal.tsx
@@ -0,0 +1,198 @@
+import { Modal, TableRow } from "@namada/components";
+import { shortenAddress } from "@namada/utils";
+import { ModalTransition } from "App/Common/ModalTransition";
+import { TableWithPaginator } from "App/Common/TableWithPaginator";
+import BigNumber from "bignumber.js";
+import { useCallback, useState } from "react";
+import { IoClose } from "react-icons/io5";
+import { twMerge } from "tailwind-merge";
+import { ReferralReward } from "./types";
+
+// TODO:
+// - Once we pay a user we need to update their entry in the DB to last_paid_epoch
+// - After this we should send a confirmation email to Paul et al to let them know:
+// - A user has been paid
+// - The amount paid
+// - The address of the user paid
+// - The epoch at which the user was paid
+
+export const ReferralSheetModal = ({
+ rewardsData,
+ onClose,
+}: {
+ rewardsData: ReferralReward[];
+ onClose: () => void;
+}): JSX.Element => {
+ const [selectedReferrer, setSelectedReferrer] = useState
(null);
+
+ const headers = [
+ "Referrer Address",
+ "Referee Address",
+ "Epoch",
+ "Reward (NAM)",
+ ];
+
+ const renderRow = useCallback(
+ (r: ReferralReward): TableRow => ({
+ key: `rw-${r.referrerAddress}-${r.refereeAddress}-${r.epoch}`,
+ cells: [
+ ,
+ ,
+ {r.epoch}
,
+ {r.amount.toFormat(6)}
,
+ ],
+ }),
+ []
+ );
+
+ const byReferrer = rewardsData.reduce>(
+ (a, r) => ((a[r.referrerAddress] ??= []).push(r), a),
+ {}
+ );
+
+ if (!selectedReferrer && Object.keys(byReferrer).length)
+ setSelectedReferrer(Object.keys(byReferrer)[0]);
+
+ const rows =
+ selectedReferrer ? (byReferrer[selectedReferrer] ?? []) : rewardsData;
+
+ const totals = rewardsData.reduce>((acc, r) => {
+ const amt = new BigNumber(r.amount); // ensure BigNumber
+ acc[r.referrerAddress] = (acc[r.referrerAddress] ?? new BigNumber(0)).plus(
+ amt
+ );
+ return acc;
+ }, {});
+
+ return (
+
+
+ {/* header */}
+
+
+
+
+
+ {/* body – single scroll */}
+
+ {rows.length === 0 ?
+
No rewards found
+ : <>
+ {/* totals */}
+
+
Referrer Total
+
+ {selectedReferrer && totals[selectedReferrer] && (
+
+ )}
+
+
+
+ {/* epoch table */}
+
+
Rewards by Epoch
+
+ {/* referrer selector */}
+
+ {Object.keys(byReferrer).map((addr) => (
+ setSelectedReferrer(addr)}
+ className={`px-4 py-2 whitespace-nowrap ${
+ selectedReferrer === addr ?
+ "border-b-2 border-yellow text-yellow"
+ : "text-neutral-400"
+ }`}
+ >
+ {shortenAddress(addr, 6, 4)}
+
+ ))}
+
+
+
{}} /* no-op */
+ tableProps={{
+ className: twMerge(
+ "w-full min-w-[600px] [&_td]:px-3 [&_th]:px-3 [&_td]:h-[64px]",
+ "[&_td:first-child]:pl-4 [&_td:last-child]:pr-4",
+ "[&_td:first-child]:rounded-s-md [&_td:last-child]:rounded-e-md"
+ ),
+ }}
+ headProps={{ className: "text-neutral-500" }}
+ />
+
+ >
+ }
+
+
+
+ );
+};
+
+const Addr = ({ value }: { value: string }): JSX.Element => (
+
+ {shortenAddress(value, 10, 6)}
+
+);
+
+const TotalCard = ({
+ addr,
+ amount,
+}: {
+ addr: string;
+ amount: BigNumber;
+}): JSX.Element => {
+ const validatorCut = amount.multipliedBy(0.05);
+ const referrerCut = validatorCut.multipliedBy(0.25);
+ const toolBuilderCut = referrerCut;
+ return (
+
+
+
Referrer:
+
+ {shortenAddress(addr, 10, 6)}
+ {
+ navigator.clipboard.writeText(addr);
+ }}
+ >
+ Copy
+
+
+
+
+ Total NAM Referred:
+ {amount.toFormat(6)}
+
+
+ Validator Cut:
+
+ {validatorCut.minus(referrerCut).minus(toolBuilderCut).toFormat(6)}
+
+
+
+ Referrer Cut:
+
+ {referrerCut.toFormat(6)}
+
+
+
+ );
+};
diff --git a/apps/namadillo/src/App/Referrals/Referrals.tsx b/apps/namadillo/src/App/Referrals/Referrals.tsx
new file mode 100644
index 0000000000..283eddadf8
--- /dev/null
+++ b/apps/namadillo/src/App/Referrals/Referrals.tsx
@@ -0,0 +1,76 @@
+import { Panel } from "@namada/components";
+import { routes } from "App/routes";
+import { defaultAccountAtom } from "atoms/accounts";
+import { useAtomValue } from "jotai";
+import { useEffect, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { getReferralsFromSupabase } from "../../utils/supabase";
+import { ReferralsTable } from "./ReferralsTable";
+import { Referral } from "./types";
+
+export const Referrals = (): JSX.Element => {
+ const [referrals, setReferrals] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const defaultAccount = useAtomValue(defaultAccountAtom);
+ const isValidityOps =
+ defaultAccount.data?.address &&
+ [
+ "tnam1q8lhvxys53dlc8wzlg7dyqf9avd0vff6wvav4amt",
+ "tnam1qr0e06vqhw9u0yqy9d5zmtq0q8ekckhe2vkqc3ky",
+ ].includes(defaultAccount.data?.address);
+
+ const navigate = useNavigate();
+ useEffect(() => {
+ const fetchReferrals = async (): Promise => {
+ try {
+ setLoading(true);
+ const { success, data, error } = await getReferralsFromSupabase();
+
+ if (success && data) {
+ setReferrals(data as Referral[]);
+ } else {
+ console.error("Failed to fetch referrals:", error);
+ setError("Failed to fetch referrals");
+ }
+ } catch (err) {
+ console.error("Error fetching referrals:", err);
+ setError("Error fetching referrals");
+ } finally {
+ setLoading(false);
+ }
+ };
+ if (!isValidityOps) return navigate(routes.root);
+ fetchReferrals();
+ }, []);
+
+ if (!isValidityOps) {
+ return You are not authorized to view this page
;
+ }
+
+ return (
+
+
+
Referrals
+
+ {loading && (
+
Loading referrals...
+ )}
+
+ {error &&
{error}
}
+
+ {!loading && !error && referrals.length === 0 && (
+
No referrals found
+ )}
+
+ {!loading && !error && referrals.length > 0 && (
+
+ )}
+
+
+ );
+};
diff --git a/apps/namadillo/src/App/Referrals/ReferralsTable.tsx b/apps/namadillo/src/App/Referrals/ReferralsTable.tsx
new file mode 100644
index 0000000000..c789ea365d
--- /dev/null
+++ b/apps/namadillo/src/App/Referrals/ReferralsTable.tsx
@@ -0,0 +1,385 @@
+import { ActionButton, TableRow } from "@namada/components";
+import { shortenAddress } from "@namada/utils";
+import { TableWithPaginator } from "App/Common/TableWithPaginator";
+import { chainStatusAtom } from "atoms/chain";
+import BigNumber from "bignumber.js";
+import { useAtomValue } from "jotai";
+import { useCallback, useState } from "react";
+import { IoIosCopy } from "react-icons/io";
+import {
+ IoCheckmarkCircleOutline,
+ IoCloseCircleOutline,
+} from "react-icons/io5";
+import { twMerge } from "tailwind-merge";
+import { downloadMultiSheetExcel } from "./excel";
+import { ReferralSheetModal } from "./ReferralSheetModal";
+import {
+ Referral,
+ ReferralReward,
+ ReferralsTableProps,
+ RewardData,
+ ValidatorInfo,
+} from "./types";
+
+export const ReferralsTable = ({
+ id,
+ referrals,
+ resultsPerPage = 50,
+ initialPage = 0,
+ tableClassName,
+}: ReferralsTableProps): JSX.Element => {
+ const [page, setPage] = useState(initialPage);
+ const [isGenerating, setIsGenerating] = useState(false);
+ const [generationError, setGenerationError] = useState(null);
+ const [rewardsData, setRewardsData] = useState([]);
+ const [showModal, setShowModal] = useState(false);
+ const chainStatus = useAtomValue(chainStatusAtom);
+
+ const headers = [
+ "Referrer Address",
+ "Referee Address",
+ "Start Epoch",
+ "Created At",
+ "Is Active",
+ "Check On Chain",
+ ];
+
+ const renderRow = useCallback(
+ (ref: Referral): TableRow => ({
+ key: `referral-${ref.id}`,
+ cells: [
+
+ {shortenAddress(ref.referrer_address, 10, 6)}
+ {
+ navigator.clipboard.writeText(ref.referrer_address);
+ }}
+ className="text-neutral-400 hover:text-white"
+ title="Copy address"
+ >
+
+
+
,
+
+ {shortenAddress(ref.referee_address, 10, 6)}
+ {
+ navigator.clipboard.writeText(ref.referee_address);
+ }}
+ className="text-neutral-400 hover:text-white"
+ title="Copy address"
+ >
+
+
+
,
+
+ {ref.last_paid_epoch}
+
,
+
+ {ref.created_at ? new Date(ref.created_at).toLocaleString() : "N/A"}
+
,
+
+ {ref.active ?
+
+ : }
+
,
+ ,
+ ],
+ }),
+ []
+ );
+
+ // Sort referrals - active ones first
+ const sortedReferrals = [...referrals].sort((a, b) => {
+ // Sort by active status (active first)
+ if (a.active && !b.active) return -1;
+ if (!a.active && b.active) return 1;
+ // If both have same active status, sort by creation date (newest first)
+ if (a.created_at && b.created_at) {
+ return (
+ new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
+ );
+ }
+ return 0;
+ });
+
+ const paginatedItems = sortedReferrals.slice(
+ page * resultsPerPage,
+ page * resultsPerPage + resultsPerPage
+ );
+ const pageCount = Math.ceil(referrals.length / resultsPerPage);
+
+ const generateReferralSheet = async (): Promise => {
+ if (!chainStatus?.epoch) {
+ setGenerationError("Chain status not available");
+ return;
+ }
+ setIsGenerating(true);
+ setGenerationError(null);
+ setRewardsData([]);
+
+ try {
+ type ReferralEpochMapItem = {
+ type: "current" | "previous";
+ referrerAddress: string;
+ refereeAddress: string;
+ epoch: number;
+ };
+ type ResponseData = ReferralEpochMapItem & {
+ data: RewardData[] | null;
+ success: boolean;
+ };
+
+ // map: referralKey → { epoch: { amount, validator } }
+ const cumByReferral = new Map<
+ string,
+ Record
+ >();
+ const fetchPromises: Promise[] = [];
+ const referralEpochMap: ReferralEpochMapItem[] = [];
+
+ for (const {
+ referrer_address,
+ referee_address,
+ last_paid_epoch,
+ active,
+ } of referrals) {
+ // Dont fetch inactive referrals
+ if (!active) continue;
+
+ console.log(
+ `Fetching rewards for ${referrer_address}-${referee_address}, last paid: ${last_paid_epoch}`
+ );
+
+ // fetch previous epoch cumulative
+ if (last_paid_epoch > 0) {
+ referralEpochMap.push({
+ type: "previous",
+ referrerAddress: referrer_address,
+ refereeAddress: referee_address,
+ epoch: last_paid_epoch - 1,
+ });
+ fetchPromises.push(
+ fetch(
+ `${process.env.INDEXER_URL}/api/v1/pos/reward/${referee_address}/tnam1q8lhvxys53dlc8wzlg7dyqf9avd0vff6wvav4amt/${
+ last_paid_epoch - 1
+ }`
+ )
+ );
+ }
+
+ // Ensure we're fetching ALL epochs since last_paid_epoch
+ if (chainStatus.epoch > last_paid_epoch) {
+ console.log(
+ `Fetching ${chainStatus.epoch - last_paid_epoch} epochs for this referral`
+ );
+ }
+
+ // fetch current epochs
+ for (let epoch = last_paid_epoch; epoch < chainStatus.epoch; epoch++) {
+ referralEpochMap.push({
+ type: "current",
+ referrerAddress: referrer_address,
+ refereeAddress: referee_address,
+ epoch,
+ });
+ fetchPromises.push(
+ fetch(
+ `${process.env.INDEXER_URL}/api/v1/pos/reward/${referee_address}/tnam1q8lhvxys53dlc8wzlg7dyqf9avd0vff6wvav4amt/${epoch}`
+ )
+ );
+ }
+ }
+
+ const responses = await Promise.all(fetchPromises);
+ const responseData: ResponseData[] = await Promise.all(
+ responses.map(async (res, i) => {
+ if (res.ok) {
+ const data = (await res.json()) as RewardData[];
+ return { ...referralEpochMap[i], data, success: true };
+ }
+ return { ...referralEpochMap[i], data: null, success: false };
+ })
+ );
+
+ console.log("Processed response data count:", responseData.length);
+ console.log("Cumulative rewards map entries:", cumByReferral.size);
+
+ // build cumulative map
+ responseData.forEach((r) => {
+ if (!r.success || !r.data?.length) return;
+ const key = `${r.referrerAddress}-${r.refereeAddress}`;
+ const amt = new BigNumber(r.data[0].minDenomAmount).dividedBy(1e6);
+ if (!cumByReferral.has(key)) cumByReferral.set(key, {});
+ cumByReferral.get(key)![r.epoch] = {
+ amount: amt,
+ validator: r.data[0].validator,
+ };
+ });
+
+ // compute incremental rewards
+ const rewards: ReferralReward[] = [];
+
+ console.log("Processed response data count:", responseData.length);
+ console.log("Cumulative rewards map entries:", cumByReferral.size);
+
+ for (const [key, epochMap] of cumByReferral) {
+ const [referrerAddress, refereeAddress] = key.split("-");
+ console.log(`Processing pair: ${referrerAddress}-${refereeAddress}`);
+ console.log(`Epochs available:`, Object.keys(epochMap));
+
+ const startEpoch = referrals.find(
+ (r) =>
+ r.referrer_address === referrerAddress &&
+ r.referee_address === refereeAddress
+ )!.last_paid_epoch;
+
+ console.log(`Starting epoch: ${startEpoch}`);
+
+ const epochs = Object.keys(epochMap)
+ .map((n) => +n)
+ .filter((e) => e >= startEpoch)
+ .sort((a, b) => a - b);
+
+ console.log(`Filtered epochs to process: ${epochs.join(", ")}`);
+
+ // Track number of rewards added for this referral pair
+ let rewardsAdded = 0;
+
+ for (const epoch of epochs) {
+ const curr = epochMap[epoch];
+ const prev = epochMap[epoch - 1];
+
+ // Calculate delta between current and previous epoch
+ const delta = prev ? curr.amount.minus(prev.amount) : curr.amount;
+
+ // Always add the reward, even if zero or negative (for debugging)
+ // In production you might want to filter these out again
+ if (true) {
+ // Changed from delta.isGreaterThan(0)
+ rewardsAdded++;
+ rewards.push({
+ epoch,
+ referrerAddress,
+ refereeAddress,
+ amount: delta.isLessThan(0) ? curr.amount : delta, // Use full amount if delta is negative
+ validator: curr.validator,
+ });
+ }
+ }
+
+ console.log(`Added ${rewardsAdded} rewards for this referral pair`);
+ }
+
+ console.log(`Total rewards to be exported: ${rewards.length}`);
+ console.log(
+ `Unique epochs in rewards:`,
+ [...new Set(rewards.map((r) => r.epoch))].sort((a, b) => a - b)
+ );
+
+ // Group rewards by referrer address for the multi-tab file
+ const rewardsByReferrer: Record = {};
+ rewards.forEach((reward) => {
+ if (!rewardsByReferrer[reward.referrerAddress]) {
+ rewardsByReferrer[reward.referrerAddress] = [];
+ }
+ rewardsByReferrer[reward.referrerAddress].push(reward);
+ });
+
+ // Sort rewards by epoch so entries with the same epoch are grouped together
+ const rewardsSortedByEpoch = [...rewards].sort((a, b) => {
+ // First sort by epoch
+ if (a.epoch !== b.epoch) return a.epoch - b.epoch;
+ // Then by referrer address for consistent ordering within the same epoch
+ return a.referrerAddress.localeCompare(b.referrerAddress);
+ });
+ setRewardsData(rewardsSortedByEpoch);
+
+ if (rewards.length > 0) {
+ // Log data to help troubleshoot generation
+ console.log(
+ `Generated ${rewards.length} reward entries for ${Object.keys(rewardsByReferrer).length} referrers`
+ );
+
+ // Group rewards by epoch to check how many rewards per epoch
+ const rewardsByEpoch = rewards.reduce(
+ (acc, reward) => {
+ acc[reward.epoch] = (acc[reward.epoch] || 0) + 1;
+ return acc;
+ },
+ {} as Record
+ );
+
+ console.log("Rewards per epoch:", rewardsByEpoch);
+
+ // Download the multi-sheet Excel file
+ downloadMultiSheetExcel(rewardsByReferrer);
+ }
+
+ setShowModal(true);
+ } catch (error) {
+ console.error("Error generating referral sheet:", error);
+ setGenerationError("Failed to generate referral sheet");
+ } finally {
+ setIsGenerating(false);
+ }
+ };
+
+ return (
+
+
+ {generationError && (
+
{generationError}
+ )}
+
+ {isGenerating ? "Generating..." : "Generate Referral Sheets"}
+
+
+ {showModal && rewardsData.length > 0 && (
+
setShowModal(false)}
+ />
+ )}
+
+ );
+};
diff --git a/apps/namadillo/src/App/Referrals/excel.ts b/apps/namadillo/src/App/Referrals/excel.ts
new file mode 100644
index 0000000000..fba88dd124
--- /dev/null
+++ b/apps/namadillo/src/App/Referrals/excel.ts
@@ -0,0 +1,284 @@
+import { shortenAddress } from "@namada/utils";
+import * as XLSX from "xlsx";
+import { ReferralReward } from "./types";
+
+// Helper function to generate CSV data
+const generateCsvContent = (rewardsData: ReferralReward[]): string => {
+ // CSV Headers
+ const headers =
+ "Referrer Address,Referee Address,Epoch,Reward (NAM),Validator Name\r\n";
+
+ if (!rewardsData || rewardsData.length === 0) {
+ console.warn("No rewards data to generate CSV");
+ return headers;
+ }
+
+ try {
+ // Sort rewards by referee address to group them
+ const sortedRewards = [...rewardsData].sort(
+ (a, b) =>
+ a.refereeAddress.localeCompare(b.refereeAddress) || a.epoch - b.epoch
+ );
+
+ // Format each reward as a CSV row and join with Windows-style line breaks (CRLF)
+ let currentRefereeAddress: string | null = null;
+ const csvRows: string[] = [];
+
+ sortedRewards.forEach((r) => {
+ // Check if we have all required data
+ if (!r || !r.validator) {
+ console.warn("Invalid reward data item", r);
+ return;
+ }
+
+ // Add a blank row when referee address changes
+ if (
+ currentRefereeAddress !== null &&
+ currentRefereeAddress !== r.refereeAddress
+ ) {
+ csvRows.push(""); // Add blank row
+ }
+
+ currentRefereeAddress = r.refereeAddress;
+
+ // Escape any commas in text fields with quotes
+ const validatorName =
+ r.validator.name ?
+ `"${r.validator.name.replace(/"/g, '""')}"`
+ : "Unknown";
+
+ csvRows.push(
+ [
+ `"${r.referrerAddress}"`,
+ `"${r.refereeAddress}"`,
+ r.epoch,
+ r.amount.toFixed(6),
+ validatorName,
+ ].join(",")
+ );
+ });
+
+ return headers + csvRows.join("\r\n");
+ } catch (error) {
+ console.error("Error generating CSV content:", error);
+ return headers + "Error generating CSV data";
+ }
+};
+
+// Helper function to download CSV
+const downloadCsv = (data: string, filename: string): void => {
+ // Use BOM (Byte Order Mark) to help Excel recognize UTF-8
+ const BOM = "\uFEFF";
+ const csvContent = BOM + data;
+
+ // Create a blob with proper MIME type for CSV
+ const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8" });
+ const url = URL.createObjectURL(blob);
+
+ const link = document.createElement("a");
+ link.setAttribute("href", url);
+ link.setAttribute("download", filename);
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+};
+
+// Helper function to create a multi-sheet Excel file
+export const downloadMultiSheetExcel = (
+ rewardsByReferrer: Record
+): void => {
+ try {
+ // Create a new workbook
+ const workbook = XLSX.utils.book_new();
+
+ // First, add a sheet with all rewards combined
+ const allRewards = Object.values(rewardsByReferrer).flat();
+ if (allRewards.length > 0) {
+ // Sort all rewards by referee address to group them together
+ const sortedAllRewards = [...allRewards].sort(
+ (a, b) =>
+ a.refereeAddress.localeCompare(b.refereeAddress) || a.epoch - b.epoch
+ );
+
+ // Convert rewards to array format with blank rows between referee addresses
+ const allRewardsFormatted: (Record | null)[] = [];
+ let currentRefereeAddress: string | null = null;
+
+ sortedAllRewards.forEach((reward) => {
+ // Add a blank row when referee address changes
+ if (
+ currentRefereeAddress !== null &&
+ currentRefereeAddress !== reward.refereeAddress
+ ) {
+ allRewardsFormatted.push(null); // Add blank row
+ }
+
+ currentRefereeAddress = reward.refereeAddress;
+
+ allRewardsFormatted.push({
+ "Referrer Address": reward.referrerAddress,
+ "Referee Address": reward.refereeAddress,
+ Epoch: String(reward.epoch),
+ "Reward (NAM)": String(reward.amount.toFixed(6)),
+ "Validator Name": reward.validator.name || "Unknown",
+ });
+ });
+
+ // Convert to AOA format with null rows becoming empty arrays (blank rows)
+ const headers = [
+ "Referrer Address",
+ "Referee Address",
+ "Epoch",
+ "Reward (NAM)",
+ "Validator Name",
+ ];
+ const dataRows = allRewardsFormatted.map((row) =>
+ row ? Object.values(row) : Array(headers.length).fill("")
+ );
+
+ // Create worksheet with formatting options
+ const worksheet = XLSX.utils.aoa_to_sheet([
+ headers, // Headers
+ ...dataRows, // Data rows with blank rows
+ ]);
+
+ // Auto-size columns based on content (filter out null entries for fitToColumn)
+ const columnWidths = fitToColumn(
+ allRewardsFormatted.filter(Boolean) as Record[]
+ );
+ worksheet["!cols"] = columnWidths;
+
+ XLSX.utils.book_append_sheet(workbook, worksheet, "All Rewards");
+ }
+
+ // Add a sheet for each referrer
+ for (const [referrerAddress, rewards] of Object.entries(
+ rewardsByReferrer
+ )) {
+ if (rewards.length > 0) {
+ // Sort rewards by referee address to group them together
+ const sortedRewards = [...rewards].sort(
+ (a, b) =>
+ a.refereeAddress.localeCompare(b.refereeAddress) ||
+ a.epoch - b.epoch
+ );
+
+ // Convert rewards to array format with blank rows between referee addresses
+ const referrerRewardsFormatted: (Record | null)[] = [];
+ let currentRefereeAddress: string | null = null;
+
+ sortedRewards.forEach((reward) => {
+ // Add a blank row when referee address changes
+ if (
+ currentRefereeAddress !== null &&
+ currentRefereeAddress !== reward.refereeAddress
+ ) {
+ referrerRewardsFormatted.push(null); // Add blank row
+ }
+
+ currentRefereeAddress = reward.refereeAddress;
+
+ referrerRewardsFormatted.push({
+ "Referrer Address": reward.referrerAddress,
+ "Referee Address": reward.refereeAddress,
+ Epoch: String(reward.epoch),
+ "Reward (NAM)": String(reward.amount.toFixed(6)),
+ "Validator Name": reward.validator.name || "Unknown",
+ });
+ });
+
+ // Create worksheet - use shortened address for sheet name
+ const shortAddr = shortenAddress(referrerAddress, 8, 4);
+
+ // Convert to AOA format with null rows becoming empty arrays (blank rows)
+ const headers = [
+ "Referrer Address",
+ "Referee Address",
+ "Epoch",
+ "Reward (NAM)",
+ "Validator Name",
+ ];
+ const dataRows = referrerRewardsFormatted.map((row) =>
+ row ? Object.values(row) : Array(headers.length).fill("")
+ );
+
+ // Create worksheet with formatting options
+ const worksheet = XLSX.utils.aoa_to_sheet([
+ headers, // Headers
+ ...dataRows, // Data rows with blank rows
+ ]);
+
+ // Auto-size columns based on content (filter out null entries for fitToColumn)
+ const columnWidths = fitToColumn(
+ referrerRewardsFormatted.filter(Boolean) as Record[]
+ );
+ worksheet["!cols"] = columnWidths;
+
+ XLSX.utils.book_append_sheet(workbook, worksheet, shortAddr);
+ }
+ }
+
+ // Generate Excel file with formatting options
+ const excelBuffer = XLSX.write(workbook, {
+ bookType: "xlsx",
+ type: "array",
+ cellStyles: true,
+ });
+
+ // Generate filename with current date and time
+ const now = new Date();
+ const dateString = now.toISOString().split("T")[0];
+ const filename = `Referral-Rewards-${dateString}.xlsx`;
+
+ // Convert to Blob and download
+ const blob = new Blob([excelBuffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+ } catch (error) {
+ console.error("Error creating Excel file:", error);
+
+ // Fallback: Just download a single CSV with all data
+ const allRewards = Object.values(rewardsByReferrer).flat();
+ if (allRewards.length > 0) {
+ downloadCsv(generateCsvContent(allRewards), "referral_rewards.csv");
+ }
+ }
+};
+
+// Helper function to calculate column widths based on content
+export const fitToColumn = (
+ data: Record[]
+): { wch: number }[] => {
+ if (!data || data.length === 0) return [];
+
+ // Get all the keys from the first object
+ const columnNames = Object.keys(data[0]);
+
+ // Initialize the width array with the column header lengths
+ const columnWidths = columnNames.map((name) => ({
+ wch: Math.max(10, name.length * 1.2), // Base width on header with minimum of 10
+ }));
+
+ // Check the width needed for each cell value
+ data.forEach((row) => {
+ columnNames.forEach((col, i) => {
+ const value = row[col]?.toString() || "";
+ const cellWidth = Math.min(50, value.length * 1.2); // Cap width at 50 chars
+
+ if (cellWidth > columnWidths[i].wch) {
+ columnWidths[i].wch = cellWidth;
+ }
+ });
+ });
+
+ return columnWidths;
+};
diff --git a/apps/namadillo/src/App/Referrals/types.ts b/apps/namadillo/src/App/Referrals/types.ts
new file mode 100644
index 0000000000..f90f7f79b8
--- /dev/null
+++ b/apps/namadillo/src/App/Referrals/types.ts
@@ -0,0 +1,46 @@
+import BigNumber from "bignumber.js";
+
+export type Referral = {
+ id: number;
+ referrer_address: string;
+ referee_address: string;
+ last_paid_epoch: number;
+ active: boolean;
+ created_at?: string;
+};
+export type ReferralsTableProps = {
+ id: string;
+ referrals: Referral[];
+ resultsPerPage?: number;
+ initialPage?: number;
+ tableClassName?: string;
+};
+
+export type ValidatorInfo = {
+ address: string;
+ votingPower: string;
+ maxCommission: string;
+ commission: string;
+ state: string;
+ name: string;
+ email: string;
+ website: string;
+ description: string;
+ discordHandle: string | null;
+ avatar: string;
+ validatorId: string;
+ rank: string | null;
+};
+
+export type RewardData = {
+ minDenomAmount: string;
+ validator: ValidatorInfo;
+};
+
+export type ReferralReward = {
+ epoch: number;
+ referrerAddress: string;
+ refereeAddress: string;
+ amount: BigNumber;
+ validator: ValidatorInfo;
+};
diff --git a/apps/namadillo/src/App/Staking/AllValidatorsTable.tsx b/apps/namadillo/src/App/Staking/AllValidatorsTable.tsx
index 5c0b856944..93f4e2d0a4 100644
--- a/apps/namadillo/src/App/Staking/AllValidatorsTable.tsx
+++ b/apps/namadillo/src/App/Staking/AllValidatorsTable.tsx
@@ -29,7 +29,7 @@ export const AllValidatorsTable = ({
initialPage = 0,
}: AllValidatorsProps): JSX.Element => {
const validators = useAtomValue(allValidatorsAtom);
- const [searchTerm, setSearchTerm] = useState("");
+ const [_, setSearchTerm] = useState("");
const userHasAccount = useUserHasAccount();
const filteredValidators = useValidatorFilter({
diff --git a/apps/namadillo/src/App/Staking/IncrementBonding.tsx b/apps/namadillo/src/App/Staking/IncrementBonding.tsx
index 80b4b9eb2c..fac799124b 100644
--- a/apps/namadillo/src/App/Staking/IncrementBonding.tsx
+++ b/apps/namadillo/src/App/Staking/IncrementBonding.tsx
@@ -62,7 +62,7 @@ const IncrementBonding = (): JSX.Element => {
const validatorLink =
isNamadaAddress(searchParams.get("validator") ?? "") ?
searchParams.get("validator")
- : null;
+ : "ValidityOps#1";
const [filter, setFilter] = useState(validatorLink ?? "");
const [onlyMyValidators, setOnlyMyValidators] = useState(false);
const [validatorFilter, setValidatorFilter] =
diff --git a/apps/namadillo/src/App/routes.ts b/apps/namadillo/src/App/routes.ts
index b72e21797d..42ff2d6ad9 100644
--- a/apps/namadillo/src/App/routes.ts
+++ b/apps/namadillo/src/App/routes.ts
@@ -1,6 +1,9 @@
export const routes = {
root: "/",
+ // Referrals
+ referrals: "/referrals",
+
// Staking
staking: "/staking",
stakingBondingIncrement: "/staking/bonding/increment",
diff --git a/apps/namadillo/src/atoms/staking/services.ts b/apps/namadillo/src/atoms/staking/services.ts
index 03bc739a7d..d4381370f5 100644
--- a/apps/namadillo/src/atoms/staking/services.ts
+++ b/apps/namadillo/src/atoms/staking/services.ts
@@ -83,6 +83,7 @@ export const createWithdrawTx = async (
gasConfig: GasConfig
): Promise> => {
const sdk = await getSdkInstance();
+
return await buildTx(
sdk,
account,
@@ -92,7 +93,6 @@ export const createWithdrawTx = async (
sdk.tx.buildWithdraw
);
};
-
export const createClaimTx = async (
chain: ChainSettings,
account: Account,
diff --git a/apps/namadillo/src/hooks/useTransactionCallbacks.tsx b/apps/namadillo/src/hooks/useTransactionCallbacks.tsx
index 2e2d7f120e..4c001297f3 100644
--- a/apps/namadillo/src/hooks/useTransactionCallbacks.tsx
+++ b/apps/namadillo/src/hooks/useTransactionCallbacks.tsx
@@ -1,10 +1,12 @@
import { accountBalanceAtom, defaultAccountAtom } from "atoms/accounts";
import { shieldedBalanceAtom } from "atoms/balance/atoms";
+import { chainStatusAtom } from "atoms/chain";
import { shouldUpdateBalanceAtom, shouldUpdateProposalAtom } from "atoms/etc";
import { claimableRewardsAtom } from "atoms/staking";
import { useAtomValue, useSetAtom } from "jotai";
import { TransferStep, TransferTransactionData } from "types";
import { useTransactionEventListener } from "utils";
+import { saveReferralToSupabase } from "utils/supabase";
import { useTransactionActions } from "./useTransactionActions";
export const useTransactionCallback = (): void => {
@@ -16,7 +18,7 @@ export const useTransactionCallback = (): void => {
const { changeTransaction } = useTransactionActions();
const shouldUpdateProposal = useSetAtom(shouldUpdateProposalAtom);
const shouldUpdateBalance = useSetAtom(shouldUpdateBalanceAtom);
-
+ const chainStatus = useAtomValue(chainStatusAtom);
const onBalanceUpdate = (): void => {
// TODO: refactor this after event subscription is enabled on indexer
shouldUpdateBalance(true);
@@ -31,7 +33,15 @@ export const useTransactionCallback = (): void => {
}
};
- useTransactionEventListener("Bond.Success", onBalanceUpdate);
+ const successfulBond = async (): Promise => {
+ onBalanceUpdate();
+ const referrerAddress = localStorage.getItem("referrerAddress");
+ const refereeAddress = localStorage.getItem("refereeAddress");
+ const epoch = chainStatus?.epoch;
+ await saveReferralToSupabase(referrerAddress!, refereeAddress!, epoch!);
+ };
+
+ useTransactionEventListener("Bond.Success", successfulBond);
useTransactionEventListener("Unbond.Success", onBalanceUpdate);
useTransactionEventListener("Withdraw.Success", onBalanceUpdate);
useTransactionEventListener("Redelegate.Success", onBalanceUpdate);
diff --git a/apps/namadillo/src/utils/supabase.ts b/apps/namadillo/src/utils/supabase.ts
new file mode 100644
index 0000000000..ccf29867ad
--- /dev/null
+++ b/apps/namadillo/src/utils/supabase.ts
@@ -0,0 +1,86 @@
+import { createClient } from "@supabase/supabase-js";
+
+const supabaseUrl = process.env.SUPABASE_URL;
+const supabaseKey = process.env.SUPABASE_ANON_KEY;
+const supabase = createClient(supabaseUrl!, supabaseKey!);
+
+export const saveReferralToSupabase = async (
+ referrerAddress: string,
+ refereeAddress: string,
+ epoch: number
+): Promise<{ success: boolean; error?: unknown; data?: unknown }> => {
+ try {
+ // Make sure we have the required data
+ if (!referrerAddress || !refereeAddress || !epoch) {
+ console.error("Missing required referral data");
+ return { success: false, error: "Missing required referral data" };
+ }
+
+ // First check if this referee already exists in the database
+ const { data: existingReferrals, error: fetchError } = await supabase
+ .from("referrals")
+ .select("*")
+ .eq("referee_address", refereeAddress)
+ .eq("referrer_address", referrerAddress)
+ .limit(1);
+
+ if (fetchError) {
+ console.error("Error checking existing referral:", fetchError);
+ return { success: false, error: fetchError };
+ }
+
+ // If referee already exists, skip insertion
+ if (existingReferrals && existingReferrals.length > 0) {
+ console.log("Referee already exists in database, skipping insertion");
+ return { success: true, data: existingReferrals[0] };
+ }
+
+ // Create referral record with timestamp
+ const referralData = {
+ referrer_address: referrerAddress,
+ referee_address: refereeAddress,
+ last_paid_epoch: epoch,
+ active: true,
+ };
+
+ // Send data to Supabase
+ const { data, error } = await supabase
+ .from("referrals")
+ .insert([referralData]);
+
+ if (error) {
+ console.error("Error saving referral:", error);
+ return { success: false, error };
+ }
+
+ console.log("Referral saved successfully:", data);
+ return { success: true, data };
+ } catch (err) {
+ console.error("Unexpected error saving referral:", err);
+ return { success: false, error: err };
+ }
+};
+
+export const getReferralsFromSupabase = async (): Promise<{
+ success: boolean;
+ error?: unknown;
+ data?: unknown;
+}> => {
+ try {
+ const { data, error } = await supabase
+ .from("referrals")
+ .select("*")
+ .eq("active", true);
+
+ if (error) {
+ console.error("Error fetching referrals:", error);
+ return { success: false, error };
+ }
+
+ console.log("Referrals fetched successfully:", data);
+ return { success: true, data };
+ } catch (err) {
+ console.error("Unexpected error fetching referrals:", err);
+ return { success: false, error: err };
+ }
+};
diff --git a/apps/namadillo/vite.config.mjs b/apps/namadillo/vite.config.mjs
index 671ffe3868..ff364c1fe4 100644
--- a/apps/namadillo/vite.config.mjs
+++ b/apps/namadillo/vite.config.mjs
@@ -1,12 +1,14 @@
/* eslint-disable */
import react from "@vitejs/plugin-react";
-import { defineConfig } from "vite";
+import { defineConfig, loadEnv } from "vite";
import checker from "vite-plugin-checker";
import { nodePolyfills } from "vite-plugin-node-polyfills";
import { VitePWA } from "vite-plugin-pwa";
import tsconfigPaths from "vite-tsconfig-paths";
-export default defineConfig(() => {
+export default defineConfig(({ mode }) => {
+ const env = loadEnv(mode, process.cwd(), "");
+
return {
server: {
headers: {
@@ -44,6 +46,11 @@ export default defineConfig(() => {
plugins: () => [tsconfigPaths()],
format: "es",
},
+ define: {
+ "process.env.SUPABASE_URL": JSON.stringify(env.SUPABASE_URL),
+ "process.env.SUPABASE_ANON_KEY": JSON.stringify(env.SUPABASE_ANON_KEY),
+ "process.env.INDEXER_URL": JSON.stringify(env.INDEXER_URL),
+ },
optimizeDeps: {
esbuildOptions: {
// Node.js global to browser globalThis
diff --git a/yarn.lock b/yarn.lock
index beb4c80fd3..bcee655934 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3829,6 +3829,7 @@ __metadata:
"@namada/chain-registry": "npm:^1.0.0"
"@namada/indexer-client": "npm:2.4.4"
"@playwright/test": "npm:^1.24.1"
+ "@supabase/supabase-js": "npm:^2.49.4"
"@svgr/webpack": "npm:^6.5.1"
"@tailwindcss/container-queries": "npm:^0.1.1"
"@tanstack/query-core": "npm:^5.40.0"
@@ -3908,6 +3909,7 @@ __metadata:
vite-tsconfig-paths: "npm:^5.0.1"
web-vitals: "npm:^2.1.4"
wonka: "npm:^6.3.4"
+ xlsx: "npm:^0.18.5"
languageName: unknown
linkType: soft
@@ -4739,6 +4741,77 @@ __metadata:
languageName: node
linkType: hard
+"@supabase/auth-js@npm:2.69.1":
+ version: 2.69.1
+ resolution: "@supabase/auth-js@npm:2.69.1"
+ dependencies:
+ "@supabase/node-fetch": "npm:^2.6.14"
+ checksum: 10c0/efc08fc6be48769efc84105617f2cb681791641ba86480d29a316ae83beac8cf4f747bf00b4947c32992f9b7b0b40e3c2bf74013f01e070c38558169ab68b6f1
+ languageName: node
+ linkType: hard
+
+"@supabase/functions-js@npm:2.4.4":
+ version: 2.4.4
+ resolution: "@supabase/functions-js@npm:2.4.4"
+ dependencies:
+ "@supabase/node-fetch": "npm:^2.6.14"
+ checksum: 10c0/35871341ca96c35a416d81a6f035bd0d594d278f5cbe4492173766b3d6b9acfc52374b0a2b50e31a900a8e3a9dcb131d1eadf3808a9a9e1c10bbab7a2045d2d3
+ languageName: node
+ linkType: hard
+
+"@supabase/node-fetch@npm:2.6.15, @supabase/node-fetch@npm:^2.6.14":
+ version: 2.6.15
+ resolution: "@supabase/node-fetch@npm:2.6.15"
+ dependencies:
+ whatwg-url: "npm:^5.0.0"
+ checksum: 10c0/98d25cab2eba53c93c59e730d52d50065b1a7fe216c65224471e83e2064ebd45ae51ad09cb39ec263c3cb59e3d41870fc2e789ea2e9587480d7ba212b85daf38
+ languageName: node
+ linkType: hard
+
+"@supabase/postgrest-js@npm:1.19.4":
+ version: 1.19.4
+ resolution: "@supabase/postgrest-js@npm:1.19.4"
+ dependencies:
+ "@supabase/node-fetch": "npm:^2.6.14"
+ checksum: 10c0/1d14adc841f720e0035045f8b06cf2cb9f3b0a83ac903e268d5afb80b64d240ae64cb24372e0e9c857420b07010bfdb9a806f024fe60ac13468fd791ada2eb7f
+ languageName: node
+ linkType: hard
+
+"@supabase/realtime-js@npm:2.11.2":
+ version: 2.11.2
+ resolution: "@supabase/realtime-js@npm:2.11.2"
+ dependencies:
+ "@supabase/node-fetch": "npm:^2.6.14"
+ "@types/phoenix": "npm:^1.5.4"
+ "@types/ws": "npm:^8.5.10"
+ ws: "npm:^8.18.0"
+ checksum: 10c0/1e91c8e70d4bf2cd25ed9d7d8c75c0fcc2ef4f53d03647cbaac790cf9f359295b5aa6ce0876f12e7e15804cfe9979398cd57fc92f19a43ee4e691d29abbe8e14
+ languageName: node
+ linkType: hard
+
+"@supabase/storage-js@npm:2.7.1":
+ version: 2.7.1
+ resolution: "@supabase/storage-js@npm:2.7.1"
+ dependencies:
+ "@supabase/node-fetch": "npm:^2.6.14"
+ checksum: 10c0/bcaa8bd275c59b8c5f6f00b9590ef54f008b63aacdcd8bf1747cb73f61ea7bd321bb816314ae0cf1bb318cd4d398515f9a135bde84ef960c19ac3c11e38d00fd
+ languageName: node
+ linkType: hard
+
+"@supabase/supabase-js@npm:^2.49.4":
+ version: 2.49.4
+ resolution: "@supabase/supabase-js@npm:2.49.4"
+ dependencies:
+ "@supabase/auth-js": "npm:2.69.1"
+ "@supabase/functions-js": "npm:2.4.4"
+ "@supabase/node-fetch": "npm:2.6.15"
+ "@supabase/postgrest-js": "npm:1.19.4"
+ "@supabase/realtime-js": "npm:2.11.2"
+ "@supabase/storage-js": "npm:2.7.1"
+ checksum: 10c0/43f5500b4cee89fa975ef13036846e6406d7719c79e1c9563b9e6a54529aa7b8d17e86bd0cd2cd2394ce6894e85628f43a29809572a438d2fc493175bb25ac25
+ languageName: node
+ linkType: hard
+
"@surma/rollup-plugin-off-main-thread@npm:^2.2.3":
version: 2.2.3
resolution: "@surma/rollup-plugin-off-main-thread@npm:2.2.3"
@@ -5638,6 +5711,13 @@ __metadata:
languageName: node
linkType: hard
+"@types/phoenix@npm:^1.5.4":
+ version: 1.6.6
+ resolution: "@types/phoenix@npm:1.6.6"
+ checksum: 10c0/4dfcb3fd36341ed5500de030291af14163c599857e00d2d4ff065d4c4600317d5d20aa170913fb9609747a09436e3add44db7d0c709bdf80f36cddcc67a42021
+ languageName: node
+ linkType: hard
+
"@types/postcss-modules-local-by-default@npm:^4.0.2":
version: 4.0.2
resolution: "@types/postcss-modules-local-by-default@npm:4.0.2"
@@ -5913,6 +5993,15 @@ __metadata:
languageName: node
linkType: hard
+"@types/ws@npm:^8.5.10":
+ version: 8.18.1
+ resolution: "@types/ws@npm:8.18.1"
+ dependencies:
+ "@types/node": "npm:*"
+ checksum: 10c0/61aff1129143fcc4312f083bc9e9e168aa3026b7dd6e70796276dcfb2c8211c4292603f9c4864fae702f2ed86e4abd4d38aa421831c2fd7f856c931a481afbab
+ languageName: node
+ linkType: hard
+
"@types/ws@npm:^8.5.5":
version: 8.5.13
resolution: "@types/ws@npm:8.5.13"
@@ -6483,6 +6572,13 @@ __metadata:
languageName: node
linkType: hard
+"adler-32@npm:~1.3.0":
+ version: 1.3.1
+ resolution: "adler-32@npm:1.3.1"
+ checksum: 10c0/c1b7185526ee1bbe0eac8ed414d5226af4cd02a0540449a72ec1a75f198c5e93352ba4d7b9327231eea31fd83c2d080d13baf16d8ed5710fb183677beb85f612
+ languageName: node
+ linkType: hard
+
"adm-zip@npm:~0.5.x":
version: 0.5.16
resolution: "adm-zip@npm:0.5.16"
@@ -7827,6 +7923,16 @@ __metadata:
languageName: node
linkType: hard
+"cfb@npm:~1.2.1":
+ version: 1.2.2
+ resolution: "cfb@npm:1.2.2"
+ dependencies:
+ adler-32: "npm:~1.3.0"
+ crc-32: "npm:~1.2.0"
+ checksum: 10c0/87f6d9c3878268896ed6ca29dfe32a2aa078b12d0f21d8405c95911b74ab6296823d7312bbf5e18326d00b16cc697f587e07a17018c5edf7a1ba31dd5bc6da36
+ languageName: node
+ linkType: hard
+
"chain-registry@npm:^1.63.100":
version: 1.69.89
resolution: "chain-registry@npm:1.69.89"
@@ -8113,6 +8219,13 @@ __metadata:
languageName: node
linkType: hard
+"codepage@npm:~1.15.0":
+ version: 1.15.0
+ resolution: "codepage@npm:1.15.0"
+ checksum: 10c0/2455b482302cb784b46dea60a8ee83f0c23e794bdd979556bdb107abe681bba722af62a37f5c955ff4efd68fdb9688c3986e719b4fd536c0e06bb25bc82abea3
+ languageName: node
+ linkType: hard
+
"collect-v8-coverage@npm:^1.0.0":
version: 1.0.2
resolution: "collect-v8-coverage@npm:1.0.2"
@@ -8569,6 +8682,15 @@ __metadata:
languageName: node
linkType: hard
+"crc-32@npm:~1.2.0, crc-32@npm:~1.2.1":
+ version: 1.2.2
+ resolution: "crc-32@npm:1.2.2"
+ bin:
+ crc32: bin/crc32.njs
+ checksum: 10c0/11dcf4a2e77ee793835d49f2c028838eae58b44f50d1ff08394a610bfd817523f105d6ae4d9b5bef0aad45510f633eb23c903e9902e4409bed1ce70cb82b9bf0
+ languageName: node
+ linkType: hard
+
"create-ecdh@npm:^4.0.4":
version: 4.0.4
resolution: "create-ecdh@npm:4.0.4"
@@ -11081,6 +11203,13 @@ __metadata:
languageName: node
linkType: hard
+"frac@npm:~1.1.2":
+ version: 1.1.2
+ resolution: "frac@npm:1.1.2"
+ checksum: 10c0/640740eb58b590eb38c78c676955bee91cd22d854f5876241a15c49d4495fa53a84898779dcf7eca30aabfe1c1a4a705752b5f224934257c5dda55c545413ba7
+ languageName: node
+ linkType: hard
+
"fraction.js@npm:^4.3.7":
version: 4.3.7
resolution: "fraction.js@npm:4.3.7"
@@ -18980,6 +19109,15 @@ __metadata:
languageName: node
linkType: hard
+"ssf@npm:~0.11.2":
+ version: 0.11.2
+ resolution: "ssf@npm:0.11.2"
+ dependencies:
+ frac: "npm:~1.1.2"
+ checksum: 10c0/c3fd24a90dc37a9dc5c4154cb4121e27507c33ebfeee3532aaf03625756b2c006cf79c0a23db0ba16c4a6e88e1349455327867e03453fc9d54b32c546bc18ca6
+ languageName: node
+ linkType: hard
+
"sshpk@npm:^1.7.0":
version: 1.18.0
resolution: "sshpk@npm:1.18.0"
@@ -21804,6 +21942,13 @@ __metadata:
languageName: node
linkType: hard
+"wmf@npm:~1.0.1":
+ version: 1.0.2
+ resolution: "wmf@npm:1.0.2"
+ checksum: 10c0/3fa5806f382632cadfe65d4ef24f7a583b0c0720171edb00e645af5248ad0bb6784e8fcee1ccd9f475a1a12a7523e2512e9c063731fbbdae14dc469e1c033d93
+ languageName: node
+ linkType: hard
+
"wonka@npm:^6.3.4":
version: 6.3.4
resolution: "wonka@npm:6.3.4"
@@ -21818,6 +21963,13 @@ __metadata:
languageName: node
linkType: hard
+"word@npm:~0.3.0":
+ version: 0.3.0
+ resolution: "word@npm:0.3.0"
+ checksum: 10c0/c6da2a9f7a0d81a32fa6768a638d21b153da2be04f94f3964889c7cc1365d74b6ecb43b42256c3f926cd59512d8258206991c78c21000c3da96d42ff1238b840
+ languageName: node
+ linkType: hard
+
"wordwrap@npm:^1.0.0":
version: 1.0.0
resolution: "wordwrap@npm:1.0.0"
@@ -22193,6 +22345,23 @@ __metadata:
languageName: node
linkType: hard
+"xlsx@npm:^0.18.5":
+ version: 0.18.5
+ resolution: "xlsx@npm:0.18.5"
+ dependencies:
+ adler-32: "npm:~1.3.0"
+ cfb: "npm:~1.2.1"
+ codepage: "npm:~1.15.0"
+ crc-32: "npm:~1.2.1"
+ ssf: "npm:~0.11.2"
+ wmf: "npm:~1.0.1"
+ word: "npm:~0.3.0"
+ bin:
+ xlsx: bin/xlsx.njs
+ checksum: 10c0/787cfa77034a3e86fdcde21572f1011c8976f87823a5e0ee5057f13b2f6e48f17a1710732a91b8ae15d7794945c7cba8a3ca904ea7150e028260b0ab8e1158c8
+ languageName: node
+ linkType: hard
+
"xml-name-validator@npm:^4.0.0":
version: 4.0.0
resolution: "xml-name-validator@npm:4.0.0"