Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions app/copy-trade/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import React, { useState, useRef, useEffect, Suspense } from "react";
import React, { useState, useRef, useEffect, useCallback, Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useWallet } from "@/lib/contexts/WalletContext";
import { getTrace, ReasoningTrace } from "@/lib/api/traces";
Expand Down Expand Up @@ -61,13 +61,38 @@ function CopyTradeContent() {
const balance = useUsdcBalance(address);
const reserve = useAccountReserve(address);
const buy = useBuy();
const submitQueue = useSubmitQueueState(address);

// Allocation as a share of the wallet's USDC balance, in basis points.
const [allocationBps, setAllocationBps] = useState(1250);
const [slippage, setSlippage] = useState(1.0);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [reviewModel, setReviewModel] = useState<{ model: ReviewModel; changedFrom?: ReviewModel } | null>(null);
const reviewResolver = useRef<((approved: boolean) => void) | null>(null);
const sliderRef = useRef<HTMLDivElement>(null);
const reviewPreparedBuy = useCallback<PreparedWriteReview>(async (_prepared, model, changedFrom, forceReview = false) => {
if (!forceReview && shouldSkipTxReview()) return true;
return new Promise<boolean>((resolve) => {
reviewResolver.current = resolve;
setReviewModel({ model, changedFrom });
});
}, []);
const closeReview = useCallback((approved: boolean) => {
setReviewModel(null);
const resolve = reviewResolver.current;
reviewResolver.current = null;
resolve?.(approved);
}, []);

useEffect(
() => () => {
const resolve = reviewResolver.current;
reviewResolver.current = null;
resolve?.(false);
},
[],
);

useEffect(() => {
if (!traceId) return;
Expand All @@ -78,6 +103,10 @@ function CopyTradeContent() {

// Side comes from the trace's edge. No trace or zero edge means no trade.
const side: "Yes" | "No" | null = trace && trace.edge !== 0 ? (trace.edge > 0 ? "Yes" : "No") : null;
const buyLabel = side && onChainMarketId ? `Buy ${side} on #${onChainMarketId}` : "";
const waitingForBuy =
submitQueue.waiting.some((job) => job.label === buyLabel) ||
(submitQueue.lockWaiting && submitQueue.running?.label === buyLabel);
const allocation = allocationBps / 100;
const amountRaw = balance.data !== undefined ? shareOf(balance.data, allocationBps) : null;
const slippageBps = Math.round(slippage * 100);
Expand Down Expand Up @@ -418,7 +447,7 @@ function CopyTradeContent() {
{/* Status message when not pending */}
{!buy.isPending && statusMessage && (
<div className="rounded-2xl border border-outline-variant bg-surface p-4 text-sm text-on-surface-variant">
{statusMessage}
{waitingForBuy ? "Waiting for your previous transaction to finish…" : statusMessage}
</div>
)}
<div className="flex gap-3">
Expand Down Expand Up @@ -454,10 +483,21 @@ function CopyTradeContent() {
<p className="text-center font-body-md text-body-md text-on-surface-variant text-xs">
Your wallet signs a buy on the OracleDesk market contract on Stellar Testnet. The trade fails instead of filling below the slippage limit.
</p>
<div className="text-center">
<TxReviewPreferenceControl />
</div>
</div>
</div>
</div>
</main>
{reviewModel && (
<TxReview
model={reviewModel.model}
changedFrom={reviewModel.changedFrom}
onConfirm={() => closeReview(true)}
onCancel={() => closeReview(false)}
/>
)}
</div>
);
}
Expand Down
45 changes: 43 additions & 2 deletions app/markets/quick-view/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import React, { Suspense, useEffect, useState } from "react";
import React, { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
Expand Down Expand Up @@ -92,11 +92,19 @@ function MarketQuickViewContent() {
const balance = useUsdcBalance(address);
const reserve = useAccountReserve(address);
const buy = useBuy();
const submitQueue = useSubmitQueueState(address);
const [now, setNow] = useState<number | null>(null);

useEffect(() => {
const interval = setInterval(() => setNow(Date.now()), 1_000);
return () => clearInterval(interval);
}, []);
const [showModal] = useState(true);
const [side, setSide] = useState<"Yes" | "No">(initialSide);
const [amountInput, setAmountInput] = useState("10");
const [buyStatus, setBuyStatus] = useState<string | null>(null);
const [reviewModel, setReviewModel] = useState<{ model: ReviewModel; changedFrom?: ReviewModel } | null>(null);
const reviewResolver = useRef<((approved: boolean) => void) | null>(null);
const amountRaw = tryParseUsdc(amountInput);
const debouncedAmount = useDebounced(amountRaw, 400);
const quote = useQuoteBuy(onChainMarketId, side, debouncedAmount);
Expand All @@ -116,8 +124,33 @@ function MarketQuickViewContent() {
const yesProb = view ? Math.round(view.yesBps / 100) : backendProb;
const noProb = 100 - yesProb;
const chainStatus = view?.market.status;
// Compare with when the market was read, not "now", to keep render pure.
// Use a timer-backed timestamp so freshness changes are reflected without impure render reads.
const isOpen = chainStatus?.tag === "Open" && Number(view?.market.close_time ?? 0) * 1000 > chain.dataUpdatedAt;
const isFresh = (updatedAt: number, maxAge: number) =>
now !== null && now >= updatedAt && now - updatedAt <= maxAge;
const tradePreflight = preflightBuy(
{
config:
config.data && isFresh(config.dataUpdatedAt, 60_000)
? { paused: config.data.paused }
: undefined,
market: view?.market,
marketIsStale: Boolean(view && now !== null && !isFresh(chain.dataUpdatedAt, 15_000)),
ledgerNow:
ledgerNow.data !== undefined && isFresh(ledgerNow.dataUpdatedAt, 5_000)
? ledgerNow.data
: undefined,
position: position.data && isFresh(position.dataUpdatedAt, 15_000) ? position.data : undefined,
positionIsStale: Boolean(position.data && now !== null && !isFresh(position.dataUpdatedAt, 15_000)),
usdcBalance:
balance.data !== undefined && isFresh(balance.dataUpdatedAt, 15_000)
? balance.data
: undefined,
},
side,
amountRaw ?? 0n,
);
const buyPreflightFailure = tradePreflight.ok === false ? tradePreflight.failures[0] : null;

const isLoading = backendById.isLoading || (backendByChainId.isLoading && !marketId) || (chain.isLoading && !market);
const notFound = !market && !view;
Expand Down Expand Up @@ -455,6 +488,14 @@ function MarketQuickViewContent() {
</div>
)}
</AnimatePresence>
{reviewModel && (
<TxReview
model={reviewModel.model}
changedFrom={reviewModel.changedFrom}
onConfirm={() => closeReview(true)}
onCancel={() => closeReview(false)}
/>
)}
</main>
);
}
183 changes: 181 additions & 2 deletions app/portfolio/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
"use client";

import React, { useState, useEffect, useRef } from "react";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { motion } from "framer-motion";
import { usePortfolio, usePositions } from "@/lib/hooks/usePortfolio";
import { formatUsdc } from "@/lib/stellar/units";
import { useWallet } from "@/lib/contexts/WalletContext";
import { useRedeemAll, type PreparedWriteReview } from "@/lib/hooks/useStellar";
import { shouldSkipTxReview, TxReview, TxReviewPreferenceControl } from "@/components/tx-review";
import type { ReviewModel } from "@/lib/stellar/tx-decode";

const TerminalLog = () => {
const [logs, setLogs] = useState([
Expand Down Expand Up @@ -152,9 +156,50 @@ const PositionRow = ({ market, category, platform, side, sideColor, entry, prob,

export default function PortfolioPage() {
const [status, setStatus] = useState<"OPEN" | "CLOSED">("OPEN");
const wallet = useWallet();
const redeemBatch = useRedeemAll();
const [reviewModel, setReviewModel] = useState<{ model: ReviewModel; changedFrom?: ReviewModel } | null>(null);
const reviewResolver = useRef<((approved: boolean) => void) | null>(null);
const { data: summary, isLoading: isSummaryLoading } = usePortfolio();
const { data: positionsData, isLoading: isPositionsLoading } = usePositions({ status });
const positions = positionsData?.positions ?? [];
const currentRedeemIds = new Set(redeemBatch.discovery?.redeemables.map((item) => item.marketId.toString()) ?? []);
const currentRedeemed = redeemBatch.discovery?.redeemables.filter(
(item) => redeemBatch.states[item.marketId.toString()]?.s === "redeemed",
) ?? [];
const expectedRedeemTotal = redeemBatch.discovery?.redeemables.reduce((total, item) => total + item.expectedPayout, 0n) ?? 0n;
const redeemedPayoutTotal = currentRedeemed.reduce(
(total, item) => {
const state = redeemBatch.states[item.marketId.toString()];
return total + (state?.s === "redeemed" ? state.payout : 0n);
},
0n,
);
const resumedItems = Object.entries(redeemBatch.states).filter(([id, item]) =>
!currentRedeemIds.has(id) && (item.s === "redeemed" || item.s === "failed" || item.s === "skipped"),
);
const reviewRedeem = useCallback<PreparedWriteReview>(async (_prepared, model, changedFrom, forceReview = false) => {
if (!forceReview && shouldSkipTxReview()) return true;
return new Promise<boolean>((resolve) => {
reviewResolver.current = resolve;
setReviewModel({ model, changedFrom });
});
}, []);
const closeReview = useCallback((approved: boolean) => {
setReviewModel(null);
const resolve = reviewResolver.current;
reviewResolver.current = null;
resolve?.(approved);
}, []);

useEffect(
() => () => {
const resolve = reviewResolver.current;
reviewResolver.current = null;
resolve?.(false);
},
[],
);

return (
<main className="max-w-container-max-width mx-auto px-gutter py-8">
Expand Down Expand Up @@ -220,10 +265,136 @@ export default function PortfolioPage() {
</div>
</section>

<section className="mb-12 rounded-xl border border-outline-variant bg-white p-6 shadow-sm" aria-labelledby="connected-redemptions-title">
<div className="flex flex-col gap-5 md:flex-row md:items-start md:justify-between">
<div>
<h2 id="connected-redemptions-title" className="font-headline-sm text-headline-sm text-on-surface">
Connected wallet redemptions
</h2>
<p className="mt-1 max-w-2xl text-sm text-on-surface-variant">
Scan on-chain positions for resolved markets and redeem them one at a time. Each transaction is signed separately by your wallet.
</p>
<div className="mt-2">
<TxReviewPreferenceControl />
</div>
{redeemBatch.discovery && (
<p className="mt-2 text-xs text-on-surface-variant" role="status">
Scanned {redeemBatch.discovery.scannedMarkets.toString()} of {redeemBatch.discovery.totalMarkets.toString()} markets; found{" "}
{redeemBatch.discovery.redeemables.length} redeemable position(s).
{redeemBatch.discovery.scanLimited ? " The scan limit was reached; newer markets were checked first." : ""}
</p>
)}
{redeemBatch.discovery && redeemBatch.discovery.redeemables.length > 0 && (
<p className="mt-1 text-xs text-on-surface-variant">
Expected total: {formatUsdc(expectedRedeemTotal, { maxDecimals: 2, grouping: true })} USDC ·
{" "}{currentRedeemed.length} of {redeemBatch.discovery.redeemables.length} redeemed
{currentRedeemed.length > 0
? ` (${formatUsdc(redeemedPayoutTotal, { maxDecimals: 2, grouping: true })} USDC received)`
: ""}
</p>
)}
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => void redeemBatch.scan()}
disabled={!wallet.isConnected || wallet.isWrongNetwork || redeemBatch.scanning || redeemBatch.running}
className="rounded border border-outline-variant px-4 py-2 text-sm font-semibold text-primary disabled:cursor-not-allowed disabled:opacity-50"
>
{redeemBatch.scanning ? "Scanning ledger…" : "Scan wallet"}
</button>
<button
type="button"
onClick={() => void redeemBatch.redeemAll(reviewRedeem)}
disabled={!wallet.isConnected || wallet.isWrongNetwork || !redeemBatch.discovery?.redeemables.length || redeemBatch.running || redeemBatch.scanning}
className="rounded bg-primary px-4 py-2 text-sm font-semibold text-on-primary disabled:cursor-not-allowed disabled:opacity-50"
>
Redeem all
</button>
{redeemBatch.running ? (
<button type="button" onClick={redeemBatch.cancel} className="rounded border border-outline-variant px-4 py-2 text-sm font-semibold">
Stop after current
</button>
) : Object.values(redeemBatch.states).some((item) => item.s === "failed") ? (
<button
type="button"
onClick={() => void redeemBatch.retryFailed(reviewRedeem)}
className="rounded border border-outline-variant px-4 py-2 text-sm font-semibold"
>
Retry failed
</button>
) : null}
</div>
</div>

{!wallet.isConnected && <p className="mt-4 text-sm text-on-surface-variant">Connect a Stellar wallet to scan your positions.</p>}
{wallet.isWrongNetwork && <p className="mt-4 text-sm text-error">Switch your wallet to Stellar Testnet to redeem positions.</p>}
{redeemBatch.error && <p className="mt-4 text-sm text-error" role="alert">{redeemBatch.error}</p>}

{redeemBatch.discovery && redeemBatch.discovery.redeemables.length > 0 && (
<ul className="mt-5 divide-y divide-outline-variant rounded border border-outline-variant">
{redeemBatch.discovery.redeemables.map((item) => {
const id = item.marketId.toString();
const itemState = redeemBatch.states[id] ?? { s: "queued" as const };
const payout = formatUsdc(item.expectedPayout, { maxDecimals: 2, grouping: true });
return (
<li key={id} className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-semibold text-on-surface">Market #{id} · {item.settlement.toUpperCase()} winner</p>
<p className="text-sm text-on-surface-variant">
Estimated payout: {payout} USDC
{item.archived ? " · archived ledger entry" : ""}
</p>
{itemState.s === "failed" && <p className="mt-1 text-sm text-error">{itemState.error}</p>}
{itemState.s === "redeemed" && <p className="mt-1 text-sm text-secondary">Redeemed · {formatUsdc(itemState.payout, { maxDecimals: 2, grouping: true })} USDC</p>}
{itemState.s === "signing" && <p className="mt-1 text-sm text-primary">Waiting for wallet signature…</p>}
{itemState.s === "submitting" && <p className="mt-1 text-sm text-primary">Submitted to Stellar…</p>}
{itemState.s === "skipped" && <p className="mt-1 text-sm text-on-surface-variant">Skipped</p>}
</div>
{!redeemBatch.running && itemState.s !== "redeemed" && itemState.s !== "skipped" && (
<button
type="button"
onClick={() => redeemBatch.skip(id)}
className="self-start rounded border border-outline-variant px-3 py-1.5 text-xs font-semibold text-on-surface-variant sm:self-center"
>
Skip
</button>
)}
</li>
);
})}
</ul>
)}
{redeemBatch.discovery && redeemBatch.discovery.redeemables.length === 0 && (
<p className="mt-5 text-sm text-on-surface-variant">No resolved positions are ready to redeem.</p>
)}
{resumedItems.length > 0 && (
<div className="mt-5 border-t border-outline-variant pt-4">
<h3 className="font-label-caps text-xs font-bold uppercase tracking-wide text-on-surface-variant">
Saved batch progress
</h3>
<ul className="mt-2 space-y-2 text-sm">
{resumedItems.map(([id, item]) => (
<li key={id} className="flex flex-wrap justify-between gap-2">
<span>Market #{id}</span>
<span className={item.s === "failed" ? "text-error" : item.s === "redeemed" ? "text-secondary" : "text-on-surface-variant"}>
{item.s === "redeemed"
? `Redeemed · ${formatUsdc(item.payout, { maxDecimals: 2, grouping: true })} USDC`
: item.s === "failed"
? item.error
: "Skipped"}
</span>
</li>
))}
</ul>
</div>
)}
</section>

{/* Detailed Positions Table */}
<section className="bg-white border border-outline-variant rounded shadow-sm overflow-hidden mb-12">
<div className="px-6 py-4 border-b border-outline-variant flex flex-col sm:flex-row justify-between items-start sm:items-center bg-surface-container-low gap-4">
<h2 className="font-headline-sm text-headline-sm text-on-surface">{status === 'OPEN' ? 'Open' : 'Resolved'} Positions</h2>
<h2 className="font-headline-sm text-headline-sm text-on-surface">Agent {status === 'OPEN' ? 'Open' : 'Resolved'} Positions</h2>
<div className="flex items-center gap-4">
<div className="flex bg-surface-container rounded-lg p-1 border border-outline-variant">
<button
Expand Down Expand Up @@ -369,6 +540,14 @@ export default function PortfolioPage() {
</div>
</aside>
</div>
{reviewModel && (
<TxReview
model={reviewModel.model}
changedFrom={reviewModel.changedFrom}
onConfirm={() => closeReview(true)}
onCancel={() => closeReview(false)}
/>
)}
</main>
);
}
Loading