diff --git a/.env.example b/.env.example index 670b698b2a..1462106a21 100644 --- a/.env.example +++ b/.env.example @@ -56,7 +56,8 @@ DISCORD_CLIENT_ID=your_discord_client_id DISCORD_CLIENT_SECRET=your_discord_client_secret DISCORD_BOT_TOKEN=your_discord_bot_token DISCORD_CALLBACK_URL=http://localhost:3000/v1/campaigns/first-squeezer/discord/callback -DISCORD_GUILD_ID=your_discord_server_guild_id +# Note: Discord guild ID and Juicer role ID are hardcoded in src/lib/constants/campaigns.ts +# (stable identifiers, same across all environments). # First Squeezer NFT Campaign Configuration # Campaign signer private key (generates signatures for NFT claiming) diff --git a/src/endpoints/firstSqueezerCampaign.ts b/src/endpoints/firstSqueezerCampaign.ts index b079342979..53c86d1656 100644 --- a/src/endpoints/firstSqueezerCampaign.ts +++ b/src/endpoints/firstSqueezerCampaign.ts @@ -7,12 +7,35 @@ import { getTwitterApiIoService } from "../services/TwitterApiIoService"; import { getDiscordOAuthService } from "../services/DiscordOAuthService"; import { getPonderClient } from "../services/PonderClient"; import { prisma } from "../db/prisma"; -import { FIRST_SQUEEZER_NFT_CONTRACT } from "../lib/constants/campaigns"; +import { + FIRST_SQUEEZER_NFT_CONTRACT, + FIRST_SQUEEZER_TESTNET_NFT_CONTRACT, +} from "../lib/constants/campaigns"; /** * First Squeezer Campaign - Social OAuth Endpoints (Twitter & Discord) */ +/** + * Returns true if the wallet ran claim() on the testnet First Squeezer NFT + * contract (Oct 2025 campaign). Used by the mainnet claim eligibility gate. + * Throws on RPC failure — callers must fail closed, not permit claims. + */ +async function hasClaimedTestnetNFT(walletAddress: string): Promise { + if (!process.env.CITREA_5115_RPC_URL) { + throw new Error("CITREA_5115_RPC_URL not configured"); + } + const provider = new ethers.providers.JsonRpcProvider( + process.env.CITREA_5115_RPC_URL, + ); + const contract = new ethers.Contract( + FIRST_SQUEEZER_TESTNET_NFT_CONTRACT, + ["function hasClaimed(address) view returns (bool)"], + provider, + ); + return contract.hasClaimed(walletAddress); +} + /** * @swagger * /v1/campaigns/first-squeezer/twitter/start: @@ -380,6 +403,100 @@ export function createTwitterStatusHandler(logger: Logger) { }; } +/** + * @swagger + * /v1/campaigns/first-squeezer/twitter/mark-followed: + * post: + * tags: [Campaign] + * summary: Mark a wallet as having followed @JuiceSwap_com (honor system) + * description: > + * Sets `twitterVerifiedAt` for the wallet. Does NOT verify the follow on + * Twitter's side and does NOT touch `twitterUsername` / `twitterUserId`, + * preserving data from any prior OAuth-based verification for the same + * wallet. + * parameters: + * - in: query + * name: walletAddress + * required: true + * schema: + * type: string + * example: "0x2F0cC51C02E5D4EC68bC155728798969D5c0F714" + * responses: + * 200: + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * verifiedAt: + * type: string + * format: date-time + * default: + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ +export function createTwitterMarkFollowedHandler(logger: Logger) { + return async function handleTwitterMarkFollowed( + req: Request, + res: Response, + ): Promise { + const log = logger.child({ endpoint: "twitter-mark-followed" }); + + try { + const walletAddress = req.query.walletAddress as string; + + if (!walletAddress || !/^0x[a-fA-F0-9]{40}$/.test(walletAddress)) { + res.status(400).json({ message: "Invalid wallet address" }); + return; + } + + const normalizedAddress = walletAddress.toLowerCase(); + + // Find or create user + let user = await prisma.user.findUnique({ + where: { address: normalizedAddress }, + }); + if (!user) { + user = await prisma.user.create({ + data: { address: normalizedAddress }, + }); + } + + // Upsert only `twitterVerifiedAt`. `twitterUsername` and `twitterUserId` + // are intentionally omitted from the update so any values populated via + // a previous OAuth flow are preserved. + const verifiedAt = new Date(); + await prisma.ogCampaignUser.upsert({ + where: { userId: user.id }, + update: { twitterVerifiedAt: verifiedAt }, + create: { + userId: user.id, + twitterVerifiedAt: verifiedAt, + }, + }); + + log.info( + { walletAddress: normalizedAddress }, + "Twitter follow marked (honor system)", + ); + + res + .status(200) + .json({ success: true, verifiedAt: verifiedAt.toISOString() }); + } catch (error: any) { + log.error( + { error: error.message, stack: error.stack }, + "Error in handleTwitterMarkFollowed", + ); + res.status(500).json({ message: "Failed to mark Twitter follow" }); + } + }; +} + /** * Discord OAuth Endpoints */ @@ -517,12 +634,12 @@ export function createDiscordCallbackHandler(logger: Logger) { // Get Discord OAuth service const discordService = getDiscordOAuthService(); - // Complete OAuth flow (includes guild membership check) + // Complete OAuth flow (includes Juicer role check) log.debug({ state }, "Starting Discord OAuth flow completion"); - const { walletAddress, discordUser, isInGuild } = + const { walletAddress, discordUser, hasJuicerRole } = await discordService.completeOAuthFlow(code, state); log.debug( - { walletAddress, username: discordUser.username, isInGuild }, + { walletAddress, username: discordUser.username, hasJuicerRole }, "Discord OAuth flow completed successfully", ); @@ -541,14 +658,14 @@ export function createDiscordCallbackHandler(logger: Logger) { return; } - // Check if user is in the JuiceSwap Discord guild - if (!isInGuild) { + // Gate: user must carry the Juicer role in the JuiceSwap Discord + if (!hasJuicerRole) { log.warn( { walletAddress, discordUsername: discordUser.username }, - "User is not in JuiceSwap Discord guild", + "User does not have the Juicer role in JuiceSwap Discord", ); res.redirect( - `${process.env.FRONTEND_URL || "http://localhost:3001"}/oauth-callback?discord=error&message=${encodeURIComponent("You must join the JuiceSwap Discord server first")}`, + `${process.env.FRONTEND_URL || "http://localhost:3001"}/oauth-callback?discord=error&message=${encodeURIComponent("Your Discord account must have the Juicer role in the JuiceSwap server")}`, ); return; } @@ -832,7 +949,7 @@ export function createBAppsStatusHandler(logger: Logger) { const ponderClient = getPonderClient(log); const response = await ponderClient.post("/campaign/progress", { walletAddress: normalizedAddress, - chainId: ChainId.CITREA_TESTNET, + chainId: ChainId.CITREA_MAINNET, }); log.debug( @@ -869,7 +986,9 @@ export function createBAppsStatusHandler(logger: Logger) { * get: * tags: [Campaign] * summary: Get NFT claim signature - * description: Requires Twitter, Discord, and 3 swaps completed + * description: | + * Requires both social verifications and a prior claim on the testnet + * First Squeezer NFT. * parameters: * - in: query * name: walletAddress @@ -957,50 +1076,44 @@ export function createNFTSignatureHandler(logger: Logger) { return; } - // Check Twitter and Discord verification - const campaign = user.ogCampaign; - const twitterVerified = !!campaign.twitterVerifiedAt; - const discordVerified = !!campaign.discordVerifiedAt; - - // Check bApps completion (3 swaps) via Ponder API - let bappsCompleted = false; - + // Gate: caller must have claimed the testnet First Squeezer NFT. + // Fail CLOSED on RPC errors — this check is the sole enforcer of the gate. try { - const ponderClient = getPonderClient(log); - const response = await ponderClient.post("/campaign/progress", { - walletAddress: normalizedAddress, - chainId: ChainId.CITREA_TESTNET, - }); - - const completedTasks = response.data?.completedTasks || 0; - bappsCompleted = completedTasks === 3; - - log.debug( - { walletAddress: normalizedAddress, completedTasks, bappsCompleted }, - "Ponder API verification", - ); + const hasTestnetClaim = await hasClaimedTestnetNFT(normalizedAddress); + if (!hasTestnetClaim) { + log.debug( + { walletAddress: normalizedAddress }, + "Not eligible: no testnet First Squeezer claim on record", + ); + res.status(403).json({ + message: + "Only wallets that claimed the testnet First Squeezer NFT are eligible", + eligible: false, + }); + return; + } } catch (error: any) { log.error( - { - error: error.message, - context: "NFT signature - bApps verification", - }, - "Failed to verify bApps completion via Ponder", + { error: error.message, context: "testnet claim gate" }, + "Failed to verify testnet claim — failing closed", ); res - .status(500) - .json({ message: "Failed to verify campaign completion" }); + .status(503) + .json({ message: "Could not verify eligibility; please retry" }); return; } - // Verify ALL steps completed - if (!twitterVerified || !discordVerified || !bappsCompleted) { + // Check Twitter and Discord verification. + const campaign = user.ogCampaign; + const twitterVerified = !!campaign.twitterVerifiedAt; + const discordVerified = !!campaign.discordVerifiedAt; + + if (!twitterVerified || !discordVerified) { log.debug( { walletAddress: normalizedAddress, twitterVerified, discordVerified, - bappsCompleted, }, "User has not completed all verifications", ); @@ -1008,20 +1121,19 @@ export function createNFTSignatureHandler(logger: Logger) { message: "Complete all verification steps first", twitterVerified, discordVerified, - bappsCompleted, }); return; } // Check if NFT already claimed (query contract) try { - if (!process.env.CITREA_5115_RPC_URL) { + if (!process.env.CITREA_4114_RPC_URL) { throw new Error( - "CITREA_5115_RPC_URL environment variable is required", + "CITREA_4114_RPC_URL environment variable is required", ); } const provider = new ethers.providers.JsonRpcProvider( - process.env.CITREA_5115_RPC_URL, + process.env.CITREA_4114_RPC_URL, ); const nftContract = new ethers.Contract( contractAddress, @@ -1052,15 +1164,16 @@ export function createNFTSignatureHandler(logger: Logger) { { error: error.message, context: "NFT claim status check" }, "Failed to check NFT claim status, continuing with signature generation", ); - // Continue even if check fails - contract will reject if already claimed + // The contract still prevents double claims. } // Generate signature (matches contract verification) // keccak256(abi.encodePacked(address(this), block.chainid, msg.sender)) + // Chain id must match the target chain. const signer = new ethers.Wallet(signerPrivateKey); const messageHash = ethers.utils.solidityKeccak256( ["address", "uint256", "address"], - [contractAddress, ChainId.CITREA_TESTNET, normalizedAddress], + [contractAddress, ChainId.CITREA_MAINNET, normalizedAddress], ); const signature = await signer.signMessage( ethers.utils.arrayify(messageHash), @@ -1088,3 +1201,55 @@ export function createNFTSignatureHandler(logger: Logger) { } }; } + +/** + * @swagger + * /v1/campaigns/first-squeezer/eligibility: + * get: + * tags: [Campaign] + * summary: Check whether a wallet is eligible for the mainnet First Squeezer NFT + * description: | + * Returns `{ eligible: true }` iff the wallet ran claim() on the testnet + * First Squeezer NFT contract (Oct 2025 campaign). Used by the frontend + * for pre-flight UX; the authoritative gate is enforced in /nft/signature. + * parameters: + * - in: query + * name: walletAddress + * required: true + * schema: + * type: string + * responses: + * 200: + * content: + * application/json: + * schema: + * type: object + * properties: + * eligible: + * type: boolean + */ +export function createFirstSqueezerEligibilityHandler(logger: Logger) { + return async function handleFirstSqueezerEligibility( + req: Request, + res: Response, + ): Promise { + const log = logger.child({ endpoint: "first-squeezer-eligibility" }); + + const walletAddress = req.query.walletAddress as string; + if (!walletAddress || !/^0x[a-fA-F0-9]{40}$/.test(walletAddress)) { + res.status(400).json({ message: "Invalid wallet address" }); + return; + } + + try { + const eligible = await hasClaimedTestnetNFT(walletAddress.toLowerCase()); + res.status(200).json({ eligible }); + } catch (error: any) { + log.error( + { error: error.message, walletAddress }, + "Eligibility check failed", + ); + res.status(503).json({ message: "Could not verify eligibility" }); + } + }; +} diff --git a/src/lib/constants/campaigns.ts b/src/lib/constants/campaigns.ts index 57e60dfb16..b62221b659 100644 --- a/src/lib/constants/campaigns.ts +++ b/src/lib/constants/campaigns.ts @@ -1,7 +1,21 @@ /** * Campaign contract addresses and configuration - * These are public, immutable smart contract addresses deployed on-chain + * These are public, immutable identifiers — stable across environments. */ export const FIRST_SQUEEZER_NFT_CONTRACT = "0x428B878cB6383216AaDc4e8495037E8d31612621" as const; + +// Testnet First Squeezer NFT (Oct 2025 campaign). Identical address to +// FIRST_SQUEEZER_NFT_CONTRACT today purely by deterministic deployment-nonce +// coincidence; keep a separate constant so future redeploys don't silently +// break the mainnet claim eligibility gate. +export const FIRST_SQUEEZER_TESTNET_NFT_CONTRACT = + "0x428B878cB6383216AaDc4e8495037E8d31612621" as const; + +// JuiceSwap Discord server (guild) ID. +export const DISCORD_GUILD_ID = "1416006072748998720" as const; + +// Juicer role in the JuiceSwap Discord. Required for First Squeezer mainnet +// eligibility — gates the Discord verification condition. +export const DISCORD_JUICER_ROLE_ID = "1418526943501881445" as const; diff --git a/src/server.ts b/src/server.ts index 2c63350eed..02fcefc6ba 100644 --- a/src/server.ts +++ b/src/server.ts @@ -38,11 +38,13 @@ import { createTwitterStartHandler, createTwitterCallbackHandler, createTwitterStatusHandler, + createTwitterMarkFollowedHandler, createDiscordStartHandler, createDiscordCallbackHandler, createDiscordStatusHandler, createBAppsStatusHandler, createNFTSignatureHandler, + createFirstSqueezerEligibilityHandler, } from "./endpoints/firstSqueezerCampaign"; import { quoteLimiter, generalLimiter } from "./middleware/rateLimiter"; import { @@ -310,11 +312,14 @@ async function bootstrap() { const handleTwitterStart = createTwitterStartHandler(logger); const handleTwitterCallback = createTwitterCallbackHandler(logger); const handleTwitterStatus = createTwitterStatusHandler(logger); + const handleTwitterMarkFollowed = createTwitterMarkFollowedHandler(logger); const handleDiscordStart = createDiscordStartHandler(logger); const handleDiscordCallback = createDiscordCallbackHandler(logger); const handleDiscordStatus = createDiscordStatusHandler(logger); const handleBAppsStatus = createBAppsStatusHandler(logger); const handleNFTSignature = createNFTSignatureHandler(logger); + const handleFirstSqueezerEligibility = + createFirstSqueezerEligibilityHandler(logger); const handleLightningInvoice = createLightningInvoiceHandler(logger); const handleValidateLightningAddress = createValidateLightningAddressHandler(logger); @@ -586,6 +591,11 @@ async function bootstrap() { generalLimiter, handleTwitterStatus, ); + app.post( + "/v1/campaigns/first-squeezer/twitter/mark-followed", + generalLimiter, + handleTwitterMarkFollowed, + ); // Campaign endpoints - Discord OAuth app.get( @@ -618,6 +628,13 @@ async function bootstrap() { handleNFTSignature, ); + // Campaign endpoints - Mainnet claim eligibility pre-flight + app.get( + "/v1/campaigns/first-squeezer/eligibility", + generalLimiter, + handleFirstSqueezerEligibility, + ); + // Bridge Swap endpoints app.post( "/v1/bridge-swap", diff --git a/src/services/DiscordOAuthService.ts b/src/services/DiscordOAuthService.ts index ea2f448693..9dd0b002e9 100644 --- a/src/services/DiscordOAuthService.ts +++ b/src/services/DiscordOAuthService.ts @@ -1,4 +1,8 @@ import axios from "axios"; +import { + DISCORD_GUILD_ID, + DISCORD_JUICER_ROLE_ID, +} from "../lib/constants/campaigns"; import { generateState } from "../utils/pkce"; import { prisma } from "../db/prisma"; @@ -15,7 +19,6 @@ interface DiscordOAuthConfig { clientSecret: string; botToken: string; callbackUrl: string; - guildId: string; } interface DiscordTokenResponse { @@ -234,7 +237,35 @@ export class DiscordOAuthService { */ public async isUserInGuild(accessToken: string): Promise { const guilds = await this.getUserGuilds(accessToken); - return guilds.some((guild) => guild.id === this.config.guildId); + return guilds.some((guild) => guild.id === DISCORD_GUILD_ID); + } + + /** + * Check whether the user carries the Juicer role in the JuiceSwap guild. + * Uses the bot token (no extra OAuth scope needed). Returns false if the + * user is not a guild member — expected to be a no-op path since callers + * invoke addUserToGuild first. + */ + public async hasJuicerRole(userId: string): Promise { + const url = `${this.ADD_GUILD_MEMBER_URL}/${DISCORD_GUILD_ID}/members/${userId}`; + try { + const response = await axios.get<{ roles: string[] }>(url, { + headers: { + Authorization: `Bot ${this.config.botToken}`, + }, + }); + return response.data.roles.includes(DISCORD_JUICER_ROLE_ID); + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 404) { + return false; + } + if (axios.isAxiosError(error)) { + throw new Error( + `Failed to fetch Discord guild member: ${error.response?.data?.message || error.message}`, + ); + } + throw error; + } } /** @@ -247,7 +278,7 @@ export class DiscordOAuthService { accessToken: string, ): Promise { try { - const url = `${this.ADD_GUILD_MEMBER_URL}/${this.config.guildId}/members/${userId}`; + const url = `${this.ADD_GUILD_MEMBER_URL}/${DISCORD_GUILD_ID}/members/${userId}`; await axios.put( url, @@ -272,7 +303,8 @@ export class DiscordOAuthService { } /** - * Complete OAuth flow: exchange code, get user info, add to guild, and verify membership + * Complete OAuth flow: exchange code, get user info, add to guild, and + * verify the user carries the Juicer role. */ public async completeOAuthFlow( code: string, @@ -280,7 +312,7 @@ export class DiscordOAuthService { ): Promise<{ walletAddress: string; discordUser: DiscordUserData; - isInGuild: boolean; + hasJuicerRole: boolean; }> { // Exchange code for token const { accessToken, walletAddress } = await this.exchangeCodeForToken( @@ -291,16 +323,17 @@ export class DiscordOAuthService { // Get user info const discordUser = await this.getUserInfo(accessToken); - // Add user to Discord guild (auto-invite with guilds.join scope) + // Auto-invite into the guild so the bot can subsequently read the member + // object. Idempotent (Discord returns 204 if already a member). await this.addUserToGuild(discordUser.id, accessToken); - // Check if user is in the JuiceSwap Discord guild (should be true after auto-add) - const isInGuild = await this.isUserInGuild(accessToken); + // Gate: the user must carry the Juicer role. + const hasJuicerRole = await this.hasJuicerRole(discordUser.id); return { walletAddress, discordUser, - isInGuild, + hasJuicerRole, }; } @@ -342,15 +375,13 @@ export function getDiscordOAuthService(): DiscordOAuthService { clientSecret: process.env.DISCORD_CLIENT_SECRET || "", botToken: process.env.DISCORD_BOT_TOKEN || "", callbackUrl: process.env.DISCORD_CALLBACK_URL || "", - guildId: process.env.DISCORD_GUILD_ID || "", }; if ( !config.clientId || !config.clientSecret || !config.botToken || - !config.callbackUrl || - !config.guildId + !config.callbackUrl ) { throw new Error("Missing Discord OAuth environment variables"); }