diff --git a/lib/price.ts b/lib/price.ts
index 908c8827..64f124b4 100644
--- a/lib/price.ts
+++ b/lib/price.ts
@@ -8,20 +8,54 @@ 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",
+ name: "getReserveForToken",
stateMutability: "view",
inputs: [
{ name: "token", type: "address" },
- { name: "amount", type: "uint256" },
+ { name: "tokensToMint", type: "uint256" },
+ ],
+ outputs: [{ name: "reserveAmount", type: "uint256" }],
+ },
+ {
+ type: "function",
+ name: "getRefundForTokens",
+ stateMutability: "view",
+ inputs: [
+ { name: "token", type: "address" },
+ { name: "tokensToBurn", type: "uint256" },
+ ],
+ outputs: [{ name: "refundAmount", type: "uint256" }],
+ },
+ {
+ type: "function",
+ name: "mint",
+ stateMutability: "nonpayable",
+ inputs: [
+ { name: "token", type: "address" },
+ { name: "tokensToMint", type: "uint256" },
+ { name: "maxReserveAmount", type: "uint256" },
+ { name: "receiver", type: "address" },
+ ],
+ outputs: [],
+ },
+ {
+ type: "function",
+ name: "burn",
+ stateMutability: "nonpayable",
+ inputs: [
+ { name: "token", type: "address" },
+ { name: "tokensToBurn", type: "uint256" },
+ { name: "minRefund", type: "uint256" },
+ { name: "receiver", type: "address" },
],
- outputs: [{ name: "price", type: "uint256" }],
+ outputs: [],
},
] as const;
-const erc20Abi = [
+export const erc20Abi = [
{
type: "function",
name: "totalSupply",
@@ -29,6 +63,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 {
@@ -57,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/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..8a463a72
--- /dev/null
+++ b/src/components/TradingWidget.tsx
@@ -0,0 +1,231 @@
+"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" ? "getReserveForToken" : "getRefundForTokens",
+ 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, address],
+ });
+ setTxHash(hash);
+ setTxState("pending");
+ await publicClient.waitForTransactionReceipt({ hash });
+ } else {
+ // 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,
+ abi: mcv2BondAbi,
+ functionName: "burn",
+ args: [tokenAddress, parsedAmount, minRefund, address],
+ });
+ 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)}
+
+ )}
+
+ );
+}