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)} +

+ )} +
+ ); +}