From f3818420ae11489cfb0413dfc626bcd97e55fb46 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Mon, 30 Mar 2026 19:13:39 +0100 Subject: [PATCH 1/3] [#638] Fix PLOT-mode MAX buy path and remove 5s delays - handleBuyMax: skip getZapQuote in PLOT mode; binary-search getReserveForToken to find max mintable storyline tokens - DonateWidget: remove 5s setTimeout (unnecessary on Base mainnet) - usePublish: remove 5s setTimeout (unnecessary on Base mainnet) Fixes realproject7/plotlink#638 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/DonateWidget.tsx | 2 -- src/components/TradingWidget.tsx | 43 ++++++++++++++++++++++++++++---- src/hooks/usePublish.ts | 1 - 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/components/DonateWidget.tsx b/src/components/DonateWidget.tsx index c92c6635..2c705148 100644 --- a/src/components/DonateWidget.tsx +++ b/src/components/DonateWidget.tsx @@ -89,9 +89,7 @@ export function DonateWidget({ storylineId, writerAddress }: DonateWidgetProps) setTxState("pending"); await publicClient.waitForTransactionReceipt({ hash }); - // Trigger donation indexer (delay for RPC propagation on Base Sepolia) setTxState("indexing"); - await new Promise((r) => setTimeout(r, 5000)); const indexRes = await fetch("/api/index/donation", { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/src/components/TradingWidget.tsx b/src/components/TradingWidget.tsx index 251ef219..439341fb 100644 --- a/src/components/TradingWidget.tsx +++ b/src/components/TradingWidget.tsx @@ -179,15 +179,48 @@ export function TradingWidget({ tokenAddress }: { tokenAddress: Address }) { if (maxBalance <= BigInt(0)) return; - const fromToken = getTokenAddress(payToken); - const quote = await getZapQuote(fromToken, tokenAddress, maxBalance, "exact-input"); - if (quote.tokensOut && quote.tokensOut > BigInt(0)) { - setAmount(formatUnits(quote.tokensOut, 18)); + if (isPlotMode) { + // PLOT mode: binary search for max storyline tokens mintable within PLOT balance + let lo = BigInt(1); + let hi = maxBalance; // upper bound: 1 storyline token per 1 PLOT (always overshoots) + let best = BigInt(0); + + for (let i = 0; i < 64; i++) { + if (lo > hi) break; + const mid = (lo + hi) / BigInt(2); + try { + const [reserveNeeded] = await publicClient.readContract({ + address: MCV2_BOND, + abi: mcv2BondAbi, + functionName: "getReserveForToken", + args: [tokenAddress, mid], + }) as [bigint, bigint]; + if (reserveNeeded <= maxBalance) { + best = mid; + lo = mid + BigInt(1); + } else { + hi = mid - BigInt(1); + } + } catch { + hi = mid - BigInt(1); + } + } + + if (best > BigInt(0)) { + setAmount(formatUnits(best, 18)); + } + } else { + // Zap mode (ETH/USDC/HUNT): get quote from zap contract + const fromToken = getTokenAddress(payToken); + const quote = await getZapQuote(fromToken, tokenAddress, maxBalance, "exact-input"); + if (quote.tokensOut && quote.tokensOut > BigInt(0)) { + setAmount(formatUnits(quote.tokensOut, 18)); + } } } catch { // Silently fail — user can enter amount manually } - }, [address, isConnected, isEthMode, isErc20ZapMode, erc20BalanceToken, payToken, tokenAddress, ethBalanceData]); + }, [address, isConnected, isEthMode, isErc20ZapMode, isPlotMode, erc20BalanceToken, payToken, tokenAddress, ethBalanceData]); const executeTrade = useCallback(async () => { if (!address || parsedAmount === BigInt(0)) return; diff --git a/src/hooks/usePublish.ts b/src/hooks/usePublish.ts index 018dfb15..37e84d68 100644 --- a/src/hooks/usePublish.ts +++ b/src/hooks/usePublish.ts @@ -113,7 +113,6 @@ export function usePublish() { // 4. Trigger indexer setState("indexing"); - await new Promise((r) => setTimeout(r, 5000)); const indexerRes = await fetch(opts.indexerRoute, { method: "POST", headers: { "Content-Type": "application/json" }, From 3737b9301ec90c6df2ef7d8c0a6c3e5f78062bbb Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Mon, 30 Mar 2026 19:16:45 +0100 Subject: [PATCH 2/3] [#638] Replace sequential binary search with batched multicall probes Addresses T2b review feedback: the original binary search made up to 64 sequential RPC calls (~3-12s). Now uses multicall to batch probes: 1. Exponential search (1 multicall, 20 probes) finds the magnitude 2. Two rounds of 16-point linear probes (2 multicalls) narrow to answer Total: 3 RPC round-trips instead of up to 64. Also fixes upper-bound issue: exponential probing correctly handles early-curve positions where tokens cost far less than 1 PLOT each. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/TradingWidget.tsx | 102 ++++++++++++++++++++++++------- 1 file changed, 80 insertions(+), 22 deletions(-) diff --git a/src/components/TradingWidget.tsx b/src/components/TradingWidget.tsx index 439341fb..e73a0896 100644 --- a/src/components/TradingWidget.tsx +++ b/src/components/TradingWidget.tsx @@ -180,34 +180,92 @@ export function TradingWidget({ tokenAddress }: { tokenAddress: Address }) { if (maxBalance <= BigInt(0)) return; if (isPlotMode) { - // PLOT mode: binary search for max storyline tokens mintable within PLOT balance - let lo = BigInt(1); - let hi = maxBalance; // upper bound: 1 storyline token per 1 PLOT (always overshoots) - let best = BigInt(0); - - for (let i = 0; i < 64; i++) { - if (lo > hi) break; - const mid = (lo + hi) / BigInt(2); - try { - const [reserveNeeded] = await publicClient.readContract({ - address: MCV2_BOND, - abi: mcv2BondAbi, - functionName: "getReserveForToken", - args: [tokenAddress, mid], - }) as [bigint, bigint]; + // PLOT mode: find max storyline tokens mintable within PLOT balance. + // Uses batched multicall probes to minimize RPC round-trips (2-4 calls total). + + // Step 1: Exponential search to find upper bound (tokens can be very cheap early on the curve) + const expProbes: bigint[] = []; + for (let exp = BigInt(0); exp < BigInt(20); exp++) { + expProbes.push(BigInt(10) ** exp * BigInt(10) ** BigInt(18)); // 1, 10, 100, ... × 1e18 + } + + const expResults = await publicClient.multicall({ + contracts: expProbes.map((probe) => ({ + address: MCV2_BOND, + abi: mcv2BondAbi, + functionName: "getReserveForToken" as const, + args: [tokenAddress, probe], + })), + allowFailure: true, + }); + + // Find the highest probe that fits within maxBalance + let lo = BigInt(0); + let hi = BigInt(0); + for (let i = 0; i < expResults.length; i++) { + const r = expResults[i]; + if (r.status === "success") { + const [reserveNeeded] = r.result as unknown as [bigint, bigint]; if (reserveNeeded <= maxBalance) { - best = mid; - lo = mid + BigInt(1); + lo = expProbes[i]; + hi = i + 1 < expProbes.length ? expProbes[i + 1] : expProbes[i] * BigInt(10); } else { - hi = mid - BigInt(1); + hi = expProbes[i]; + break; } - } catch { - hi = mid - BigInt(1); + } else { + hi = i > 0 ? expProbes[i] : expProbes[0]; + break; } } - if (best > BigInt(0)) { - setAmount(formatUnits(best, 18)); + if (lo <= BigInt(0)) { + // Even 1 token exceeds balance — nothing to do + } else { + // Step 2-3: Two rounds of 16-point linear probes to narrow down (~2 multicalls) + let best = lo; + for (let round = 0; round < 2; round++) { + const step = (hi - lo) / BigInt(17); + if (step <= BigInt(0)) break; + + const probes: bigint[] = []; + for (let i = 1; i <= 16; i++) { + probes.push(lo + step * BigInt(i)); + } + + const results = await publicClient.multicall({ + contracts: probes.map((probe) => ({ + address: MCV2_BOND, + abi: mcv2BondAbi, + functionName: "getReserveForToken" as const, + args: [tokenAddress, probe], + })), + allowFailure: true, + }); + + let narrowedHi = hi; + for (let i = 0; i < results.length; i++) { + const r = results[i]; + if (r.status === "success") { + const [reserveNeeded] = r.result as unknown as [bigint, bigint]; + if (reserveNeeded <= maxBalance) { + best = probes[i]; + lo = probes[i]; + } else { + narrowedHi = probes[i]; + break; + } + } else { + narrowedHi = i > 0 ? probes[i] : lo; + break; + } + } + hi = narrowedHi; + } + + if (best > BigInt(0)) { + setAmount(formatUnits(best, 18)); + } } } else { // Zap mode (ETH/USDC/HUNT): get quote from zap contract From 0dcdb00d5bee9428756559115fbb34b6a97e00e0 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Mon, 30 Mar 2026 19:19:01 +0100 Subject: [PATCH 3/3] [#638] Add sub-1-token probes for fractional PLOT balances Exponential search now starts from 1e12 (0.000001 tokens) instead of 1e18 (1 whole token), so MAX works for wallets that can only afford a fractional storyline token on the bonding curve. Addresses T2a review: previous code no-oped when 1 whole token exceeded the user's PLOT balance. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/TradingWidget.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/TradingWidget.tsx b/src/components/TradingWidget.tsx index e73a0896..8ff508ed 100644 --- a/src/components/TradingWidget.tsx +++ b/src/components/TradingWidget.tsx @@ -183,10 +183,12 @@ export function TradingWidget({ tokenAddress }: { tokenAddress: Address }) { // PLOT mode: find max storyline tokens mintable within PLOT balance. // Uses batched multicall probes to minimize RPC round-trips (2-4 calls total). - // Step 1: Exponential search to find upper bound (tokens can be very cheap early on the curve) + // Step 1: Exponential search to find upper bound. + // Start from 1e12 (0.000001 tokens) to handle fractional balances, + // up to 1e37 (1e19 whole tokens) for cheap early-curve positions. const expProbes: bigint[] = []; - for (let exp = BigInt(0); exp < BigInt(20); exp++) { - expProbes.push(BigInt(10) ** exp * BigInt(10) ** BigInt(18)); // 1, 10, 100, ... × 1e18 + for (let exp = 12; exp <= 37; exp++) { + expProbes.push(BigInt(10) ** BigInt(exp)); } const expResults = await publicClient.multicall({