From 2d57258afcc228f2a18bcfed4b5ae1a29211d68a Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sat, 14 Mar 2026 19:47:14 +0000 Subject: [PATCH 1/4] [#24] Add trading widget to story page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #24 - P5-2a: TradingWidget component with buy/sell tabs, amount input, and estimated cost/return from MCV2_Bond view functions - P5-2b: Buy flow — checks allowance, approves PLOT_TOKEN if needed, calls MCV2_Bond.mint() with 3% slippage protection - P5-2c: Sell flow — calls MCV2_Bond.burn() with 3% slippage protection - Shows tx state progression (approving → confirming → pending → done) - Extended lib/price.ts with mint/burn/approve/allowance ABIs - All addresses from lib/contracts/constants.ts, reserve label adapts to testnet (WETH) vs mainnet ($PLOT) Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/price.ts | 63 +++++++- src/app/story/[storylineId]/page.tsx | 4 + src/components/TradingWidget.tsx | 212 +++++++++++++++++++++++++++ 3 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 src/components/TradingWidget.tsx diff --git a/lib/price.ts b/lib/price.ts index 908c8827..e077e388 100644 --- a/lib/price.ts +++ b/lib/price.ts @@ -8,7 +8,7 @@ import { MCV2_BOND } from "./contracts/constants"; * - MCV2_Bond.priceForNextMint: cost (in reserve token) to mint 1 token * - ERC-20 totalSupply: total minted supply of the storyline token */ -const mcv2BondAbi = [ +export const mcv2BondAbi = [ { type: "function", name: "priceForNextMint", @@ -19,9 +19,41 @@ const mcv2BondAbi = [ ], outputs: [{ name: "price", type: "uint256" }], }, + { + type: "function", + name: "priceForNextBurn", + stateMutability: "view", + inputs: [ + { name: "token", type: "address" }, + { name: "amount", type: "uint256" }, + ], + outputs: [{ name: "price", type: "uint256" }], + }, + { + type: "function", + name: "mint", + stateMutability: "nonpayable", + inputs: [ + { name: "token", type: "address" }, + { name: "tokensToMint", type: "uint256" }, + { name: "maxReserveAmount", type: "uint256" }, + ], + outputs: [], + }, + { + type: "function", + name: "burn", + stateMutability: "nonpayable", + inputs: [ + { name: "token", type: "address" }, + { name: "tokensToBurn", type: "uint256" }, + { name: "minRefund", type: "uint256" }, + ], + outputs: [], + }, ] as const; -const erc20Abi = [ +export const erc20Abi = [ { type: "function", name: "totalSupply", @@ -29,6 +61,33 @@ const erc20Abi = [ inputs: [], outputs: [{ name: "", type: "uint256" }], }, + { + type: "function", + name: "allowance", + stateMutability: "view", + inputs: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + ], + outputs: [{ name: "", type: "uint256" }], + }, + { + type: "function", + name: "approve", + stateMutability: "nonpayable", + inputs: [ + { name: "spender", type: "address" }, + { name: "amount", type: "uint256" }, + ], + outputs: [{ name: "", type: "bool" }], + }, + { + type: "function", + name: "balanceOf", + stateMutability: "view", + inputs: [{ name: "account", type: "address" }], + outputs: [{ name: "", type: "uint256" }], + }, ] as const; export interface TokenPriceInfo { diff --git a/src/app/story/[storylineId]/page.tsx b/src/app/story/[storylineId]/page.tsx index e94170e4..54e36a81 100644 --- a/src/app/story/[storylineId]/page.tsx +++ b/src/app/story/[storylineId]/page.tsx @@ -1,5 +1,6 @@ import { createServerClient, type Storyline, type Plot } from "../../../../lib/supabase"; import { DeadlineCountdown } from "../../../components/DeadlineCountdown"; +import { TradingWidget } from "../../../components/TradingWidget"; import { getTokenPrice, type TokenPriceInfo } from "../../../../lib/price"; import { IS_TESTNET } from "../../../../lib/contracts/constants"; import { type Address } from "viem"; @@ -52,6 +53,9 @@ export default async function StoryPage({ params }: { params: Params }) { return (
+ {sl.token_address && ( + + )}
{plots.map((plot) => ( diff --git a/src/components/TradingWidget.tsx b/src/components/TradingWidget.tsx new file mode 100644 index 00000000..5b7aa1f4 --- /dev/null +++ b/src/components/TradingWidget.tsx @@ -0,0 +1,212 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { useAccount, useWriteContract } from "wagmi"; +import { useQuery } from "@tanstack/react-query"; +import { parseUnits, formatUnits, type Address } from "viem"; +import { publicClient } from "../../lib/rpc"; +import { mcv2BondAbi, erc20Abi } from "../../lib/price"; +import { MCV2_BOND, PLOT_TOKEN, IS_TESTNET } from "../../lib/contracts/constants"; + +type Tab = "buy" | "sell"; +type TxState = "idle" | "approving" | "confirming" | "pending" | "done" | "error"; + +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); +} + +export function TradingWidget({ tokenAddress }: { tokenAddress: Address }) { + const { address, isConnected } = useAccount(); + const [tab, setTab] = useState("buy"); + const [amount, setAmount] = useState(""); + const [txState, setTxState] = useState("idle"); + const [error, setError] = useState(null); + const [txHash, setTxHash] = useState(null); + + const { writeContractAsync } = useWriteContract(); + + const reserveLabel = IS_TESTNET ? "WETH" : "$PLOT"; + const parsedAmount = + amount && !isNaN(Number(amount)) && Number(amount) > 0 + ? parseUnits(amount, 18) + : BigInt(0); + + // Fetch price estimate + const { data: estimate } = useQuery({ + queryKey: ["trade-estimate", tab, tokenAddress, amount], + queryFn: async () => { + if (parsedAmount === BigInt(0)) return null; + const result = await publicClient.readContract({ + address: MCV2_BOND, + abi: mcv2BondAbi, + functionName: tab === "buy" ? "priceForNextMint" : "priceForNextBurn", + args: [tokenAddress, parsedAmount], + }); + return result; + }, + enabled: parsedAmount > BigInt(0), + refetchInterval: 15000, + }); + + const executeTrade = useCallback(async () => { + if (!address || parsedAmount === BigInt(0) || !estimate) return; + + try { + setError(null); + setTxHash(null); + + if (tab === "buy") { + // Buy: approve PLOT_TOKEN → mint + const maxCost = applySlippage(estimate, true); + + // Check allowance + const allowance = await publicClient.readContract({ + address: PLOT_TOKEN, + abi: erc20Abi, + functionName: "allowance", + args: [address, MCV2_BOND], + }); + + if (allowance < maxCost) { + setTxState("approving"); + const approveHash = await writeContractAsync({ + address: PLOT_TOKEN, + abi: erc20Abi, + functionName: "approve", + args: [MCV2_BOND, maxCost], + }); + await publicClient.waitForTransactionReceipt({ hash: approveHash }); + } + + // Mint + setTxState("confirming"); + const hash = await writeContractAsync({ + address: MCV2_BOND, + abi: mcv2BondAbi, + functionName: "mint", + args: [tokenAddress, parsedAmount, maxCost], + }); + setTxHash(hash); + setTxState("pending"); + await publicClient.waitForTransactionReceipt({ hash }); + } else { + // Sell: burn storyline tokens → receive PLOT_TOKEN + const minRefund = applySlippage(estimate, false); + + setTxState("confirming"); + const hash = await writeContractAsync({ + address: MCV2_BOND, + abi: mcv2BondAbi, + functionName: "burn", + args: [tokenAddress, parsedAmount, minRefund], + }); + setTxHash(hash); + setTxState("pending"); + await publicClient.waitForTransactionReceipt({ hash }); + } + + setTxState("done"); + setAmount(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Transaction failed"); + setTxState("error"); + } + }, [address, parsedAmount, estimate, tab, tokenAddress, writeContractAsync]); + + const reset = useCallback(() => { + setTxState("idle"); + setError(null); + setTxHash(null); + }, []); + + if (!isConnected) return null; + + return ( +
+

Trade

+ + {/* Tabs */} +
+ {(["buy", "sell"] as const).map((t) => ( + + ))} +
+ + {/* Amount input */} +
+ + { + setAmount(e.target.value); + if (txState !== "idle") reset(); + }} + disabled={txState !== "idle" && txState !== "error" && txState !== "done"} + className="border-border bg-background text-foreground mt-1 w-full rounded border px-3 py-2 text-sm focus:border-accent focus:outline-none disabled:opacity-50" + /> +
+ + {/* Estimate */} + {estimate != null && parsedAmount > BigInt(0) && ( +
+ {tab === "buy" ? "Estimated cost" : "Estimated return"}:{" "} + + {formatUnits(estimate, 18)} {reserveLabel} + + (3% slippage tolerance) +
+ )} + + {/* Action button */} + + + {/* Status */} + {error &&

{error}

} + {txHash && txState === "done" && ( +

+ Tx: {txHash.slice(0, 10)}...{txHash.slice(-8)} +

+ )} +
+ ); +} From 69586201884f9037a233288bc70809fb33fad7b1 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sat, 14 Mar 2026 19:50:47 +0000 Subject: [PATCH 2/4] [#24] Fix MCV2_Bond mint/burn ABI to include receiver parameter Adds required 4th `receiver` parameter to mint() and burn() ABI signatures and passes connected wallet address as receiver in both trade flows. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/price.ts | 2 ++ src/components/TradingWidget.tsx | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/price.ts b/lib/price.ts index e077e388..9cde2178 100644 --- a/lib/price.ts +++ b/lib/price.ts @@ -37,6 +37,7 @@ export const mcv2BondAbi = [ { name: "token", type: "address" }, { name: "tokensToMint", type: "uint256" }, { name: "maxReserveAmount", type: "uint256" }, + { name: "receiver", type: "address" }, ], outputs: [], }, @@ -48,6 +49,7 @@ export const mcv2BondAbi = [ { name: "token", type: "address" }, { name: "tokensToBurn", type: "uint256" }, { name: "minRefund", type: "uint256" }, + { name: "receiver", type: "address" }, ], outputs: [], }, diff --git a/src/components/TradingWidget.tsx b/src/components/TradingWidget.tsx index 5b7aa1f4..8dd9f0bd 100644 --- a/src/components/TradingWidget.tsx +++ b/src/components/TradingWidget.tsx @@ -91,7 +91,7 @@ export function TradingWidget({ tokenAddress }: { tokenAddress: Address }) { address: MCV2_BOND, abi: mcv2BondAbi, functionName: "mint", - args: [tokenAddress, parsedAmount, maxCost], + args: [tokenAddress, parsedAmount, maxCost, address], }); setTxHash(hash); setTxState("pending"); @@ -105,7 +105,7 @@ export function TradingWidget({ tokenAddress }: { tokenAddress: Address }) { address: MCV2_BOND, abi: mcv2BondAbi, functionName: "burn", - args: [tokenAddress, parsedAmount, minRefund], + args: [tokenAddress, parsedAmount, minRefund, address], }); setTxHash(hash); setTxState("pending"); From bae547b343e4f5bdd04d11b924f91b3d4ac82e07 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sat, 14 Mar 2026 19:51:53 +0000 Subject: [PATCH 3/4] [#24] Add allowance check for storyline token before burn Mirrors the buy flow's approve pattern: checks storyline token allowance for MCV2_BOND and approves if insufficient before calling burn(). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/TradingWidget.tsx | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/components/TradingWidget.tsx b/src/components/TradingWidget.tsx index 8dd9f0bd..48b654b8 100644 --- a/src/components/TradingWidget.tsx +++ b/src/components/TradingWidget.tsx @@ -97,9 +97,28 @@ export function TradingWidget({ tokenAddress }: { tokenAddress: Address }) { setTxState("pending"); await publicClient.waitForTransactionReceipt({ hash }); } else { - // Sell: burn storyline tokens → receive PLOT_TOKEN + // 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, + functionName: "allowance", + args: [address, MCV2_BOND], + }); + + if (allowance < parsedAmount) { + setTxState("approving"); + const approveHash = await writeContractAsync({ + address: tokenAddress, + abi: erc20Abi, + functionName: "approve", + args: [MCV2_BOND, parsedAmount], + }); + await publicClient.waitForTransactionReceipt({ hash: approveHash }); + } + setTxState("confirming"); const hash = await writeContractAsync({ address: MCV2_BOND, From 1d570518ddcf06ce630a2e0176e0b9e1ea9daa55 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sat, 14 Mar 2026 19:53:28 +0000 Subject: [PATCH 4/4] [#24] Fix estimate ABIs to match IMCV2_Bond interface Switch from custom priceForNextMint/priceForNextBurn to the real MCV2_Bond view functions: getReserveForToken and getRefundForTokens. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/price.ts | 14 +++++++------- src/components/TradingWidget.tsx | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/price.ts b/lib/price.ts index 9cde2178..64f124b4 100644 --- a/lib/price.ts +++ b/lib/price.ts @@ -11,23 +11,23 @@ import { MCV2_BOND } from "./contracts/constants"; export const mcv2BondAbi = [ { type: "function", - name: "priceForNextMint", + name: "getReserveForToken", stateMutability: "view", inputs: [ { name: "token", type: "address" }, - { name: "amount", type: "uint256" }, + { name: "tokensToMint", type: "uint256" }, ], - outputs: [{ name: "price", type: "uint256" }], + outputs: [{ name: "reserveAmount", type: "uint256" }], }, { type: "function", - name: "priceForNextBurn", + name: "getRefundForTokens", stateMutability: "view", inputs: [ { name: "token", type: "address" }, - { name: "amount", type: "uint256" }, + { name: "tokensToBurn", type: "uint256" }, ], - outputs: [{ name: "price", type: "uint256" }], + outputs: [{ name: "refundAmount", type: "uint256" }], }, { type: "function", @@ -118,7 +118,7 @@ export async function getTokenPrice( publicClient.readContract({ address: MCV2_BOND, abi: mcv2BondAbi, - functionName: "priceForNextMint", + functionName: "getReserveForToken", args: [tokenAddress, oneToken], }), publicClient.readContract({ diff --git a/src/components/TradingWidget.tsx b/src/components/TradingWidget.tsx index 48b654b8..8a463a72 100644 --- a/src/components/TradingWidget.tsx +++ b/src/components/TradingWidget.tsx @@ -46,7 +46,7 @@ export function TradingWidget({ tokenAddress }: { tokenAddress: Address }) { const result = await publicClient.readContract({ address: MCV2_BOND, abi: mcv2BondAbi, - functionName: tab === "buy" ? "priceForNextMint" : "priceForNextBurn", + functionName: tab === "buy" ? "getReserveForToken" : "getRefundForTokens", args: [tokenAddress, parsedAmount], }); return result;