Skip to content

Commit cdad3d9

Browse files
committed
feat(mod/tip-eth): add tipping mod
1 parent 0fed948 commit cdad3d9

File tree

7 files changed

+280
-0
lines changed

7 files changed

+280
-0
lines changed

.changeset/little-suits-brush.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@mod-protocol/mod-registry": minor
3+
---
4+
5+
feat: add `tip-eth` mod
+155
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { NextRequest } from "next/server";
2+
import { createPublicClient, formatEther, http, parseEther } from "viem";
3+
import * as chains from "viem/chains";
4+
5+
export function numberWithCommas(x: string | number) {
6+
var parts = x.toString().split(".");
7+
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
8+
return parts.join(".");
9+
}
10+
11+
export async function getEthUsdPrice(): Promise<number> {
12+
const client = createPublicClient({
13+
transport: http(),
14+
chain: chains.mainnet,
15+
});
16+
17+
// roundId uint80, answer int256, startedAt uint256, updatedAt uint256, answeredInRound uint80
18+
const [, answer] = await client.readContract({
19+
abi: [
20+
{
21+
inputs: [],
22+
name: "latestRoundData",
23+
outputs: [
24+
{ internalType: "uint80", name: "roundId", type: "uint80" },
25+
{ internalType: "int256", name: "answer", type: "int256" },
26+
{ internalType: "uint256", name: "startedAt", type: "uint256" },
27+
{ internalType: "uint256", name: "updatedAt", type: "uint256" },
28+
{ internalType: "uint80", name: "answeredInRound", type: "uint80" },
29+
],
30+
stateMutability: "view",
31+
type: "function",
32+
},
33+
],
34+
functionName: "latestRoundData",
35+
// https://docs.chain.link/data-feeds/price-feeds/addresses?network=ethereum&page=1&search=usdc#ethereum-mainnet
36+
address: "0x986b5E1e1755e3C2440e960477f25201B0a8bbD4",
37+
});
38+
39+
const ethPriceUsd = (1 / Number(answer)) * 1e18;
40+
41+
return ethPriceUsd;
42+
}
43+
44+
async function getBalancesOnChains({
45+
address,
46+
chains,
47+
minBalance = BigInt(0),
48+
ethUsdPrice,
49+
}: {
50+
address: `0x${string}`;
51+
chains: chains.Chain[];
52+
minBalance?: bigint;
53+
ethUsdPrice?: number;
54+
}) {
55+
const balances = await Promise.all(
56+
chains.map(async (chain) => {
57+
const client = createPublicClient({
58+
transport: http(),
59+
chain,
60+
});
61+
const balance = await client.getBalance({ address });
62+
return {
63+
chain,
64+
balance,
65+
balanceUsd: ethUsdPrice
66+
? ethUsdPrice * parseFloat(formatEther(balance))
67+
: undefined,
68+
};
69+
})
70+
);
71+
return balances
72+
.filter((b) => b.balance > minBalance)
73+
.sort((a, b) => Number(b.balance - a.balance));
74+
}
75+
76+
export async function GET(request: NextRequest) {
77+
const fid = request.nextUrl.searchParams.get("fid");
78+
const amountUsd = request.nextUrl.searchParams.get("amountUsd");
79+
const fromAddress = request.nextUrl.searchParams.get("fromAddress");
80+
81+
if (!fid || !amountUsd || !fromAddress) {
82+
return new Response("Missing parameters", { status: 400 });
83+
}
84+
85+
// TODO: Add message via tx data
86+
87+
const verificationsRes = await fetch(
88+
`https://nemes.farcaster.xyz:2281/v1/verificationsByFid?fid=${fid}`
89+
);
90+
const verificationsResJson = await verificationsRes.json();
91+
const verification = verificationsResJson.messages[0];
92+
if (!verification) {
93+
return new Response("No verified addresses for user", { status: 404 });
94+
}
95+
const { address } = verification.data.verificationAddEthAddressBody;
96+
97+
const ethPriceUsd = await getEthUsdPrice();
98+
const amountEth = parseEther(
99+
(parseFloat(amountUsd) / ethPriceUsd).toString()
100+
);
101+
102+
// Find a chain where both users have balances
103+
const candidateChains = [
104+
chains.arbitrum,
105+
chains.optimism,
106+
chains.base,
107+
chains.zora,
108+
];
109+
110+
const [senderBalances, recipientBalances] = await Promise.all([
111+
getBalancesOnChains({
112+
address: fromAddress as `0x${string}`,
113+
chains: candidateChains,
114+
minBalance: amountEth,
115+
ethUsdPrice: ethPriceUsd,
116+
}),
117+
getBalancesOnChains({
118+
address: address,
119+
chains: candidateChains,
120+
}),
121+
]);
122+
const senderChainIds = senderBalances.map(({ chain }) => chain.id);
123+
124+
// Suggested chain is the one where the recipient has the most balance and the sender has enough balance
125+
const suggestedChain =
126+
recipientBalances.filter(({ chain }) =>
127+
senderChainIds.includes(chain.id)
128+
)[0]?.chain || senderBalances[0]?.chain;
129+
130+
if (!suggestedChain) {
131+
return new Response("No chain with enough ETH", { status: 404 });
132+
}
133+
134+
return Response.json({
135+
tx: {
136+
to: address,
137+
from: fromAddress,
138+
value: amountEth.toString(),
139+
data: "0x",
140+
},
141+
valueUsdFormatted: `${numberWithCommas(parseFloat(amountUsd).toFixed(2))}`,
142+
valueEthFormatted: parseFloat(formatEther(amountEth)).toPrecision(4),
143+
suggestedChain: suggestedChain,
144+
chainBalances: senderBalances.map(({ chain, balance }) => ({
145+
chain,
146+
balance: balance.toString(),
147+
})),
148+
});
149+
}
150+
151+
// needed for preflight requests to succeed
152+
export const OPTIONS = async (request: NextRequest) => {
153+
// Return Response
154+
return Response.json({});
155+
};

mods/tip-eth/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as default } from "./src/manifest";

mods/tip-eth/package.json

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"name": "@mods/tip-eth",
3+
"main": "./index.ts",
4+
"types": "./index.ts",
5+
"version": "0.1.0",
6+
"private": true,
7+
"dependencies": {
8+
"@mod-protocol/core": "^0.1.0"
9+
}
10+
}

mods/tip-eth/src/action.ts

+84
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { ModElement } from "@mod-protocol/core";
2+
3+
const action: ModElement[] = [
4+
{
5+
type: "vertical-layout",
6+
elements: [
7+
{
8+
if: {
9+
value: "{{refs.tipReq.response.data}}",
10+
match: {
11+
NOT: {
12+
equals: "",
13+
},
14+
},
15+
},
16+
then: {
17+
type: "vertical-layout",
18+
elements: [
19+
{
20+
type: "text",
21+
label:
22+
"Sending {{refs.tipReq.response.data.valueEthFormatted}} (${{refs.tipReq.response.data.valueUsdFormatted}}) to {{refs.tipReq.response.data.tx.to}}",
23+
},
24+
{
25+
type: "text",
26+
label:
27+
"Suggested chain: {{refs.tipReq.response.data.suggestedChain.name}}",
28+
variant: "secondary",
29+
},
30+
{
31+
type: "button",
32+
label: "Send",
33+
onclick: {
34+
type: "SENDETHTRANSACTION",
35+
ref: "sendEthTx",
36+
txData: {
37+
from: "{{user.wallet.address}}",
38+
to: "{{refs.tipReq.response.data.tx.to}}",
39+
value: "{{refs.tipReq.response.data.tx.value}}",
40+
data: "{{refs.tipReq.response.data.tx.data}}",
41+
},
42+
chainId: "{{refs.tipReq.response.data.suggestedChain.id}}",
43+
},
44+
},
45+
],
46+
},
47+
else: {
48+
type: "horizontal-layout",
49+
elements: [
50+
{
51+
type: "button",
52+
label: "$1.00",
53+
onclick: {
54+
type: "GET",
55+
ref: "tipReq",
56+
url: "{{api}}/tip-eth?fid={{author.farcaster.fid}}&amountUsd=1.00&fromAddress={{user.wallet.address}}",
57+
},
58+
},
59+
{
60+
type: "button",
61+
label: "$5.00",
62+
onclick: {
63+
type: "GET",
64+
ref: "tipReq",
65+
url: "{{api}}/tip-eth?fid={{author.farcaster.fid}}&amountUsd=5.00&fromAddress={{user.wallet.address}}",
66+
},
67+
},
68+
{
69+
type: "button",
70+
label: "$10.00",
71+
onclick: {
72+
type: "GET",
73+
ref: "tipReq",
74+
url: "{{api}}/tip-eth?fid={{author.farcaster.fid}}&amountUsd=10.00&fromAddress={{user.wallet.address}}",
75+
},
76+
},
77+
],
78+
},
79+
},
80+
],
81+
},
82+
];
83+
84+
export default action;

mods/tip-eth/src/manifest.ts

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { ModManifest } from "@mod-protocol/core";
2+
import action from "./action";
3+
4+
const manifest: ModManifest = {
5+
slug: "tip-eth",
6+
name: "Send the author a tip",
7+
custodyAddress: "stephancill.eth",
8+
version: "0.0.1",
9+
logo: "https://i.imgur.com/MKmOtSU.png",
10+
custodyGithubUsername: "stephancill",
11+
actionEntrypoints: action,
12+
};
13+
14+
export default manifest;

mods/tip-eth/tsconfig.json

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"extends": "tsconfig/base.json",
3+
"include": [
4+
"."
5+
],
6+
"exclude": [
7+
"dist",
8+
"build",
9+
"node_modules"
10+
]
11+
}

0 commit comments

Comments
 (0)