From 15c1f8933f562014a2c537e1350da87e16736c4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Pradel?= Date: Fri, 29 Nov 2024 12:30:56 +0100 Subject: [PATCH 1/7] feat: add direct calls stats for protocol users --- apps/server/src/api/protocols/users/index.ts | 88 ++++++++++++++++---- 1 file changed, 73 insertions(+), 15 deletions(-) diff --git a/apps/server/src/api/protocols/users/index.ts b/apps/server/src/api/protocols/users/index.ts index b335457..41591bb 100644 --- a/apps/server/src/api/protocols/users/index.ts +++ b/apps/server/src/api/protocols/users/index.ts @@ -1,10 +1,12 @@ import type { Protocol } from "@stackspulse/protocols"; +import type postgres from "postgres"; import { z } from "zod"; import { sql } from "~/db/db"; import { apiCacheConfig } from "~/lib/api"; import { getValidatedQueryZod } from "~/lib/nitro"; const protocolUsersRouteSchema = z.object({ + mode: z.enum(["direct", "nested"]).optional(), date: z.enum(["7d", "30d", "all"]), limit: z.coerce.number().min(1).max(100).optional(), }); @@ -17,16 +19,77 @@ export type ProtocolUsersRouteResponse = { export default defineCachedEventHandler(async (event) => { const query = await getValidatedQueryZod(event, protocolUsersRouteSchema); const limit = query.limit || 10; + const mode = query.mode || "nested"; + const daysToSubtractMap = { + all: undefined, + "7d": 7, + "30d": 30, + }; + const daysToSubtract = daysToSubtractMap[query.date]; + let result: postgres.Row[]; + if (mode === "direct") { + result = await getProtocolUsersDirect({ + limit, + daysToSubtract, + }); + } else { + result = await getProtocolUsersNested({ + limit, + daysToSubtract, + }); + } + + const stats: ProtocolUsersRouteResponse = result.map((stat) => ({ + protocol_name: stat.protocol_name as Protocol, + unique_senders: Number.parseInt(stat.unique_senders), + })); + + return stats; +}, apiCacheConfig); + +interface QueryParams { + limit: number; + daysToSubtract?: number; +} + +const getProtocolUsersDirect = async ({ + limit, + daysToSubtract, +}: QueryParams) => { + let dateCondition = ""; + if (daysToSubtract) { + dateCondition = `AND txs.block_time >= EXTRACT(EPOCH FROM (NOW() - INTERVAL '${daysToSubtract} days'))`; + } + + const result = await sql` + SELECT + dapps.id as protocol_name, + COUNT(DISTINCT txs.sender_address) AS unique_senders + FROM + txs + JOIN + dapps ON txs.contract_call_contract_id = ANY (dapps.contracts) + WHERE + txs.type_id = 2 + ${sql.unsafe(dateCondition)} + GROUP BY + dapps.id + ORDER BY + unique_senders DESC + LIMIT ${limit}; + `; + + return result; +}; + +const getProtocolUsersNested = async ({ + limit, + daysToSubtract, +}: QueryParams) => { let dateCondition = ""; - if (query.date !== "all") { - const daysToSubtract = { - "7d": 7, - "30d": 30, - }; - dateCondition = `AND txs.block_time >= EXTRACT(EPOCH FROM (NOW() - INTERVAL '${ - daysToSubtract[query.date] - } days'))`; + if (daysToSubtract) { + dateCondition = `AND txs.block_time >= EXTRACT(EPOCH FROM (NOW() - INTERVAL '${daysToSubtract} days'))`; } const result = await sql` @@ -84,10 +147,5 @@ ORDER BY LIMIT ${limit}; `; - const stats: ProtocolUsersRouteResponse = result.map((stat) => ({ - protocol_name: stat.protocol_name as Protocol, - unique_senders: Number.parseInt(stat.unique_senders), - })); - - return stats; -}, apiCacheConfig); + return result; +}; From 56f30454ecb2b99f0edc84f0a456f5ea650f1715 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Pradel?= Date: Fri, 29 Nov 2024 13:48:48 +0100 Subject: [PATCH 2/7] ui for nested / direct --- .../TopProtocolsBarListQuery.tsx | 3 + .../Stats/TopProtocolsBarList/index.tsx | 69 +++++++++++++------ .../web/src/hooks/api/useGetProtocolsUsers.ts | 6 +- apps/web/src/lib/api.ts | 1 + 4 files changed, 57 insertions(+), 22 deletions(-) diff --git a/apps/web/src/components/Stats/TopProtocolsBarList/TopProtocolsBarListQuery.tsx b/apps/web/src/components/Stats/TopProtocolsBarList/TopProtocolsBarListQuery.tsx index a3a584f..d9d2ab8 100644 --- a/apps/web/src/components/Stats/TopProtocolsBarList/TopProtocolsBarListQuery.tsx +++ b/apps/web/src/components/Stats/TopProtocolsBarList/TopProtocolsBarListQuery.tsx @@ -5,13 +5,16 @@ import { protocolsInfo } from "@stackspulse/protocols"; interface TopProtocolsBarListClientProps { dateFilter: ProtocolUsersRouteQuery["date"]; + modeFilter: ProtocolUsersRouteQuery["mode"]; } export const TopProtocolsBarListQuery = ({ dateFilter, + modeFilter, }: TopProtocolsBarListClientProps) => { const { data: stats } = useGetProtocolsUsers({ date: dateFilter, + mode: modeFilter, limit: 6, }); diff --git a/apps/web/src/components/Stats/TopProtocolsBarList/index.tsx b/apps/web/src/components/Stats/TopProtocolsBarList/index.tsx index 5524d76..7bdbdee 100644 --- a/apps/web/src/components/Stats/TopProtocolsBarList/index.tsx +++ b/apps/web/src/components/Stats/TopProtocolsBarList/index.tsx @@ -9,30 +9,54 @@ import { TopProtocolsBarListQuery } from "./TopProtocolsBarListQuery"; export const TopProtocolsBarList = () => { const [dateFilter, setDateFilter] = useState("all"); + const [modeFilter, setModeFilter] = + useState("nested"); return (
- - Top Stacks protocols - - - - - - - - - - - - +
+ + Top Stacks protocols + + + {modeFilter === "nested" + ? "Unique addresses that have interacted with the protocol contracts" + : "Unique addresses that have interacted with the protocol directly"} + +
+ +
+ + + + + + + + + + + + + + + + + + + +
@@ -77,7 +101,10 @@ export const TopProtocolsBarList = () => { /> } > - + ); diff --git a/apps/web/src/hooks/api/useGetProtocolsUsers.ts b/apps/web/src/hooks/api/useGetProtocolsUsers.ts index 647546c..92e70e3 100644 --- a/apps/web/src/hooks/api/useGetProtocolsUsers.ts +++ b/apps/web/src/hooks/api/useGetProtocolsUsers.ts @@ -6,14 +6,18 @@ import type { import { useSuspenseQuery } from "@tanstack/react-query"; export const useGetProtocolsUsers = ({ + mode, date, limit, }: ProtocolUsersRouteQuery) => { return useSuspenseQuery({ - queryKey: ["get-protocols-users", date, limit], + queryKey: ["get-protocols-users", mode, date, limit], queryFn: async () => { const url = new URL(`${env.NEXT_PUBLIC_API_URL}/api/protocols/users`); url.searchParams.set("date", date); + if (mode) { + url.searchParams.set("mode", mode); + } if (limit) { url.searchParams.set("limit", limit.toString()); } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 38d2cbe..4a3e932 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -11,6 +11,7 @@ export type ProtocolUsersRouteResponse = { }[]; export type ProtocolUsersRouteQuery = { + mode?: "direct" | "nested"; /** * Date range to query */ From 11b6d0748522a74042dc7f4d3ae56808ab7bb883 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Pradel?= Date: Fri, 29 Nov 2024 13:50:39 +0100 Subject: [PATCH 3/7] inverse buttons --- .../src/components/Stats/TopProtocolsBarList/index.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/Stats/TopProtocolsBarList/index.tsx b/apps/web/src/components/Stats/TopProtocolsBarList/index.tsx index 7bdbdee..420589b 100644 --- a/apps/web/src/components/Stats/TopProtocolsBarList/index.tsx +++ b/apps/web/src/components/Stats/TopProtocolsBarList/index.tsx @@ -45,16 +45,16 @@ export const TopProtocolsBarList = () => { - - - + + +
From d28cb5c9a154cd6ae6c43719a0516b40080e2817 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Pradel?= Date: Fri, 29 Nov 2024 14:05:32 +0100 Subject: [PATCH 4/7] up twitter lib --- apps/server/package.json | 2 +- apps/server/src/lib/twitter.ts | 4 ++-- pnpm-lock.yaml | 26 ++++++++++++-------------- 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index 051c926..0aef425 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -21,7 +21,7 @@ "h3": "1.13.0", "nitro-cors": "0.7.1", "postgres": "3.4.5", - "twitter-api-v2": "1.17.1", + "twitter-api-v2": "1.18.2", "unstorage": "1.13.1", "zod": "3.23.8", "zod-validation-error": "3.4.0" diff --git a/apps/server/src/lib/twitter.ts b/apps/server/src/lib/twitter.ts index 5a38d65..f8ed5cf 100644 --- a/apps/server/src/lib/twitter.ts +++ b/apps/server/src/lib/twitter.ts @@ -29,11 +29,11 @@ export const sendTweet = async ({ }); }), ) - : []; + : undefined; const data = await twitterClient.v2.tweet({ text: message, - media: { media_ids: mediaIds }, + media: { media_ids: mediaIds as [string, string] }, }); return data.data.id; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43cb4e2..0bad9e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -61,8 +61,8 @@ importers: specifier: 3.4.5 version: 3.4.5 twitter-api-v2: - specifier: 1.17.1 - version: 1.17.1 + specifier: 1.18.2 + version: 1.18.2 unstorage: specifier: 1.13.1 version: 1.13.1(ioredis@5.4.1) @@ -93,7 +93,7 @@ importers: version: 3.0.5(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@sentry/nextjs': specifier: 8.41.0 - version: 8.41.0(@opentelemetry/core@1.28.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.28.0(@opentelemetry/api@1.9.0))(next@15.0.3(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.95.0) + version: 8.41.0(@opentelemetry/core@1.28.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.28.0(@opentelemetry/api@1.9.0))(next@15.0.3(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.95.0) '@stacks/stacks-blockchain-api-types': specifier: 7.14.1 version: 7.14.1 @@ -132,7 +132,7 @@ importers: version: 2.5.11 next: specifier: 15.0.3 - version: 15.0.3(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 15.0.3(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: specifier: 18.3.1 version: 18.3.1 @@ -5461,8 +5461,8 @@ packages: resolution: {integrity: sha512-DUHWQAcC8BTiUZDRzAYGvpSpGLiaOQPfYXlCieQbwUvmml/LRGIe3raKdrOPOoiX0DYlzxs2nH6BoWJoZrj8hA==} hasBin: true - twitter-api-v2@1.17.1: - resolution: {integrity: sha512-eLVetUOGiKalx/7NlF8+heMmtEXBhObP0mS9RFgcgjh5KTVq7SOWaIMIe1IrsAAV9DFCjd6O7fL+FMp6sibQYg==} + twitter-api-v2@1.18.2: + resolution: {integrity: sha512-ggImmoAeVgETYqrWeZy+nWnDpwgTP+IvFEc03Pitt1HcgMX+Yw17rP38Fb5FFTinuyNvS07EPtAfZ184uIyB0A==} type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} @@ -7900,7 +7900,7 @@ snapshots: dependencies: '@sentry/types': 8.41.0 - '@sentry/nextjs@8.41.0(@opentelemetry/core@1.28.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.28.0(@opentelemetry/api@1.9.0))(next@15.0.3(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.95.0)': + '@sentry/nextjs@8.41.0(@opentelemetry/core@1.28.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.28.0(@opentelemetry/api@1.9.0))(next@15.0.3(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.95.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/instrumentation-http': 0.53.0(@opentelemetry/api@1.9.0) @@ -7915,7 +7915,7 @@ snapshots: '@sentry/vercel-edge': 8.41.0 '@sentry/webpack-plugin': 2.22.6(webpack@5.95.0) chalk: 3.0.0 - next: 15.0.3(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 15.0.3(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) resolve: 1.22.8 rollup: 3.29.5 stacktrace-parser: 0.1.10 @@ -10306,7 +10306,7 @@ snapshots: neo-async@2.6.2: {} - next@15.0.3(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next@15.0.3(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@next/env': 15.0.3 '@swc/counter': 0.1.3 @@ -10316,7 +10316,7 @@ snapshots: postcss: 8.4.31 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - styled-jsx: 5.1.6(@babel/core@7.26.0)(react@18.3.1) + styled-jsx: 5.1.6(react@18.3.1) optionalDependencies: '@next/swc-darwin-arm64': 15.0.3 '@next/swc-darwin-x64': 15.0.3 @@ -11371,12 +11371,10 @@ snapshots: dependencies: js-tokens: 9.0.1 - styled-jsx@5.1.6(@babel/core@7.26.0)(react@18.3.1): + styled-jsx@5.1.6(react@18.3.1): dependencies: client-only: 0.0.1 react: 18.3.1 - optionalDependencies: - '@babel/core': 7.26.0 sucrase@3.35.0: dependencies: @@ -11543,7 +11541,7 @@ snapshots: turbo-windows-64: 2.3.3 turbo-windows-arm64: 2.3.3 - twitter-api-v2@1.17.1: {} + twitter-api-v2@1.18.2: {} type-check@0.4.0: dependencies: From 1a331f8c58499aa9235628a0359520389f2a9d68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Pradel?= Date: Fri, 29 Nov 2024 14:08:03 +0100 Subject: [PATCH 5/7] changeset --- .changeset/twenty-days-pull.md | 5 +++++ .changeset/wise-colts-stare.md | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 .changeset/twenty-days-pull.md create mode 100644 .changeset/wise-colts-stare.md diff --git a/.changeset/twenty-days-pull.md b/.changeset/twenty-days-pull.md new file mode 100644 index 0000000..2743a04 --- /dev/null +++ b/.changeset/twenty-days-pull.md @@ -0,0 +1,5 @@ +--- +"stackspulse": minor +--- + +Allow users to select direct / nested for the protocols users chart. diff --git a/.changeset/wise-colts-stare.md b/.changeset/wise-colts-stare.md new file mode 100644 index 0000000..cbeaba5 --- /dev/null +++ b/.changeset/wise-colts-stare.md @@ -0,0 +1,5 @@ +--- +"@stackspulse/server": minor +--- + +Accept `mode` param for the protocol users. From 183cd484515df165392dcb3e26dea97b27a25a40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Pradel?= Date: Fri, 29 Nov 2024 14:30:57 +0100 Subject: [PATCH 6/7] send tweet reply with direct calls stats --- .changeset/fast-turtles-brush.md | 5 ++ .../src/api/root/tweet-weekly-users/index.ts | 62 ++++++++++++++++--- apps/server/src/lib/twitter.ts | 3 + 3 files changed, 61 insertions(+), 9 deletions(-) create mode 100644 .changeset/fast-turtles-brush.md diff --git a/.changeset/fast-turtles-brush.md b/.changeset/fast-turtles-brush.md new file mode 100644 index 0000000..526c746 --- /dev/null +++ b/.changeset/fast-turtles-brush.md @@ -0,0 +1,5 @@ +--- +"stackspulse": minor +--- + +Send weekly tweet reply to direct calls made to protocols. diff --git a/apps/server/src/api/root/tweet-weekly-users/index.ts b/apps/server/src/api/root/tweet-weekly-users/index.ts index 3cd600a..a580558 100644 --- a/apps/server/src/api/root/tweet-weekly-users/index.ts +++ b/apps/server/src/api/root/tweet-weekly-users/index.ts @@ -9,35 +9,79 @@ import { sendTweet } from "~/lib/twitter"; export default defineEventHandler(async () => { const apiParams = new URLSearchParams(); apiParams.append("noCache", "true"); + apiParams.append("mode", "nested"); apiParams.append("date", "7d"); apiParams.append("limit", "5"); - const stats: ProtocolUsersRouteResponse = await fetch( + // Get stats with nested mode + console.log("Getting stats with nested mode...", apiParams.toString()); + const statsNested: ProtocolUsersRouteResponse = await fetch( `${env.API_URL}/api/protocols/users?${apiParams.toString()}`, ).then((res) => res.json()); - const data = stats.map((stat) => ({ + const dataNested = statsNested.map((stat) => ({ name: protocolsInfo[stat.protocol_name].name, value: stat.unique_senders, })); + // Get stats with direct mode + apiParams.set("mode", "direct"); + console.log("Getting stats with direct mode...", apiParams.toString()); + const statsDirect: ProtocolUsersRouteResponse = await fetch( + `${env.API_URL}/api/protocols/users?${apiParams.toString()}`, + ).then((res) => res.json()); + + const dataDirect = statsDirect.map((stat) => ({ + name: protocolsInfo[stat.protocol_name].name, + value: stat.unique_senders, + })); + + // Generate nested tweet const params = new URLSearchParams(); params.append("title", "Last 7 Days Unique Users"); - params.append("data", JSON.stringify(data)); + params.append("data", JSON.stringify(dataNested)); + + const imageUrlNested = `${ + env.WEB_URL + }/api/images/weekly-users?${params.toString()}`; + + let messageNested = "📈 Last 7 days unique users:\n\n"; + for (const stat of statsNested) { + messageNested += `\n- @${protocolsInfo[stat.protocol_name].x.replace( + "https://twitter.com/", + "", + )}: ${stat.unique_senders.toLocaleString("en-US")} users`; + } + + const nestedTweetId = await sendTweet({ + message: messageNested, + images: [imageUrlNested], + }); + console.log("Nested tweet id:", nestedTweetId); + + // Generate direct tweet + params.set("title", "Last 7 Days Direct Unique Users"); + params.set("data", JSON.stringify(dataDirect)); - const imageUrl = `${ + const imageUrlDirect = `${ env.WEB_URL }/api/images/weekly-users?${params.toString()}`; - let message = "📈 Last 7 days unique users:\n\n"; - for (const stat of stats) { - message += `\n- @${protocolsInfo[stat.protocol_name].x.replace( + let messageDirect = + "📈 Last 7 days unique users that interacted directly with the protocol:\n\n"; + for (const stat of statsDirect) { + messageDirect += `\n- @${protocolsInfo[stat.protocol_name].x.replace( "https://twitter.com/", "", )}: ${stat.unique_senders.toLocaleString("en-US")} users`; } - const tweetId = await sendTweet({ message, images: [imageUrl] }); + const directTweetId = await sendTweet({ + message: messageDirect, + images: [imageUrlDirect], + in_reply_to_tweet_id: nestedTweetId, + }); + console.log("Direct tweet id:", directTweetId); - return { ok: true, tweetId }; + return { ok: true, nestedTweetId, directTweetId }; }); diff --git a/apps/server/src/lib/twitter.ts b/apps/server/src/lib/twitter.ts index f8ed5cf..70a776a 100644 --- a/apps/server/src/lib/twitter.ts +++ b/apps/server/src/lib/twitter.ts @@ -11,9 +11,11 @@ const twitterClient = new TwitterApi({ export const sendTweet = async ({ message, images, + in_reply_to_tweet_id, }: { message: string; images?: string[]; + in_reply_to_tweet_id?: string; }) => { if (env.TWITTER_API_KEY === "dev") { console.log("Debug Send Tweet:\n", message); @@ -34,6 +36,7 @@ export const sendTweet = async ({ const data = await twitterClient.v2.tweet({ text: message, media: { media_ids: mediaIds as [string, string] }, + reply: in_reply_to_tweet_id ? { in_reply_to_tweet_id } : undefined, }); return data.data.id; From b5fb0b1fb5911a991f94e0e83d54dac83a7b6e57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Pradel?= Date: Fri, 29 Nov 2024 14:34:06 +0100 Subject: [PATCH 7/7] Update index.ts --- apps/server/src/api/root/tweet-weekly-users/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/api/root/tweet-weekly-users/index.ts b/apps/server/src/api/root/tweet-weekly-users/index.ts index a580558..e9e4eed 100644 --- a/apps/server/src/api/root/tweet-weekly-users/index.ts +++ b/apps/server/src/api/root/tweet-weekly-users/index.ts @@ -68,7 +68,7 @@ export default defineEventHandler(async () => { }/api/images/weekly-users?${params.toString()}`; let messageDirect = - "📈 Last 7 days unique users that interacted directly with the protocol:\n\n"; + "📈 Last 7 days unique users that interacted directly with the protocols:\n\n"; for (const stat of statsDirect) { messageDirect += `\n- @${protocolsInfo[stat.protocol_name].x.replace( "https://twitter.com/",