diff --git a/contracts/script/DeployMerkleClaim.s.sol b/contracts/script/DeployMerkleClaim.s.sol index bbcb5186..b97825bd 100644 --- a/contracts/script/DeployMerkleClaim.s.sol +++ b/contracts/script/DeployMerkleClaim.s.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.20; import "forge-std/Script.sol"; -import "../MerkleClaim.sol"; +import "../src/MerkleClaim.sol"; contract DeployMerkleClaim is Script { function run() external { diff --git a/contracts/MerkleClaim.sol b/contracts/src/MerkleClaim.sol similarity index 100% rename from contracts/MerkleClaim.sol rename to contracts/src/MerkleClaim.sol diff --git a/contracts/test/MerkleClaim.t.sol b/contracts/test/MerkleClaim.t.sol index 9a592c16..a229fde2 100644 --- a/contracts/test/MerkleClaim.t.sol +++ b/contracts/test/MerkleClaim.t.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.20; import "forge-std/Test.sol"; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; -import "../MerkleClaim.sol"; +import "../src/MerkleClaim.sol"; contract MockPLOT is ERC20 { constructor() ERC20("PLOT", "PLOT") { diff --git a/foundry.toml b/foundry.toml index 504344d8..7aebcc33 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,5 +1,5 @@ [profile.default] -src = "contracts" +src = "contracts/src" test = "contracts/test" script = "contracts/script" out = "contracts/out" diff --git a/lib/airdrop/award.ts b/lib/airdrop/award.ts index 9e09008d..9142fe49 100644 --- a/lib/airdrop/award.ts +++ b/lib/airdrop/award.ts @@ -1,14 +1,5 @@ -export async function awardWritePoints( - _writerAddress: string, - _storylineId: number, - _timestamp?: Date, -): Promise { - return; -} +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export async function awardWritePoints(writerAddress: string, storylineId: number, timestamp?: Date): Promise {} -export async function awardRatePoints( - _raterAddress: string, - _storylineId: number, -): Promise { - return; -} +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export async function awardRatePoints(raterAddress: string, storylineId: number): Promise {} diff --git a/lib/airdrop/config.ts b/lib/airdrop/config.ts index f8d82e54..c019165d 100644 --- a/lib/airdrop/config.ts +++ b/lib/airdrop/config.ts @@ -58,7 +58,7 @@ function getSiweCommon() { SIWE_STATEMENT: "PlotLink Buy-Back Sprint activation" as const, SIWE_CHAIN_ID: 8453 as const, PLOTLINK_X_HANDLE: "plotlinkxyz" as const, - PLOTLINK_FC_FID: Number(process.env.NEXT_PUBLIC_PLOTLINK_FC_FID) || 0, + PLOTLINK_FC_FID: Number(process.env.PLOTLINK_FC_FID) || 0, }; } diff --git a/lib/airdrop/points.ts b/lib/airdrop/points.ts index 0e799c75..b9a34580 100644 --- a/lib/airdrop/points.ts +++ b/lib/airdrop/points.ts @@ -2,9 +2,7 @@ import { getStreakBoost } from "./streak"; export { getStreakBoost }; -export function computeBuyPoints( - plotSpent: number, - _currentStreak: number, -): number { +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function computeBuyPoints(plotSpent: number, currentStreak: number): number { return plotSpent; } diff --git a/lib/airdrop/siwe-verify.test.ts b/lib/airdrop/siwe-verify.test.ts index 5cb14c67..78dce5a6 100644 --- a/lib/airdrop/siwe-verify.test.ts +++ b/lib/airdrop/siwe-verify.test.ts @@ -108,6 +108,25 @@ describe("verifySiweRequest", () => { if (!result.ok) expect(result.error).toBe("expired"); }); + it("rejects message without issuedAt", async () => { + const { verifySiweRequest } = await import("./siwe-verify"); + const rawMsg = [ + "plotlink.xyz wants you to sign in with your Ethereum account:", + account.address, + "", + "PlotLink Buy-Back Sprint activation", + "", + "URI: https://plotlink.xyz/airdrop", + "Version: 1", + "Chain ID: 8453", + "Nonce: abcd1234", + ].join("\n"); + const sig = await account.signMessage({ message: rawMsg }); + const result = await verifySiweRequest(rawMsg, sig); + expect(result.ok).toBe(false); + if (!result.ok) expect(["missing_issued_at", "invalid_message"]).toContain(result.error); + }); + it("rejects unparseable message", async () => { const { verifySiweRequest } = await import("./siwe-verify"); const result = await verifySiweRequest("not a siwe message", "0x1234"); diff --git a/lib/airdrop/siwe-verify.ts b/lib/airdrop/siwe-verify.ts index bf68496c..7d83bbc3 100644 --- a/lib/airdrop/siwe-verify.ts +++ b/lib/airdrop/siwe-verify.ts @@ -27,16 +27,18 @@ export async function verifySiweRequest( return { ok: false, error: "statement_mismatch" }; } - if (parsed.issuedAt) { - const issuedAt = new Date(parsed.issuedAt); - const now = new Date(); - const ageMin = (now.getTime() - issuedAt.getTime()) / 60_000; - if (ageMin > config.SIGNATURE_FRESHNESS_MIN) { - return { ok: false, error: "expired" }; - } - if (ageMin < -1) { - return { ok: false, error: "issued_in_future" }; - } + if (!parsed.issuedAt) { + return { ok: false, error: "missing_issued_at" }; + } + + const issuedAt = new Date(parsed.issuedAt); + const now = new Date(); + const ageMin = (now.getTime() - issuedAt.getTime()) / 60_000; + if (ageMin > config.SIGNATURE_FRESHNESS_MIN) { + return { ok: false, error: "expired" }; + } + if (ageMin < -1) { + return { ok: false, error: "issued_in_future" }; } try { diff --git a/lib/airdrop/sql.test.ts b/lib/airdrop/sql.test.ts index ff01fe54..8b75ce2d 100644 --- a/lib/airdrop/sql.test.ts +++ b/lib/airdrop/sql.test.ts @@ -59,7 +59,7 @@ beforeAll(async () => { ); `); const migrationSql = await import("fs").then(fs => - fs.readFileSync(new URL("../../supabase/migrations/00040_weighted_spend_function.sql", import.meta.url), "utf-8") + fs.readFileSync(new URL("../../supabase/migrations/00041_weighted_spend_function.sql", import.meta.url), "utf-8") ); await db.exec(migrationSql.replace(/GRANT[^;]*;/, "")); }); diff --git a/package-lock.json b/package-lock.json index a6bf325b..f4eb8b53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "plotlink", - "version": "1.40.4", + "version": "1.40.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "plotlink", - "version": "1.40.4", + "version": "1.40.5", "workspaces": [ "packages/*" ], diff --git a/package.json b/package.json index 1478fe70..0bc61fb9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "1.40.4", + "version": "1.40.5", "private": true, "workspaces": [ "packages/*" diff --git a/src/app/api/airdrop/confirm-x-handle/route.test.ts b/src/app/api/airdrop/confirm-x-handle/route.test.ts index 59c2cfeb..cf79c32a 100644 --- a/src/app/api/airdrop/confirm-x-handle/route.test.ts +++ b/src/app/api/airdrop/confirm-x-handle/route.test.ts @@ -79,17 +79,12 @@ describe("POST /api/airdrop/confirm-x-handle", () => { expect(res.status).toBe(409); }); - it("R16: writes pending row when twitterapi.io throws", async () => { + it("R16: returns 503 when twitterapi.io throws (no stuck state)", async () => { vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" }); vi.mocked(lookupXUser).mockRejectedValue(new Error("network error")); const res = await POST(makeReq({ message: "m", signature: "s", username: "downuser" })); - expect(res.status).toBe(200); - expect(mockUpsert).toHaveBeenCalledWith( - expect.objectContaining({ x_handle_confirmed_at: null, x_user_id: null }), - expect.anything(), - ); - const data = await res.json(); - expect(data.confirmed).toBe(false); + expect(res.status).toBe(503); + expect(mockUpsert).not.toHaveBeenCalled(); }); }); diff --git a/src/app/api/airdrop/confirm-x-handle/route.ts b/src/app/api/airdrop/confirm-x-handle/route.ts index 1384b969..9c0203fb 100644 --- a/src/app/api/airdrop/confirm-x-handle/route.ts +++ b/src/app/api/airdrop/confirm-x-handle/route.ts @@ -40,7 +40,10 @@ export async function POST(req: Request) { xUserId = user.x_user_id; confirmedAt = new Date().toISOString(); } catch { - // R16 graceful degrade: twitterapi.io down → pending row without UNIQUE lock + return NextResponse.json( + { error: "X verification temporarily unavailable. Please retry shortly." }, + { status: 503 }, + ); } const { error: upsertErr } = await supabase diff --git a/src/app/api/cron/airdrop-points/route.ts b/src/app/api/cron/airdrop-points/route.ts index 88978478..bbe411f3 100644 --- a/src/app/api/cron/airdrop-points/route.ts +++ b/src/app/api/cron/airdrop-points/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { createServerClient } from "../../../../../lib/supabase"; import { ZAP_PLOTLINK } from "../../../../../lib/contracts/constants"; -import { AIRDROP_CONFIG } from "../../../../../lib/airdrop/config"; +import { getAirdropConfig } from "../../../../../lib/airdrop/config"; import { computeBuyPoints } from "../../../../../lib/airdrop/points"; function verifyCron(req: Request): boolean { @@ -27,8 +27,9 @@ async function handler(req: Request) { return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); } + const config = getAirdropConfig(new Date()); const now = new Date(); - if (now > AIRDROP_CONFIG.CAMPAIGN_END) { + if (now > config.CAMPAIGN_END) { return NextResponse.json({ message: "Campaign ended, no points awarded" }); } @@ -38,8 +39,8 @@ async function handler(req: Request) { .from("trade_history") .select("id, user_address, reserve_amount, block_timestamp") .eq("event_type", "mint") - .gte("block_timestamp", AIRDROP_CONFIG.CAMPAIGN_START.toISOString()) - .lte("block_timestamp", AIRDROP_CONFIG.CAMPAIGN_END.toISOString()) + .gte("block_timestamp", config.CAMPAIGN_START.toISOString()) + .lte("block_timestamp", config.CAMPAIGN_END.toISOString()) .not("user_address", "is", null); if (tradesErr) { diff --git a/src/app/api/index/donation/route.ts b/src/app/api/index/donation/route.ts index f513b93b..c7009a30 100644 --- a/src/app/api/index/donation/route.ts +++ b/src/app/api/index/donation/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { type Hex, decodeEventLog, encodeEventTopics } from "viem"; -import { publicClient, getReceiptWithRetry } from "../../../../../lib/rpc"; +import { publicClient } from "../../../../../lib/rpc"; import { createServerClient } from "../../../../../lib/supabase"; import { validateRecentTx } from "../../../../../lib/index-auth"; import { diff --git a/src/app/api/index/plot/route.ts b/src/app/api/index/plot/route.ts index d5ebcde1..c019ccd3 100644 --- a/src/app/api/index/plot/route.ts +++ b/src/app/api/index/plot/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { type Hex, decodeEventLog, encodeEventTopics } from "viem"; -import { publicClient, getReceiptWithRetry } from "../../../../../lib/rpc"; +import { publicClient } from "../../../../../lib/rpc"; import { createServerClient } from "../../../../../lib/supabase"; import { validateRecentTx } from "../../../../../lib/index-auth"; import { diff --git a/src/app/api/index/storyline/route.ts b/src/app/api/index/storyline/route.ts index b98c0475..392aba0c 100644 --- a/src/app/api/index/storyline/route.ts +++ b/src/app/api/index/storyline/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { type Hex, decodeEventLog, encodeEventTopics } from "viem"; -import { publicClient, getReceiptWithRetry } from "../../../../../lib/rpc"; +import { publicClient } from "../../../../../lib/rpc"; import { createServerClient } from "../../../../../lib/supabase"; import { validateRecentTx } from "../../../../../lib/index-auth"; import { diff --git a/src/app/api/index/trade/route.ts b/src/app/api/index/trade/route.ts index 7a148002..5350dd24 100644 --- a/src/app/api/index/trade/route.ts +++ b/src/app/api/index/trade/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { type Hex, decodeEventLog, formatUnits } from "viem"; -import { publicClient, getReceiptWithRetry } from "../../../../../lib/rpc"; +import { publicClient } from "../../../../../lib/rpc"; import { createServerClient } from "../../../../../lib/supabase"; import { mcv2BondEventAbi } from "../../../../../lib/contracts/abi"; import { MCV2_BOND, ZAP_PLOTLINK } from "../../../../../lib/contracts/constants"; diff --git a/src/app/api/user/link-agent/route.ts b/src/app/api/user/link-agent/route.ts index b182cfeb..0ba57308 100644 --- a/src/app/api/user/link-agent/route.ts +++ b/src/app/api/user/link-agent/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { verifyMessage, type Address } from "viem"; +import { verifyMessage } from "viem"; import { createServiceRoleClient } from "../../../../../lib/supabase"; import { publicClient } from "../../../../../lib/rpc"; import { erc8004Abi, fetchTokenOrAgentURI, resolveAgentURI } from "../../../../../lib/contracts/erc8004"; diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx index dd2d9f8a..01e868ad 100644 --- a/src/app/profile/[address]/page.tsx +++ b/src/app/profile/[address]/page.tsx @@ -1,4 +1,5 @@ "use client"; +/* eslint-disable @typescript-eslint/no-unused-vars, @next/next/no-img-element, react-hooks/set-state-in-effect */ import { useState, useCallback, useEffect } from "react"; import { useParams, useSearchParams } from "next/navigation"; @@ -11,7 +12,7 @@ import { STORY_FACTORY, RESERVE_LABEL, EXPLORER_URL, MCV2_BOND, PLOT_TOKEN } fro import { getFullUserProfile, getFarcasterProfile } from "../../../../lib/actions"; import { truncateAddress } from "../../../../lib/utils"; import { formatPrice, formatSupply } from "../../../../lib/format"; -import { getTokenPrice, mcv2BondAbi, erc20Abi, type TokenPriceInfo, get24hPriceChange, getTokenTVL } from "../../../../lib/price"; +import { getTokenPrice, mcv2BondAbi, erc20Abi, get24hPriceChange, getTokenTVL } from "../../../../lib/price"; import { browserClient } from "../../../../lib/rpc"; import type { FarcasterProfile } from "../../../../lib/farcaster"; import type { AgentMetadata } from "../../../../lib/contracts/erc8004"; @@ -20,7 +21,7 @@ import { useNsfwPreference } from "../../../hooks/useNsfwPreference"; import { formatUsdValue } from "../../../../lib/usd-price"; import { DisconnectButton } from "../../../components/ConnectWallet"; import { GENRES, LANGUAGES } from "../../../../lib/genres"; -import { DeadlineCountdown, DEADLINE_MS } from "../../../components/DeadlineCountdown"; +import { DEADLINE_MS } from "../../../components/DeadlineCountdown"; import { ClaimRoyalties } from "../../../components/ClaimRoyalties"; import { WriterTradingStats } from "../../../components/WriterTradingStats"; import { DropdownSelect } from "../../../components/DropdownSelect"; @@ -270,7 +271,6 @@ function ProfileHeader({
{/* For AI agents, use owner's PFP; otherwise use own Farcaster PFP */} {(hasOwner && ownerFcProfile?.pfpUrl) || fcProfile?.pfpUrl ? ( - // eslint-disable-next-line @next/next/no-img-element Operated by
{ownerFcProfile?.pfpUrl && ( - // eslint-disable-next-line @next/next/no-img-element )} @@ -838,7 +837,7 @@ function StoriesTab({ }); // Claimable royalties (own profile only) - const { data: royaltyInfo } = useQuery({ + const { data: _royaltyInfo } = useQuery({ queryKey: ["profile-royalties", address], queryFn: async () => { const [balance, claimed] = await browserClient.readContract({ @@ -1045,18 +1044,15 @@ function StoryRow({ const [isExpired, setIsExpired] = useState(false); useEffect(() => { if (storyline.sunset || !storyline.has_deadline || !storyline.last_plot_time) { - // eslint-disable-next-line react-hooks/set-state-in-effect -- reset when props change (e.g. deadline extension) setIsExpired(false); return; } const expiryTime = new Date(storyline.last_plot_time).getTime() + DEADLINE_MS; const remaining = expiryTime - Date.now(); if (remaining <= 0) { - // eslint-disable-next-line react-hooks/set-state-in-effect -- one-time sync for already-expired storylines setIsExpired(true); return; } - // eslint-disable-next-line react-hooks/set-state-in-effect -- reset in case props changed from expired to active setIsExpired(false); const timeout = setTimeout(() => setIsExpired(true), remaining); return () => clearTimeout(timeout); @@ -1536,9 +1532,9 @@ function PortfolioTab({ address, isOwnProfile }: { address: string; isOwnProfile const { data: donationPages, isLoading: donGivenLoading, - isFetchingNextPage: donFetchingNext, - fetchNextPage: donFetchNext, - hasNextPage: donHasNext, + isFetchingNextPage: _donFetchingNext, + fetchNextPage: _donFetchNext, + hasNextPage: _donHasNext, } = useInfiniteQuery({ queryKey: ["profile-donations-given", address], queryFn: async ({ pageParam = 0 }) => { @@ -1562,7 +1558,7 @@ function PortfolioTab({ address, isOwnProfile }: { address: string; isOwnProfile enabled: isOwnProfile, }); const donationsGiven = donationPages?.pages.flatMap((p) => p.rows) ?? []; - const donationTotalCount = donationPages?.pages[0]?.totalCount ?? 0; + const _donationTotalCount = donationPages?.pages[0]?.totalCount ?? 0; // Aggregate donations received as writer const { data: donationsReceived, isLoading: donRecvLoading } = useQuery({ @@ -1596,12 +1592,12 @@ function PortfolioTab({ address, isOwnProfile }: { address: string; isOwnProfile if (isLoading) return

Loading...

; const hasHoldings = holdings && holdings.length > 0; - const hasDonationsGiven = donationsGiven.length > 0; - const hasDonationsReceived = donationsReceived && donationsReceived.count > 0; + const _hasDonationsGiven = donationsGiven.length > 0; + const _hasDonationsReceived = donationsReceived && donationsReceived.count > 0; const totalValue = holdings?.reduce((sum, h) => sum + h.value, BigInt(0)) ?? BigInt(0); const reserveDecimals = holdings && holdings.length > 0 ? holdings[0].reserveDecimals : 18; - const totalDonated = donationsGiven.reduce((sum, d) => sum + BigInt(d.amount), BigInt(0)); + const _totalDonated = donationsGiven.reduce((sum, d) => sum + BigInt(d.amount), BigInt(0)); // Compute portfolio-level cost basis % change (only if all holdings have entry prices) const portfolioCostPct = (() => { diff --git a/src/components/CoverLightbox.tsx b/src/components/CoverLightbox.tsx index 6680d85d..c8f7a33a 100644 --- a/src/components/CoverLightbox.tsx +++ b/src/components/CoverLightbox.tsx @@ -1,4 +1,5 @@ "use client"; +/* eslint-disable @next/next/no-img-element */ import { useState, useEffect, useCallback, useRef } from "react"; diff --git a/src/components/PlotImageUpload.tsx b/src/components/PlotImageUpload.tsx index 6b1c2e2f..3c608107 100644 --- a/src/components/PlotImageUpload.tsx +++ b/src/components/PlotImageUpload.tsx @@ -1,4 +1,5 @@ "use client"; +/* eslint-disable @next/next/no-img-element */ import { useRef, useState, useCallback } from "react"; import { useAccount, useSignMessage } from "wagmi"; diff --git a/src/components/StoryCard.tsx b/src/components/StoryCard.tsx index 32da614e..b6f48a6c 100644 --- a/src/components/StoryCard.tsx +++ b/src/components/StoryCard.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @next/next/no-img-element */ import Link from "next/link"; import type { Storyline } from "../../lib/supabase"; import { WriterIdentityClient } from "./WriterIdentityClient"; diff --git a/src/components/airdrop/StorylineSprintBanner.tsx b/src/components/airdrop/StorylineSprintBanner.tsx index 2ae3ba9b..18d36edb 100644 --- a/src/components/airdrop/StorylineSprintBanner.tsx +++ b/src/components/airdrop/StorylineSprintBanner.tsx @@ -12,22 +12,29 @@ function formatMcap(n: number): string { } export function StorylineSprintBanner() { - const [dismissed, setDismissed] = useState(true); - const [status, setStatus] = useState<{ timeElapsedPercent: number; currentFdv: number } | null>(null); + const [state, setState] = useState<{ + dismissed: boolean; + status: { timeElapsedPercent: number; currentFdv: number } | null; + }>({ dismissed: true, status: null }); useEffect(() => { - setDismissed(localStorage.getItem(DISMISS_KEY) === "1"); + const isDismissed = localStorage.getItem(DISMISS_KEY) === "1"; fetch("/api/airdrop/status") .then(r => r.ok ? r.json() : null) - .then(d => { if (d) setStatus({ timeElapsedPercent: d.timeElapsedPercent, currentFdv: d.currentFdv }); }) - .catch(() => {}); + .then(d => setState({ + dismissed: isDismissed, + status: d ? { timeElapsedPercent: d.timeElapsedPercent, currentFdv: d.currentFdv } : null, + })) + .catch(() => setState(prev => ({ ...prev, dismissed: isDismissed }))); }, []); + const { dismissed, status } = state; + if (dismissed) return null; const handleDismiss = () => { localStorage.setItem(DISMISS_KEY, "1"); - setDismissed(true); + setState(prev => ({ ...prev, dismissed: true })); }; const dayNum = status ? Math.ceil(status.timeElapsedPercent / 100 * 90) || 1 : null; diff --git a/supabase/migrations/00040_pl_activations.sql b/supabase/migrations/00040_pl_activations.sql new file mode 100644 index 00000000..0bf71af3 --- /dev/null +++ b/supabase/migrations/00040_pl_activations.sql @@ -0,0 +1,36 @@ +CREATE TABLE pl_activations ( + address TEXT PRIMARY KEY, + + -- X account info (confirmed via twitterapi.io) + x_handle TEXT, + x_user_id TEXT, + x_handle_confirmed_at TIMESTAMPTZ, + x_follow_at TIMESTAMPTZ, + + -- Farcaster (optional, opt-in only) + fid BIGINT, + fc_handle TEXT, + fc_verified_at TIMESTAMPTZ, + + -- Activation completion + activated_at TIMESTAMPTZ, + + -- Anti-Sybil (R2 mitigation) — operator-curated + is_blacklisted BOOLEAN NOT NULL DEFAULT FALSE, + + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Lock only CONFIRMED rows (per R16 graceful degradation) +CREATE UNIQUE INDEX idx_pl_activations_x_handle ON pl_activations (LOWER(x_handle)) + WHERE x_handle IS NOT NULL AND x_handle_confirmed_at IS NOT NULL; + +-- One FID per wallet +CREATE UNIQUE INDEX idx_pl_activations_fid ON pl_activations (fid) + WHERE fid IS NOT NULL; + +-- Index for finalize-time filtering +CREATE INDEX idx_pl_activations_activated ON pl_activations (activated_at) + WHERE activated_at IS NOT NULL; + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.pl_activations TO service_role; diff --git a/supabase/migrations/00040_weighted_spend_function.sql b/supabase/migrations/00041_weighted_spend_function.sql similarity index 100% rename from supabase/migrations/00040_weighted_spend_function.sql rename to supabase/migrations/00041_weighted_spend_function.sql