Skip to content

Commit

Permalink
mod: erc-20
Browse files Browse the repository at this point in the history
  • Loading branch information
stephancill committed Jan 11, 2024
1 parent 3d10652 commit fb01ee1
Show file tree
Hide file tree
Showing 13 changed files with 950 additions and 14 deletions.
6 changes: 5 additions & 1 deletion examples/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,21 @@
"@lit-protocol/types": "^2.2.61",
"@mod-protocol/core": "^0.1.1",
"@reservoir0x/reservoir-sdk": "^1.8.4",
"@uniswap/smart-order-router": "^3.20.1",
"@vercel/postgres-kysely": "^0.6.0",
"chatgpt": "^5.2.5",
"cheerio": "^1.0.0-rc.12",
"ethers": "^5.7.2",
"kysely": "^0.26.3",
"next": "^13.5.6",
"open-graph-scraper": "^6.3.2",
"pg": "^8.11.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"reverse-mirage": "^1.0.3",
"siwe": "^1.1.6",
"uint8arrays": "^3.0.0"
"uint8arrays": "^3.0.0",
"viem2": "npm:viem@^2.0.6"
},
"devDependencies": {
"@types/node": "^17.0.12",
Expand Down
340 changes: 340 additions & 0 deletions examples/api/src/app/api/erc-20/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,340 @@
import { FarcasterUser } from "@mod-protocol/core";
import { Token } from "@uniswap/sdk-core";
import * as smartOrderRouter from "@uniswap/smart-order-router";
import { USDC_BASE } from "@uniswap/smart-order-router";
import { NextRequest, NextResponse } from "next/server";
import { publicActionReverseMirage, priceQuote } from "reverse-mirage";
import { PublicClient, createClient, http, parseUnits } from "viem2";
import * as chains from "viem2/chains";

const { AIRSTACK_API_KEY } = process.env;
const AIRSTACK_API_URL = "https://api.airstack.xyz/gql";

const chainByName: { [key: string]: chains.Chain } = Object.entries(
chains
).reduce(
(acc: { [key: string]: chains.Chain }, [key, chain]) => {
acc[key] = chain;
return acc;
},
{ ethereum: chains.mainnet } // Convenience for ethereum, which is 'homestead' otherwise
);

const chainById = Object.values(chains).reduce(
(acc: { [key: number]: chains.Chain }, cur) => {
if (cur.id) acc[cur.id] = cur;
return acc;
},
{}
);

function numberWithCommas(x: string | number) {
var parts = x.toString().split(".");
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return parts.join(".");
}

const query = `
query MyQuery($identity: Identity!, $token_address: Address!, $blockchain: TokenBlockchain, $cursor: String) {
SocialFollowings(
input: {
filter: {
identity: {_eq: $identity},
dappName: {_eq: farcaster}
},
blockchain: ALL,
limit: 200,
cursor: $cursor
}
) {
pageInfo {
hasNextPage
nextCursor
}
Following {
followingProfileId,
followingAddress {
socials {
profileDisplayName
profileName
profileImage
profileBio
}
tokenBalances(
input: {
filter: {
tokenAddress: {_eq: $token_address},
formattedAmount: {_gt: 0}
},
blockchain: $blockchain
}
) {
owner {
identity
}
formattedAmount
}
}
}
}
}
`;

async function getFollowingHolderInfo({
fid,
tokenAddress,
blockchain,
}: {
fid: string;
tokenAddress: string;
blockchain: string;
}): Promise<{ user: FarcasterUser; amount: number }[]> {
const acc: any[] = [];

let hasNextPage = true;
let cursor = "";

try {
while (hasNextPage) {
hasNextPage = false;
const res = await fetch(AIRSTACK_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: AIRSTACK_API_KEY, // Add API key to Authorization header
},
body: JSON.stringify({
query,
variables: {
identity: `fc_fid:${fid}`,
token_address: tokenAddress,
blockchain,
cursor,
},
}),
});
const json = await res?.json();
const result = json?.data.SocialFollowings.Following.filter(
(item) => item.followingAddress.tokenBalances.length > 0
);
acc.push(...result);

hasNextPage = json?.data.SocialFollowings.pageInfo.hasNextPage;
cursor = json?.data.SocialFollowings.pageInfo.nextCursor;
}
} catch (error) {
console.error(error);
}

const result = acc
.map((item) => {
const socialData = item.followingAddress.socials[0];
return {
user: {
displayName: socialData.profileDisplayName,
username: socialData.profileName,
fid: item.followingProfileId,
pfp: socialData.profileImage,
} as FarcasterUser,
amount: item.followingAddress.tokenBalances[0].formattedAmount,
};
})
.sort((a, b) => Number(b.amount) - Number(a.amount));

return result;
}

async function getPriceData({
tokenAddress,
blockchain,
}: {
tokenAddress: string;
blockchain: string;
}): Promise<{
unitPriceUsd: string;
marketCapUsd?: string;
volume24hUsd?: string;
change24h?: string;
}> {
// 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
const params = new URLSearchParams({
contract_addresses: tokenAddress,
vs_currencies: "usd",
include_market_cap: "true",
include_24hr_vol: "true",
include_24hr_change: "true",
include_last_updated_at: "true",
});
const coingecko = await fetch(
`https://api.coingecko.com/api/v3/simple/token_price/${blockchain}?${params.toString()}`
);
const coingeckoJson = await coingecko.json();

if (coingeckoJson[tokenAddress]) {
const {
usd: unitPriceUsd,
usd_market_cap: marketCapUsd,
usd_24h_vol: volume24hUsd,
usd_24h_change: change24h,
} = coingeckoJson[tokenAddress];

const unitPriceUsdFormatted = `${numberWithCommas(
parseFloat(unitPriceUsd).toPrecision(4)
)}`;
const marketCapUsdFormatted = `${parseFloat(
parseFloat(marketCapUsd).toFixed(0)
).toLocaleString()}`;
const volume24hUsdFormatted = `${parseFloat(
parseFloat(volume24hUsd).toFixed(0)
).toLocaleString()}`;

const change24hNumber = parseFloat(change24h);
const change24hPartial = parseFloat(
change24hNumber.toFixed(2)
).toLocaleString();
const change24hFormatted =
change24hNumber > 0 ? `+${change24hPartial}%` : `-${change24hPartial}%`;

return {
unitPriceUsd: unitPriceUsdFormatted,
marketCapUsd: marketCapUsdFormatted,
volume24hUsd: volume24hUsdFormatted,
change24h: change24hFormatted,
};
}

// Use on-chain data as fallback
const chain = chainByName[blockchain.toLowerCase()];
const url = `https://api.1inch.dev/price/v1.1/${chain.id}/${tokenAddress}?currency=USD`;
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${process.env["1INCH_API_KEY"]}`,
},
});
const resJson = await res.json();

return {
unitPriceUsd: parseFloat(resJson[tokenAddress]).toPrecision(4),
};
}

async function tokenInfo({
tokenAddress,
blockchain,
}: {
tokenAddress: string;
blockchain: string;
}): Promise<{
symbol: string;
name: string;
image?: string;
}> {
//0x4ed4e862860bed51a9570b96d89af5e1b0efefed
// https://api.coingecko.com/api/v3/coins/0x4ed4e862860bed51a9570b96d89af5e1b0efefed/market_chart?vs_currency=usd&days=1
// 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
// https://api.coingecko.com/api/v3/coins/ethereum/contract/0xd7c1eb0fe4a30d3b2a846c04aa6300888f087a5f
// https://api.coingecko.com/api/v3/coins/base/contract/0x27d2decb4bfc9c76f0309b8e88dec3a601fe25a8
const res = await fetch(
`https://api.coingecko.com/api/v3/coins/${blockchain}/contract/${tokenAddress}`
);

if (res.ok) {
const json = await res?.json();
return {
symbol: json.symbol,
name: json.name,
image: json.image?.thumb,
};
}

// Use on-chain data as fallback
const chain = chainByName[blockchain];
const client = (
createClient({
transport: http(),
chain,
}) as PublicClient
).extend(publicActionReverseMirage);

const token = await client.getERC20({
erc20: {
address: tokenAddress as `0x${string}`,
chainID: chain.id,
},
});

return {
symbol: token.symbol,
name: token.name,
};
}

export async function GET(request: NextRequest) {
const fid = request.nextUrl.searchParams.get("fid")?.toLowerCase();
const token = request.nextUrl.searchParams.get("token")?.toLowerCase();
let tokenAddress = request.nextUrl.searchParams
.get("tokenAddress")
?.toLowerCase();
let blockchain = request.nextUrl.searchParams
.get("blockchain")
?.toLowerCase();

if (token) {
// Splitting the string at '/erc20:'
const parts = token.split("/erc20:");

// Extracting the chain ID
const chainIdPart = parts[0];
const chainId = chainIdPart.split(":")[1];

// The token address is the second part of the split, but without '0x' if present
tokenAddress = parts[1];

const [blockchainName] = Object.entries(chainByName).find(
([, value]) => value.id.toString() == chainId
);
blockchain = blockchainName;
}

if (!tokenAddress) {
return NextResponse.json({
error: "Missing tokenAddress",
});
}

if (!blockchain) {
return NextResponse.json({
error: "Missing or invalid blockchain (ethereum, polygon, base)",
});
}

const [holderData, priceData, tokenData] = await Promise.all([
getFollowingHolderInfo({
blockchain: blockchain,
tokenAddress: tokenAddress,
fid: fid,
}),
getPriceData({
blockchain: blockchain,
tokenAddress: tokenAddress,
}),
tokenInfo({
tokenAddress,
blockchain,
}),
]);

return NextResponse.json({
holderData: {
holders: [...(holderData || [])],
holdersCount: holderData?.length || 0,
},
priceData,
tokenData,
});
}

// needed for preflight requests to succeed
export const OPTIONS = async (request: NextRequest) => {
return NextResponse.json({});
};
15 changes: 15 additions & 0 deletions examples/nextjs-shadcn/src/app/dummy-casts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,4 +165,19 @@ export const dummyCastData: Array<{
},
],
},
{
avatar_url:
"https://res.cloudinary.com/merkle-manufactory/image/fetch/c_fill,f_png,w_144/https%3A%2F%2Flh3.googleusercontent.com%2F-S5cdhOpZtJ_Qzg9iPWELEsRTkIsZ7qGYmVlwEORgFB00WWAtZGefRnS4Bjcz5ah40WVOOWeYfU5pP9Eekikb3cLMW2mZQOMQHlWhg",
display_name: "David Furlong",
username: "df",
timestamp: "2023-08-17 09:16:52.293739",
text: "Just bought this token 🚀🚀🚀",
embeds: [
{
url: "eip155:1/erc20:0xd7c1eb0fe4a30d3b2a846c04aa6300888f087a5f",
status: "loaded",
metadata: {},
},
],
},
];
Loading

0 comments on commit fb01ee1

Please sign in to comment.