From 549c62d7a15c5177497bf1325048c9f2308ed1c4 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sun, 22 Mar 2026 10:30:06 +0000 Subject: [PATCH 1/2] [#244][#245] Zap v2 frontend: multi-token lib + UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #245: Rewrite lib/zap.ts for ZapPlotLinkV2 multi-token ABI: - New ABI: mint(fromToken, storylineToken, ...), mintReverse(...), estimateMint(...), estimateMintReverse(...) - getZapQuote calls contract estimates via simulateContract (non-view) - buildZapMintTx handles ETH (payable) vs ERC-20 (approval) paths - Removed V4 Quoter calls — contract handles full path internally - 3% slippage buffer on bonding curve execution Update constants.ts: - ZAP_PLOTLINK mainnet: 0xEF6a8640c836b16Eb8cCD8016Ead4C8517aC3033 - Added USDC, HUNT, ETH_ADDRESS constants - Added SUPPORTED_ZAP_TOKENS list for frontend selector #244: Multi-token selector on TradingWidget buy tab: - 4 options: ETH, USDC, HUNT, PLOT (default: ETH) - ETH: payable zap tx, no approval, balance via useBalance - USDC/HUNT: approve to ZAP_PLOTLINK → zap tx, balance via balanceOf - PLOT: existing direct MCV2_Bond.mint flow unchanged - USDC handled as 6 decimals for balance display - Sell tab: unchanged - Trade indexing fires for all modes - isZapAvailable hides selector when zap address is zero Fixes #244, Fixes #245 Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/contracts/constants.ts | 24 ++- lib/zap.ts | 309 +++++++------------------------ src/components/TradingWidget.tsx | 148 +++++++++++---- 3 files changed, 202 insertions(+), 279 deletions(-) diff --git a/lib/contracts/constants.ts b/lib/contracts/constants.ts index 391d2c1f..0b96722f 100644 --- a/lib/contracts/constants.ts +++ b/lib/contracts/constants.ts @@ -33,10 +33,10 @@ export const STORY_FACTORY = (process.env.NEXT_PUBLIC_CONTRACT_ADDRESS ?? ? "0xfa5489b6710Ba2f8406b37fA8f8c3018e51FA229" : "0x337c5b96f03fB335b433291695A4171fd5dED8B0")) as `0x${string}`; -/** ZapPlotLink — one-click buy (ETH -> PLOT -> storyline token via Uniswap V4 + MCV2) */ +/** ZapPlotLinkV2 — one-click buy (ETH/USDC/HUNT -> PLOT -> storyline token via Uniswap V4 + MCV2) */ export const ZAP_PLOTLINK = (IS_TESTNET ? "0xC7C47D820D2D5b09797be2F438Cf329Ad7315682" - : "0x0000000000000000000000000000000000000000") as `0x${string}`; + : "0xEF6a8640c836b16Eb8cCD8016Ead4C8517aC3033") as `0x${string}`; /** $PLOT protocol token * Testnet: PL_TEST ERC-20 on Base Sepolia @@ -48,6 +48,26 @@ export const PLOT_TOKEN = (IS_TESTNET /** Human-readable label for the reserve token */ export const RESERVE_LABEL = IS_TESTNET ? "PL_TEST" : "PLOT"; +// --------------------------------------------------------------------------- +// Supported Zap input tokens (Base) +// --------------------------------------------------------------------------- + +/** USDC on Base */ +export const USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as const; + +/** HUNT on Base */ +export const HUNT = "0x37f0c2915CeCC7e977183B8543Fc0864d03E064C" as const; + +/** ETH represented as address(0) in the Zap contract */ +export const ETH_ADDRESS = "0x0000000000000000000000000000000000000000" as const; + +/** Supported input tokens for the Zap UI selector */ +export const SUPPORTED_ZAP_TOKENS = [ + { symbol: "ETH", address: ETH_ADDRESS as `0x${string}`, decimals: 18 }, + { symbol: "USDC", address: USDC as `0x${string}`, decimals: 6 }, + { symbol: "HUNT", address: HUNT as `0x${string}`, decimals: 18 }, +] as const; + // --------------------------------------------------------------------------- // Mint Club V2 // --------------------------------------------------------------------------- diff --git a/lib/zap.ts b/lib/zap.ts index 3e3814c3..a8ed6d22 100644 --- a/lib/zap.ts +++ b/lib/zap.ts @@ -1,314 +1,141 @@ /** - * ZapPlotLink frontend wrappers. + * ZapPlotLinkV2 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. + * Multi-token zap: ETH/USDC/HUNT → PLOT → storyline token in one tx. + * The contract handles the full swap path internally — no V4 Quoter + * calls needed from the frontend. */ import { type Address, parseAbi } from "viem"; import { browserClient as publicClient } from "./rpc"; -import { ZAP_PLOTLINK, UNISWAP_V4_QUOTER, PLOT_TOKEN } from "./contracts/constants"; +import { ZAP_PLOTLINK, ETH_ADDRESS } from "./contracts/constants"; // --------------------------------------------------------------------------- -// ABI (only the functions we call) +// ABI — ZapPlotLinkV2 (multi-token interface) // --------------------------------------------------------------------------- -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)", +export const zapPlotLinkV2Abi = parseAbi([ + "function mint(address fromToken, address storylineToken, uint256 storylineAmount, uint256 maxFromTokenAmount) external payable returns (uint256 fromTokenUsed)", + "function mintReverse(address fromToken, address storylineToken, uint256 fromTokenAmount, uint256 minStorylineAmount) external payable returns (uint256 storylineAmount)", + "function estimateMint(address fromToken, address storylineToken, uint256 storylineAmount) external returns (uint256 fromTokenAmount, uint256 totalPlotRequired)", + "function estimateMintReverse(address fromToken, address storylineToken, uint256 fromTokenAmount) external returns (uint256 storylineAmount, uint256 plotAmount)", ]); -/** - * 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 }; -} +const SLIPPAGE_BPS = 300; // 3% slippage buffer for bonding curve // --------------------------------------------------------------------------- -// Quote helpers +// Quote types // --------------------------------------------------------------------------- export type ZapMode = "exact-output" | "exact-input"; export interface ZapQuote { - /** PLOT tokens needed/received (bonding curve side) */ + /** fromToken amount needed (exact-output) or spent (exact-input) */ + fromTokenAmount: bigint; + /** PLOT tokens involved in the bonding curve leg */ plotAmount: bigint; - /** Estimated ETH cost (including 0.5% swap slippage buffer) */ - ethCost: bigint; - /** For exact-input: estimated storyline tokens out */ + /** For exact-input: estimated storyline tokens received */ 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); - } -} +// --------------------------------------------------------------------------- +// Quote +// --------------------------------------------------------------------------- /** - * 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?" + * Get a quote for a zap mint. Calls the contract's non-view estimate + * functions via simulateContract (eth_call). * - * @param tokenAddress Storyline token address - * @param amount Token amount (exact-output) or ETH amount in wei (exact-input) + * @param fromToken Input token address (address(0) for ETH) + * @param storylineToken Storyline token to mint + * @param amount Storyline tokens (exact-output) or fromToken amount (exact-input) * @param mode Quote mode */ export async function getZapQuote( - tokenAddress: Address, + fromToken: Address, + storylineToken: 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({ + const { result } = await publicClient.simulateContract({ address: ZAP_PLOTLINK, - abi: zapPlotLinkAbi, - functionName: "estimateMintCostInPlot", - args: [tokenAddress, amount], + abi: zapPlotLinkV2Abi, + functionName: "estimateMint", + args: [fromToken, storylineToken, amount], }); - // Step 2: How much ETH to buy that much PLOT on Uniswap V4? - const ethNeeded = await quotePlotToEth(plotRequired); + const fromTokenAmount = result[0]; + const plotRequired = result[1]; - // Add 0.5% slippage buffer - const ethCost = ethNeeded > BigInt(0) - ? ethNeeded + (ethNeeded * BigInt(SLIPPAGE_BPS)) / BigInt(10000) - : BigInt(0); + // Add 3% slippage buffer to fromTokenAmount + const withSlippage = fromTokenAmount + (fromTokenAmount * BigInt(SLIPPAGE_BPS)) / BigInt(10000); - return { plotAmount: plotRequired, ethCost, mode }; + return { fromTokenAmount: withSlippage, plotAmount: plotRequired, 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); + const { result } = await publicClient.simulateContract({ + address: ZAP_PLOTLINK, + abi: zapPlotLinkV2Abi, + functionName: "estimateMintReverse", + args: [fromToken, storylineToken, 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], - }); - } + const tokensOut = result[0]; + const plotAmount = result[1]; - return { plotAmount: plotReceived, ethCost: amount, tokensOut, mode }; + return { fromTokenAmount: amount, plotAmount, tokensOut, mode }; } } // --------------------------------------------------------------------------- -// Transaction helpers +// Transaction builder // --------------------------------------------------------------------------- /** - * Build the transaction parameters for a zap mint. - * Returns args suitable for wagmi's writeContract. + * Build wagmi-compatible transaction params for a zap mint. * - * @param tokenAddress Storyline token address - * @param amount Token amount (exact-output) or ETH wei (exact-input) + * - ETH: payable tx with msg.value, no prior approval needed + * - USDC/HUNT: non-payable tx, requires prior ERC-20 approval to ZAP_PLOTLINK + * + * @param fromToken Input token address (address(0) for ETH) + * @param storylineToken Storyline token to mint + * @param amount Storyline tokens (exact-output) or fromToken amount (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 + * @param quote The quote from getZapQuote */ export function buildZapMintTx( - tokenAddress: Address, + fromToken: Address, + storylineToken: Address, amount: bigint, mode: ZapMode, - receiver: Address, - ethValue: bigint, - minTokensOut?: bigint, + quote: ZapQuote, ) { + const isEth = fromToken === ETH_ADDRESS; + if (mode === "exact-output") { return { address: ZAP_PLOTLINK, - abi: zapPlotLinkAbi, + abi: zapPlotLinkV2Abi, functionName: "mint" as const, - args: [tokenAddress, amount, receiver] as const, - value: ethValue, + args: [fromToken, storylineToken, amount, quote.fromTokenAmount] as const, + value: isEth ? quote.fromTokenAmount : BigInt(0), gas: BigInt(3_000_000), }; } else { - // Apply 3% slippage to minTokensOut for exact-input protection - const minOut = minTokensOut ?? BigInt(0); + // Apply 3% slippage to minStorylineAmount + const minOut = quote.tokensOut ?? BigInt(0); const slippageProtected = minOut > BigInt(0) - ? minOut - (minOut * BigInt(300)) / BigInt(10000) + ? minOut - (minOut * BigInt(SLIPPAGE_BPS)) / BigInt(10000) : BigInt(0); return { address: ZAP_PLOTLINK, - abi: zapPlotLinkAbi, + abi: zapPlotLinkV2Abi, functionName: "mintReverse" as const, - args: [tokenAddress, slippageProtected, receiver] as const, - value: ethValue, + args: [fromToken, storylineToken, amount, slippageProtected] as const, + value: isEth ? amount : BigInt(0), 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 f079c454..bcb396d4 100644 --- a/src/components/TradingWidget.tsx +++ b/src/components/TradingWidget.tsx @@ -6,12 +6,15 @@ 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, ZAP_PLOTLINK } from "../../lib/contracts/constants"; +import { + MCV2_BOND, PLOT_TOKEN, RESERVE_LABEL, EXPLORER_URL, + ZAP_PLOTLINK, SUPPORTED_ZAP_TOKENS, ETH_ADDRESS, +} 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"; +type PayToken = "ETH" | "USDC" | "HUNT" | "PLOT"; const SLIPPAGE_BPS = 300; // 3% slippage tolerance @@ -24,6 +27,16 @@ function applySlippage(amount: bigint, isBuy: boolean): bigint { const isZapAvailable = ZAP_PLOTLINK !== "0x0000000000000000000000000000000000000000"; +function getTokenDecimals(payToken: PayToken): number { + if (payToken === "USDC") return 6; + return 18; +} + +function getTokenAddress(payToken: PayToken): Address { + const token = SUPPORTED_ZAP_TOKENS.find((t) => t.symbol === payToken); + return token?.address ?? ETH_ADDRESS as Address; +} + export function TradingWidget({ tokenAddress }: { tokenAddress: Address }) { const { address, isConnected } = useAccount(); const [tab, setTab] = useState("buy"); @@ -36,29 +49,58 @@ export function TradingWidget({ tokenAddress }: { tokenAddress: Address }) { const { writeContractAsync } = useWriteContract(); const { data: ethBalanceData, refetch: refetchEthBalance } = useBalance({ address }); + const isPlotMode = payToken === "PLOT" || !isZapAvailable; + const isEthMode = payToken === "ETH" && isZapAvailable; + const isErc20ZapMode = (payToken === "USDC" || payToken === "HUNT") && isZapAvailable; + const isZapMode = tab === "buy" && !isPlotMode && isZapAvailable; + const parsedAmount = amount && !isNaN(Number(amount)) && Number(amount) > 0 - ? parseUnits(amount, 18) + ? parseUnits(amount, 18) // storyline tokens are always 18 decimals : BigInt(0); - 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); + // Balance token for PLOT mode / sell + const balanceToken = tab === "buy" && isPlotMode ? PLOT_TOKEN : tokenAddress; + // ERC-20 balance token for USDC/HUNT modes + const erc20BalanceToken = isErc20ZapMode ? getTokenAddress(payToken) : undefined; + const { data: tradeData, refetch: refetchTradeData } = useQuery({ - queryKey: ["trade-data", balanceToken, address, tab, tokenAddress, amount, payToken], + queryKey: ["trade-data", address, tab, tokenAddress, amount, payToken], queryFn: async () => { - if (isEthMode) { - // ETH mode: use zap quote instead of multicall + if (tab === "buy" && isZapMode) { + // Zap mode (ETH/USDC/HUNT): get quote from contract let zapQuote = null; + let erc20Balance: bigint | undefined; + if (hasAmount) { - zapQuote = await getZapQuote(tokenAddress, parsedAmount, "exact-output"); + try { + zapQuote = await getZapQuote( + getTokenAddress(payToken), + tokenAddress, + parsedAmount, + "exact-output", + ); + } catch { + zapQuote = null; + } + } + + // Fetch ERC-20 balance for USDC/HUNT + if (isErc20ZapMode && erc20BalanceToken && address) { + erc20Balance = await publicClient.readContract({ + address: erc20BalanceToken, + abi: erc20Abi, + functionName: "balanceOf", + args: [address], + }); } - return { balance: undefined, estimate: null, zapQuote }; + + return { balance: erc20Balance, estimate: null, zapQuote }; } + // PLOT mode or sell: existing multicall const contracts: Array<{ address: Address; abi: typeof erc20Abi | typeof mcv2BondAbi; functionName: string; args?: readonly unknown[] }> = [ { address: balanceToken, @@ -91,9 +133,17 @@ export function TradingWidget({ tokenAddress }: { tokenAddress: Address }) { refetchInterval: 60000, }); - const balance = isEthMode ? ethBalanceData?.value : tradeData?.balance; + // Resolve balance based on mode + const balance = (() => { + if (tab === "sell") return tradeData?.balance; + if (isEthMode) return ethBalanceData?.value; + if (isErc20ZapMode) return tradeData?.balance; + return tradeData?.balance; // PLOT mode + })(); + const estimate = tradeData?.estimate ?? null; const zapQuote = tradeData?.zapQuote ?? null; + const refetchBalance = useCallback(() => { refetchTradeData(); if (isEthMode) refetchEthBalance(); @@ -107,17 +157,39 @@ export function TradingWidget({ tokenAddress }: { tokenAddress: Address }) { setTxHash(null); let tradeHash: string | null = null; - if (isEthMode && zapQuote) { - // ETH mode: use ZapPlotLink + if (tab === "buy" && isZapMode && zapQuote) { + const fromToken = getTokenAddress(payToken); + + // ERC-20 zap tokens need approval to ZAP_PLOTLINK first + if (isErc20ZapMode) { + const allowance = await publicClient.readContract({ + address: fromToken, + abi: erc20Abi, + functionName: "allowance", + args: [address, ZAP_PLOTLINK], + }); + + if (allowance < zapQuote.fromTokenAmount) { + setTxState("approving"); + const approveHash = await writeContractAsync({ + address: fromToken, + abi: erc20Abi, + functionName: "approve", + args: [ZAP_PLOTLINK, zapQuote.fromTokenAmount], + }); + await publicClient.waitForTransactionReceipt({ hash: approveHash }); + } + } + setTxState("confirming"); - const tx = buildZapMintTx(tokenAddress, parsedAmount, "exact-output", address, zapQuote.ethCost); + const tx = buildZapMintTx(fromToken, tokenAddress, parsedAmount, "exact-output", zapQuote); 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 + } else if (tab === "buy" && isPlotMode && estimate) { + // PLOT mode: approve PLOT_TOKEN → MCV2_Bond.mint const maxCost = applySlippage(estimate, true); const allowance = await publicClient.readContract({ @@ -204,7 +276,7 @@ export function TradingWidget({ tokenAddress }: { tokenAddress: Address }) { setError(err instanceof Error ? err.message : "Transaction failed"); setTxState("error"); } - }, [address, parsedAmount, estimate, zapQuote, tab, isEthMode, tokenAddress, writeContractAsync, refetchBalance]); + }, [address, parsedAmount, estimate, zapQuote, tab, payToken, isZapMode, isPlotMode, isErc20ZapMode, tokenAddress, writeContractAsync, refetchBalance]); const reset = useCallback(() => { setTxState("idle"); @@ -213,17 +285,21 @@ export function TradingWidget({ tokenAddress }: { tokenAddress: Address }) { }, []); // Pre-validate balance - const insufficientBalance = - balance !== undefined && - parsedAmount > BigInt(0) && - (isEthMode - ? zapQuote != null && zapQuote.ethCost > balance - : tab === "buy" - ? estimate != null && applySlippage(estimate, true) > balance - : parsedAmount > balance); + const insufficientBalance = (() => { + if (balance === undefined || parsedAmount <= BigInt(0)) return false; + if (tab === "sell") return parsedAmount > balance; + if (isZapMode && zapQuote) return zapQuote.fromTokenAmount > balance; + if (isPlotMode && estimate) return applySlippage(estimate, true) > balance; + return false; + })(); if (!isConnected) return null; + // Display helpers + const balanceDecimals = isEthMode ? 18 : isErc20ZapMode ? getTokenDecimals(payToken) : 18; + const balanceLabel = isZapMode ? payToken : tab === "buy" ? RESERVE_LABEL : "tokens"; + const estimateDecimals = isZapMode ? (isEthMode ? 18 : getTokenDecimals(payToken)) : 18; + return (

Trade

@@ -253,7 +329,7 @@ export function TradingWidget({ tokenAddress }: { tokenAddress: Address }) { {tab === "buy" && isZapAvailable && (
Pay with - {(["ETH", "PLOT"] as const).map((t) => ( + {(["ETH", "USDC", "HUNT", "PLOT"] as const).map((t) => ( ))}
@@ -303,7 +379,7 @@ export function TradingWidget({ tokenAddress }: { tokenAddress: Address }) { {balance !== undefined && (

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

)} {insufficientBalance && ( @@ -312,16 +388,16 @@ export function TradingWidget({ tokenAddress }: { tokenAddress: Address }) { {/* Estimate */} - {isEthMode && zapQuote && parsedAmount > BigInt(0) && ( + {isZapMode && zapQuote && parsedAmount > BigInt(0) && (
Est. cost:{" "} - {formatUnits(zapQuote.ethCost, 18)} ETH + {formatUnits(zapQuote.fromTokenAmount, estimateDecimals)} {payToken} - (incl. 0.5% swap slippage) + (incl. 3% slippage)
)} - {!isEthMode && estimate != null && parsedAmount > BigInt(0) && ( + {!isZapMode && estimate != null && parsedAmount > BigInt(0) && (
{tab === "buy" ? "Max cost" : "Min return"}:{" "} @@ -337,14 +413,14 @@ export function TradingWidget({ tokenAddress }: { tokenAddress: Address }) { disabled={ (txState === "idle" && ( parsedAmount === BigInt(0) || - (isEthMode ? !zapQuote : !estimate) || + (isZapMode ? !zapQuote : !estimate) || insufficientBalance )) || (txState !== "idle" && txState !== "done" && txState !== "error") } className="bg-accent text-background mt-3 w-full rounded py-2 text-xs font-medium transition-opacity disabled:opacity-40" > - {txState === "idle" && (tab === "buy" ? `Buy with ${isEthMode ? "ETH" : RESERVE_LABEL}` : "Sell Tokens")} + {txState === "idle" && (tab === "buy" ? `Buy with ${payToken === "PLOT" ? RESERVE_LABEL : payToken}` : "Sell Tokens")} {txState === "approving" && "Approving..."} {txState === "confirming" && "Confirm in wallet..."} {txState === "pending" && "Pending..."} From 1d47e5812918c3de6dfc27bd5f81375bd8961e89 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sun, 22 Mar 2026 10:32:13 +0000 Subject: [PATCH 2/2] =?UTF-8?q?[#244][#245]=20Disable=20zap=20on=20testnet?= =?UTF-8?q?=20=E2=80=94=20V1=20contract=20incompatible=20with=20V2=20ABI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set testnet ZAP_PLOTLINK to zero address until a V2 contract is deployed to Base Sepolia. This prevents the V2 frontend from calling V2 ABI functions against the old V1 Sepolia deployment. The isZapAvailable check in TradingWidget already hides the token selector when ZAP_PLOTLINK is zero, so testnet users fall back to direct PLOT mode only. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/contracts/constants.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/contracts/constants.ts b/lib/contracts/constants.ts index 0b96722f..61e1e2ad 100644 --- a/lib/contracts/constants.ts +++ b/lib/contracts/constants.ts @@ -33,9 +33,10 @@ export const STORY_FACTORY = (process.env.NEXT_PUBLIC_CONTRACT_ADDRESS ?? ? "0xfa5489b6710Ba2f8406b37fA8f8c3018e51FA229" : "0x337c5b96f03fB335b433291695A4171fd5dED8B0")) as `0x${string}`; -/** ZapPlotLinkV2 — one-click buy (ETH/USDC/HUNT -> PLOT -> storyline token via Uniswap V4 + MCV2) */ +/** ZapPlotLinkV2 — one-click buy (ETH/USDC/HUNT -> PLOT -> storyline token via Uniswap V4 + MCV2) + * Testnet: disabled (V1 contract incompatible with V2 ABI) */ export const ZAP_PLOTLINK = (IS_TESTNET - ? "0xC7C47D820D2D5b09797be2F438Cf329Ad7315682" + ? "0x0000000000000000000000000000000000000000" : "0xEF6a8640c836b16Eb8cCD8016Ead4C8517aC3033") as `0x${string}`; /** $PLOT protocol token