Skip to content

Commit fb01ee1

Browse files
committed
mod: erc-20
1 parent 3d10652 commit fb01ee1

File tree

13 files changed

+950
-14
lines changed

13 files changed

+950
-14
lines changed

examples/api/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,21 @@
1515
"@lit-protocol/types": "^2.2.61",
1616
"@mod-protocol/core": "^0.1.1",
1717
"@reservoir0x/reservoir-sdk": "^1.8.4",
18+
"@uniswap/smart-order-router": "^3.20.1",
1819
"@vercel/postgres-kysely": "^0.6.0",
1920
"chatgpt": "^5.2.5",
2021
"cheerio": "^1.0.0-rc.12",
22+
"ethers": "^5.7.2",
2123
"kysely": "^0.26.3",
2224
"next": "^13.5.6",
2325
"open-graph-scraper": "^6.3.2",
2426
"pg": "^8.11.3",
2527
"react": "^18.2.0",
2628
"react-dom": "^18.2.0",
29+
"reverse-mirage": "^1.0.3",
2730
"siwe": "^1.1.6",
28-
"uint8arrays": "^3.0.0"
31+
"uint8arrays": "^3.0.0",
32+
"viem2": "npm:viem@^2.0.6"
2933
},
3034
"devDependencies": {
3135
"@types/node": "^17.0.12",
Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
import { FarcasterUser } from "@mod-protocol/core";
2+
import { Token } from "@uniswap/sdk-core";
3+
import * as smartOrderRouter from "@uniswap/smart-order-router";
4+
import { USDC_BASE } from "@uniswap/smart-order-router";
5+
import { NextRequest, NextResponse } from "next/server";
6+
import { publicActionReverseMirage, priceQuote } from "reverse-mirage";
7+
import { PublicClient, createClient, http, parseUnits } from "viem2";
8+
import * as chains from "viem2/chains";
9+
10+
const { AIRSTACK_API_KEY } = process.env;
11+
const AIRSTACK_API_URL = "https://api.airstack.xyz/gql";
12+
13+
const chainByName: { [key: string]: chains.Chain } = Object.entries(
14+
chains
15+
).reduce(
16+
(acc: { [key: string]: chains.Chain }, [key, chain]) => {
17+
acc[key] = chain;
18+
return acc;
19+
},
20+
{ ethereum: chains.mainnet } // Convenience for ethereum, which is 'homestead' otherwise
21+
);
22+
23+
const chainById = Object.values(chains).reduce(
24+
(acc: { [key: number]: chains.Chain }, cur) => {
25+
if (cur.id) acc[cur.id] = cur;
26+
return acc;
27+
},
28+
{}
29+
);
30+
31+
function numberWithCommas(x: string | number) {
32+
var parts = x.toString().split(".");
33+
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
34+
return parts.join(".");
35+
}
36+
37+
const query = `
38+
query MyQuery($identity: Identity!, $token_address: Address!, $blockchain: TokenBlockchain, $cursor: String) {
39+
SocialFollowings(
40+
input: {
41+
filter: {
42+
identity: {_eq: $identity},
43+
dappName: {_eq: farcaster}
44+
},
45+
blockchain: ALL,
46+
limit: 200,
47+
cursor: $cursor
48+
}
49+
) {
50+
pageInfo {
51+
hasNextPage
52+
nextCursor
53+
}
54+
Following {
55+
followingProfileId,
56+
followingAddress {
57+
socials {
58+
profileDisplayName
59+
profileName
60+
profileImage
61+
profileBio
62+
}
63+
tokenBalances(
64+
input: {
65+
filter: {
66+
tokenAddress: {_eq: $token_address},
67+
formattedAmount: {_gt: 0}
68+
},
69+
blockchain: $blockchain
70+
}
71+
) {
72+
owner {
73+
identity
74+
}
75+
formattedAmount
76+
}
77+
}
78+
}
79+
}
80+
}
81+
`;
82+
83+
async function getFollowingHolderInfo({
84+
fid,
85+
tokenAddress,
86+
blockchain,
87+
}: {
88+
fid: string;
89+
tokenAddress: string;
90+
blockchain: string;
91+
}): Promise<{ user: FarcasterUser; amount: number }[]> {
92+
const acc: any[] = [];
93+
94+
let hasNextPage = true;
95+
let cursor = "";
96+
97+
try {
98+
while (hasNextPage) {
99+
hasNextPage = false;
100+
const res = await fetch(AIRSTACK_API_URL, {
101+
method: "POST",
102+
headers: {
103+
"Content-Type": "application/json",
104+
Authorization: AIRSTACK_API_KEY, // Add API key to Authorization header
105+
},
106+
body: JSON.stringify({
107+
query,
108+
variables: {
109+
identity: `fc_fid:${fid}`,
110+
token_address: tokenAddress,
111+
blockchain,
112+
cursor,
113+
},
114+
}),
115+
});
116+
const json = await res?.json();
117+
const result = json?.data.SocialFollowings.Following.filter(
118+
(item) => item.followingAddress.tokenBalances.length > 0
119+
);
120+
acc.push(...result);
121+
122+
hasNextPage = json?.data.SocialFollowings.pageInfo.hasNextPage;
123+
cursor = json?.data.SocialFollowings.pageInfo.nextCursor;
124+
}
125+
} catch (error) {
126+
console.error(error);
127+
}
128+
129+
const result = acc
130+
.map((item) => {
131+
const socialData = item.followingAddress.socials[0];
132+
return {
133+
user: {
134+
displayName: socialData.profileDisplayName,
135+
username: socialData.profileName,
136+
fid: item.followingProfileId,
137+
pfp: socialData.profileImage,
138+
} as FarcasterUser,
139+
amount: item.followingAddress.tokenBalances[0].formattedAmount,
140+
};
141+
})
142+
.sort((a, b) => Number(b.amount) - Number(a.amount));
143+
144+
return result;
145+
}
146+
147+
async function getPriceData({
148+
tokenAddress,
149+
blockchain,
150+
}: {
151+
tokenAddress: string;
152+
blockchain: string;
153+
}): Promise<{
154+
unitPriceUsd: string;
155+
marketCapUsd?: string;
156+
volume24hUsd?: string;
157+
change24h?: string;
158+
}> {
159+
// https://api.coingecko.com/api/v3/simple/token_price/ethereum?contract_addresses=0xd7c1eb0fe4a30d3b2a846c04aa6300888f087a5f&vs_currencies=usd&points&vs_currencies=usd&include_market_cap=true&include_24hr_vol=true&include_24hr_change=true&include_last_updated_at=true
160+
const params = new URLSearchParams({
161+
contract_addresses: tokenAddress,
162+
vs_currencies: "usd",
163+
include_market_cap: "true",
164+
include_24hr_vol: "true",
165+
include_24hr_change: "true",
166+
include_last_updated_at: "true",
167+
});
168+
const coingecko = await fetch(
169+
`https://api.coingecko.com/api/v3/simple/token_price/${blockchain}?${params.toString()}`
170+
);
171+
const coingeckoJson = await coingecko.json();
172+
173+
if (coingeckoJson[tokenAddress]) {
174+
const {
175+
usd: unitPriceUsd,
176+
usd_market_cap: marketCapUsd,
177+
usd_24h_vol: volume24hUsd,
178+
usd_24h_change: change24h,
179+
} = coingeckoJson[tokenAddress];
180+
181+
const unitPriceUsdFormatted = `${numberWithCommas(
182+
parseFloat(unitPriceUsd).toPrecision(4)
183+
)}`;
184+
const marketCapUsdFormatted = `${parseFloat(
185+
parseFloat(marketCapUsd).toFixed(0)
186+
).toLocaleString()}`;
187+
const volume24hUsdFormatted = `${parseFloat(
188+
parseFloat(volume24hUsd).toFixed(0)
189+
).toLocaleString()}`;
190+
191+
const change24hNumber = parseFloat(change24h);
192+
const change24hPartial = parseFloat(
193+
change24hNumber.toFixed(2)
194+
).toLocaleString();
195+
const change24hFormatted =
196+
change24hNumber > 0 ? `+${change24hPartial}%` : `-${change24hPartial}%`;
197+
198+
return {
199+
unitPriceUsd: unitPriceUsdFormatted,
200+
marketCapUsd: marketCapUsdFormatted,
201+
volume24hUsd: volume24hUsdFormatted,
202+
change24h: change24hFormatted,
203+
};
204+
}
205+
206+
// Use on-chain data as fallback
207+
const chain = chainByName[blockchain.toLowerCase()];
208+
const url = `https://api.1inch.dev/price/v1.1/${chain.id}/${tokenAddress}?currency=USD`;
209+
const res = await fetch(url, {
210+
headers: {
211+
Authorization: `Bearer ${process.env["1INCH_API_KEY"]}`,
212+
},
213+
});
214+
const resJson = await res.json();
215+
216+
return {
217+
unitPriceUsd: parseFloat(resJson[tokenAddress]).toPrecision(4),
218+
};
219+
}
220+
221+
async function tokenInfo({
222+
tokenAddress,
223+
blockchain,
224+
}: {
225+
tokenAddress: string;
226+
blockchain: string;
227+
}): Promise<{
228+
symbol: string;
229+
name: string;
230+
image?: string;
231+
}> {
232+
//0x4ed4e862860bed51a9570b96d89af5e1b0efefed
233+
// https://api.coingecko.com/api/v3/coins/0x4ed4e862860bed51a9570b96d89af5e1b0efefed/market_chart?vs_currency=usd&days=1
234+
// https://api.coingecko.com/api/v3/simple/token_price/ethereum?contract_addresses=0xd7c1eb0fe4a30d3b2a846c04aa6300888f087a5f&vs_currencies=usd&points&vs_currencies=usd&include_market_cap=true&include_24hr_vol=true&include_24hr_change=true&include_last_updated_at=true
235+
// https://api.coingecko.com/api/v3/coins/ethereum/contract/0xd7c1eb0fe4a30d3b2a846c04aa6300888f087a5f
236+
// https://api.coingecko.com/api/v3/coins/base/contract/0x27d2decb4bfc9c76f0309b8e88dec3a601fe25a8
237+
const res = await fetch(
238+
`https://api.coingecko.com/api/v3/coins/${blockchain}/contract/${tokenAddress}`
239+
);
240+
241+
if (res.ok) {
242+
const json = await res?.json();
243+
return {
244+
symbol: json.symbol,
245+
name: json.name,
246+
image: json.image?.thumb,
247+
};
248+
}
249+
250+
// Use on-chain data as fallback
251+
const chain = chainByName[blockchain];
252+
const client = (
253+
createClient({
254+
transport: http(),
255+
chain,
256+
}) as PublicClient
257+
).extend(publicActionReverseMirage);
258+
259+
const token = await client.getERC20({
260+
erc20: {
261+
address: tokenAddress as `0x${string}`,
262+
chainID: chain.id,
263+
},
264+
});
265+
266+
return {
267+
symbol: token.symbol,
268+
name: token.name,
269+
};
270+
}
271+
272+
export async function GET(request: NextRequest) {
273+
const fid = request.nextUrl.searchParams.get("fid")?.toLowerCase();
274+
const token = request.nextUrl.searchParams.get("token")?.toLowerCase();
275+
let tokenAddress = request.nextUrl.searchParams
276+
.get("tokenAddress")
277+
?.toLowerCase();
278+
let blockchain = request.nextUrl.searchParams
279+
.get("blockchain")
280+
?.toLowerCase();
281+
282+
if (token) {
283+
// Splitting the string at '/erc20:'
284+
const parts = token.split("/erc20:");
285+
286+
// Extracting the chain ID
287+
const chainIdPart = parts[0];
288+
const chainId = chainIdPart.split(":")[1];
289+
290+
// The token address is the second part of the split, but without '0x' if present
291+
tokenAddress = parts[1];
292+
293+
const [blockchainName] = Object.entries(chainByName).find(
294+
([, value]) => value.id.toString() == chainId
295+
);
296+
blockchain = blockchainName;
297+
}
298+
299+
if (!tokenAddress) {
300+
return NextResponse.json({
301+
error: "Missing tokenAddress",
302+
});
303+
}
304+
305+
if (!blockchain) {
306+
return NextResponse.json({
307+
error: "Missing or invalid blockchain (ethereum, polygon, base)",
308+
});
309+
}
310+
311+
const [holderData, priceData, tokenData] = await Promise.all([
312+
getFollowingHolderInfo({
313+
blockchain: blockchain,
314+
tokenAddress: tokenAddress,
315+
fid: fid,
316+
}),
317+
getPriceData({
318+
blockchain: blockchain,
319+
tokenAddress: tokenAddress,
320+
}),
321+
tokenInfo({
322+
tokenAddress,
323+
blockchain,
324+
}),
325+
]);
326+
327+
return NextResponse.json({
328+
holderData: {
329+
holders: [...(holderData || [])],
330+
holdersCount: holderData?.length || 0,
331+
},
332+
priceData,
333+
tokenData,
334+
});
335+
}
336+
337+
// needed for preflight requests to succeed
338+
export const OPTIONS = async (request: NextRequest) => {
339+
return NextResponse.json({});
340+
};

examples/nextjs-shadcn/src/app/dummy-casts.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,4 +165,19 @@ export const dummyCastData: Array<{
165165
},
166166
],
167167
},
168+
{
169+
avatar_url:
170+
"https://res.cloudinary.com/merkle-manufactory/image/fetch/c_fill,f_png,w_144/https%3A%2F%2Flh3.googleusercontent.com%2F-S5cdhOpZtJ_Qzg9iPWELEsRTkIsZ7qGYmVlwEORgFB00WWAtZGefRnS4Bjcz5ah40WVOOWeYfU5pP9Eekikb3cLMW2mZQOMQHlWhg",
171+
display_name: "David Furlong",
172+
username: "df",
173+
timestamp: "2023-08-17 09:16:52.293739",
174+
text: "Just bought this token 🚀🚀🚀",
175+
embeds: [
176+
{
177+
url: "eip155:1/erc20:0xd7c1eb0fe4a30d3b2a846c04aa6300888f087a5f",
178+
status: "loaded",
179+
metadata: {},
180+
},
181+
],
182+
},
168183
];

0 commit comments

Comments
 (0)