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..3e3814c3 --- /dev/null +++ b/lib/zap.ts @@ -0,0 +1,314 @@ +/** + * 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)", +]); + +/** + * V4 Quoter ABI — quoteExactInputSingle and quoteExactOutputSingle. + * + * These functions are NOT view — they execute state changes internally and + * revert with the result. Must be called via eth_call (simulateContract). + */ +const quoterAbi = [ + { + name: "quoteExactInputSingle", + type: "function", + stateMutability: "nonpayable", + inputs: [ + { + name: "params", + type: "tuple", + components: [ + { + name: "poolKey", + type: "tuple", + components: [ + { name: "currency0", type: "address" }, + { name: "currency1", type: "address" }, + { name: "fee", type: "uint24" }, + { name: "tickSpacing", type: "int24" }, + { name: "hooks", type: "address" }, + ], + }, + { name: "zeroForOne", type: "bool" }, + { name: "exactAmount", type: "uint128" }, + { name: "hookData", type: "bytes" }, + ], + }, + ], + outputs: [ + { name: "amountOut", type: "uint256" }, + { name: "gasEstimate", type: "uint256" }, + ], + }, + { + name: "quoteExactOutputSingle", + type: "function", + stateMutability: "nonpayable", + inputs: [ + { + name: "params", + type: "tuple", + components: [ + { + name: "poolKey", + type: "tuple", + components: [ + { name: "currency0", type: "address" }, + { name: "currency1", type: "address" }, + { name: "fee", type: "uint24" }, + { name: "tickSpacing", type: "int24" }, + { name: "hooks", type: "address" }, + ], + }, + { name: "zeroForOne", type: "bool" }, + { name: "exactAmount", type: "uint128" }, + { name: "hookData", type: "bytes" }, + ], + }, + ], + outputs: [ + { name: "amountIn", type: "uint256" }, + { name: "gasEstimate", type: "uint256" }, + ], + }, +] as const; + +const WETH: Address = "0x4200000000000000000000000000000000000006"; +const POOL_FEE = 3000; // 0.30% — must match deployed pool +const TICK_SPACING = 60; +const HOOKS: Address = "0x0000000000000000000000000000000000000000"; +const SLIPPAGE_BPS = 50; // 0.5% slippage buffer + +// Pool key tokens sorted (currency0 < currency1) +function getPoolKey(): { currency0: Address; currency1: Address } { + const wethNum = BigInt(WETH); + const plotNum = BigInt(PLOT_TOKEN); + if (wethNum < plotNum) { + return { currency0: WETH, currency1: PLOT_TOKEN }; + } + return { currency0: PLOT_TOKEN, currency1: WETH }; +} + +// --------------------------------------------------------------------------- +// 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; +} + +/** + * Quote how much PLOT is received for a given ETH input via Uniswap V4. + * Uses the V4 Quoter's quoteExactInputSingle (called via eth_call). + */ +async function quoteEthToPlot(ethAmount: bigint): Promise { + const { currency0, currency1 } = getPoolKey(); + const zeroForOne = currency0 === WETH; // true if WETH is currency0 + + try { + const { result } = await publicClient.simulateContract({ + address: UNISWAP_V4_QUOTER as Address, + abi: quoterAbi, + functionName: "quoteExactInputSingle", + args: [ + { + poolKey: { currency0, currency1, fee: POOL_FEE, tickSpacing: TICK_SPACING, hooks: HOOKS }, + zeroForOne, + exactAmount: ethAmount, + hookData: "0x", + }, + ], + }); + return result[0]; + } catch { + // Fallback: if quoter call fails, return 0 to indicate unavailable + return BigInt(0); + } +} + +/** + * Quote how much ETH is needed to buy a given amount of PLOT via Uniswap V4. + * Uses the V4 Quoter's quoteExactOutputSingle (called via eth_call). + */ +async function quotePlotToEth(plotAmount: bigint): Promise { + const { currency0, currency1 } = getPoolKey(); + const zeroForOne = currency0 === WETH; // swapping WETH in for PLOT out + + try { + const { result } = await publicClient.simulateContract({ + address: UNISWAP_V4_QUOTER as Address, + abi: quoterAbi, + functionName: "quoteExactOutputSingle", + args: [ + { + poolKey: { currency0, currency1, fee: POOL_FEE, tickSpacing: TICK_SPACING, hooks: HOOKS }, + zeroForOne, + exactAmount: plotAmount, + hookData: "0x", + }, + ], + }); + return result[0]; + } catch { + return BigInt(0); + } +} + +/** + * 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? + const ethNeeded = await quotePlotToEth(plotRequired); + + // Add 0.5% slippage buffer + const ethCost = ethNeeded > BigInt(0) + ? ethNeeded + (ethNeeded * BigInt(SLIPPAGE_BPS)) / BigInt(10000) + : BigInt(0); + + return { plotAmount: plotRequired, ethCost, mode }; + } else { + // exact-input: user sends `amount` ETH + // Step 1: How much PLOT do we get for `amount` ETH via Uniswap V4? + const plotReceived = await quoteEthToPlot(amount); + + // Step 2: How many storyline tokens for that PLOT? + let tokensOut = BigInt(0); + if (plotReceived > BigInt(0)) { + tokensOut = await publicClient.readContract({ + address: ZAP_PLOTLINK, + abi: zapPlotLinkAbi, + functionName: "estimateMintReverseFromPlot", + args: [tokenAddress, plotReceived], + }); + } + + return { plotAmount: plotReceived, 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) + * @param minTokensOut Minimum tokens for exact-input slippage protection + */ +export function buildZapMintTx( + tokenAddress: Address, + amount: bigint, + mode: ZapMode, + receiver: Address, + ethValue: bigint, + minTokensOut?: 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 { + // Apply 3% slippage to minTokensOut for exact-input protection + const minOut = minTokensOut ?? BigInt(0); + const slippageProtected = minOut > BigInt(0) + ? minOut - (minOut * BigInt(300)) / BigInt(10000) + : BigInt(0); + + return { + address: ZAP_PLOTLINK, + abi: zapPlotLinkAbi, + functionName: "mintReverse" as const, + args: [tokenAddress, slippageProtected, receiver] as const, + value: ethValue, + gas: BigInt(3_000_000), + }; + } +} + +/** + * Execute a zap mint end-to-end: get quote, then submit transaction. + * + * @param tokenAddress Storyline token address + * @param amount Token amount (exact-output) or ETH amount in wei (exact-input) + * @param mode Zap mode + * @param receiver Address to receive minted tokens + * @param writeContractAsync wagmi writeContractAsync function + * @returns Transaction hash + */ +export async function executeZapMint( + tokenAddress: Address, + amount: bigint, + mode: ZapMode, + receiver: Address, + writeContractAsync: (args: ReturnType) => Promise
, +): Promise
{ + const quote = await getZapQuote(tokenAddress, amount, mode); + const tx = buildZapMintTx( + tokenAddress, + amount, + mode, + receiver, + quote.ethCost, + quote.tokensOut, + ); + return writeContractAsync(tx); +} diff --git a/src/components/TradingWidget.tsx b/src/components/TradingWidget.tsx index 99a0b664..f079c454 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 } 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 }) {