Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
315 changes: 315 additions & 0 deletions src/endpoints/juicerCampaign.ts
Original file line number Diff line number Diff line change
@@ -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<number>([
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<number> {
const ponder = getPonderClient(logger);
const res = await ponder.get<PonderPointsResponse>(
`/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<boolean> {
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<void> {
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<void> {
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" });
}
};
}
16 changes: 16 additions & 0 deletions src/lib/constants/campaigns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading