From 5d62187c2cfdba042177d699471c3c1848e9a847 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 24 Mar 2026 19:35:19 +0000 Subject: [PATCH 1/8] [#504] Replace activity tab with unified reverse-chronological feed - Unified feed merging: created storyline, published plot, bought/sold tokens, donated, and rated events - Data from storylines, plots, trade_history, donations, and ratings tables, fetched in parallel - Each entry shows event type label (color-coded), story link, detail (token amounts, prices, star ratings), timestamp, and tx link - Pagination via "Load more" button (30 entries per page) - Enhanced empty state for addresses with no activity - Genesis plots (plot_index=0) deduplicated with created_storyline Fixes #504 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/profile/[address]/page.tsx | 308 +++++++++++++++++++---------- 1 file changed, 206 insertions(+), 102 deletions(-) diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx index 64c009ee..8127a7b4 100644 --- a/src/app/profile/[address]/page.tsx +++ b/src/app/profile/[address]/page.tsx @@ -855,123 +855,227 @@ function PortfolioTab({ address }: { address: string }) { } // --------------------------------------------------------------------------- -// Activity Tab +// Activity Tab — unified reverse-chronological feed // --------------------------------------------------------------------------- -const ACTIVITY_PAGE_SIZE = 20; +interface FeedEntry { + type: "created_storyline" | "published_plot" | "bought" | "sold" | "donated" | "rated"; + timestamp: string; + storylineId: number; + storyTitle?: string; + txHash?: string; + detail?: string; +} + +const FEED_PAGE_SIZE = 30; function ActivityTab({ address }: { address: string }) { - const { data: donations = [], isLoading: donLoading } = useQuery({ - queryKey: ["profile-donations", address], - queryFn: async () => { - if (!supabase) return []; - const { data } = await supabase - .from("donations") - .select("*") - .eq("donor_address", address) - .eq("contract_address", STORY_FACTORY.toLowerCase()) - .order("block_timestamp", { ascending: false }) - .limit(ACTIVITY_PAGE_SIZE) - .returns(); - return data ?? []; - }, - }); + const [visibleCount, setVisibleCount] = useState(FEED_PAGE_SIZE); - const { data: ratings = [], isLoading: ratLoading } = useQuery({ - queryKey: ["profile-ratings", address], - queryFn: async () => { + const { data: feed = [], isLoading } = useQuery({ + queryKey: ["profile-activity-feed", address], + queryFn: async (): Promise => { if (!supabase) return []; - const { data } = await supabase - .from("ratings") - .select("*") - .eq("rater_address", address) - .eq("contract_address", STORY_FACTORY.toLowerCase()) - .order("created_at", { ascending: false }) - .limit(ACTIVITY_PAGE_SIZE); - return data ?? []; + + // Fetch all event sources in parallel + const [storylinesRes, plotsRes, tradesRes, donationsRes, ratingsRes] = await Promise.all([ + // Storylines created by this address + supabase + .from("storylines") + .select("storyline_id, title, block_timestamp, tx_hash") + .eq("writer_address", address) + .eq("hidden", false) + .eq("contract_address", STORY_FACTORY.toLowerCase()) + .order("block_timestamp", { ascending: false }), + // Plots published by this address + supabase + .from("plots") + .select("storyline_id, plot_index, title, block_timestamp, tx_hash") + .eq("writer_address", address) + .eq("hidden", false) + .eq("contract_address", STORY_FACTORY.toLowerCase()) + .order("block_timestamp", { ascending: false }), + // Trades by this address + supabase + .from("trade_history") + .select("storyline_id, event_type, reserve_amount, price_per_token, block_timestamp, tx_hash") + .eq("user_address", address) + .eq("contract_address", STORY_FACTORY.toLowerCase()) + .order("block_timestamp", { ascending: false }), + // Donations by this address + supabase + .from("donations") + .select("storyline_id, amount, block_timestamp, tx_hash") + .eq("donor_address", address) + .eq("contract_address", STORY_FACTORY.toLowerCase()) + .order("block_timestamp", { ascending: false }), + // Ratings by this address + supabase + .from("ratings") + .select("storyline_id, rating, created_at") + .eq("rater_address", address) + .eq("contract_address", STORY_FACTORY.toLowerCase()) + .order("created_at", { ascending: false }), + ]); + + const entries: FeedEntry[] = []; + + // Created storylines + for (const s of (storylinesRes.data ?? []) as { storyline_id: number; title: string; block_timestamp: string | null; tx_hash: string }[]) { + if (!s.block_timestamp) continue; + entries.push({ + type: "created_storyline", + timestamp: s.block_timestamp, + storylineId: s.storyline_id, + storyTitle: s.title, + txHash: s.tx_hash, + }); + } + + // Published plots (skip genesis plot_index=0, already covered by created_storyline) + for (const p of (plotsRes.data ?? []) as { storyline_id: number; plot_index: number; title: string; block_timestamp: string | null; tx_hash: string }[]) { + if (!p.block_timestamp || p.plot_index === 0) continue; + entries.push({ + type: "published_plot", + timestamp: p.block_timestamp, + storylineId: p.storyline_id, + detail: p.title || `Chapter ${p.plot_index}`, + txHash: p.tx_hash, + }); + } + + // Trades + for (const t of (tradesRes.data ?? []) as { storyline_id: number; event_type: string; reserve_amount: number; price_per_token: number; block_timestamp: string; tx_hash: string }[]) { + const tokenAmount = t.price_per_token > 0 + ? formatPrice(t.reserve_amount / t.price_per_token) + : null; + entries.push({ + type: t.event_type === "mint" ? "bought" : "sold", + timestamp: t.block_timestamp, + storylineId: t.storyline_id, + detail: tokenAmount + ? `${tokenAmount} tokens for ${formatPrice(t.reserve_amount)} ${RESERVE_LABEL}` + : `${formatPrice(t.reserve_amount)} ${RESERVE_LABEL}`, + txHash: t.tx_hash, + }); + } + + // Donations + for (const d of (donationsRes.data ?? []) as { storyline_id: number; amount: string; block_timestamp: string | null; tx_hash: string }[]) { + if (!d.block_timestamp) continue; + entries.push({ + type: "donated", + timestamp: d.block_timestamp, + storylineId: d.storyline_id, + detail: `${formatPrice(formatUnits(BigInt(d.amount), 18))} ${RESERVE_LABEL}`, + txHash: d.tx_hash, + }); + } + + // Ratings + for (const r of (ratingsRes.data ?? []) as { storyline_id: number; rating: number; created_at: string }[]) { + entries.push({ + type: "rated", + timestamp: r.created_at, + storylineId: r.storyline_id, + detail: `${"★".repeat(r.rating)}${"☆".repeat(5 - r.rating)}`, + }); + } + + // Sort reverse-chronological + entries.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); + return entries; }, }); - const isLoading = donLoading || ratLoading; - const hasActivity = donations.length > 0 || ratings.length > 0; - if (isLoading) return

Loading...

; - if (!hasActivity) { - return

No activity yet.

; + if (feed.length === 0) { + return ( +
+

No activity yet.

+

+ This address has no on-chain activity on PlotLink. +

+
+ ); } + const visible = feed.slice(0, visibleCount); + const hasMore = visibleCount < feed.length; + return ( -
- {donations.length > 0 && ( -
-

Donations

-
- {donations.map((d) => ( -
-
- - Story #{d.storyline_id} - - {d.block_timestamp && ( - - )} -
-
- - {formatPrice(formatUnits(BigInt(d.amount), 18))} {RESERVE_LABEL} - - {d.tx_hash && ( - - ↗ - - )} -
-
- ))} -
-
+
+
+ {visible.map((entry, i) => ( + + ))} +
+ {hasMore && ( + )} +
+ ); +} - {ratings.length > 0 && ( -
-

Ratings

-
- {ratings.map((r: { id: number; storyline_id: number; rating: number; comment: string | null; created_at: string }) => ( -
-
- - Story #{r.storyline_id} - - {"★".repeat(r.rating)}{"☆".repeat(5 - r.rating)} -
- -
- ))} -
-
- )} +const EVENT_LABELS: Record = { + created_storyline: "Created", + published_plot: "Published", + bought: "Bought", + sold: "Sold", + donated: "Donated", + rated: "Rated", +}; + +const EVENT_COLORS: Record = { + created_storyline: "text-accent", + published_plot: "text-accent", + bought: "text-green-700", + sold: "text-red-700", + donated: "text-accent", + rated: "text-muted", +}; + +function FeedRow({ entry }: { entry: FeedEntry }) { + return ( +
+
+ + {EVENT_LABELS[entry.type]} + + + {entry.storyTitle ?? `Story #${entry.storylineId}`} + + {entry.detail && ( + {entry.detail} + )} +
+
+ + {entry.txHash && ( + + ↗ + + )} +
); } From 585eac6266477f1a66132dbb14a69d28cf4f2ced Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 24 Mar 2026 19:39:19 +0000 Subject: [PATCH 2/8] [#504] Add claimed royalties event type and per-source query limits - Add claimed_royalties feed entry type with on-chain getRoyaltyInfo check (shows cumulative total if claimed > 0) - Add .limit(200) to all 5 Supabase feed queries to bound data transfer - Handle storylineId=0 for non-story entries (royalties) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/profile/[address]/page.tsx | 59 +++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx index 8127a7b4..3f3d43bb 100644 --- a/src/app/profile/[address]/page.tsx +++ b/src/app/profile/[address]/page.tsx @@ -859,7 +859,7 @@ function PortfolioTab({ address }: { address: string }) { // --------------------------------------------------------------------------- interface FeedEntry { - type: "created_storyline" | "published_plot" | "bought" | "sold" | "donated" | "rated"; + type: "created_storyline" | "published_plot" | "bought" | "sold" | "donated" | "rated" | "claimed_royalties"; timestamp: string; storylineId: number; storyTitle?: string; @@ -877,7 +877,9 @@ function ActivityTab({ address }: { address: string }) { queryFn: async (): Promise => { if (!supabase) return []; - // Fetch all event sources in parallel + const PER_SOURCE_LIMIT = 200; + + // Fetch all event sources in parallel (bounded per source) const [storylinesRes, plotsRes, tradesRes, donationsRes, ratingsRes] = await Promise.all([ // Storylines created by this address supabase @@ -886,7 +888,8 @@ function ActivityTab({ address }: { address: string }) { .eq("writer_address", address) .eq("hidden", false) .eq("contract_address", STORY_FACTORY.toLowerCase()) - .order("block_timestamp", { ascending: false }), + .order("block_timestamp", { ascending: false }) + .limit(PER_SOURCE_LIMIT), // Plots published by this address supabase .from("plots") @@ -894,28 +897,32 @@ function ActivityTab({ address }: { address: string }) { .eq("writer_address", address) .eq("hidden", false) .eq("contract_address", STORY_FACTORY.toLowerCase()) - .order("block_timestamp", { ascending: false }), + .order("block_timestamp", { ascending: false }) + .limit(PER_SOURCE_LIMIT), // Trades by this address supabase .from("trade_history") .select("storyline_id, event_type, reserve_amount, price_per_token, block_timestamp, tx_hash") .eq("user_address", address) .eq("contract_address", STORY_FACTORY.toLowerCase()) - .order("block_timestamp", { ascending: false }), + .order("block_timestamp", { ascending: false }) + .limit(PER_SOURCE_LIMIT), // Donations by this address supabase .from("donations") .select("storyline_id, amount, block_timestamp, tx_hash") .eq("donor_address", address) .eq("contract_address", STORY_FACTORY.toLowerCase()) - .order("block_timestamp", { ascending: false }), + .order("block_timestamp", { ascending: false }) + .limit(PER_SOURCE_LIMIT), // Ratings by this address supabase .from("ratings") .select("storyline_id, rating, created_at") .eq("rater_address", address) .eq("contract_address", STORY_FACTORY.toLowerCase()) - .order("created_at", { ascending: false }), + .order("created_at", { ascending: false }) + .limit(PER_SOURCE_LIMIT), ]); const entries: FeedEntry[] = []; @@ -982,6 +989,26 @@ function ActivityTab({ address }: { address: string }) { }); } + // Claimed royalties (on-chain — summary entry if claimed > 0) + try { + const [, claimed] = await browserClient.readContract({ + address: MCV2_BOND, + abi: mcv2BondAbi, + functionName: "getRoyaltyInfo", + args: [address as Address, PLOT_TOKEN], + }); + if (claimed > BigInt(0)) { + entries.push({ + type: "claimed_royalties", + timestamp: new Date().toISOString(), + storylineId: 0, + detail: `${formatPrice(formatUnits(claimed, 18))} ${RESERVE_LABEL} total claimed`, + }); + } + } catch { + // Royalty info unavailable — skip + } + // Sort reverse-chronological entries.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); return entries; @@ -1029,6 +1056,7 @@ const EVENT_LABELS: Record = { sold: "Sold", donated: "Donated", rated: "Rated", + claimed_royalties: "Claimed", }; const EVENT_COLORS: Record = { @@ -1038,6 +1066,7 @@ const EVENT_COLORS: Record = { sold: "text-red-700", donated: "text-accent", rated: "text-muted", + claimed_royalties: "text-green-700", }; function FeedRow({ entry }: { entry: FeedEntry }) { @@ -1047,12 +1076,16 @@ function FeedRow({ entry }: { entry: FeedEntry }) { {EVENT_LABELS[entry.type]} - - {entry.storyTitle ?? `Story #${entry.storylineId}`} - + {entry.storylineId > 0 ? ( + + {entry.storyTitle ?? `Story #${entry.storylineId}`} + + ) : ( + Royalties + )} {entry.detail && ( {entry.detail} )} From 7a285ab70580f60b91c62f93fd76acf18f4cb91e Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 24 Mar 2026 19:47:19 +0000 Subject: [PATCH 3/8] [#504] Move claimed royalties from fake feed entry to summary card The MCV2_Bond contract doesn't emit individual claim events, so per-event timestamps and tx hashes aren't available. Replace the fake feed entry (which used new Date() as timestamp) with a static summary card showing cumulative claimed amount from on-chain getRoyaltyInfo. The claimed_royalties type remains in the feed infrastructure for future use when an indexer captures claim events. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/profile/[address]/page.tsx | 47 +++++++++++++++++------------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx index 3f3d43bb..39e57aaa 100644 --- a/src/app/profile/[address]/page.tsx +++ b/src/app/profile/[address]/page.tsx @@ -989,34 +989,28 @@ function ActivityTab({ address }: { address: string }) { }); } - // Claimed royalties (on-chain — summary entry if claimed > 0) - try { - const [, claimed] = await browserClient.readContract({ - address: MCV2_BOND, - abi: mcv2BondAbi, - functionName: "getRoyaltyInfo", - args: [address as Address, PLOT_TOKEN], - }); - if (claimed > BigInt(0)) { - entries.push({ - type: "claimed_royalties", - timestamp: new Date().toISOString(), - storylineId: 0, - detail: `${formatPrice(formatUnits(claimed, 18))} ${RESERVE_LABEL} total claimed`, - }); - } - } catch { - // Royalty info unavailable — skip - } - // Sort reverse-chronological entries.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); return entries; }, }); + // Claimed royalties (on-chain cumulative — shown as summary, not feed entry) + const { data: claimedRoyalties } = useQuery({ + queryKey: ["profile-claimed-royalties", address], + queryFn: async () => { + const [, claimed] = await browserClient.readContract({ + address: MCV2_BOND, + abi: mcv2BondAbi, + functionName: "getRoyaltyInfo", + args: [address as Address, PLOT_TOKEN], + }); + return claimed; + }, + }); + if (isLoading) return

Loading...

; - if (feed.length === 0) { + if (feed.length === 0 && (!claimedRoyalties || claimedRoyalties === BigInt(0))) { return (

No activity yet.

@@ -1032,6 +1026,17 @@ function ActivityTab({ address }: { address: string }) { return (
+ {/* Claimed royalties summary (on-chain, no per-event history available) */} + {claimedRoyalties && claimedRoyalties > BigInt(0) && ( +
+

Claimed Royalties

+ + {formatPrice(formatUnits(claimedRoyalties, 18))} {RESERVE_LABEL} + + total claimed to date +
+ )} +
{visible.map((entry, i) => ( From accbf8599f09a11b39e3a551d75a919fe83bd703 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 24 Mar 2026 19:49:13 +0000 Subject: [PATCH 4/8] [#504] Derive claimed royalties from on-chain Transfer logs Replace summary card with real feed entries from ERC-20 Transfer events on PLOT_TOKEN where from=MCV2_BOND and to=address. Each claim now appears as a proper feed entry with real block timestamp, tx hash link, and claimed amount. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/profile/[address]/page.tsx | 63 ++++++++++++++++++------------ 1 file changed, 37 insertions(+), 26 deletions(-) diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx index 39e57aaa..8272884e 100644 --- a/src/app/profile/[address]/page.tsx +++ b/src/app/profile/[address]/page.tsx @@ -989,28 +989,50 @@ function ActivityTab({ address }: { address: string }) { }); } + // Claimed royalties — derive from ERC-20 Transfer events on PLOT_TOKEN + // where from=MCV2_BOND and to=address (royalty payouts) + try { + const transferEventAbi = [{ + type: "event", + name: "Transfer", + inputs: [ + { name: "from", type: "address", indexed: true }, + { name: "to", type: "address", indexed: true }, + { name: "value", type: "uint256", indexed: false }, + ], + }] as const; + + const claimLogs = await browserClient.getLogs({ + address: PLOT_TOKEN, + event: transferEventAbi[0], + args: { from: MCV2_BOND, to: address as Address }, + fromBlock: BigInt(0), + toBlock: "latest", + }); + + for (const log of claimLogs) { + const blockTimestamp = await browserClient.getBlock({ blockNumber: log.blockNumber! }); + const ts = new Date(Number(blockTimestamp.timestamp) * 1000).toISOString(); + entries.push({ + type: "claimed_royalties", + timestamp: ts, + storylineId: 0, + detail: `${formatPrice(formatUnits(log.args.value ?? BigInt(0), 18))} ${RESERVE_LABEL}`, + txHash: log.transactionHash ?? undefined, + }); + } + } catch { + // Claim log query unavailable — skip + } + // Sort reverse-chronological entries.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); return entries; }, }); - // Claimed royalties (on-chain cumulative — shown as summary, not feed entry) - const { data: claimedRoyalties } = useQuery({ - queryKey: ["profile-claimed-royalties", address], - queryFn: async () => { - const [, claimed] = await browserClient.readContract({ - address: MCV2_BOND, - abi: mcv2BondAbi, - functionName: "getRoyaltyInfo", - args: [address as Address, PLOT_TOKEN], - }); - return claimed; - }, - }); - if (isLoading) return

Loading...

; - if (feed.length === 0 && (!claimedRoyalties || claimedRoyalties === BigInt(0))) { + if (feed.length === 0) { return (

No activity yet.

@@ -1026,17 +1048,6 @@ function ActivityTab({ address }: { address: string }) { return (
- {/* Claimed royalties summary (on-chain, no per-event history available) */} - {claimedRoyalties && claimedRoyalties > BigInt(0) && ( -
-

Claimed Royalties

- - {formatPrice(formatUnits(claimedRoyalties, 18))} {RESERVE_LABEL} - - total claimed to date -
- )} -
{visible.map((entry, i) => ( From 5e09b79e8a22ac892488caa1d5d21a9d2f9ebca1 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 24 Mar 2026 19:51:22 +0000 Subject: [PATCH 5/8] [#504] Filter sell refunds from claimed royalties Transfer logs Exclude PLOT_TOKEN Transfer events whose tx_hash matches a trade in trade_history (sell refunds from MCV2_BOND.burn). Only Transfer events not associated with any indexed trade are treated as royalty claims. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/profile/[address]/page.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx index 8272884e..4c4fbb22 100644 --- a/src/app/profile/[address]/page.tsx +++ b/src/app/profile/[address]/page.tsx @@ -1010,7 +1010,15 @@ function ActivityTab({ address }: { address: string }) { toBlock: "latest", }); + // Exclude Transfer events that are sell refunds (same tx as a trade) + const tradeTxHashes = new Set( + (tradesRes.data ?? []).map((t: { tx_hash: string }) => t.tx_hash.toLowerCase()), + ); + for (const log of claimLogs) { + const txHash = log.transactionHash?.toLowerCase(); + if (txHash && tradeTxHashes.has(txHash)) continue; // sell refund, not a claim + const blockTimestamp = await browserClient.getBlock({ blockNumber: log.blockNumber! }); const ts = new Date(Number(blockTimestamp.timestamp) * 1000).toISOString(); entries.push({ From eaf7a4b0211fc61d57d92b86c0f12273a0a6d3d5 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 24 Mar 2026 19:53:09 +0000 Subject: [PATCH 6/8] [#504] Use unbounded trade tx_hash query for sell refund exclusion The per-source .limit(200) on trade_history was causing older sell refunds to escape the royalty claim filter. Now fetches ALL trade tx_hashes (lightweight select of tx_hash only) to accurately exclude sell refunds from Transfer log scan regardless of trade count. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/profile/[address]/page.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx index 4c4fbb22..aee117ed 100644 --- a/src/app/profile/[address]/page.tsx +++ b/src/app/profile/[address]/page.tsx @@ -1010,9 +1010,14 @@ function ActivityTab({ address }: { address: string }) { toBlock: "latest", }); - // Exclude Transfer events that are sell refunds (same tx as a trade) + // Fetch ALL trade tx hashes (unbounded) to filter sell refunds accurately + const { data: allTradeTxRows } = await supabase + .from("trade_history") + .select("tx_hash") + .eq("user_address", address) + .eq("contract_address", STORY_FACTORY.toLowerCase()); const tradeTxHashes = new Set( - (tradesRes.data ?? []).map((t: { tx_hash: string }) => t.tx_hash.toLowerCase()), + (allTradeTxRows ?? []).map((t: { tx_hash: string }) => t.tx_hash.toLowerCase()), ); for (const log of claimLogs) { From 6f6b6d4cfd04e61cd3c2ad32c6ea8ef796090591 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 24 Mar 2026 19:58:23 +0000 Subject: [PATCH 7/8] [#504] Fix trade_history queries to use MCV2_BOND contract_address The trade indexers write trade_history.contract_address = MCV2_BOND, not STORY_FACTORY. Fixed all 6 trade_history queries in the profile page: activity feed trades, sell refund exclusion, total holders, per-story holders, entry price, and last traded lookups. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/profile/[address]/page.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx index aee117ed..8b491301 100644 --- a/src/app/profile/[address]/page.tsx +++ b/src/app/profile/[address]/page.tsx @@ -258,7 +258,7 @@ function StoriesTab({ .from("trade_history") .select("user_address, storyline_id") .in("storyline_id", storylineIds) - .eq("contract_address", STORY_FACTORY.toLowerCase()); + .eq("contract_address", MCV2_BOND.toLowerCase()); if (!trades || trades.length === 0) return 0; // Build map: token_address -> unique user addresses @@ -452,7 +452,7 @@ function StoryRow({ storyline }: { storyline: Storyline }) { .from("trade_history") .select("user_address") .eq("storyline_id", storyline.storyline_id) - .eq("contract_address", STORY_FACTORY.toLowerCase()); + .eq("contract_address", MCV2_BOND.toLowerCase()); if (!trades || trades.length === 0) return 0; const uniqueUsers = [...new Set( @@ -609,7 +609,7 @@ function PortfolioTab({ address }: { address: string }) { .eq("user_address", address) .eq("storyline_id", sl.storyline_id) .eq("event_type", "mint") - .eq("contract_address", STORY_FACTORY.toLowerCase()) + .eq("contract_address", MCV2_BOND.toLowerCase()) .order("block_timestamp", { ascending: true }) .limit(1); if (firstMint && firstMint.length > 0) { @@ -620,7 +620,7 @@ function PortfolioTab({ address }: { address: string }) { .select("block_timestamp") .eq("user_address", address) .eq("storyline_id", sl.storyline_id) - .eq("contract_address", STORY_FACTORY.toLowerCase()) + .eq("contract_address", MCV2_BOND.toLowerCase()) .order("block_timestamp", { ascending: false }) .limit(1); if (lastTrade && lastTrade.length > 0) { @@ -899,12 +899,12 @@ function ActivityTab({ address }: { address: string }) { .eq("contract_address", STORY_FACTORY.toLowerCase()) .order("block_timestamp", { ascending: false }) .limit(PER_SOURCE_LIMIT), - // Trades by this address + // Trades by this address (trade_history uses MCV2_BOND as contract_address) supabase .from("trade_history") .select("storyline_id, event_type, reserve_amount, price_per_token, block_timestamp, tx_hash") .eq("user_address", address) - .eq("contract_address", STORY_FACTORY.toLowerCase()) + .eq("contract_address", MCV2_BOND.toLowerCase()) .order("block_timestamp", { ascending: false }) .limit(PER_SOURCE_LIMIT), // Donations by this address @@ -1015,7 +1015,7 @@ function ActivityTab({ address }: { address: string }) { .from("trade_history") .select("tx_hash") .eq("user_address", address) - .eq("contract_address", STORY_FACTORY.toLowerCase()); + .eq("contract_address", MCV2_BOND.toLowerCase()); const tradeTxHashes = new Set( (allTradeTxRows ?? []).map((t: { tx_hash: string }) => t.tx_hash.toLowerCase()), ); From af51c03f2e467e68296f0feb57ee9c93869cc717 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 24 Mar 2026 20:00:16 +0000 Subject: [PATCH 8/8] [#504] Move claimed royalties to profile header per operator direction Per operator guidance: skip claimed_royalties from chronological feed (not indexable without dedicated claim event), show cumulative total as static stat in profile header from on-chain getRoyaltyInfo, add TODO for future claim indexer. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/profile/[address]/page.tsx | 75 +++++++++++------------------- 1 file changed, 27 insertions(+), 48 deletions(-) diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx index 8b491301..14676210 100644 --- a/src/app/profile/[address]/page.tsx +++ b/src/app/profile/[address]/page.tsx @@ -38,6 +38,20 @@ export default function ProfilePage() { const isAgent = !agentLoading && agentMeta !== null && agentMeta !== undefined; + // Cumulative claimed royalties (on-chain) + const { data: claimedRoyalties } = useQuery({ + queryKey: ["profile-claimed-royalties", address], + queryFn: async () => { + const [, claimed] = await browserClient.readContract({ + address: MCV2_BOND, + abi: mcv2BondAbi, + functionName: "getRoyaltyInfo", + args: [address as Address, PLOT_TOKEN], + }); + return claimed; + }, + }); + return (
{/* Tab navigation */} @@ -92,6 +107,7 @@ function ProfileHeader({ agentMeta, agentLoading, isAgent, + claimedRoyalties, }: { address: string; fcProfile: FarcasterProfile | null; @@ -99,6 +115,7 @@ function ProfileHeader({ agentMeta: AgentMetadata | null; agentLoading: boolean; isAgent: boolean; + claimedRoyalties: bigint | null; }) { const displayName = agentMeta?.name ?? fcProfile?.displayName ?? null; @@ -194,6 +211,13 @@ function ProfileHeader({ {!agentMeta?.description && fcProfile?.bio && (

{fcProfile.bio}

)} + + {/* Cumulative claimed royalties */} + {claimedRoyalties && claimedRoyalties > BigInt(0) && ( +
+ Royalties claimed: {formatPrice(formatUnits(claimedRoyalties, 18))} {RESERVE_LABEL} +
+ )}
@@ -989,54 +1013,9 @@ function ActivityTab({ address }: { address: string }) { }); } - // Claimed royalties — derive from ERC-20 Transfer events on PLOT_TOKEN - // where from=MCV2_BOND and to=address (royalty payouts) - try { - const transferEventAbi = [{ - type: "event", - name: "Transfer", - inputs: [ - { name: "from", type: "address", indexed: true }, - { name: "to", type: "address", indexed: true }, - { name: "value", type: "uint256", indexed: false }, - ], - }] as const; - - const claimLogs = await browserClient.getLogs({ - address: PLOT_TOKEN, - event: transferEventAbi[0], - args: { from: MCV2_BOND, to: address as Address }, - fromBlock: BigInt(0), - toBlock: "latest", - }); - - // Fetch ALL trade tx hashes (unbounded) to filter sell refunds accurately - const { data: allTradeTxRows } = await supabase - .from("trade_history") - .select("tx_hash") - .eq("user_address", address) - .eq("contract_address", MCV2_BOND.toLowerCase()); - const tradeTxHashes = new Set( - (allTradeTxRows ?? []).map((t: { tx_hash: string }) => t.tx_hash.toLowerCase()), - ); - - for (const log of claimLogs) { - const txHash = log.transactionHash?.toLowerCase(); - if (txHash && tradeTxHashes.has(txHash)) continue; // sell refund, not a claim - - const blockTimestamp = await browserClient.getBlock({ blockNumber: log.blockNumber! }); - const ts = new Date(Number(blockTimestamp.timestamp) * 1000).toISOString(); - entries.push({ - type: "claimed_royalties", - timestamp: ts, - storylineId: 0, - detail: `${formatPrice(formatUnits(log.args.value ?? BigInt(0), 18))} ${RESERVE_LABEL}`, - txHash: log.transactionHash ?? undefined, - }); - } - } catch { - // Claim log query unavailable — skip - } + // TODO: Claimed royalties feed entries require a dedicated claim event + // indexer. For now, cumulative claimed amount is shown in the profile header + // via on-chain getRoyaltyInfo. // Sort reverse-chronological entries.sort((a, b) => b.timestamp.localeCompare(a.timestamp));