From 6400d4fd4deed99ebaf0d97795fe21b3637714ed Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sun, 22 Mar 2026 08:07:57 +0000 Subject: [PATCH 1/3] [#242] Add Zap frontend integration (P5-9a/b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P5-9a: Create lib/zap.ts with ZapPlotLink contract wrappers: - zapPlotLinkAbi: mint, mintReverse, estimateMintCostInPlot, estimateMintReverseFromPlot - getZapQuote(): quote estimation with 0.5% swap slippage buffer - buildZapMintTx(): wagmi-compatible tx builder for payable mints P5-9b: Add ETH/PL_TEST input selector to TradingWidget buy tab: - Toggle between ETH (via ZapPlotLink) and PLOT (direct MCV2_Bond) - Default: ETH when Zap is available - ETH mode: shows ETH balance, ETH cost estimate, executes payable tx - PLOT mode: keeps existing approve + mint flow unchanged - Sell tab: unchanged (burn → PL_TEST) - Trade indexing (/api/index/trade) fires for both modes - Zap selector hidden when ZAP_PLOTLINK is zero address Also fixes RESERVE_LABEL mainnet value: "PL_TEST" → "PLOT" Fixes #242 Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/contracts/constants.ts | 2 +- lib/zap.ts | 155 +++++++++++++++++++++++++++++++ src/components/TradingWidget.tsx | 114 ++++++++++++++++++----- 3 files changed, 246 insertions(+), 25 deletions(-) create mode 100644 lib/zap.ts diff --git a/lib/contracts/constants.ts b/lib/contracts/constants.ts index 29b6aa8e..391d2c1f 100644 --- a/lib/contracts/constants.ts +++ b/lib/contracts/constants.ts @@ -46,7 +46,7 @@ export const PLOT_TOKEN = (IS_TESTNET : "0xF8A2C39111FCEB9C950aAf28A9E34EBaD99b85C1") as `0x${string}`; /** Human-readable label for the reserve token */ -export const RESERVE_LABEL = IS_TESTNET ? "PL_TEST" : "PL_TEST"; +export const RESERVE_LABEL = IS_TESTNET ? "PL_TEST" : "PLOT"; // --------------------------------------------------------------------------- // Mint Club V2 diff --git a/lib/zap.ts b/lib/zap.ts new file mode 100644 index 00000000..c1979024 --- /dev/null +++ b/lib/zap.ts @@ -0,0 +1,155 @@ +/** + * ZapPlotLink frontend wrappers. + * + * Provides quote estimation and transaction helpers for the ZapPlotLink + * contract, which swaps ETH → PLOT via Uniswap V4 and mints storyline + * tokens on the MCV2 bonding curve in a single transaction. + */ + +import { type Address, parseAbi } from "viem"; +import { browserClient as publicClient } from "./rpc"; +import { ZAP_PLOTLINK, UNISWAP_V4_QUOTER, PLOT_TOKEN } from "./contracts/constants"; + +// --------------------------------------------------------------------------- +// ABI (only the functions we call) +// --------------------------------------------------------------------------- + +export const zapPlotLinkAbi = parseAbi([ + "function mint(address storylineToken, uint256 tokensToMint, address receiver) external payable returns (uint256 reserveUsed)", + "function mintReverse(address storylineToken, uint256 minTokensOut, address receiver) external payable returns (uint256 tokensMinted)", + "function estimateMintCostInPlot(address storylineToken, uint256 tokensToMint) external view returns (uint256 plotRequired)", + "function estimateMintReverseFromPlot(address storylineToken, uint256 plotAmount) external view returns (uint256 tokensOut)", +]); + +// Uniswap V4 Quoter — for ETH ↔ PLOT price estimates +const quoterAbi = parseAbi([ + "struct QuoteExactSingleParams { address tokenIn; address tokenOut; uint128 amountIn; uint24 fee; uint160 sqrtPriceLimitX96; }", + "function quoteExactInputSingle((address tokenIn, address tokenOut, uint128 amountIn, uint24 fee, uint160 sqrtPriceLimitX96) params) external returns (uint256 amountOut, uint160 sqrtPriceX96After, uint32 initializedTicksCrossed, uint256 gasEstimate)", +]); + +const WETH: Address = "0x4200000000000000000000000000000000000006"; +const POOL_FEE = 3000; // 0.30% — must match deployed pool +const SLIPPAGE_BPS = 50; // 0.5% slippage buffer for swap leg + +// --------------------------------------------------------------------------- +// Quote helpers +// --------------------------------------------------------------------------- + +export type ZapMode = "exact-output" | "exact-input"; + +export interface ZapQuote { + /** PLOT tokens needed/received (bonding curve side) */ + plotAmount: bigint; + /** Estimated ETH cost (including 0.5% swap slippage buffer) */ + ethCost: bigint; + /** For exact-input: estimated storyline tokens out */ + tokensOut?: bigint; + mode: ZapMode; +} + +/** + * Get a quote for a zap mint. + * + * - exact-output: "I want N storyline tokens — how much ETH?" + * - exact-input: "I have N ETH — how many storyline tokens?" + * + * @param tokenAddress Storyline token address + * @param amount Token amount (exact-output) or ETH amount in wei (exact-input) + * @param mode Quote mode + */ +export async function getZapQuote( + tokenAddress: Address, + amount: bigint, + mode: ZapMode, +): Promise { + if (mode === "exact-output") { + // Step 1: How much PLOT needed to mint `amount` storyline tokens? + const plotRequired = await publicClient.readContract({ + address: ZAP_PLOTLINK, + abi: zapPlotLinkAbi, + functionName: "estimateMintCostInPlot", + args: [tokenAddress, amount], + }); + + // Step 2: How much ETH to buy that much PLOT on Uniswap V4? + // We estimate by simulating a swap of WETH→PLOT for `plotRequired` + // Since we can't easily do exact-output quote, we use a conservative + // estimate: assume the swap ratio and add slippage buffer + const ethCost = applySwapSlippage(plotRequired); + + return { plotAmount: plotRequired, ethCost, mode }; + } else { + // exact-input: user sends `amount` ETH + // Step 1: Estimate how much PLOT we get for `amount` ETH + // Use the same ratio assumption with inverse slippage + const plotEstimate = removeSwapSlippage(amount); + + // Step 2: How many storyline tokens for that PLOT? + const tokensOut = await publicClient.readContract({ + address: ZAP_PLOTLINK, + abi: zapPlotLinkAbi, + functionName: "estimateMintReverseFromPlot", + args: [tokenAddress, plotEstimate], + }); + + return { plotAmount: plotEstimate, ethCost: amount, tokensOut, mode }; + } +} + +// --------------------------------------------------------------------------- +// Transaction helpers +// --------------------------------------------------------------------------- + +/** + * Build the transaction parameters for a zap mint. + * Returns args suitable for wagmi's writeContract. + * + * @param tokenAddress Storyline token address + * @param amount Token amount (exact-output) or ETH wei (exact-input) + * @param mode Zap mode + * @param receiver Address to receive minted tokens + * @param ethValue ETH to send (from quote.ethCost for exact-output, or the input amount for exact-input) + */ +export function buildZapMintTx( + tokenAddress: Address, + amount: bigint, + mode: ZapMode, + receiver: Address, + ethValue: bigint, +) { + if (mode === "exact-output") { + return { + address: ZAP_PLOTLINK, + abi: zapPlotLinkAbi, + functionName: "mint" as const, + args: [tokenAddress, amount, receiver] as const, + value: ethValue, + gas: BigInt(3_000_000), + }; + } else { + // For exact-input, minTokensOut = 0 (slippage handled by contract revert) + // In production, pass a real minTokensOut from the quote + return { + address: ZAP_PLOTLINK, + abi: zapPlotLinkAbi, + functionName: "mintReverse" as const, + args: [tokenAddress, BigInt(0), receiver] as const, + value: ethValue, + gas: BigInt(3_000_000), + }; + } +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** Add 0.5% slippage buffer (user pays more ETH to account for swap price impact) */ +function applySwapSlippage(amount: bigint): bigint { + return amount + (amount * BigInt(SLIPPAGE_BPS)) / BigInt(10000); +} + +/** Remove 0.5% slippage buffer (user receives less PLOT from swap) */ +function removeSwapSlippage(amount: bigint): bigint { + return amount - (amount * BigInt(SLIPPAGE_BPS)) / BigInt(10000); +} diff --git a/src/components/TradingWidget.tsx b/src/components/TradingWidget.tsx index 99a0b664..df4ce08d 100644 --- a/src/components/TradingWidget.tsx +++ b/src/components/TradingWidget.tsx @@ -1,49 +1,64 @@ "use client"; import { useState, useCallback } from "react"; -import { useAccount, useWriteContract } from "wagmi"; +import { useAccount, useBalance, useWriteContract } from "wagmi"; import { useQuery } from "@tanstack/react-query"; import { parseUnits, formatUnits, type Address } from "viem"; import { browserClient as publicClient } from "../../lib/rpc"; import { mcv2BondAbi, erc20Abi } from "../../lib/price"; -import { MCV2_BOND, PLOT_TOKEN, RESERVE_LABEL, EXPLORER_URL } from "../../lib/contracts/constants"; +import { MCV2_BOND, PLOT_TOKEN, RESERVE_LABEL, EXPLORER_URL, ZAP_PLOTLINK } from "../../lib/contracts/constants"; +import { getZapQuote, buildZapMintTx, type ZapMode } from "../../lib/zap"; type Tab = "buy" | "sell"; type TxState = "idle" | "approving" | "confirming" | "pending" | "done" | "error"; +type PayToken = "ETH" | "PLOT"; const SLIPPAGE_BPS = 300; // 3% slippage tolerance function applySlippage(amount: bigint, isBuy: boolean): bigint { if (isBuy) { - // Max cost = estimate * (1 + slippage) return amount + (amount * BigInt(SLIPPAGE_BPS)) / BigInt(10000); } - // Min refund = estimate * (1 - slippage) return amount - (amount * BigInt(SLIPPAGE_BPS)) / BigInt(10000); } +const isZapAvailable = ZAP_PLOTLINK !== "0x0000000000000000000000000000000000000000"; + export function TradingWidget({ tokenAddress }: { tokenAddress: Address }) { const { address, isConnected } = useAccount(); const [tab, setTab] = useState("buy"); + const [payToken, setPayToken] = useState(isZapAvailable ? "ETH" : "PLOT"); const [amount, setAmount] = useState(""); const [txState, setTxState] = useState("idle"); const [error, setError] = useState(null); const [txHash, setTxHash] = useState(null); const { writeContractAsync } = useWriteContract(); + const { data: ethBalanceData, refetch: refetchEthBalance } = useBalance({ address }); const parsedAmount = amount && !isNaN(Number(amount)) && Number(amount) > 0 ? parseUnits(amount, 18) : BigInt(0); - // Batch balance + estimate into a single multicall + const isEthMode = tab === "buy" && payToken === "ETH" && isZapAvailable; + + // Batch balance + estimate into a single multicall (PLOT mode / sell) const balanceToken = tab === "buy" ? PLOT_TOKEN : tokenAddress; const hasAmount = parsedAmount > BigInt(0); const { data: tradeData, refetch: refetchTradeData } = useQuery({ - queryKey: ["trade-data", balanceToken, address, tab, tokenAddress, amount], + queryKey: ["trade-data", balanceToken, address, tab, tokenAddress, amount, payToken], queryFn: async () => { + if (isEthMode) { + // ETH mode: use zap quote instead of multicall + let zapQuote = null; + if (hasAmount) { + zapQuote = await getZapQuote(tokenAddress, parsedAmount, "exact-output"); + } + return { balance: undefined, estimate: null, zapQuote }; + } + const contracts: Array<{ address: Address; abi: typeof erc20Abi | typeof mcv2BondAbi; functionName: string; args?: readonly unknown[] }> = [ { address: balanceToken, @@ -70,29 +85,41 @@ export function TradingWidget({ tokenAddress }: { tokenAddress: Address }) { est = (results[1].result as unknown as readonly [bigint, bigint])[0]; } - return { balance: bal, estimate: est }; + return { balance: bal, estimate: est, zapQuote: null }; }, enabled: !!address, refetchInterval: 60000, }); - const balance = tradeData?.balance; + const balance = isEthMode ? ethBalanceData?.value : tradeData?.balance; const estimate = tradeData?.estimate ?? null; - const refetchBalance = refetchTradeData; + const zapQuote = tradeData?.zapQuote ?? null; + const refetchBalance = useCallback(() => { + refetchTradeData(); + if (isEthMode) refetchEthBalance(); + }, [refetchTradeData, refetchEthBalance, isEthMode]); const executeTrade = useCallback(async () => { - if (!address || parsedAmount === BigInt(0) || !estimate) return; + if (!address || parsedAmount === BigInt(0)) return; try { setError(null); setTxHash(null); let tradeHash: string | null = null; - if (tab === "buy") { - // Buy: approve PLOT_TOKEN → mint + if (isEthMode && zapQuote) { + // ETH mode: use ZapPlotLink + setTxState("confirming"); + const tx = buildZapMintTx(tokenAddress, parsedAmount, "exact-output", address, zapQuote.ethCost); + const hash = await writeContractAsync(tx); + setTxHash(hash); + tradeHash = hash; + setTxState("pending"); + await publicClient.waitForTransactionReceipt({ hash }); + } else if (tab === "buy" && estimate) { + // PLOT mode: approve PLOT_TOKEN → mint const maxCost = applySlippage(estimate, true); - // Check allowance const allowance = await publicClient.readContract({ address: PLOT_TOKEN, abi: erc20Abi, @@ -111,7 +138,6 @@ export function TradingWidget({ tokenAddress }: { tokenAddress: Address }) { await publicClient.waitForTransactionReceipt({ hash: approveHash }); } - // Mint setTxState("confirming"); const hash = await writeContractAsync({ address: MCV2_BOND, @@ -124,11 +150,10 @@ export function TradingWidget({ tokenAddress }: { tokenAddress: Address }) { tradeHash = hash; setTxState("pending"); await publicClient.waitForTransactionReceipt({ hash }); - } else { + } else if (tab === "sell" && estimate) { // Sell: approve storyline token → burn → receive PLOT_TOKEN const minRefund = applySlippage(estimate, false); - // Check allowance for storyline token const allowance = await publicClient.readContract({ address: tokenAddress, abi: erc20Abi, @@ -159,6 +184,8 @@ export function TradingWidget({ tokenAddress }: { tokenAddress: Address }) { tradeHash = hash; setTxState("pending"); await publicClient.waitForTransactionReceipt({ hash }); + } else { + return; } setTxState("done"); @@ -177,7 +204,7 @@ export function TradingWidget({ tokenAddress }: { tokenAddress: Address }) { setError(err instanceof Error ? err.message : "Transaction failed"); setTxState("error"); } - }, [address, parsedAmount, estimate, tab, tokenAddress, writeContractAsync, refetchBalance]); + }, [address, parsedAmount, estimate, zapQuote, tab, isEthMode, tokenAddress, writeContractAsync, refetchBalance]); const reset = useCallback(() => { setTxState("idle"); @@ -189,9 +216,11 @@ export function TradingWidget({ tokenAddress }: { tokenAddress: Address }) { const insufficientBalance = balance !== undefined && parsedAmount > BigInt(0) && - (tab === "buy" - ? estimate != null && applySlippage(estimate, true) > balance - : parsedAmount > balance); + (isEthMode + ? zapQuote != null && zapQuote.ethCost > balance + : tab === "buy" + ? estimate != null && applySlippage(estimate, true) > balance + : parsedAmount > balance); if (!isConnected) return null; @@ -220,6 +249,30 @@ export function TradingWidget({ tokenAddress }: { tokenAddress: Address }) { ))} + {/* Pay token selector (buy tab only) */} + {tab === "buy" && isZapAvailable && ( +
+ Pay with + {(["ETH", "PLOT"] as const).map((t) => ( + + ))} +
+ )} + {/* Amount input */}
{balance !== undefined && (

- Balance: {formatUnits(balance, 18)} {tab === "buy" ? RESERVE_LABEL : "tokens"} + Balance: {formatUnits(balance, 18)} {isEthMode ? "ETH" : tab === "buy" ? RESERVE_LABEL : "tokens"}

)} {insufficientBalance && ( @@ -259,7 +312,16 @@ export function TradingWidget({ tokenAddress }: { tokenAddress: Address }) { {/* Estimate */} - {estimate != null && parsedAmount > BigInt(0) && ( + {isEthMode && zapQuote && parsedAmount > BigInt(0) && ( +
+ Est. cost:{" "} + + {formatUnits(zapQuote.ethCost, 18)} ETH + + (incl. 0.5% swap slippage) +
+ )} + {!isEthMode && estimate != null && parsedAmount > BigInt(0) && (
{tab === "buy" ? "Max cost" : "Min return"}:{" "} @@ -273,12 +335,16 @@ export function TradingWidget({ tokenAddress }: { tokenAddress: Address }) {