From aed8970700e6f5bc11525fea8ee5c947ff636c72 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Fri, 8 May 2026 20:06:15 +0200 Subject: [PATCH] feat(juicer): /v1/campaigns/juicer/progress + /spend endpoints JP-trade flow for the Juicer NFT loyalty drop. The wallet trades JUICER_JP_COST (=5000) Juice Points for a backend signature, then mints with that signature. What ships - src/endpoints/juicerCampaign.ts * GET /v1/campaigns/juicer/progress - returns { availableJp, totalEarnedJp, spentJp, cost, isEligibleForNFT, nftMinted, ... }. Reads totalEarnedJp from ponder /points/{address}, spent from the new juicer_spend table, and best-effort hasClaimed from the NFT contract. * POST /v1/campaigns/juicer/spend - idempotent: if a spend row already exists for (wallet, chain) the stored signature is returned, otherwise validates JP balance, generates the claim signature, INSERTs the row, returns 201. - prisma/schema.prisma: new JuicerSpend model (table juicer_spend) with unique (wallet_address, chain_id) constraint as the source of truth for spent JP. - src/lib/constants/campaigns.ts: JUICER_NFT_CONTRACT (env-driven so it stays empty until smart-contracts#56 is deployed) + JUICER_JP_COST = 5000. - src/server.ts: mount the two new routes under generalLimiter. Fail-closed properties - /spend returns 503 if JUICER_NFT_CONTRACT env var is empty (contract not deployed yet) so the frontend renders a "not yet live" state rather than minting against a placeholder address. - /spend returns 503 if ponder is unreachable, never silently lets a wallet spend against a stale or missing balance. - The unique (walletAddress, chainId) constraint plus the contract's hasClaimed mapping form a defence-in-depth pair against double-claim. Hard dependencies before this can serve real data - JuiceSwapxyz/ponder#138 (fixes /points 500s) - JuiceSwapxyz/smart-contracts#56 (deploys JuicerNFT, populates JUICER_NFT_CONTRACT env var) - JuiceSwapxyz/bapp#741 (consumer) Migration - Apply prisma migrate to provision juicer_spend before deploy. --- prisma/schema.prisma | 30 +++ src/endpoints/juicerCampaign.ts | 315 ++++++++++++++++++++++++++++++++ src/lib/constants/campaigns.ts | 16 ++ src/server.ts | 18 ++ 4 files changed, 379 insertions(+) create mode 100644 src/endpoints/juicerCampaign.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5b36a4594f..28db986c3b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -147,3 +147,33 @@ enum SwapType { reverse chain } + +/** + * Juicer NFT campaign — atomic JP-spend record. + * + * Each row represents a single Juicer NFT mint claim. The unique + * (walletAddress, chainId) constraint guarantees a wallet can only + * trade JP once per chain (the on-chain hasClaimed mapping is the + * authoritative second line of defence). Inserting a row deducts + * `amount` JP from the wallet's available balance going forward. + * + * The signature column is set after generation so /spend is idempotent: + * a retried POST returns the same signature as the first call instead + * of issuing a new one. + */ +model JuicerSpend { + id String @id @default(uuid()) + walletAddress String @map("wallet_address") + chainId Int @map("chain_id") + amount Int // JP spent (typically JUICER_JP_COST) + signature String // backend-issued claim signature + contractAddress String @map("contract_address") // JuicerNFT contract this signature is bound to + spentAt DateTime @default(now()) @map("spent_at") + txHash String? @map("tx_hash") // populated once frontend reports the on-chain claim + txConfirmedAt DateTime? @map("tx_confirmed_at") + + @@unique([walletAddress, chainId], map: "juicer_spend_wallet_chain_unique") + @@index([walletAddress]) + @@index([chainId]) + @@map("juicer_spend") +} diff --git a/src/endpoints/juicerCampaign.ts b/src/endpoints/juicerCampaign.ts new file mode 100644 index 0000000000..80e0a1e5b9 --- /dev/null +++ b/src/endpoints/juicerCampaign.ts @@ -0,0 +1,315 @@ +/** + * Juicer NFT campaign — eligibility + JP-trade endpoints. + * + * GET /v1/campaigns/juicer/progress?walletAddress=&chainId= + * POST /v1/campaigns/juicer/spend { walletAddress, chainId } + * + * Eligibility model + * ----------------- + * The Juicer NFT is paid for in Juice Points: a wallet trades + * `JUICER_JP_COST` JP for a backend signature, then mints with that + * signature. Spend records live in `juicer_spend` (one row per + * (wallet, chain)) and act as the source of truth for "available JP". + * + * availableJp = totalEarnedJp - JUICER_JP_COST × (spendRecord ? 1 : 0) + * + * The endpoint is idempotent: a retried POST /spend returns the same + * signature as the first call (signatures are deterministic from + * `(contract, chainId, wallet)`), so a wallet whose first claim tx + * failed can retry without losing JP twice or being locked out. + * + * Hard dependency on the Juice Points system: + * - Ponder must serve `/points/{address}` (PR JuiceSwapxyz/ponder#138). + * - Frontend must hit the real API (PR JuiceSwapxyz/bapp#740). + * - JuicerNFT contract must be deployed and `JUICER_NFT_CONTRACT` + * env var populated (PR JuiceSwapxyz/smart-contracts#56). + * + * If any of those preconditions are unmet the relevant handler fails + * closed with a 503 instead of returning fake data. + */ + +import { Request, Response } from "express"; +import Logger from "bunyan"; +import { ethers } from "ethers"; +import { ChainId } from "@juiceswapxyz/sdk-core"; +import { prisma } from "../db/prisma"; +import { getPonderClient } from "../services/PonderClient"; +import { + JUICER_NFT_CONTRACT, + JUICER_JP_COST, +} from "../lib/constants/campaigns"; + +const ADDRESS_RE = /^0x[a-fA-F0-9]{40}$/; +const SUPPORTED_CHAIN_IDS = new Set([ + ChainId.CITREA_MAINNET, + ChainId.CITREA_TESTNET, +]); + +interface PonderPointsResponse { + total: number; + swaps: { count: number; points: number }; + liquidity: { + days: number; + points: number; + currentUsdValue: number; + meetsMinimum: boolean; + }; +} + +/** + * Reads total earned JP for a wallet from the ponder /points endpoint. + * Throws if ponder is unreachable so callers can fail closed. + */ +async function fetchTotalEarnedJp( + logger: Logger, + walletAddress: string, +): Promise { + const ponder = getPonderClient(logger); + const res = await ponder.get( + `/points/${walletAddress.toLowerCase()}`, + ); + if (res.status !== 200 || typeof res.data?.total !== "number") { + throw new Error(`Unexpected ponder response: HTTP ${res.status}`); + } + return res.data.total; +} + +/** + * Best-effort on-chain `hasClaimed` check. Returns `false` (with a warning + * logged) if the RPC is unreachable — the contract itself enforces + * one-mint-per-address as the second line of defence. + */ +async function hasClaimedOnChain( + logger: Logger, + walletAddress: string, + chainId: number, +): Promise { + const rpcEnvVar = + chainId === ChainId.CITREA_MAINNET + ? "CITREA_4114_RPC_URL" + : "CITREA_5115_RPC_URL"; + const rpcUrl = process.env[rpcEnvVar]; + if (!rpcUrl) { + logger.warn( + { rpcEnvVar }, + "RPC URL env var not set — skipping on-chain hasClaimed check", + ); + return false; + } + if (!JUICER_NFT_CONTRACT) { + return false; + } + try { + const provider = new ethers.providers.JsonRpcProvider(rpcUrl); + const contract = new ethers.Contract( + JUICER_NFT_CONTRACT, + ["function hasClaimed(address) view returns (bool)"], + provider, + ); + return await contract.hasClaimed(walletAddress); + } catch (err: any) { + logger.warn( + { error: err.message }, + "On-chain hasClaimed check failed — defaulting to false", + ); + return false; + } +} + +interface ValidatedInput { + walletAddress: string; // lowercased + chainId: number; +} + +function validateRequest( + req: Request, + res: Response, +): ValidatedInput | null { + const walletAddressRaw = + (req.method === "GET" ? req.query.walletAddress : req.body?.walletAddress) ?? + ""; + const chainIdRaw = + (req.method === "GET" ? req.query.chainId : req.body?.chainId) ?? ""; + + const walletAddress = String(walletAddressRaw); + if (!ADDRESS_RE.test(walletAddress)) { + res.status(400).json({ message: "Invalid walletAddress" }); + return null; + } + + const chainId = Number(chainIdRaw); + if (!Number.isFinite(chainId) || !SUPPORTED_CHAIN_IDS.has(chainId)) { + res + .status(400) + .json({ message: "Unsupported chainId (must be Citrea mainnet or testnet)" }); + return null; + } + + return { walletAddress: walletAddress.toLowerCase(), chainId }; +} + +/** + * GET /v1/campaigns/juicer/progress + */ +export function createJuicerProgressHandler(logger: Logger) { + return async function handle(req: Request, res: Response): Promise { + const log = logger.child({ endpoint: "juicer-progress" }); + const input = validateRequest(req, res); + if (!input) { + return; + } + const { walletAddress, chainId } = input; + + let totalEarnedJp: number; + try { + totalEarnedJp = await fetchTotalEarnedJp(log, walletAddress); + } catch (err: any) { + log.error( + { error: err.message, walletAddress }, + "Failed to read JP from ponder — failing closed", + ); + res.status(503).json({ message: "Indexer not ready; try again shortly" }); + return; + } + + const spend = await prisma.juicerSpend.findUnique({ + where: { + walletAddress_chainId: { walletAddress, chainId }, + }, + }); + + const spentJp = spend?.amount ?? 0; + const availableJp = Math.max(0, totalEarnedJp - spentJp); + + const nftMinted = + !!spend?.txConfirmedAt || + (await hasClaimedOnChain(log, walletAddress, chainId)); + + res.status(200).json({ + walletAddress, + chainId, + availableJp, + totalEarnedJp, + spentJp, + cost: JUICER_JP_COST, + isEligibleForNFT: !nftMinted && availableJp >= JUICER_JP_COST, + nftMinted, + nftTxHash: spend?.txHash ?? undefined, + nftMintedAt: spend?.txConfirmedAt?.toISOString() ?? undefined, + }); + }; +} + +/** + * POST /v1/campaigns/juicer/spend + * + * Idempotent: if a spend row already exists for (wallet, chain) the + * stored signature is returned instead of regenerating one or + * deducting JP twice. + */ +export function createJuicerSpendHandler(logger: Logger) { + return async function handle(req: Request, res: Response): Promise { + const log = logger.child({ endpoint: "juicer-spend" }); + const input = validateRequest(req, res); + if (!input) { + return; + } + const { walletAddress, chainId } = input; + + if (!JUICER_NFT_CONTRACT) { + log.warn("JUICER_NFT_CONTRACT not configured — rejecting spend"); + res + .status(503) + .json({ message: "Juicer NFT not deployed yet; try again later" }); + return; + } + const signerPrivateKey = process.env.CAMPAIGN_SIGNER_PRIVATE_KEY; + if (!signerPrivateKey) { + log.error("CAMPAIGN_SIGNER_PRIVATE_KEY not configured"); + res.status(500).json({ message: "Signing not configured" }); + return; + } + + // Short-circuit: if a row already exists, just return the stored signature. + const existing = await prisma.juicerSpend.findUnique({ + where: { walletAddress_chainId: { walletAddress, chainId } }, + }); + if (existing) { + log.debug( + { walletAddress, chainId, spentAt: existing.spentAt }, + "Re-issuing existing signature (idempotent)", + ); + res.status(200).json({ + signature: existing.signature, + spentJp: existing.amount, + remainingJp: undefined, // computed by /progress; avoid extra ponder hop here + }); + return; + } + + // Fresh spend: validate balance, generate signature, insert row. + let totalEarnedJp: number; + try { + totalEarnedJp = await fetchTotalEarnedJp(log, walletAddress); + } catch (err: any) { + log.error( + { error: err.message, walletAddress }, + "Failed to read JP from ponder — refusing to spend", + ); + res.status(503).json({ message: "Indexer not ready; try again shortly" }); + return; + } + if (totalEarnedJp < JUICER_JP_COST) { + res.status(403).json({ + message: `Not enough Juice Points (need ${JUICER_JP_COST}, have ${totalEarnedJp})`, + }); + return; + } + + // Generate signature: keccak256(abi.encodePacked(contract, chainid, wallet)) + const signer = new ethers.Wallet(signerPrivateKey); + const messageHash = ethers.utils.solidityKeccak256( + ["address", "uint256", "address"], + [JUICER_NFT_CONTRACT, chainId, walletAddress], + ); + const signature = await signer.signMessage( + ethers.utils.arrayify(messageHash), + ); + + // Insert spend row. The unique (walletAddress, chainId) constraint + // means a concurrent request that wins the race short-circuits at + // the findUnique above on retry; if both miss, one INSERT will lose + // and we fall back to returning the stored row. + try { + const created = await prisma.juicerSpend.create({ + data: { + walletAddress, + chainId, + amount: JUICER_JP_COST, + signature, + contractAddress: JUICER_NFT_CONTRACT, + }, + }); + res.status(201).json({ + signature: created.signature, + spentJp: created.amount, + remainingJp: totalEarnedJp - created.amount, + }); + } catch (err: any) { + // Race-loser: the unique constraint just fired. Re-read and return. + const persisted = await prisma.juicerSpend.findUnique({ + where: { walletAddress_chainId: { walletAddress, chainId } }, + }); + if (persisted) { + res.status(200).json({ + signature: persisted.signature, + spentJp: persisted.amount, + remainingJp: totalEarnedJp - persisted.amount, + }); + return; + } + log.error({ error: err.message }, "Failed to record JP spend"); + res.status(500).json({ message: "Failed to record JP spend" }); + } + }; +} diff --git a/src/lib/constants/campaigns.ts b/src/lib/constants/campaigns.ts index b62221b659..b034dcbad3 100644 --- a/src/lib/constants/campaigns.ts +++ b/src/lib/constants/campaigns.ts @@ -19,3 +19,19 @@ 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; + +/** + * Juicer NFT (post-launch loyalty drop). Mints are paid in Juice Points + * (JUICER_JP_COST per mint). The contract address is the deployed + * `contracts/nft/JuicerNFT.sol` from JuiceSwapxyz/smart-contracts on + * Citrea Mainnet — populate via the JUICER_NFT_CONTRACT env var once + * `JuiceSwapxyz/smart-contracts#56` is merged and deployed. Until then + * the spend handler fails closed with 503 so the frontend can render a + * "not yet live" state. + */ +export const JUICER_NFT_CONTRACT = (process.env.JUICER_NFT_CONTRACT ?? "") as + | `0x${string}` + | ""; + +/** Juice Points required to mint one Juicer NFT. Frontend mirrors this. */ +export const JUICER_JP_COST = 5000; diff --git a/src/server.ts b/src/server.ts index 4ec94b18e3..e709a1822c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -46,6 +46,10 @@ import { createNFTSignatureHandler, createFirstSqueezerEligibilityHandler, } from "./endpoints/firstSqueezerCampaign"; +import { + createJuicerProgressHandler, + createJuicerSpendHandler, +} from "./endpoints/juicerCampaign"; import { quoteLimiter, generalLimiter } from "./middleware/rateLimiter"; import { validateBody, @@ -320,6 +324,8 @@ async function bootstrap() { const handleNFTSignature = createNFTSignatureHandler(logger); const handleFirstSqueezerEligibility = createFirstSqueezerEligibilityHandler(logger); + const handleJuicerProgress = createJuicerProgressHandler(logger); + const handleJuicerSpend = createJuicerSpendHandler(logger); const handleLightningInvoice = createLightningInvoiceHandler(logger); const handleValidateLightningAddress = createValidateLightningAddressHandler(logger); @@ -639,6 +645,18 @@ async function bootstrap() { handleFirstSqueezerEligibility, ); + // Juicer NFT campaign — JP-trade flow + app.get( + "/v1/campaigns/juicer/progress", + generalLimiter, + handleJuicerProgress, + ); + app.post( + "/v1/campaigns/juicer/spend", + generalLimiter, + handleJuicerSpend, + ); + // Bridge Swap endpoints app.post( "/v1/bridge-swap",