Skip to content
Closed
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
10 changes: 10 additions & 0 deletions app/buy-no-demo/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export default function BuyNoDemoPage() {

const price = 0.68;
const shares = (parseFloat(amount) || 0) / price;
const fee = (parseFloat(amount) || 0) * 0.01;
const priceImpact = Math.min(0.99, shares ? ((parseFloat(amount) || 0) / Math.max(shares, 1)) * 0.002 : 0);
const potentialPayout = shares; // Since it's a binary outcome usually 1 USDC per share

return (
Expand Down Expand Up @@ -115,6 +117,14 @@ export default function BuyNoDemoPage() {
<span className="font-body-md text-on-surface-variant">Estimated Shares</span>
<span className="font-data-mono font-bold text-on-surface">{shares.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>
</div>
<div className="flex justify-between items-center text-sm sm:text-base">
<span className="font-body-md text-on-surface-variant">Trading fee</span>
<span className="font-data-mono font-bold text-on-surface">{fee.toFixed(2)} USDC</span>
</div>
<div className="flex justify-between items-center text-sm sm:text-base">
<span className="font-body-md text-on-surface-variant">Estimated price impact</span>
<span className="font-data-mono font-bold text-on-surface">{(priceImpact * 100).toFixed(2)}%</span>
</div>
<div className="flex justify-between items-center text-sm sm:text-base">
<span className="font-body-md text-on-surface-variant">Avg. Purchase Price</span>
<span className="font-data-mono font-bold text-on-surface">0.68 USDC</span>
Expand Down
124 changes: 116 additions & 8 deletions app/copy-trade/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,35 @@ import { getTrace, ReasoningTrace } from "@/lib/api/traces";
import { confirmCopyTrade, initiateCopyTrade } from "@/lib/api/trade";
import { ApiError } from "@/lib/api/client";
import { useMarket } from "@/lib/hooks/useMarkets";
import { useBuy, useQuoteBuy, useUsdcBalance } from "@/lib/hooks/useStellar";
import { useBuy, useQuoteBuy, useUsdcBalance, useAccountReserve, usePrepareBuy } from "@/lib/hooks/useStellar";
import { explorerTx } from "@/lib/stellar/config";
import { formatUsdc, minOut, shareOf } from "@/lib/stellar/units";
import { formatUsdc, formatXlm, minOut, shareOf } from "@/lib/stellar/units";
import { TxPhase } from "@/lib/stellar/tx-lifecycle";

/** Wait this long after the slider stops before asking for a new quote. */
const QUOTE_DEBOUNCE_MS = 400;

function renderTxPhase(phase: TxPhase): string | null {
switch (phase.name) {
case "building":
return null;
case "simulating":
return "Simulating transaction…";
case "awaiting-signature":
return "Waiting for your wallet…";
case "submitting":
return "Sending to the network…";
case "pending":
return "Waiting for a ledger…";
case "confirmed":
return "Transaction confirmed.";
case "failed":
return phase.error.message;
case "expired":
return "Transaction expired without confirmation.";
}
}

function useDebounced<T>(value: T, ms: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
Expand All @@ -37,6 +59,7 @@ function CopyTradeContent() {
const onChainMarketId = market?.onChainMarketId ?? null;

const balance = useUsdcBalance(address);
const reserve = useAccountReserve(address);
const buy = useBuy();

// Allocation as a share of the wallet's USDC balance, in basis points.
Expand All @@ -62,6 +85,24 @@ function CopyTradeContent() {
const debouncedAmount = useDebounced(amountRaw, QUOTE_DEBOUNCE_MS);
const quote = useQuoteBuy(onChainMarketId, side ?? "Yes", side ? debouncedAmount : null);
const minShares = quote.data !== undefined ? minOut(quote.data, slippageBps) : null;
const prepareBuy = usePrepareBuy({
marketId: onChainMarketId,
outcome: side ?? "Yes",
collateralIn: side ? debouncedAmount : null,
slippageBps,
holder: address,
});

const fees = prepareBuy.data?.fees;
const isUnfunded = Boolean(reserve.data && !reserve.data.funded);
const isInsufficientXlm = Boolean(
reserve.data?.funded &&
fees &&
reserve.data.spendable < fees.totalFee
);
const xlmDeficit = (reserve.data?.funded && fees && fees.totalFee > reserve.data.spendable)
? fees.totalFee - reserve.data.spendable
: null;

const handleSliderInteraction = (clientX: number) => {
if (!sliderRef.current) return;
Expand Down Expand Up @@ -119,6 +160,11 @@ function CopyTradeContent() {
return;
}

if (address?.startsWith("C")) {
setStatusMessage("Not available for passkey wallets yet.");
return;
}

setIsSubmitting(true);
try {
setStatusMessage("Signing in to OracleDesk…");
Expand All @@ -134,12 +180,13 @@ function CopyTradeContent() {
amountRaw: amountRaw.toString(),
});

setStatusMessage("Getting a fresh quote, then asking your wallet to sign…");
setStatusMessage(null);
const result = await buy.mutateAsync({
marketId: onChainMarketId,
outcome: side,
collateralIn: amountRaw,
slippageBps,
prepared: prepareBuy.data,
});

if (result.txHash) await confirmCopyTrade(copyTradeId, result.txHash).catch(() => undefined);
Expand Down Expand Up @@ -311,7 +358,26 @@ function CopyTradeContent() {
{quote.data !== undefined && minShares !== null ? (
<>
<span className="text-on-surface font-bold">{formatUsdc(quote.data, { maxDecimals: 2 })}</span>
<span className="block text-[10px] text-on-surface-variant">at least {formatUsdc(minShares, { maxDecimals: 2 })} after slippage</span>
<span className="block text-[10px] text-on-surface-variant">
at least {formatUsdc(minShares, { maxDecimals: 2 })} after slippage
{fees && (
<span
className="block text-[10px] text-on-surface-variant mt-1"
title={`Inclusion: ${formatXlm(fees.inclusionFee)} XLM · Resource: ${formatXlm(fees.resourceFee)} XLM (assumes the network base reserve of 0.5 XLM)`}
>
Network fee ≈ {formatXlm(fees.totalFee)} XLM ·{" "}
{reserve.isLoading
? "checking XLM…"
: reserve.isError
? "couldn't check XLM balance"
: reserve.data && !reserve.data.funded
? "unfunded"
: reserve.data
? `spendable ${formatXlm(reserve.data.spendable, { maxDecimals: 1 })} XLM`
: "spendable —"}
</span>
)}
</span>
</>
) : quote.isFetching ? (
<span className="text-on-surface-variant">Quoting…</span>
Expand All @@ -325,11 +391,41 @@ function CopyTradeContent() {
</div>

<div className="space-y-3">
{statusMessage ? (
{/* Blocking warnings */}
{isConnected && isUnfunded && (
<div className="rounded-2xl border border-error/20 bg-error-container/10 p-3 text-xs text-error font-medium" role="alert">
Fund this account with XLM first.
</div>
)}
{isConnected && isInsufficientXlm && xlmDeficit !== null && (
<div className="rounded-2xl border border-error/20 bg-error-container/10 p-3 text-xs text-error font-medium" role="alert">
You need about {formatXlm(xlmDeficit)} more XLM to pay the network fee.
</div>
)}

{/* Tx phase tracking */}
{buy.isPending && (
<div className="rounded-2xl border border-outline-variant bg-surface p-4 text-sm text-on-surface-variant flex items-center justify-between" role="status">
<span>{renderTxPhase(buy.phase) ?? statusMessage ?? "Processing copy trade…"}</span>
{"hash" in buy.phase && buy.phase.hash && (
<a
href={explorerTx(buy.phase.hash)}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline font-data-mono text-xs"
>
View: {buy.phase.hash.slice(0, 8)}…
</a>
)}
</div>
)}

{/* 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}
</div>
) : null}
)}
<div className="flex gap-3">
<button
className="flex-1 border border-outline text-on-surface py-4 rounded-lg font-headline-sm text-headline-sm hover:bg-surface-variant transition-all"
Expand All @@ -342,10 +438,22 @@ function CopyTradeContent() {
className="flex-[2] bg-[#005f73] text-primary-foreground py-4 rounded-lg font-headline-sm text-headline-sm shadow-lg shadow-primary-container/20 hover:brightness-110 active:scale-[0.98] transition-all flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleConfirm}
type="button"
disabled={isSubmitting || !side}
disabled={
isSubmitting ||
buy.isPending ||
!side ||
(isConnected && isUnfunded) ||
(isConnected && isInsufficientXlm)
}
>
<span className="material-symbols-outlined text-xl">bolt</span>
{isSubmitting ? "Submitting..." : "Confirm Copy Trade"}
{isSubmitting || buy.isPending
? (buy.phase.name === "awaiting-signature" ? "Signing…" : "Submitting…")
: isUnfunded
? "Fund Account"
: isInsufficientXlm
? "Insufficient XLM"
: "Confirm Copy Trade"}
</button>
</div>
<p className="text-center font-body-md text-body-md text-on-surface-variant text-xs">
Expand Down
Loading
Loading