>(({ className, ...props }, ref) => (
| [role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
));
TableCaption.displayName = "TableCaption";
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};
================================================
FILE: frontend/components.json
================================================
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "global.css",
"baseColor": "zinc",
"cssVariables": false
},
"aliases": {
"components": "components",
"utils": "utils"
}
}
================================================
FILE: frontend/global.css
================================================
@tailwind base;
@tailwind components;
@tailwind utilities;
================================================
FILE: frontend/next.config.js
================================================
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
webpack: (config) => {
config.resolve.fallback = { fs: false, net: false, tls: false };
return config;
},
};
module.exports = nextConfig;
================================================
FILE: frontend/package.json
================================================
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "prisma generate && next build",
"start": "next start -p $PORT",
"lint": "next lint"
},
"dependencies": {
"@prisma/client": "4.16.2",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-popover": "^1.0.6",
"@radix-ui/react-slot": "^1.0.2",
"@rainbow-me/rainbowkit": "^1.0.8",
"@tremor/react": "^3.6.1",
"@uidotdev/usehooks": "2.1.0",
"axios": "^1.4.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"date-fns": "^2.30.0",
"ioredis": "^5.3.2",
"next": "13.4.13",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-grid-layout": "^1.3.4",
"react-resizable": "^3.0.5",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.6",
"unstated-next": "^1.1.0",
"viem": "^1.6.0",
"wagmi": "^1.3.10"
},
"devDependencies": {
"@types/node": "20.4.10",
"@types/react": "18.2.20",
"@types/react-dom": "18.2.7",
"@types/react-grid-layout": "^1.3.2",
"autoprefixer": "^10.4.14",
"cssnano": "^6.0.1",
"eslint": "8.47.0",
"eslint-config-next": "13.4.13",
"postcss": "^8.4.27",
"prisma": "4.16.2",
"tailwindcss": "^3.3.3",
"typescript": "5.1.6"
}
}
================================================
FILE: frontend/pages/_app.tsx
================================================
import { Global } from "state/global";
import type { AppProps } from "next/app";
// CSS imports
import "global.css";
import "react-resizable/css/styles.css";
import "react-grid-layout/css/styles.css";
import "@rainbow-me/rainbowkit/styles.css";
// RainbowKit
import { base } from "wagmi/chains";
import { publicProvider } from "wagmi/providers/public";
import { configureChains, createConfig, WagmiConfig } from "wagmi";
import {
darkTheme,
getDefaultWallets,
RainbowKitProvider,
} from "@rainbow-me/rainbowkit";
// Setup provider
const { chains, publicClient } = configureChains([base], [publicProvider()]);
// Setup connector
const { connectors } = getDefaultWallets({
appName: "FriendMEX",
projectId: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID ?? "",
chains,
});
// Setup Wagmi config
const wagmiConfig = createConfig({
autoConnect: true,
connectors,
publicClient,
});
export default function FriendMEX({ Component, pageProps }: AppProps) {
return (
// Wrap in RainbowKit providers
);
}
================================================
FILE: frontend/pages/api/eth.ts
================================================
import axios from "axios";
import cache from "utils/cache";
import type { NextApiRequest, NextApiResponse } from "next";
// CoinGecko endpoint
const CG_ETHUSD: string =
"https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd";
export default async function (req: NextApiRequest, res: NextApiResponse) {
try {
// Check cache
const price: string | null = await cache.get("eth_price");
if (price) return res.status(200).json(Number(price));
// Collect data
const {
data: {
ethereum: { usd },
},
}: { data: { ethereum: { usd: number } } } = await axios.get(CG_ETHUSD);
// Update cache with 5m TTL
const ok = await cache.set("eth_usd", usd, "EX", 60 * 5);
if (ok !== "OK") throw new Error("Error updating cache");
// Return price
return res.status(200).json(usd);
} catch (e: unknown) {
// Catch errors
if (e instanceof Error) {
return res.status(500).json({ message: e.message });
}
// Return default error
return res.status(500).json({ message: "Internal server error" });
}
}
================================================
FILE: frontend/pages/api/stats/leaderboard.ts
================================================
import cache from "utils/cache";
import type { UserInfo } from "components/User";
import type { NextApiRequest, NextApiResponse } from "next";
/**
* Collect leaderboard users (limit: 50) from 15s Redis cache
* @returns {Promise} leaderboard users
*/
export async function getLeaderboardUsers(): Promise {
const res: string | null = await cache.get("leaderboard");
if (!res) return [];
// Parse as leaderboard users
return JSON.parse(res) as UserInfo[];
}
export default async function handler(_: NextApiRequest, res: NextApiResponse) {
try {
// Get leaderboard users
const users = await getLeaderboardUsers();
return res.status(200).json(users);
} catch (e: unknown) {
// Catch errors
if (e instanceof Error) {
return res.status(500).json({ message: e.message });
}
// Return default error
return res.status(500).json({ message: "Internal server error" });
}
}
================================================
FILE: frontend/pages/api/stats/newest.ts
================================================
import cache from "utils/cache";
import type { UserInfo } from "components/User";
import { NextApiRequest, NextApiResponse } from "next";
/**
* Collect newest users (limit: 50) from 15s Redis cache
* @returns {Promise} newest users
*/
export async function getNewestUsers(): Promise {
const res: string | null = await cache.get("latest_users");
if (!res) return [];
return JSON.parse(res) as UserInfo[];
}
export default async function handler(_: NextApiRequest, res: NextApiResponse) {
try {
// Get newest users
const users = await getNewestUsers();
return res.status(200).json(users);
} catch (e: unknown) {
// Catch errors
if (e instanceof Error) {
return res.status(500).json({ message: e.message });
}
// Return default error
return res.status(500).json({ message: "Internal server error" });
}
}
================================================
FILE: frontend/pages/api/stats/realized.ts
================================================
import cache from "utils/cache";
import { NextApiRequest, NextApiResponse } from "next";
export type RealizedProfitUser = {
address: string;
twitterPfpUrl?: string | null;
twitterUsername?: string | null;
profit: number;
};
/**
* Collect realized profits (limit: 100) from 15s Redis cache
* @returns {RealizedProfitUser[]} address to realized profit
*/
export async function getRealizedProfits(): Promise {
const res: string | null = await cache.get("realized_profit");
if (!res) return [];
return JSON.parse(res);
}
export default async function handler(_: NextApiRequest, res: NextApiResponse) {
try {
// Get realized profits
const profits = await getRealizedProfits();
return res.status(200).json(profits);
} catch (e: unknown) {
// Catch errors
if (e instanceof Error) {
return res.status(500).json({ message: e.message });
}
// Return default error
return res.status(500).json({ message: "Internal server error" });
}
}
================================================
FILE: frontend/pages/api/stats/trades.ts
================================================
import cache from "utils/cache";
import type { Trade } from "@prisma/client";
import { NextApiRequest, NextApiResponse } from "next";
export type TradeWithTwitterUser = Trade & {
fromUser: {
twitterUsername?: string | null;
twitterPfpUrl?: string | null;
};
subjectUser: {
twitterUsername?: string | null;
twitterPfpUrl?: string | null;
};
};
/**
* Collect newest trades (limit: 100)
* @returns {Promise} newest trades
*/
export async function getLatestTrades(): Promise {
const res: string | null = await cache.get("latest_trades");
if (!res) return [];
// Parse as Trades
return JSON.parse(res) as TradeWithTwitterUser[];
}
export default async function handler(_: NextApiRequest, res: NextApiResponse) {
try {
// Get latest trades
const trades = await getLatestTrades();
return res.status(200).json(trades);
} catch (e: unknown) {
// Catch errors
if (e instanceof Error) {
return res.status(500).json({ message: e.message });
}
// Return default error
return res.status(500).json({ message: "Internal server error" });
}
}
================================================
FILE: frontend/pages/api/token/chart.ts
================================================
import db from "prisma/index";
import cache from "utils/cache";
import { getPrice } from "utils";
import type { Trade } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
// Types
type ChartData = { timestamp: Date; "Price (ETH)": number }[];
type CachedData = { lastChecked: Date; chart: ChartData; supply: number };
function processTrades(trades: Trade[], existing?: CachedData): CachedData {
let supply: number = 0,
data: ChartData = [];
// If existing chart data
if (existing) {
// Track trades
data.push(...existing.chart);
// Append supply
supply += existing.supply;
}
// Take remaining, new trades
for (const trade of trades) {
// Modify amounts
if (trade.isBuy) supply += trade.amount;
else supply -= trade.amount;
// Calculate new price for 1 token given supply change
const price = getPrice(supply, 1);
const fees = price * 0.1;
// Add new plot data
data.push({
timestamp: new Date(trade.timestamp * 1000),
"Price (ETH)": price - fees,
});
}
return {
lastChecked: new Date(),
chart: data,
supply,
};
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
// Collect token address
let { address } = req.query;
if (!address) return res.status(400).json({ error: "Missing token address" });
// Only accept first query parameter
if (Array.isArray(address)) address = address[0];
address = address.toLowerCase();
try {
// Check cache to see if chart exists
const cacheData = await cache.get(`fmex_chart_${address}`);
let processed: CachedData;
if (cacheData) {
// If cache exists, check cache last update time
let parsedData: Omit & {
lastChecked: string;
} = JSON.parse(cacheData);
const parsedToType: CachedData = {
...parsedData,
lastChecked: new Date(parsedData.lastChecked),
};
// Time since last update > 5m
const timeDiffInMS =
new Date().getTime() - parsedToType.lastChecked.getTime();
if (timeDiffInMS > 5 * 60 * 1000) {
// Collect new trades to occur since lastChecked time
const trades: Trade[] = await db.trade.findMany({
orderBy: {
timestamp: "asc",
},
where: {
subjectAddress: address.toLowerCase(),
createdAt: {
gte: parsedToType.lastChecked,
},
},
});
// If no new updates in last 5m
if (trades.length === 0) {
// Update cache and return latest
const ok = await cache.set(
`fmex_chart_${address}`,
JSON.stringify({
...parsedToType,
lastChecked: new Date(),
})
);
if (ok != "OK") throw new Error("Errored storing in cache");
return res.status(200).json(parsedToType.chart);
}
// Augment existing trades
processed = processTrades(trades, parsedToType);
} else {
// Simply return cached data
return res.status(200).json(parsedData.chart);
}
} else {
// If cache does not exist, retrieve all trades
const trades: Trade[] = await db.trade.findMany({
orderBy: {
timestamp: "asc",
},
where: {
subjectAddress: address.toLowerCase(),
},
});
// Process trades
processed = processTrades(trades);
}
// Store in Redis
const ok = await cache.set(
`fmex_chart_${address}`,
JSON.stringify(processed)
);
if (ok != "OK") throw new Error("Errored storing in cache");
// Return new data
return res.status(200).json(processed.chart);
} catch (e: unknown) {
// Catch errors
if (e instanceof Error) {
return res.status(500).json({ message: e.message });
}
// Return default error
return res.status(500).json({ message: "Internal server error" });
}
}
================================================
FILE: frontend/pages/api/token/holdings.ts
================================================
import db from "prisma/index";
import cache from "utils/cache";
import type { Trade, User } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
type Holding = User & { balance: number };
type ExtendedTrade = Trade & { subjectUser: User };
type CachedHoldings = { lastChecked: Date; holdings: Holding[] };
function processHoldings(
trades: ExtendedTrade[],
existing?: Holding[]
): Holding[] {
// Setup balances and users
let subjectToBalance: Record = {};
let subjectToUser: Record = {};
// If existing cached holdings
if (existing) {
// Update local states
for (const holding of existing) {
subjectToBalance[holding.address] = holding.balance;
const { balance, ...rest } = holding;
subjectToUser[holding.address] = rest;
}
}
// For each trade
for (const trade of trades) {
// Initialize:
if (!subjectToBalance[trade.subjectAddress]) {
// Initialize balance
subjectToBalance[trade.subjectAddress] = 0;
// Initialize data
subjectToUser[trade.subjectAddress] = trade.subjectUser;
}
// Process trade
if (trade.isBuy) {
subjectToBalance[trade.subjectAddress] += trade.amount;
} else {
subjectToBalance[trade.subjectAddress] -= trade.amount;
}
}
// Recompile to return format
let holdings: (User & { balance: number })[] = [];
for (const address of Object.keys(subjectToBalance)) {
if (subjectToBalance[address] > 0) {
holdings.push({
...subjectToUser[address],
balance: subjectToBalance[address],
});
}
}
// Sort by most owned first
holdings = holdings.sort((a, b) => b.balance - a.balance);
return holdings;
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
// Collect token address
let { address } = req.query;
if (!address) return res.status(400).json({ error: "Missing token address" });
// Only accept first query parameter
if (Array.isArray(address)) address = address[0];
address = address.toLowerCase();
let data: CachedHoldings;
try {
// Check for cached holdings
const cachedString = await cache.get(`holdings_${address}`);
// If cache exists
if (cachedString) {
// Parse cache
const parseString: Omit & {
lastChecked: string;
} = JSON.parse(cachedString);
const parsed: CachedHoldings = {
...parseString,
lastChecked: new Date(parseString.lastChecked),
};
// Collect new trades since last checked date
const trades: ExtendedTrade[] = await db.trade.findMany({
where: {
fromAddress: address,
createdAt: {
gte: parsed.lastChecked,
},
},
orderBy: {
timestamp: "asc",
},
include: {
subjectUser: true,
},
});
// If no new trades, simply return
if (trades.length === 0) {
// Update last checked time
const ok = await cache.set(
`holdings_${address}`,
JSON.stringify({
...parsed,
lastChecked: new Date(),
})
);
if (!ok) throw new Error("Could not save to Redis");
return res.status(200).send(parsed.holdings);
}
// Parse new trades
data = {
lastChecked: new Date(),
holdings: processHoldings(trades, parsed.holdings),
};
} else {
// If no cache, collect all trades by token address
const trades: ExtendedTrade[] = await db.trade.findMany({
where: {
fromAddress: address,
},
orderBy: {
timestamp: "asc",
},
include: {
subjectUser: true,
},
});
// Assign data
data = {
lastChecked: new Date(),
holdings: processHoldings(trades),
};
}
// Store new changes in Redis
const ok = await cache.set(`holdings_${address}`, JSON.stringify(data));
if (!ok) throw new Error("Could not save to Redis");
return res.status(200).json(data.holdings);
} catch (e: unknown) {
// Catch errors
if (e instanceof Error) {
return res.status(500).json({ message: e.message });
}
// Return default error
return res.status(500).json({ message: "Internal server error" });
}
}
================================================
FILE: frontend/pages/api/token/search.ts
================================================
import db from "prisma/index";
import { getPrice } from "utils";
import type { User } from "@prisma/client";
import type { UserInfo } from "components/User";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function (req: NextApiRequest, res: NextApiResponse) {
// Collect search query
const { search }: { search: string } = req.body;
if (!search) return res.status(400).json({ error: "Missing search query" });
try {
// Search prisma (faking fuzzy search, Prisma limitation)
let users: User[] = await db.user.findMany({
where: {
OR: [
{
address: {
search: search.toLowerCase(),
},
},
{
twitterUsername: {
search,
},
},
],
},
take: 10,
});
// Augment users with cost
const augmented: UserInfo[] = users.map((user) => ({
...user,
cost: getPrice(user.supply, 1),
}));
return res.status(200).send(augmented);
} catch (e: unknown) {
// Catch errors
if (e instanceof Error) {
return res.status(500).json({ message: e.message });
}
// Return default error
return res.status(500).json({ message: "Internal server error" });
}
}
================================================
FILE: frontend/pages/api/token/trades.ts
================================================
import db from "prisma/index";
import cache from "utils/cache";
import type { NextApiRequest, NextApiResponse } from "next";
import type { TradeWithTwitterUser } from "../stats/trades";
type CachedData = { lastChecked: Date; trades: TradeWithTwitterUser[] };
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
// Collect token address
let { address } = req.query;
if (!address) return res.status(400).json({ error: "Missing token address" });
// Only accept first query parameter
if (Array.isArray(address)) address = address[0];
address = address.toLowerCase();
try {
// Check cache
const cachedString = await cache.get(`cach_trades_${address}`);
let data: CachedData;
// If cached data exists
if (cachedString) {
// Parse cache
const parseString: Omit & {
lastChecked: string;
} = JSON.parse(cachedString);
const parsed: CachedData = {
...parseString,
lastChecked: new Date(parseString.lastChecked),
};
// Time since last update > 5m
const timeDiffInMS = new Date().getTime() - parsed.lastChecked.getTime();
if (timeDiffInMS > 5 * 60 * 1000) {
// Collect new data from lastChecked point
const trades = await db.trade.findMany({
orderBy: {
timestamp: "desc",
},
where: {
subjectAddress: address.toLowerCase(),
createdAt: {
gte: parsed.lastChecked,
},
},
include: {
fromUser: {
select: {
twitterPfpUrl: true,
twitterUsername: true,
},
},
subjectUser: {
select: {
twitterPfpUrl: true,
twitterUsername: true,
},
},
},
take: 100,
});
// If no new trades, return cached
if (trades.length === 0) {
// Update last checked time
const ok = await cache.set(
`cach_trades_${address}`,
JSON.stringify({
lastChecked: new Date(),
trades: parsed.trades,
})
);
if (ok != "OK") throw new Error("Errored storing in cache");
return res.status(200).json(parsed.trades);
}
// Else, augment new trades and store
data = {
lastChecked: new Date(),
// Remove number of new trades from beginning
trades: [...trades, ...parsed.trades].slice(0, -1 * trades.length),
};
} else {
// Return cached data
return res.status(200).json(parsed.trades);
}
} else {
// If no cached data, collect trades from DB
const trades = await db.trade.findMany({
orderBy: {
timestamp: "desc",
},
where: {
subjectAddress: address.toLowerCase(),
},
include: {
fromUser: {
select: {
twitterPfpUrl: true,
twitterUsername: true,
},
},
subjectUser: {
select: {
twitterPfpUrl: true,
twitterUsername: true,
},
},
},
take: 100,
});
// Update local data
data = {
lastChecked: new Date(),
trades,
};
}
// Store in redis cache
const ok = await cache.set(`cach_trades_${address}`, JSON.stringify(data));
if (ok != "OK") throw new Error("Errored storing in cache");
return res.status(200).json(data.trades);
} catch (e: unknown) {
// Catch errors
if (e instanceof Error) {
return res.status(500).json({ message: e.message });
}
// Return default error
return res.status(500).json({ message: "Internal server error" });
}
}
================================================
FILE: frontend/pages/api/user.ts
================================================
import db from "prisma/index";
import cache from "utils/cache";
import { getPrice } from "utils";
import type { StateUser } from "state/global";
import type { NextApiRequest, NextApiResponse } from "next";
/**
* Collects StateUser object for address or throws
* @param {string} address to collect
* @returns {Promise}
*/
export async function getStateUser(address: string): Promise {
// Force lowercase
const lowerAddress: string = address.toLowerCase();
// Check for cache
const cached = await cache.get(`state_user_${lowerAddress}`);
// If cached, return
if (cached) return JSON.parse(cached) as StateUser;
// Collect from db
const { twitterPfpUrl: image, twitterUsername: username } =
await db.user.findUniqueOrThrow({
where: {
address: lowerAddress,
},
select: {
twitterPfpUrl: true,
twitterUsername: true,
},
});
// Setup user
const user: StateUser = {
address: lowerAddress,
image,
username,
};
// Store in cache
const ok = await cache.set(
`state_user_${lowerAddress}`,
JSON.stringify(user)
);
if (ok !== "OK") throw new Error("Error updating cache");
// Return data
return user;
}
export default async function (req: NextApiRequest, res: NextApiResponse) {
// Collect address from body
let { address } = req.query;
// Throw if missing parameter
if (!address) return res.status(400).json({ error: "Missing address" });
if (!Array.isArray(address)) address = [address];
try {
// Check for users
const requests = address.map((addr) => getStateUser(addr.toLowerCase()));
const results = await Promise.allSettled(requests);
let fulfilled = [];
for (const res of results) {
if (res.status === "fulfilled") fulfilled.push(res.value);
}
return res.status(200).json(fulfilled);
} catch (e: unknown) {
// Catch errors
if (e instanceof Error) {
return res.status(500).json({ message: e.message });
}
// Return default error
return res.status(500).json({ message: "Internal server error" });
}
}
================================================
FILE: frontend/pages/index.tsx
================================================
import dynamic from "next/dynamic";
import Layout from "components/Layout";
import constants from "utils/constants";
import type { StateUser } from "state/global";
import { WidthProvider, Responsive } from "react-grid-layout";
// Trading views
import Chart from "components/trading/Chart";
import Discover from "components/trading/Discover";
import NewestUsers from "components/trading/NewestUsers";
import RecentTrades from "components/trading/RecentTrades";
// import RealizedProfit from "components/trading/ProfitableUsers";
import RecentTokenTrades from "components/trading/RecentTokenTrades";
// API
import { getNewestUsers } from "./api/stats/newest";
import { type TradeWithTwitterUser, getLatestTrades } from "./api/stats/trades";
import { getLeaderboardUsers } from "./api/stats/leaderboard";
// import {
// type RealizedProfitUser,
// getRealizedProfits,
// } from "./api/stats/realized";
import type { UserInfo } from "components/User";
import type { NextPageContext } from "next";
import { getStateUser } from "./api/user";
import Favorites from "components/trading/Favorites";
const ResponsiveGridLayout = WidthProvider(Responsive);
const BuySell = dynamic(() => import("components/trading/BuySell"), {
ssr: false,
});
const Holdings = dynamic(() => import("components/trading/Holdings"), {
ssr: false,
});
export default function Home({
newestUsers,
latestTrades,
leaderboardUsers,
// realizedProfit,
user,
}: {
newestUsers: UserInfo[];
latestTrades: TradeWithTwitterUser[];
leaderboardUsers: UserInfo[];
// realizedProfit: RealizedProfitUser[];
user: StateUser;
}) {
// Layout setting
const layout = {
md: [
{ i: "chart", x: 0, y: 0.6, w: 24, h: 3 },
{ i: "discover", x: 6.6, y: 0, w: 24, h: 3 },
{ i: "holdings", x: 21.6, y: 24, w: 24, h: 3 },
{ i: "favorites", x: 21.6, y: 24, w: 24, h: 3 },
{ i: "recent_trades", x: 9.6, y: 0, w: 24, h: 3 },
{ i: "buy_sell", x: 3.6, y: 0, w: 24, h: 3 },
{ i: "recent_token_trades", x: 12, y: 0, w: 24, h: 3 },
{ i: "realized_profit", x: 15.6, y: 0, w: 24, h: 3 },
{ i: "newest_users", x: 18.6, y: 0, w: 24, h: 3 },
],
lg: [
{ i: "chart", x: 0, y: 0, w: 20, h: 3 },
{ i: "buy_sell", x: 20, y: 0, w: 8, h: 3 },
{ i: "discover", x: 28, y: 0, w: 8, h: 3 },
{ i: "recent_trades", x: 0, y: 6, w: 28, h: 3 },
{ i: "favorites", x: 28, y: 6, w: 8, h: 3 },
{ i: "recent_token_trades", x: 0, y: 12, w: 25, h: 3 },
{ i: "realized_profit", x: 18, y: 18, w: 9, h: 3 },
{ i: "newest_users", x: 27, y: 18, w: 11, h: 3 },
{ i: "holdings", x: 0, y: 24, w: 36, h: 3 },
],
};
return (
{/* Discover */}
{/* Trading chart */}
{/* Buy + Sell controller */}
{/* Recent trades */}
{/* Favorites */}
{/* Portfolio */}
{/* Recent token trades */}
{/* Most profitable users, temp disabled */}
{/*
*/}
{/* Newest users */}
);
}
export async function getServerSideProps(ctx: NextPageContext) {
// Collect query params
let { address } = ctx.query;
// If array, select first
if (Array.isArray(address)) {
address = address[0];
}
let user: StateUser;
try {
// If no address throw
if (!address) throw new Error("No address found");
// Collect user by address
user = await getStateUser(address);
} catch {
// If error, default to Cobie
user = constants.COBIE;
}
// Collect data
const newestUsers = await getNewestUsers();
const latestTrades = await getLatestTrades();
const leaderboardUsers = await getLeaderboardUsers();
// const realizedProfit = await getRealizedProfits();
return {
props: {
newestUsers,
latestTrades,
leaderboardUsers,
// realizedProfit,
user,
},
};
}
================================================
FILE: frontend/postcss.config.js
================================================
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
...(process.env.NODE_ENV === "production" ? { cssnano: {} } : {}),
},
};
================================================
FILE: frontend/prisma/index.ts
================================================
import { PrismaClient } from "@prisma/client";
// Setup global prisma
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
// Export db
const db = globalForPrisma.prisma ?? new PrismaClient();
export default db;
// Assign to global (cache)
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;
================================================
FILE: frontend/prisma/schema.prisma
================================================
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
previewFeatures = ["fullTextSearch"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
address String @id @unique
twitterUsername String?
twitterPfpUrl String?
profileChecked Boolean @default(false)
supply Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdTrades Trade[] @relation("fromUser")
subjectTrades Trade[] @relation("subjectUser")
}
model Trade {
hash String @id @unique
timestamp Int
blockNumber Int
fromAddress String
subjectAddress String
isBuy Boolean
amount Int
cost Decimal @db.Decimal(65, 0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
fromUser User @relation(name: "fromUser", fields: [fromAddress], references: [address])
subjectUser User @relation(name: "subjectUser", fields: [subjectAddress], references: [address])
}
================================================
FILE: frontend/state/global.ts
================================================
import axios from "axios";
import constants from "utils/constants";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { createContainer } from "unstated-next";
// Global state user
export type StateUser = {
address: string;
username?: string | null;
image?: string | null;
};
export enum Currency {
USD,
ETH,
}
function useGlobal(initialState: StateUser = constants.COBIE) {
// Routing
const { push } = useRouter();
// Default: @cobie
const [user, setUser] = useState(initialState);
// Currency
const [currency, setCurrency] = useState(Currency.ETH);
// ETH Price
const [eth, setEth] = useState(0);
// Favorites
const [favorites, setFavorites] = useState>({});
// On page load
useEffect(() => {
// Load eth price
async function collectEthPrice() {
const { data } = await axios.get("/api/eth");
setEth(data);
}
collectEthPrice();
// Load favorites from local storage
const localFavorites = localStorage.getItem("friendmex_favorites");
if (localFavorites) setFavorites(JSON.parse(localFavorites));
}, []);
// Update query params on user change
useEffect(() => {
// Shallow update url
push(`/?address=${user.address}`, undefined, { shallow: true });
}, [push, user.address]);
/**
* Track favorite user
* @param {StateUser} user
*/
const addFavorite = (user: StateUser) => {
const newFavorites = { ...favorites, [user.address]: user };
setFavorites({ ...newFavorites });
localStorage.setItem("friendmex_favorites", JSON.stringify(newFavorites));
};
/**
* Remove favorite user
* @param {StateUser} user
*/
const removeFavorite = (user: StateUser) => {
const { [user.address]: _, ...rest } = favorites;
setFavorites({ ...rest });
localStorage.setItem("friendmex_favorites", JSON.stringify(rest));
};
/**
* Toggles favorite user
* @param {StateUser} user
*/
const toggleFavorite = (user: StateUser) =>
user.address in favorites ? removeFavorite(user) : addFavorite(user);
return {
eth,
user,
setUser,
currency,
setCurrency,
favorites,
toggleFavorite,
};
}
export const Global = createContainer(useGlobal);
================================================
FILE: frontend/tailwind.config.js
================================================
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: [
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
"./node_modules/@tremor/**/*.{js,ts,jsx,tsx}", // Tremor module
],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
keyframes: {
"accordion-down": {
from: { height: 0 },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: 0 },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
boxShadow: {
// light
"tremor-input": "0 1px 2px 0 rgb(0 0 0 / 0.05)",
"tremor-card":
"0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
"tremor-dropdown":
"0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
// dark
"dark-tremor-input": "0 1px 2px 0 rgb(0 0 0 / 0.05)",
"dark-tremor-card":
"0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
"dark-tremor-dropdown":
"0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
},
borderRadius: {
"tremor-small": "0.375rem",
"tremor-default": "0.5rem",
"tremor-full": "9999px",
},
fontSize: {
"tremor-label": ["0.75rem"],
"tremor-default": ["0.875rem", { lineHeight: "1.25rem" }],
"tremor-title": ["1.125rem", { lineHeight: "1.75rem" }],
"tremor-metric": ["1.875rem", { lineHeight: "2.25rem" }],
},
colors: {
buy: "#00A969",
"buy-30": "#00A9690D",
sell: "#DF0E29",
"sell-30": "#DF0E290D",
"bitmex-widget": "#FAFAFA",
"bitmex-strong": "#EAECEF",
"bitmex-strong-border": "#CBD0D7",
tremor: {
brand: {
faint: "#eff6ff", // blue-50
muted: "#bfdbfe", // blue-200
subtle: "#60a5fa", // blue-400
DEFAULT: "#3b82f6", // blue-500
emphasis: "#1d4ed8", // blue-700
inverted: "#ffffff", // white
},
background: {
muted: "#f9fafb", // gray-50
subtle: "#f3f4f6", // gray-100
DEFAULT: "#ffffff", // white
emphasis: "#374151", // gray-700
},
border: {
DEFAULT: "#e5e7eb", // gray-200
},
ring: {
DEFAULT: "#e5e7eb", // gray-200
},
content: {
subtle: "#9ca3af", // gray-400
DEFAULT: "#6b7280", // gray-500
emphasis: "#374151", // gray-700
strong: "#111827", // gray-900
inverted: "#ffffff", // white
},
},
},
},
},
safelist: [
{
pattern:
/^(bg-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,
variants: ["hover", "ui-selected"],
},
{
pattern:
/^(text-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,
variants: ["hover", "ui-selected"],
},
{
pattern:
/^(border-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,
variants: ["hover", "ui-selected"],
},
{
pattern:
/^(ring-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,
},
{
pattern:
/^(stroke-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,
},
{
pattern:
/^(fill-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,
},
],
plugins: [require("tailwindcss-animate")],
};
================================================
FILE: frontend/tsconfig.json
================================================
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"baseUrl": "./"
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
================================================
FILE: frontend/utils/cache.ts
================================================
import Redis from "ioredis";
// Redis URL (default or env)
const REDIS_URL: string = process.env.REDIS_URL ?? "redis://127.0.0.1:6379";
// Export redis provider
const cache = new Redis(REDIS_URL);
export default cache;
================================================
FILE: frontend/utils/constants.ts
================================================
import type { StateUser } from "state/global";
// Default user
const COBIE: StateUser = {
address: "0x4e5f7e4a774bd30b9bdca7eb84ce3681a71676e1",
username: "cobie",
image:
"https://pbs.twimg.com/profile_images/1688496375707701248/WwWz33DI.jpg",
};
export default { COBIE };
================================================
FILE: frontend/utils/time.ts
================================================
/**
* Given some time in seconds, returns formatted string in format "{value}{unit}"
* @dev Assumes that things will fail much before a transition beyond 59m 59s
* @param {number} s seconds value
* @returns {string} formatted string
*/
export function renderTimeSince(s: number): string {
// If minutes not relevant, return seconds
if (s < 60) return `${s}s`;
// Else, get number of minutes
const seconds: number = s % 60;
const minutes: number = (s - seconds) / 60;
return `${minutes}m ${seconds}s`;
}
================================================
FILE: frontend/utils/usd.ts
================================================
/**
* Parse USD numeric in appropriate dollar format
* @param {number} value to parse
* @returns {string} parsed
*/
export function parseUSD(value: number): string {
return value.toLocaleString("us-en", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
================================================
FILE: frontend/utils/usePollData.ts
================================================
import axios from "axios";
import { useState, useEffect, useCallback } from "react";
export function usePollData(
endpoint: string,
initial: T,
frequency?: number
) {
// Loading status
const [loading, setLoading] = useState(true);
// Stored data
const [data, setData] = useState(initial);
// Time since last checked
const [lastChecked, setLastChecked] = useState(0);
/**
* Call backend, collect some generic data, set data
*/
const collectData = useCallback(async (): Promise => {
try {
// Toggle loading
setLoading(true);
// Collect data
const { data: newData }: { data: T } = await axios.get(endpoint);
// Update data
setData(newData);
} catch (e) {
// If known error
if (e instanceof Error) {
// Log message
console.error(e.message);
} else {
// Else, log full object
console.error(e);
}
} finally {
// Toggle loading
setLoading(false);
}
}, [endpoint]);
/**
* Collect data at some interval
*/
useEffect(() => {
/**
* Collection execution function
*/
async function execute(): Promise {
await collectData();
setLastChecked(0);
}
execute();
// If some update frequency exists
if (frequency) {
// Execute at set frequency
const executeInterval = setInterval(() => execute(), frequency);
// Increment lastChecked
const checkInterval = setInterval(
() => setLastChecked((previous) => previous + 1),
1 * 1000 // Every second
);
// On dismount
return () => {
// Clear intervals
clearInterval(executeInterval);
clearInterval(checkInterval);
};
}
}, [endpoint, collectData, frequency]);
return { data, lastChecked, loading };
}
================================================
FILE: frontend/utils.ts
================================================
import { twMerge } from "tailwind-merge";
import { type ClassValue, clsx } from "clsx";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* Truncates address to format 0xAAAA...AAAA
* @param {string} address to truncate
* @param {number} numTruncated numbers to truncate
* @returns {string} truncated
*/
export function truncateAddress(address: string, numTruncated: number): string {
return (
address.slice(0, numTruncated + 2) +
"..." +
address.substring(address.length - numTruncated)
);
}
/**
* getPrice function transcribed from solidity
* @dev https://basescan.org/address/0xcf205808ed36593aa40a44f10c7f7c2f67d4a4d4#code
* @param {number} supply of user token
* @param {number} amount of user token to buy or sell
* @returns {number} price of action (received or given)
*/
export function getPrice(supply: number, amount: number): number {
const sum1 =
supply === 0 ? 0 : ((supply - 1) * supply * (2 * (supply - 1) + 1)) / 6;
const sum2 =
supply === 0 && amount === 1
? 0
: ((supply - 1 + amount) *
(supply + amount) *
(2 * (supply - 1 + amount) + 1)) /
6;
const summation = sum2 - sum1;
return summation / 16000;
}
/**
* Contract address
*/
export const CONTRACT_ADDRESS = "0xcf205808ed36593aa40a44f10c7f7c2f67d4a4d4";
/**
* Contract ABI
*/
export const ABI = [
{
anonymous: false,
inputs: [
{
indexed: true,
internalType: "address",
name: "previousOwner",
type: "address",
},
{
indexed: true,
internalType: "address",
name: "newOwner",
type: "address",
},
],
name: "OwnershipTransferred",
type: "event",
},
{
anonymous: false,
inputs: [
{
indexed: false,
internalType: "address",
name: "trader",
type: "address",
},
{
indexed: false,
internalType: "address",
name: "subject",
type: "address",
},
{ indexed: false, internalType: "bool", name: "isBuy", type: "bool" },
{
indexed: false,
internalType: "uint256",
name: "shareAmount",
type: "uint256",
},
{
indexed: false,
internalType: "uint256",
name: "ethAmount",
type: "uint256",
},
{
indexed: false,
internalType: "uint256",
name: "protocolEthAmount",
type: "uint256",
},
{
indexed: false,
internalType: "uint256",
name: "subjectEthAmount",
type: "uint256",
},
{
indexed: false,
internalType: "uint256",
name: "supply",
type: "uint256",
},
],
name: "Trade",
type: "event",
},
{
inputs: [
{ internalType: "address", name: "sharesSubject", type: "address" },
{ internalType: "uint256", name: "amount", type: "uint256" },
],
name: "buyShares",
outputs: [],
stateMutability: "payable",
type: "function",
},
{
inputs: [
{ internalType: "address", name: "sharesSubject", type: "address" },
{ internalType: "uint256", name: "amount", type: "uint256" },
],
name: "getBuyPrice",
outputs: [{ internalType: "uint256", name: "", type: "uint256" }],
stateMutability: "view",
type: "function",
},
{
inputs: [
{ internalType: "address", name: "sharesSubject", type: "address" },
{ internalType: "uint256", name: "amount", type: "uint256" },
],
name: "getBuyPriceAfterFee",
outputs: [{ internalType: "uint256", name: "", type: "uint256" }],
stateMutability: "view",
type: "function",
},
{
inputs: [
{ internalType: "uint256", name: "supply", type: "uint256" },
{ internalType: "uint256", name: "amount", type: "uint256" },
],
name: "getPrice",
outputs: [{ internalType: "uint256", name: "", type: "uint256" }],
stateMutability: "pure",
type: "function",
},
{
inputs: [
{ internalType: "address", name: "sharesSubject", type: "address" },
{ internalType: "uint256", name: "amount", type: "uint256" },
],
name: "getSellPrice",
outputs: [{ internalType: "uint256", name: "", type: "uint256" }],
stateMutability: "view",
type: "function",
},
{
inputs: [
{ internalType: "address", name: "sharesSubject", type: "address" },
{ internalType: "uint256", name: "amount", type: "uint256" },
],
name: "getSellPriceAfterFee",
outputs: [{ internalType: "uint256", name: "", type: "uint256" }],
stateMutability: "view",
type: "function",
},
{
inputs: [],
name: "owner",
outputs: [{ internalType: "address", name: "", type: "address" }],
stateMutability: "view",
type: "function",
},
{
inputs: [],
name: "protocolFeeDestination",
outputs: [{ internalType: "address", name: "", type: "address" }],
stateMutability: "view",
type: "function",
},
{
inputs: [],
name: "protocolFeePercent",
outputs: [{ internalType: "uint256", name: "", type: "uint256" }],
stateMutability: "view",
type: "function",
},
{
inputs: [],
name: "renounceOwnership",
outputs: [],
stateMutability: "nonpayable",
type: "function",
},
{
inputs: [
{ internalType: "address", name: "sharesSubject", type: "address" },
{ internalType: "uint256", name: "amount", type: "uint256" },
],
name: "sellShares",
outputs: [],
stateMutability: "payable",
type: "function",
},
{
inputs: [
{ internalType: "address", name: "_feeDestination", type: "address" },
],
name: "setFeeDestination",
outputs: [],
stateMutability: "nonpayable",
type: "function",
},
{
inputs: [{ internalType: "uint256", name: "_feePercent", type: "uint256" }],
name: "setProtocolFeePercent",
outputs: [],
stateMutability: "nonpayable",
type: "function",
},
{
inputs: [{ internalType: "uint256", name: "_feePercent", type: "uint256" }],
name: "setSubjectFeePercent",
outputs: [],
stateMutability: "nonpayable",
type: "function",
},
{
inputs: [
{ internalType: "address", name: "", type: "address" },
{ internalType: "address", name: "", type: "address" },
],
name: "sharesBalance",
outputs: [{ internalType: "uint256", name: "", type: "uint256" }],
stateMutability: "view",
type: "function",
},
{
inputs: [{ internalType: "address", name: "", type: "address" }],
name: "sharesSupply",
outputs: [{ internalType: "uint256", name: "", type: "uint256" }],
stateMutability: "view",
type: "function",
},
{
inputs: [],
name: "subjectFeePercent",
outputs: [{ internalType: "uint256", name: "", type: "uint256" }],
stateMutability: "view",
type: "function",
},
{
inputs: [{ internalType: "address", name: "newOwner", type: "address" }],
name: "transferOwnership",
outputs: [],
stateMutability: "nonpayable",
type: "function",
},
];
================================================
FILE: indexer/.gitignore
================================================
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
# Logs
*.log
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
\*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
\*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
\*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
\*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.\*
================================================
FILE: indexer/README.md
================================================
# Indexer
Indexes the [FriendtechSharesV1](https://basescan.org/address/0xcf205808ed36593aa40a44f10c7f7c2f67d4a4d4#code) contract on [Base](https://base.org/) for all `buyShares` or `sellShares` function calls.
Tracks:
- All users
- All trades
- Statistics including:
- Top users by token supply
- Newest users by first seen date
- Recent trades
- Leaderboard by realized profit
## Run locally
```bash
# Add env vars
cp .env.sample .env && vim .env
# Install dependencies
pnpm install
# Run locally
pnpm run build && pnpm run start
```
================================================
FILE: indexer/package.json
================================================
{
"name": "indexer",
"author": "Anish Agnihotri",
"main": "dist/index.js",
"scripts": {
"build": "npx prisma generate && tsc",
"start": "node dist/index.js"
},
"devDependencies": {
"@types/node": "^20.4.10",
"typescript": "^5.1.6"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"dependencies": {
"@prisma/client": "5.1.1",
"axios": "^1.4.0",
"ethers": "^5.7.2",
"ioredis": "^5.3.2",
"prisma": "^5.1.1",
"winston": "^3.10.0"
}
}
================================================
FILE: indexer/prisma/schema.prisma
================================================
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
address String @id @unique
twitterUsername String?
twitterPfpUrl String?
profileChecked Boolean @default(false)
supply Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdTrades Trade[] @relation("fromUser")
subjectTrades Trade[] @relation("subjectUser")
}
model Trade {
hash String @id @unique
timestamp Int
blockNumber Int
fromAddress String
subjectAddress String
isBuy Boolean
amount Int
cost Decimal @db.Decimal(65, 0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
fromUser User @relation(name: "fromUser", fields: [fromAddress], references: [address])
subjectUser User @relation(name: "subjectUser", fields: [subjectAddress], references: [address])
}
================================================
FILE: indexer/src/index.ts
================================================
import Stats from "./stats";
import Keeper from "./keeper";
import Profile from "./profile";
/**
* Indexer execution lifecycle
*/
async function execute(): Promise {
// Collect env vars
const RPC_URL: string | undefined = process.env.RPC_URL;
const REDIS_URL: string = process.env.REDIS_URL ?? "redis://127.0.0.1:6379";
// Ensure env vars exist
if (!RPC_URL) throw new Error("Missing env vars");
// Create new keeper
const keeper = new Keeper(RPC_URL, REDIS_URL);
// Create new stats agent
const stats = new Stats(REDIS_URL);
// Create new profile agent
const profile = new Profile();
await Promise.all([
// Run keeper sync
keeper.sync(),
// Run stats 15s collection
stats.sync15s(),
// Run stats 30m collection
stats.sync30m(),
// Run profile sync
profile.sync(),
]);
}
(async () => {
try {
// Run execution lifecycle
await execute();
} catch (err: unknown) {
console.error(err);
process.exit(1);
}
})();
================================================
FILE: indexer/src/keeper.ts
================================================
import Redis from "ioredis";
import { ethers } from "ethers";
import logger from "./utils/logger";
import { getPrice } from "./utils/math";
import { chunks } from "./utils/helpers";
import constants from "./utils/constants";
import { PrismaClient } from "@prisma/client";
import type { Result } from "ethers/lib/utils";
import axios, { type AxiosInstance } from "axios";
import type { RPCMethod, Transaction } from "./utils/types";
export default class Keeper {
// Database
private db: PrismaClient;
// Redis cache
private redis: Redis;
// RPC client
private rpc: AxiosInstance;
// All tracked user address
private users: Set = new Set();
// User to token supply (address => token supply)
private supply: Record = {};
// Locally consistent latest synced block
private latestSyncedBlock: number | undefined;
/**
* Create new Keeper
* @param {string} rpc_url Base RPC
* @param {string} redis_url Cache URL
*/
constructor(rpc_url: string, redis_url: string) {
this.db = new PrismaClient();
this.redis = new Redis(redis_url);
this.rpc = axios.create({
baseURL: rpc_url,
});
}
/**
* Get chain head number
* @returns {Promise} block number
*/
async getChainBlock(): Promise {
try {
// Send request
const { data } = await this.rpc.post("/", {
id: 0,
jsonrpc: "2.0",
method: "eth_blockNumber",
params: [],
});
// Parse hex to number
return Number(data.result);
} catch {
logger.error("Failed to collect chain head number");
throw new Error("Could not collect chain head number");
}
}
/**
* Get latest synced block from cache
* @returns {Promise} block number
*/
async getSyncedBlock(): Promise {
// If latest synced block exists locally, return
if (this.latestSyncedBlock) return this.latestSyncedBlock;
// Else, get value from cache
const value: string | null = await this.redis.get("synced_block");
// If value exists
return value
? // Return numeric
Number(value)
: // Else return 1 block before contract deploy
constants.CONTRACT_DEPLOY_BLOCK - 1;
}
/**
* Loads user addresses and token supplies from backend
*/
async loadUsersAndSupplies(): Promise {
// Collect all users from database
const users: { address: string; supply: number }[] =
await this.db.user.findMany({
select: {
address: true,
supply: true,
},
});
for (const user of users) {
// Assign to list of users
this.users.add(user.address);
// Assign to local supply cache
this.supply[user.address] = user.supply;
}
logger.info(`Loaded ${users.length} users locally`);
}
/**
* Calculates trade cost based on bonding curve
* @param {string} subject address
* @param {number} amount to buy or sell
* @param {boolean} buy is a buy tx or a sell tx
* @returns {number} cost
*/
getTradeCost(subject: string, amount: number, buy: boolean): number {
// If subject supply is not tracked locally
if (!this.supply.hasOwnProperty(subject)) {
// Update to 0
this.supply[subject] = 0;
}
if (buy) {
// Return price to buy tokens
const cost = getPrice(this.supply[subject], amount);
const fees = cost * constants.FEE * 2;
return cost + fees;
} else {
// Return price to sell tokens
const cost = getPrice(this.supply[subject] - amount, amount);
const fees = cost * constants.FEE * 2;
return cost - fees;
}
}
/**
* Chunks batch data request processing to avoid 1K request limit
* @param {RPCMethod[]} batch to execute
*/
async chunkTxCall(batch: RPCMethod[]) {
let txData: {
result: {
transactionHash: string;
status: "0x0" | "0x1";
};
}[] = [];
// Execute batch data request in chunks of 950
for (const chunk of [...chunks(batch, 950)]) {
// Execute request for batch tx data
const {
data,
}: {
data: {
result: {
transactionHash: string;
status: "0x0" | "0x1";
};
}[];
} = await this.rpc.post("/", chunk);
// Concat results
txData.push(...data);
}
// Return tx data
return txData;
}
/**
* Syncs trades between a certain range of blocks
* @param {number} startBlock beginning index
* @param {number} endBlock ending index
*/
async syncTradeRange(startBlock: number, endBlock: number): Promise {
// Create block + transaction collection requests
const numBlocks: number = endBlock - startBlock;
logger.info(`Collecting ${numBlocks} blocks: ${startBlock} -> ${endBlock}`);
// Create batch requests array
const batchBlockRequests: RPCMethod[] = new Array(numBlocks)
.fill(0)
.map((_, i: number) => ({
method: "eth_getBlockByNumber",
// Hex block number, true => return all transactions
params: [`0x${(startBlock + i).toString(16)}`, true],
id: i,
jsonrpc: "2.0",
}));
// Execute request for batch blocks + transactions
const {
data: blockData,
}: {
data: {
result: {
number: string;
timestamp: string;
transactions: {
from: string;
hash: string;
to: string;
input: string;
}[];
};
}[];
} = await this.rpc.post("/", batchBlockRequests);
// Setup contract
const contractAddress: string = constants.CONTRACT_ADDRESS.toLowerCase();
const contractSignatures: string[] = [
constants.SIGNATURES.BUY,
constants.SIGNATURES.SELL,
];
// Filter for transaction hashes that are either BUY or SELL to friend.tech contract
let txHashes: string[] = [];
for (const block of blockData) {
for (const tx of block.result.transactions) {
if (
// If transaction is to contract
tx.to === contractAddress &&
// And, transaction is of format buyShares or sellShares
contractSignatures.includes(tx.input.slice(0, 10))
) {
// Track tx hash
txHashes.push(tx.hash);
}
}
}
// If no relevant tx hashes
if (txHashes.length === 0) {
// Update latest synced block
const ok = await this.redis.set("synced_block", endBlock);
if (!ok) {
logger.error("Error storing synced_block in cache");
throw new Error("Could not synced_block store in Redis");
}
logger.info("Skipping because 0 relevant txs found");
return;
}
// Check all relevant transaction hashes for success status
const txBatchRequests: RPCMethod[] = new Array(txHashes.length)
.fill(0)
.map((_, i: number) => ({
method: "eth_getTransactionReceipt",
// Hex block number, true => return all transactions
params: [txHashes[i]],
id: i,
jsonrpc: "2.0",
}));
// Execute request for batch tx data
const txData = await this.chunkTxCall(txBatchRequests);
// Create set of successful transactions
const successTxHash: Set = new Set();
for (const tx of txData) {
// Filter for success
if (tx.result.status === "0x1") {
successTxHash.add(tx.result.transactionHash.toLowerCase());
}
}
// Transform only successful transactions
let txs: Transaction[] = [];
// List of users with modified supply balances
let userDiff: Set = new Set();
// List of new, untracked users
let newUsers: Set = new Set();
// We iterate over blockData to preserve ordering
// This is necessary to appropriately calculate cost locally
for (const block of blockData) {
// For each transaction in block
for (const tx of block.result.transactions) {
// Filter for only successful transactions
if (successTxHash.has(tx.hash.toLowerCase())) {
// Decode tx input
const result: Result = ethers.utils.defaultAbiCoder.decode(
["address", "uint256"],
ethers.utils.hexDataSlice(tx.input, 4)
);
// Collect params and create tx
const subject = result[0].toLowerCase();
const amount = result[1].toNumber();
const isBuy: boolean =
tx.input.slice(0, 10) === constants.SIGNATURES.BUY;
// Calculate cost of transaction
const cost: number = this.getTradeCost(subject, amount, isBuy);
// Push newly tracked transaction
const transaction = {
hash: tx.hash,
timestamp: Number(block.result.timestamp),
blockNumber: Number(block.result.number),
from: tx.from.toLowerCase(),
subject,
isBuy,
amount,
cost: Math.trunc(cost * 1e18),
};
txs.push(transaction);
// Apply user token supply update
if (isBuy) {
this.supply[subject] += amount;
} else {
this.supply[subject] -= amount;
}
// Track user with supply diff
userDiff.add(subject);
// Track new users
if (!this.users.has(transaction.from)) {
this.users.add(transaction.from);
newUsers.add(transaction.from);
}
if (!this.users.has(transaction.subject)) {
this.users.add(transaction.subject);
newUsers.add(transaction.subject);
}
}
}
}
logger.info(`Collected ${txs.length} transactions`);
// Setup subject updates
let subjectUpserts = [];
for (const subject of new Set([...userDiff, ...newUsers])) {
subjectUpserts.push(
this.db.user.upsert({
where: {
address: subject,
},
create: {
address: subject,
supply: this.supply[subject] ?? 0,
},
update: {
supply: this.supply[subject] ?? 0,
},
})
);
}
// Setup trade updates
let tradeInsert = this.db.trade.createMany({
data: txs.map((tx) => ({
hash: tx.hash,
timestamp: tx.timestamp,
blockNumber: tx.blockNumber,
fromAddress: tx.from,
subjectAddress: tx.subject,
isBuy: tx.isBuy,
amount: tx.amount,
cost: tx.cost,
})),
});
// Insert subjects and trades as atomic transaction
await this.db.$transaction([...subjectUpserts, tradeInsert]);
logger.info(
`Added ${subjectUpserts.length} subject updates, ${txs.length} trades`
);
// Update latest synced block
const ok = await this.redis.set("synced_block", endBlock);
if (!ok) {
logger.error("Error storing synced_block in cache");
throw new Error("Could not synced_block store in Redis");
}
logger.info(`Set last synced block to ${endBlock}`);
}
async syncTrades() {
// Latest blocks
const latestChainBlock: number = await this.getChainBlock();
const latestSyncedBlock: number = await this.getSyncedBlock();
// Calculate remaining blocks to sync
const diffSync: number = latestChainBlock - latestSyncedBlock;
logger.info(`Remaining blocks to sync: ${diffSync}`);
// If diff > 0, poll by 100 blocks at a time
if (diffSync > 0) {
// Max 100 blocks to collect
const numToSync: number = Math.min(diffSync, 100);
// (Start, End) sync blocks
let startBlock: number = latestSyncedBlock;
let endBlock: number = latestSyncedBlock + numToSync;
// Sync between block ranges
try {
// Sync start -> end blocks
await this.syncTradeRange(startBlock, endBlock);
// Update last synced block
this.latestSyncedBlock = endBlock;
// Recursively resync if diffSync > 0
await this.syncTrades();
} catch (e) {
logger.error("Error when syncing between range", e);
}
}
}
async sync() {
// Sync users and token supplies if first startup
if (Object.keys(this.supply).length === 0) {
await this.loadUsersAndSupplies();
}
// Sync trades
await this.syncTrades();
// Recollect in 5s
logger.info("Sleeping for 5s");
setTimeout(() => this.sync(), 1000 * 5);
}
}
================================================
FILE: indexer/src/profile.ts
================================================
import logger from "./utils/logger";
import constants from "./utils/constants";
import { PrismaClient } from "@prisma/client";
import axios, { type AxiosInstance } from "axios";
/**
* Sleep for period of time
* @param {number} ms milliseconds to sleep
* @returns {Promise} resolves when sleep period finished
*/
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export default class Profile {
// Database
db: PrismaClient;
// Kosetto API client
client: AxiosInstance;
// Timeout duration
timeout: number = 500;
/**
* Create profile manager
*/
constructor() {
// Setup db
this.db = new PrismaClient();
// Setup api client
this.client = axios.create({
baseURL: constants.API,
});
}
/**
* Syncs users metadata
* @param address
*/
async syncUser(address: string) {
try {
// Collect user details
const {
data,
}: {
data:
| { message: string }
| { twitterUsername: string; twitterPfpUrl: string };
} = await this.client.get(`/users/${address}`);
// Check for no message
if ("message" in data) {
// Update user to profile checked
await this.db.user.update({
where: {
address,
},
data: {
profileChecked: true,
},
});
return;
} else {
// Update data
await this.db.user.update({
where: {
address,
},
data: {
twitterUsername: data.twitterUsername,
twitterPfpUrl: data.twitterPfpUrl,
profileChecked: true,
},
});
}
// If successful, reduce timeout back to 1/2s
this.timeout = 500;
// Log user
logger.info(`Profile: collected @${data.twitterUsername}: ${address}`);
} catch {
// Timeout for a few seconds, exponential backoff
this.timeout *= 2;
await sleep(this.timeout);
logger.error(
`Error on profile collection, sleeping for ${this.timeout / 1000}s`
);
}
}
/**
* Syncs profile metadata to database
*/
async syncProfiles() {
// Collect all users that have not been checked (250 at a time)
const users: { address: string }[] = await this.db.user.findMany({
orderBy: {
// Get highest supply users first
supply: "desc",
},
select: {
address: true,
},
where: {
profileChecked: false,
},
take: 250,
});
logger.info(`Collected ${users.length} to collect profile info`);
// For each user
for (const user of users) {
// Sync metadata
await this.syncUser(user.address);
}
}
async sync() {
await this.syncProfiles();
logger.info("Sleeping metadata sync for 1m");
setTimeout(() => this.syncProfiles, 1000 * 60 * 60);
}
}
================================================
FILE: indexer/src/stats.ts
================================================
import Redis from "ioredis";
import logger from "./utils/logger";
import { getPrice } from "./utils/math";
import constants from "./utils/constants";
import { PrismaClient, type User } from "@prisma/client";
export default class Stats {
// Database
private db: PrismaClient;
// Redis cache
private redis: Redis;
/**
* Create new Stats
* @param {string} redis_url Cache URL
*/
constructor(redis_url: string) {
// Setup db
this.db = new PrismaClient();
// Setup redis
this.redis = new Redis(redis_url);
}
/**
* Tracks newest 50 users
*/
async updateNewestUsers(): Promise {
const users: User[] = await this.db.user.findMany({
orderBy: {
createdAt: "desc",
},
take: 50,
});
// Augment data with cost
const augmented = users.map((user) => ({
...user,
cost: getPrice(user.supply, 1),
}));
await this.redis.set("latest_users", JSON.stringify(augmented));
}
/**
* Tracks latest 100 trades
*/
async udpateRecentTrades(): Promise {
const txs = await this.db.trade.findMany({
orderBy: {
timestamp: "desc",
},
include: {
fromUser: {
select: {
twitterUsername: true,
twitterPfpUrl: true,
},
},
subjectUser: {
select: {
twitterUsername: true,
twitterPfpUrl: true,
},
},
},
take: 100,
});
await this.redis.set("latest_trades", JSON.stringify(txs));
}
async tokenLeaderboard(): Promise {
const users: User[] = await this.db.user.findMany({
orderBy: {
supply: "desc",
},
take: 50,
});
let extended: (User & { cost: number })[] = [];
// Calculate cost per user
for (const user of users) {
// Calculate price to buy tokens
const cost = getPrice(user.supply, 1);
const fees = cost * constants.FEE * 2;
extended.push({
...user,
cost: cost + fees,
});
await this.redis.set("leaderboard", JSON.stringify(extended));
}
}
async mostProfitableUsers() {
const users = await this.db.user.findMany({
select: {
address: true,
twitterPfpUrl: true,
twitterUsername: true,
createdTrades: true,
},
});
let userToProfit: Record = {};
for (const user of users) {
userToProfit[user.address] = 0;
for (const trade of user.createdTrades) {
if (trade.isBuy) {
userToProfit[user.address] -= trade.cost.toNumber();
} else {
userToProfit[user.address] += trade.cost.toNumber();
}
}
}
// Generate subset users
let subsetUsers: {
address: string;
twitterPfpUrl?: string | null;
twitterUsername?: string | null;
profit: number;
}[] = [];
for (const user of users) {
subsetUsers.push({
address: user.address,
twitterPfpUrl: user.twitterPfpUrl,
twitterUsername: user.twitterUsername,
profit: userToProfit[user.address],
});
}
// Sort users by profit
subsetUsers.sort((a, b) => b.profit - a.profit);
subsetUsers = subsetUsers.slice(0, 100); // Take top 100
subsetUsers = subsetUsers.map((u) => ({ ...u, profit: u.profit / 1e18 })); // Parse profit to ETH
await this.redis.set("realized_profit", JSON.stringify(subsetUsers));
}
/**
* Stats synced at a 15s frequency
*/
async sync15s(): Promise {
await Promise.all([
this.updateNewestUsers(),
this.udpateRecentTrades(),
this.tokenLeaderboard(),
]);
// Recollect in 15s
logger.info("Stats: Collected quarter-minute stats");
setTimeout(() => this.sync15s(), 1000 * 15);
}
async sync30m(): Promise {
// await this.mostProfitableUsers();
// Recollect in 1h
logger.info("Stats: Collected half-hourly stats");
setTimeout(() => this.sync30m, 1000 * 60 * 60);
}
}
================================================
FILE: indexer/src/utils/constants.ts
================================================
export default {
// Friendshares contract deploy block
CONTRACT_DEPLOY_BLOCK: 2430440,
// Contract address
CONTRACT_ADDRESS: "0xCF205808Ed36593aa40a44F10c7f7C2F67d4A4d4",
// Function signatures
SIGNATURES: {
BUY: "0x6945b123",
SELL: "0xb51d0534",
},
// Creator || Protoocl fee
FEE: 0.05,
// Backend API endpoint
API: "https://prod-api.kosetto.com",
};
================================================
FILE: indexer/src/utils/helpers.ts
================================================
/**
* Generic generator to chunk array
* @param {T[]} arr to chunk
* @param {number} n max size per chunk
*/
export function* chunks(arr: T[], n: number): Generator {
for (let i = 0; i < arr.length; i += n) {
yield arr.slice(i, i + n);
}
}
================================================
FILE: indexer/src/utils/logger.ts
================================================
import * as winston from "winston"; // Logging
// Setup winston logger
const logger = winston.createLogger({
level: "info",
// Simple line-by-line output
format: winston.format.combine(
winston.format.colorize(),
winston.format.timestamp(),
winston.format.printf(
(info) => `[${info.timestamp} (${info.level})] ${info.message}`
)
),
transports: [
// Output to console
new winston.transports.Console(),
// Output to logfile
new winston.transports.File({ filename: "indexer.log", level: "debug" }),
],
});
// Export as default
export default logger;
================================================
FILE: indexer/src/utils/math.ts
================================================
/**
* getPrice function transcribed from solidity
* @dev https://basescan.org/address/0xcf205808ed36593aa40a44f10c7f7c2f67d4a4d4#code
* @param {number} supply of user token
* @param {number} amount of user token to buy or sell
* @returns {number} price of action (received or given)
*/
export function getPrice(supply: number, amount: number): number {
const sum1 =
supply === 0 ? 0 : ((supply - 1) * supply * (2 * (supply - 1) + 1)) / 6;
const sum2 =
supply === 0 && amount === 1
? 0
: ((supply - 1 + amount) *
(supply + amount) *
(2 * (supply - 1 + amount) + 1)) /
6;
const summation = sum2 - sum1;
return summation / 16000;
}
================================================
FILE: indexer/src/utils/types.ts
================================================
/**
* JSONRPC method
*/
export type RPCMethod = {
id: number;
jsonrpc: string;
params: any[];
method: string;
};
/**
* Transformed transaction
*/
export type Transaction = {
hash: string;
timestamp: number;
blockNumber: number;
from: string;
subject: string;
isBuy: boolean;
amount: number;
cost: number;
};
================================================
FILE: indexer/tsconfig.json
================================================
{
"compilerOptions": {
"lib": ["ES6"],
"target": "ES6",
"module": "CommonJS",
"moduleResolution": "node",
"outDir": "./dist",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"sourceMap": true,
"esModuleInterop": true,
"strict": true,
"resolveJsonModule": true
}
}
|