From 6e5ac56e90962d0098386d4743dc396b8da9e615 Mon Sep 17 00:00:00 2001 From: cladiousdoff Date: Sat, 17 Oct 2026 07:40:52 -0700 Subject: [PATCH] feat: add submit queue and preflight checks for trading operations - Implemented SubmitQueue class to manage job execution for trading operations, ensuring sequential processing for the same address and concurrent processing for different addresses. - Added preflight checks for buy, sell, and redeem operations to validate market conditions and user balances before executing trades. - Created tests for SubmitQueue to verify job execution order, retries on failure, and deduplication of identical submissions. - Introduced tx-decode module to decode transaction arguments and validate against expected intents. - Added redeem-all functionality to discover redeemable positions and manage redemption processes. --- app/copy-trade/page.tsx | 58 +- app/markets/quick-view/page.tsx | 103 +++- app/portfolio/page.tsx | 183 +++++- app/premium/page.tsx | 59 +- components/tx-review.tsx | 206 +++++++ docs/architecture.md | 4 +- docs/manual-test.md | 11 + lib/hooks/useStellar.ts | 647 +++++++++++++++++++-- lib/stellar/__tests__/errors.test.ts | 15 +- lib/stellar/__tests__/preflight.test.ts | 145 +++++ lib/stellar/__tests__/redeem-all.test.ts | 142 +++++ lib/stellar/__tests__/submit-queue.test.ts | 92 +++ lib/stellar/__tests__/tx-decode.test.ts | 116 ++++ lib/stellar/errors.ts | 44 +- lib/stellar/preflight.ts | 166 ++++++ lib/stellar/redeem-all.ts | 233 ++++++++ lib/stellar/submit-queue.ts | 306 ++++++++++ lib/stellar/tx-decode.ts | 198 +++++++ 18 files changed, 2657 insertions(+), 71 deletions(-) create mode 100644 components/tx-review.tsx create mode 100644 lib/stellar/__tests__/preflight.test.ts create mode 100644 lib/stellar/__tests__/redeem-all.test.ts create mode 100644 lib/stellar/__tests__/submit-queue.test.ts create mode 100644 lib/stellar/__tests__/tx-decode.test.ts create mode 100644 lib/stellar/preflight.ts create mode 100644 lib/stellar/redeem-all.ts create mode 100644 lib/stellar/submit-queue.ts create mode 100644 lib/stellar/tx-decode.ts diff --git a/app/copy-trade/page.tsx b/app/copy-trade/page.tsx index 3bd60a0..09f40d2 100644 --- a/app/copy-trade/page.tsx +++ b/app/copy-trade/page.tsx @@ -1,15 +1,18 @@ "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"; 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, type PreparedWriteReview } from "@/lib/hooks/useStellar"; import { explorerTx } from "@/lib/stellar/config"; import { formatUsdc, minOut, shareOf } from "@/lib/stellar/units"; +import { useSubmitQueueState } from "@/lib/stellar/submit-queue"; +import { shouldSkipTxReview, TxReview, TxReviewPreferenceControl } from "@/components/tx-review"; +import type { ReviewModel } from "@/lib/stellar/tx-decode"; /** Wait this long after the slider stops before asking for a new quote. */ const QUOTE_DEBOUNCE_MS = 400; @@ -38,13 +41,38 @@ function CopyTradeContent() { const balance = useUsdcBalance(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(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(null); + const reviewPreparedBuy = useCallback(async (_prepared, model, changedFrom, forceReview = false) => { + if (!forceReview && shouldSkipTxReview()) return true; + return new Promise((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; @@ -55,6 +83,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); @@ -134,12 +166,17 @@ function CopyTradeContent() { amountRaw: amountRaw.toString(), }); - setStatusMessage("Getting a fresh quote, then asking your wallet to sign…"); + setStatusMessage(waitingForBuy ? "Waiting for your previous transaction to finish…" : "Getting a fresh quote, then asking your wallet to sign…"); const result = await buy.mutateAsync({ marketId: onChainMarketId, outcome: side, collateralIn: amountRaw, slippageBps, + review: reviewPreparedBuy, + confirmBoundsChanged: async (previous, next) => + window.confirm( + `The quote changed during retry. The minimum shares decreased from ${formatUsdc(previous, { maxDecimals: 2 })} to ${formatUsdc(next, { maxDecimals: 2 })}. Continue?`, + ), }); if (result.txHash) await confirmCopyTrade(copyTradeId, result.txHash).catch(() => undefined); @@ -327,7 +364,7 @@ function CopyTradeContent() {
{statusMessage ? (
- {statusMessage} + {waitingForBuy ? "Waiting for your previous transaction to finish…" : statusMessage}
) : null}
@@ -345,16 +382,27 @@ function CopyTradeContent() { disabled={isSubmitting || !side} > bolt - {isSubmitting ? "Submitting..." : "Confirm Copy Trade"} + {waitingForBuy ? "Waiting…" : isSubmitting ? "Submitting..." : "Confirm Copy Trade"}

Your wallet signs a buy on the OracleDesk market contract on Stellar Testnet. The trade fails instead of filling below the slippage limit.

+
+ +
+ {reviewModel && ( + closeReview(true)} + onCancel={() => closeReview(false)} + /> + )} ); } diff --git a/app/markets/quick-view/page.tsx b/app/markets/quick-view/page.tsx index 63c6a06..13a3e73 100644 --- a/app/markets/quick-view/page.tsx +++ b/app/markets/quick-view/page.tsx @@ -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"; @@ -8,10 +8,14 @@ import { useQuery } from "@tanstack/react-query"; import { useMarket } from "@/lib/hooks/useMarkets"; import { getMarketByOnChainId } from "@/lib/api/markets"; import { ApiError } from "@/lib/api/client"; -import { useBuy, useOnChainMarket, usePosition, useQuoteBuy, useUsdcBalance } from "@/lib/hooks/useStellar"; +import { useBuy, useLedgerNow, useMarketConfig, useOnChainMarket, usePosition, useQuoteBuy, useUsdcBalance } from "@/lib/hooks/useStellar"; import { useWallet } from "@/lib/contexts/WalletContext"; import { explorerTx } from "@/lib/stellar/config"; import { formatUsdc, minOut, tryParseUsdc } from "@/lib/stellar/units"; +import { preflightBuy } from "@/lib/stellar/preflight"; +import { useSubmitQueueState } from "@/lib/stellar/submit-queue"; +import { shouldSkipTxReview, TxReview, TxReviewPreferenceControl } from "@/components/tx-review"; +import type { ReviewModel } from "@/lib/stellar/tx-decode"; const SLIPPAGE_BPS = 100; // 1% @@ -68,15 +72,51 @@ function MarketQuickViewContent() { const { address, isConnected, openModal } = useWallet(); const position = usePosition(onChainMarketId, address); const balance = useUsdcBalance(address); + const config = useMarketConfig(); + const ledgerNow = useLedgerNow(); const buy = useBuy(); + const submitQueue = useSubmitQueueState(address); + const [now, setNow] = useState(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(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); + const buyLabel = `Buy ${side} on #${onChainMarketId}`; + const waitingForBuy = + submitQueue.waiting.some((job) => job.label === buyLabel) || + (submitQueue.lockWaiting && submitQueue.running?.label === buyLabel); + const reviewPreparedBuy = useCallback(async (_prepared: unknown, model: ReviewModel, changedFrom?: ReviewModel, forceReview = false) => { + if (!forceReview && shouldSkipTxReview()) return true; + return new Promise((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); + }, + [], + ); // Illustrative only: there is no price history source yet. const sparklinePath = "M0 25 L10 22 L20 28 L30 18 L40 20 L50 15 L60 17 L70 10 L80 12 L90 5 L100 8"; @@ -87,8 +127,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; @@ -103,9 +168,15 @@ function MarketQuickViewContent() { setBuyStatus("Enter an amount like 10 or 2.5."); return; } - setBuyStatus("Getting a fresh quote, then asking your wallet to sign…"); + setBuyStatus(waitingForBuy ? "Waiting for your previous transaction to finish…" : "Getting a fresh quote and preparing your transaction…"); try { - const result = await buy.mutateAsync({ marketId: onChainMarketId, outcome: side, collateralIn: amountRaw, slippageBps: SLIPPAGE_BPS }); + const result = await buy.mutateAsync({ + marketId: onChainMarketId, + outcome: side, + collateralIn: amountRaw, + slippageBps: SLIPPAGE_BPS, + review: reviewPreparedBuy, + }); setBuyStatus( `Bought ${formatUsdc(result.sharesOut, { maxDecimals: 2 })} ${side.toUpperCase()} shares.` + (result.txHash ? ` View: ${explorerTx(result.txHash)}` : ""), @@ -281,10 +352,10 @@ function MarketQuickViewContent() {
@@ -297,8 +368,13 @@ function MarketQuickViewContent() { {balance.data !== undefined && Balance {formatUsdc(balance.data, { maxDecimals: 2, grouping: true })}}
- {!isOpen && view && ( -

This market is closed to trading.

+ {buyPreflightFailure && isConnected && ( +

{buyPreflightFailure.message}

+ )} + {waitingForBuy && ( +

+ Waiting for your previous transaction to finish… +

)} {position.data && (position.data.yes > 0n || position.data.no > 0n) && (

@@ -307,6 +383,7 @@ function MarketQuickViewContent() {

)} {buyStatus &&

{buyStatus}

} + )} @@ -347,6 +424,14 @@ function MarketQuickViewContent() { )} + {reviewModel && ( + closeReview(true)} + onCancel={() => closeReview(false)} + /> + )} ); } diff --git a/app/portfolio/page.tsx b/app/portfolio/page.tsx index a39e874..3548d24 100644 --- a/app/portfolio/page.tsx +++ b/app/portfolio/page.tsx @@ -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([ @@ -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(async (_prepared, model, changedFrom, forceReview = false) => { + if (!forceReview && shouldSkipTxReview()) return true; + return new Promise((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 (
@@ -220,10 +265,136 @@ export default function PortfolioPage() { +
+
+
+

+ Connected wallet redemptions +

+

+ Scan on-chain positions for resolved markets and redeem them one at a time. Each transaction is signed separately by your wallet. +

+
+ +
+ {redeemBatch.discovery && ( +

+ 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." : ""} +

+ )} + {redeemBatch.discovery && redeemBatch.discovery.redeemables.length > 0 && ( +

+ 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)` + : ""} +

+ )} +
+
+ + + {redeemBatch.running ? ( + + ) : Object.values(redeemBatch.states).some((item) => item.s === "failed") ? ( + + ) : null} +
+
+ + {!wallet.isConnected &&

Connect a Stellar wallet to scan your positions.

} + {wallet.isWrongNetwork &&

Switch your wallet to Stellar Testnet to redeem positions.

} + {redeemBatch.error &&

{redeemBatch.error}

} + + {redeemBatch.discovery && redeemBatch.discovery.redeemables.length > 0 && ( +
    + {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 ( +
  • +
    +

    Market #{id} · {item.settlement.toUpperCase()} winner

    +

    + Estimated payout: {payout} USDC + {item.archived ? " · archived ledger entry" : ""} +

    + {itemState.s === "failed" &&

    {itemState.error}

    } + {itemState.s === "redeemed" &&

    Redeemed · {formatUsdc(itemState.payout, { maxDecimals: 2, grouping: true })} USDC

    } + {itemState.s === "signing" &&

    Waiting for wallet signature…

    } + {itemState.s === "submitting" &&

    Submitted to Stellar…

    } + {itemState.s === "skipped" &&

    Skipped

    } +
    + {!redeemBatch.running && itemState.s !== "redeemed" && itemState.s !== "skipped" && ( + + )} +
  • + ); + })} +
+ )} + {redeemBatch.discovery && redeemBatch.discovery.redeemables.length === 0 && ( +

No resolved positions are ready to redeem.

+ )} + {resumedItems.length > 0 && ( +
+

+ Saved batch progress +

+
    + {resumedItems.map(([id, item]) => ( +
  • + Market #{id} + + {item.s === "redeemed" + ? `Redeemed · ${formatUsdc(item.payout, { maxDecimals: 2, grouping: true })} USDC` + : item.s === "failed" + ? item.error + : "Skipped"} + +
  • + ))} +
+
+ )} +
+ {/* Detailed Positions Table */}
-

{status === 'OPEN' ? 'Open' : 'Resolved'} Positions

+

Agent {status === 'OPEN' ? 'Open' : 'Resolved'} Positions

+ {reviewModel && ( + closeReview(true)} + onCancel={() => closeReview(false)} + /> + )}
); } \ No newline at end of file diff --git a/app/premium/page.tsx b/app/premium/page.tsx index 9e13776..9c1242e 100644 --- a/app/premium/page.tsx +++ b/app/premium/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { useWallet } from "@/lib/contexts/WalletContext"; @@ -8,6 +8,9 @@ import { useUsdcTransfer } from "@/lib/hooks/useStellar"; import { useUnlockTrace } from "@/lib/hooks/useTraces"; import { PAYMENTS_RECIPIENT, explorerTx } from "@/lib/stellar/config"; import { formatUsdc, parseUsdc } from "@/lib/stellar/units"; +import { shouldSkipTxReview, TxReview, TxReviewPreferenceControl } from "@/components/tx-review"; +import { useSubmitQueueState } from "@/lib/stellar/submit-queue"; +import type { ReviewModel } from "@/lib/stellar/tx-decode"; /** * Price of the backend's daily pass (its DAILY_PASS_PRICE_RAW). The backend @@ -85,14 +88,43 @@ const features = [ ]; export default function PremiumPage() { - const { isConnected, isWrongNetwork, openModal, ensureSession } = useWallet(); + const { address, isConnected, isWrongNetwork, openModal, ensureSession } = useWallet(); const transfer = useUsdcTransfer(); const unlock = useUnlockTrace(); + const submitQueue = useSubmitQueueState(address); const [selectedTier, setSelectedTier] = useState(null); const [paymentMethod, setPaymentMethod] = useState<"usdc" | "card">("usdc"); const [isSubmitting, setIsSubmitting] = useState(false); const [statusMessage, setStatusMessage] = useState(null); + const [reviewModel, setReviewModel] = useState<{ model: ReviewModel; changedFrom?: ReviewModel } | null>(null); + const reviewResolver = useRef<((approved: boolean) => void) | null>(null); + const waitingForTransfer = + submitQueue.waiting.some((job) => job.label === "USDC transfer") || + (submitQueue.lockWaiting && submitQueue.running?.label === "USDC transfer"); + + const reviewPreparedTransfer = useCallback(async (_prepared: unknown, model: ReviewModel, changedFrom?: ReviewModel, forceReview = false) => { + if (!forceReview && shouldSkipTxReview()) return true; + return new Promise((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); + }, + [], + ); const openCheckout = (tier: Tier) => { if (!tier.priceRaw) return; @@ -124,8 +156,12 @@ export default function PremiumPage() { return; } - setStatusMessage(`Confirm the ${formatUsdc(selectedTier.priceRaw)} USDC transfer in your wallet…`); - const { txHash } = await transfer.mutateAsync({ to: PAYMENTS_RECIPIENT, amount: selectedTier.priceRaw }); + setStatusMessage(`Review the ${formatUsdc(selectedTier.priceRaw)} USDC transfer before signing…`); + const { txHash } = await transfer.mutateAsync({ + to: PAYMENTS_RECIPIENT, + amount: selectedTier.priceRaw, + review: reviewPreparedTransfer, + }); setStatusMessage("Payment sent. Waiting for OracleDesk to verify it on-chain…"); await unlock.mutateAsync({ @@ -377,7 +413,7 @@ export default function PremiumPage() { {statusMessage && (
- {statusMessage} + {waitingForTransfer ? "Waiting for your previous transaction to finish…" : statusMessage}
)} @@ -386,17 +422,28 @@ export default function PremiumPage() { onClick={handlePayment} disabled={isSubmitting} > - {isSubmitting ? "Processing..." : paymentMethod === 'usdc' ? "Pay with Wallet" : "Proceed to Payment"} + {waitingForTransfer ? "Waiting…" : isSubmitting ? "Processing..." : paymentMethod === 'usdc' ? "Pay with Wallet" : "Proceed to Payment"}

Verified on-chain by OracleDesk before access is granted

+
+ +
)} + {reviewModel && ( + closeReview(true)} + onCancel={() => closeReview(false)} + /> + )} ); } diff --git a/components/tx-review.tsx b/components/tx-review.tsx new file mode 100644 index 0000000..7f959e1 --- /dev/null +++ b/components/tx-review.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { ReviewModel } from "@/lib/stellar/tx-decode"; + +const SKIP_REVIEW_KEY = "oracledesk_skip_tx_review"; + +export function shouldSkipTxReview(): boolean { + try { + return window.localStorage.getItem(SKIP_REVIEW_KEY) === "1"; + } catch { + return false; + } +} + +export function setSkipTxReview(skip: boolean): void { + try { + if (skip) window.localStorage.setItem(SKIP_REVIEW_KEY, "1"); + else window.localStorage.removeItem(SKIP_REVIEW_KEY); + } catch { + // Storage may be disabled; the current review still works normally. + } +} + +export function TxReviewPreferenceControl() { + return ( + + ); +} + +interface TxReviewProps { + model: ReviewModel; + changedFrom?: ReviewModel; + confirming?: boolean; + onConfirm(): void; + onCancel(): void; +} + +function formatBound(value: string | null): string { + if (!value || value === "0") return "Not set"; + const seconds = Number(value); + return Number.isFinite(seconds) ? new Date(seconds * 1_000).toLocaleString() : value; +} + +function changed(previous: string | null | undefined, current: string | null | undefined): boolean { + return previous !== current; +} + +export function TxReview({ model, changedFrom, confirming = false, onConfirm, onCancel }: TxReviewProps) { + const dialogRef = useRef(null); + const headingRef = useRef(null); + const [skipReview, setSkipReview] = useState(false); + + useEffect(() => { + const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; + headingRef.current?.focus(); + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + onCancel(); + return; + } + if (event.key !== "Tab" || !dialogRef.current) return; + const focusable = Array.from( + dialogRef.current.querySelectorAll( + 'button:not([disabled]), input:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])', + ), + ); + if (!focusable.length) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + previousFocus?.focus(); + }; + }, [onCancel]); + + const changedLabels = new Set( + (changedFrom?.args ?? []) + .filter((oldArg) => model.args.find((arg) => arg.label === oldArg.label)?.raw !== oldArg.raw) + .map((arg) => arg.label), + ); + + const changedClass = (isChanged: boolean) => + isChanged ? "rounded bg-amber-100 px-1 text-amber-950 ring-1 ring-amber-400" : ""; + + return ( +
+
+
+
+

Transaction review

+

+ Check before signing +

+
+ +
+ +
+
+

What you're doing

+
+
+
Contract
+
+ {model.contractName} {model.contractId} +
+
+
+
Function
+
{model.fn}
+
+ {model.args.map((arg) => ( +
+
{arg.label}
+
+ {arg.display} + ({arg.type}) + {arg.derivedFrom && From quote: {arg.derivedFrom}} +
+
+ ))} +
+
+ +
+

Network details

+
+ + + + + + +
+
+ +
+

Transaction hash

+

{model.hash}

+

Before your wallet signs; your wallet may change the final hash.

+
+
+ +
+ +
+ + +
+
+
+
+ ); +} + +function Row({ label, value, changed: isChanged = false }: { label: string; value: string; changed?: boolean }) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} diff --git a/docs/architecture.md b/docs/architecture.md index e3c4e85..1a2524a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,9 +25,11 @@ A backend market links to the chain through `onChainMarketId` (market-core's `u6 - `lib/stellar/clients.ts` builds a generated binding `Client` per contract, optionally with the wallet as signer. The USDC Stellar Asset Contract has no Wasm and so no bindings; `token.balance` and `token.transfer` are built with `AssembledTransaction.build`. - Reads are simulations (no fee, no signature). -- Writes (`useBuy`, `useSell`, `useRedeem`, `useUsdcTransfer`) check that a wallet is connected and on the right network, simulate, then `signAndSend`. +- Writes (`useBuy`, `useSell`, `useRedeem`, `useUsdcTransfer`) check that a wallet is connected and on the right network, run fresh contract preflight checks, simulate, then `signAndSend`. A per-address queue serializes complete build/sign/send jobs in this tab and coordinates browser tabs with Web Locks (or a local-storage lease fallback); stale-sequence errors receive one fresh-build retry. The buy, sell, redeem, and USDC-transfer paths can decode the final simulated contract invocation and show its arguments and transaction envelope for review before the wallet prompt. - `useBuy` quotes again immediately before building the transaction and sets `min_shares_out = minOut(quote, slippageBps)`, so slippage is enforced on-chain. - `useSell` turns "sell N shares" into the exact `collateral_out` the contract needs, using the contract's own FPMM math (`generated/fpmm.ts`) on freshly read reserves, with `max_shares_in` slightly above N (see the contracts repo's `docs/frontend-integration.md`). +- `lib/stellar/preflight.ts` mirrors the contract's buy, sell, and redemption checks for immediate feedback. Missing or stale preflight data remains unknown; the contract simulation is still authoritative before signing. +- The portfolio's connected-wallet redemption scan constructs Soroban ledger keys for each market and position, reads them in 200-entry RPC batches (scanning newest markets first, up to 20,000), and finds resolved/voided positions without relying on backend portfolio records. Batch submissions run sequentially through the same account queue; each item is saved in account-specific `sessionStorage` so progress can be resumed, retried, skipped, or stopped after the current wallet transaction. Chain-backed market, position, and balance queries are invalidated once after the batch completes. ## Amounts diff --git a/docs/manual-test.md b/docs/manual-test.md index 49c53ea..ec3eb4e 100644 --- a/docs/manual-test.md +++ b/docs/manual-test.md @@ -101,3 +101,14 @@ To try the same on market 0 (resolved): the buy button is disabled and the panel - [ ] `/premium` → **Get a daily pass** → **Pay with wallet**. Freighter shows a USDC `transfer` to the treasury (`CBTFA3EP…`) for 0.50 USDC. After approval the backend verifies it on-chain and says the pass is active. Note: until `PAYMENTS_RECIPIENT` is decided, this payment becomes treasury trading capital (see docs/STATUS.md). - [ ] `/copy-trade?marketId=&traceId=`: the side comes from the trace's edge. With no trace or zero edge the confirm button is disabled and the page explains why. - [ ] `/admin` with an address not in `NEXT_PUBLIC_ADMIN_ADDRESSES` shows "Access Denied". + +## Transaction review, preflight, and connected-wallet redemption + +- [ ] Start a buy on a fresh open market. Before Freighter opens, OracleDesk shows the simulated contract/function, decoded arguments (including `market_id`, outcome, collateral and minimum shares), source account, fee, sequence, validity bounds, and pre-sign hash. Canceling the review makes no wallet request. +- [ ] Confirm the review and check the arguments match the quote. If the transaction is rebuilt after becoming stale or after a `txBadSeq` retry, confirm it again even when the skip preference is enabled; changed arguments are highlighted. Repeat on `/copy-trade` and for a premium USDC payment. +- [ ] Toggle **Don't show this review again**, then initiate a new transaction: the review is skipped. Reloading keeps the preference. Use **Always review transactions** on a transaction screen to clear it. +- [ ] Try a buy on a closed market: the action is rejected with the contract-linked preflight reason before a wallet prompt. Buy, sell, and redeem preflight edge cases are also covered by the focused automated unit tests; contract simulation remains authoritative. +- [ ] Open `/portfolio` with a connected Testnet wallet and select **Scan wallet**. The panel reports scanned markets and any discovered resolved YES/NO or voided positions with estimated payouts. It scans ledger keys directly and reports if the 20,000-market cap is reached. +- [ ] Select **Redeem all** when positions are found. Each position is prepared and reviewed separately, then signed in sequence; confirm each in Freighter. Verify successful items show their payout and the wallet balance/position reads refresh after the batch. +- [ ] Cause one redemption to fail while other items remain eligible. The batch continues, records the failure, and offers **Retry failed**. **Skip** omits an item; **Stop after current** does not interrupt an active wallet prompt but prevents the next redemption. +- [ ] Reload `/portfolio` mid-batch with the same account. Saved per-account progress is shown; scan again to refresh redeemable positions, then retry failures if appropriate. Switch accounts and verify the other wallet does not see the prior account's saved progress. diff --git a/lib/hooks/useStellar.ts b/lib/hooks/useStellar.ts index 574738f..77b8cfc 100644 --- a/lib/hooks/useStellar.ts +++ b/lib/hooks/useStellar.ts @@ -1,18 +1,34 @@ "use client"; +import { useEffect, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { rpc } from "@stellar/stellar-sdk"; import type { AssembledTransaction } from "@stellar/stellar-sdk/contract"; import { useWallet } from "@/lib/contexts/WalletContext"; import { marketCore, reasoningRegistry, token, Outcomes, type OutcomeTag, type Signer } from "@/lib/stellar/clients"; +import { CONTRACTS, RPC_URL } from "@/lib/stellar/config"; import { describeError, UserFacingError, type ContractName } from "@/lib/stellar/errors"; import { collateralOutForShares, priceYesBps } from "@/lib/stellar/generated/fpmm"; -import type { Market as OnChainMarket, Position } from "@/lib/stellar/generated/market-core"; +import type { Config, Market as OnChainMarket, Position } from "@/lib/stellar/generated/market-core"; import { fetchAndVerifyTrace, type TraceCheck } from "@/lib/stellar/trace"; import { maxIn, minOut } from "@/lib/stellar/units"; +import { preflightBuy, preflightRedeem, preflightSell, type Preflight, type PreflightFailure, type PreflightInput } from "@/lib/stellar/preflight"; +import { getSubmitQueue } from "@/lib/stellar/submit-queue"; +import { assertIntentMatches, describeAssembled, type ReviewModel } from "@/lib/stellar/tx-decode"; +import { + discoverRedeemable, + loadRedeemAllResume, + saveRedeemAllResume, + type ItemState, + type RedeemAllResume, + type RedeemDiscovery, +} from "@/lib/stellar/redeem-all"; /** Query keys all start with "stellar" so a disconnect can drop them in one go. */ export const stellarKeys = { marketCount: ["stellar", "marketCount"] as const, + config: ["stellar", "config"] as const, + ledgerNow: ["stellar", "ledgerNow"] as const, market: (id: string) => ["stellar", "market", id] as const, markets: (limit: number) => ["stellar", "markets", limit] as const, position: (id: string, holder: string) => ["stellar", "position", id, holder] as const, @@ -22,6 +38,53 @@ export const stellarKeys = { }; const toId = (id: bigint | string | number) => BigInt(id); +const rpcServer = new rpc.Server(RPC_URL, { allowHttp: RPC_URL.startsWith("http://") }); + +async function fetchLedgerNow(): Promise { + const latest = await rpcServer.getLatestLedger(); + const response = await rpcServer.getTransactions({ + startLedger: latest.sequence, + pagination: { limit: 1 }, + }); + return BigInt(response.latestLedgerCloseTimestamp); +} + +async function fetchMarketConfig(): Promise { + return simulated(await marketCore().get_config()); +} + +async function fetchUsdcBalanceIfAvailable(owner: string): Promise { + try { + return simulated(await token.balance(owner)); + } catch (error) { + if (describeError(error).kind === "no-trustline") return undefined; + throw error; + } +} + +function throwPreflight(preflight: Preflight): void { + if (preflight.ok !== false) return; + const first = preflight.failures[0] as PreflightFailure; + throw new UserFacingError({ + kind: first.code === "InsufficientBalance" ? "insufficient-balance" : "contract", + code: first.code, + message: first.message, + }); +} + +function queueId(): string { + return typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +function assertSignerCurrent(guard: () => Signer, expectedAddress: string): Signer { + const current = guard(); + if (current.publicKey !== expectedAddress) { + throw new UserFacingError({ kind: "disconnected", message: "Your wallet changed before this transaction could be signed." }); + } + return current; +} /** * The value of a simulated call. Contract `Result`s are unwrapped; failures @@ -68,6 +131,22 @@ export function useMarketCount() { }); } +export function useMarketConfig() { + return useQuery({ + queryKey: stellarKeys.config, + queryFn: fetchMarketConfig, + staleTime: 60_000, + }); +} + +export function useLedgerNow() { + return useQuery({ + queryKey: stellarKeys.ledgerNow, + queryFn: fetchLedgerNow, + staleTime: 5_000, + }); +} + export interface OnChainMarketView { id: string; market: OnChainMarket; @@ -214,6 +293,229 @@ export interface BuyParams { outcome: OutcomeTag; collateralIn: bigint; slippageBps: number; + confirmBoundsChanged?: (previous: bigint, next: bigint) => Promise; + review?: PreparedWriteReview; +} + +export interface PreparedWrite { + tx: AssembledTransaction; + kind: "buy" | "sell" | "redeem" | "usdc-transfer"; + contract: ContractName | "usdc"; + derived: Record; + intent: Record; + builtAt: number; + quote?: bigint; +} + +export type PreparedWriteReview = ( + prepared: PreparedWrite, + model: ReviewModel, + changedFrom?: ReviewModel, + forceReview?: boolean, +) => Promise; + +export async function prepareBuy(signer: Signer, params: Omit): Promise { + const id = toId(params.marketId); + const quote = simulated( + await marketCore().quote_buy({ market_id: id, outcome: Outcomes[params.outcome], collateral_in: params.collateralIn }), + ); + const minSharesOut = minOut(quote, params.slippageBps); + const tx = await marketCore(signer).buy({ + trader: signer.publicKey, + market_id: id, + outcome: Outcomes[params.outcome], + collateral_in: params.collateralIn, + min_shares_out: minSharesOut, + }); + simulated(tx); + return { + tx, + kind: "buy", + contract: "marketCore", + derived: { min_shares_out: { value: minSharesOut, from: `quote ${quote.toString()} shares, ${params.slippageBps / 100}% slippage` } }, + intent: { + trader: signer.publicKey, + market_id: id.toString(), + outcome: params.outcome, + collateral_in: params.collateralIn.toString(), + min_shares_out: minSharesOut.toString(), + }, + builtAt: Date.now(), + quote, + }; +} + +export async function prepareSell( + signer: Signer, + params: { marketId: bigint | string; outcome: OutcomeTag; collateralOut: bigint; maxSharesIn: bigint }, +): Promise { + const id = toId(params.marketId); + const tx = await marketCore(signer).sell({ + trader: signer.publicKey, + market_id: id, + outcome: Outcomes[params.outcome], + collateral_out: params.collateralOut, + max_shares_in: params.maxSharesIn, + }); + simulated(tx); + return { + tx, + kind: "sell", + contract: "marketCore", + derived: { max_shares_in: { value: params.maxSharesIn, from: "quote and slippage limit" } }, + intent: { + trader: signer.publicKey, + market_id: id.toString(), + outcome: params.outcome, + collateral_out: params.collateralOut.toString(), + max_shares_in: params.maxSharesIn.toString(), + }, + builtAt: Date.now(), + }; +} + +export async function prepareRedeem(signer: Signer, marketId: bigint | string): Promise { + const id = toId(marketId); + const tx = await marketCore(signer).redeem({ holder: signer.publicKey, market_id: id }); + simulated(tx); + return { + tx, + kind: "redeem", + contract: "marketCore", + derived: {}, + intent: { holder: signer.publicKey, market_id: id.toString() }, + builtAt: Date.now(), + }; +} + +export async function redeemOnce( + signer: Signer, + marketId: bigint | string, + onSubmitting?: () => void, + review?: PreparedWriteReview, + attempt: 0 | 1 = 0, +): Promise<{ payout: bigint; txHash: string | null; holder: string }> { + const id = toId(marketId); + const view = await fetchOnChainMarket(id); + const position = simulated(await marketCore().get_position({ market_id: id, holder: signer.publicKey })); + throwPreflight(preflightRedeem({ market: view.market, position })); + const initial = await prepareRedeem(signer, id); + const reviewed = await reviewPreparedWrite(initial, review, () => prepareRedeem(signer, id), undefined, attempt === 1); + const prepared = reviewed.prepared; + onSubmitting?.(); + const { value, txHash } = await submitPrepared(prepared); + return { payout: value, txHash, holder: signer.publicKey }; +} + +export async function prepareUsdcTransfer( + signer: Signer, + params: { to: string; amount: bigint }, +): Promise { + const tx = await token.transfer({ from: signer.publicKey, to: params.to, amount: params.amount }, signer); + simulated(tx, "marketCore"); + return { + tx, + kind: "usdc-transfer", + contract: "usdc", + derived: {}, + intent: { from: signer.publicKey, to: params.to, amount: params.amount.toString() }, + builtAt: Date.now(), + }; +} + +export async function submitPrepared( + prepared: PreparedWrite, +): Promise<{ value: T; txHash: string | null }> { + return send(prepared.tx, prepared.contract === "usdc" ? "marketCore" : prepared.contract); +} + +async function reviewPreparedWrite( + initial: PreparedWrite, + review: PreparedWriteReview | undefined, + rebuild: () => Promise, + changedFrom?: ReviewModel, + forceInitialReview = false, +): Promise<{ prepared: PreparedWrite; model?: ReviewModel }> { + if (!review) return { prepared: initial }; + + let current = initial; + let previousModel = changedFrom; + let forceReview = forceInitialReview || changedFrom !== undefined; + while (true) { + const model = describeAssembled(current.tx, current.contract === "usdc" ? "sac" : marketCore().spec, current.derived); + assertPreparedInvocation(current, model); + assertIntentMatches(model, current.intent); + if (!(await review(current, model, previousModel, forceReview))) { + throw new UserFacingError({ kind: "rejected", message: "You cancelled the transaction review." }); + } + if (Date.now() - current.builtAt <= 20_000) return { prepared: current, model }; + + const beforeRefresh = model; + const refreshed = await rebuild(); + const freshModel = describeAssembled( + refreshed.tx, + refreshed.contract === "usdc" ? "sac" : marketCore().spec, + refreshed.derived, + ); + assertPreparedInvocation(refreshed, freshModel); + assertIntentMatches(freshModel, refreshed.intent); + current = refreshed; + previousModel = beforeRefresh; + forceReview = true; + } +} + +function assertPreparedInvocation(prepared: PreparedWrite, model: ReviewModel): void { + const expectedContract = prepared.contract === "usdc" ? CONTRACTS.usdc : CONTRACTS[prepared.contract]; + const expectedFunction = { + buy: "buy", + sell: "sell", + redeem: "redeem", + "usdc-transfer": "transfer", + }[prepared.kind]; + if (model.contractId !== expectedContract || model.fn !== expectedFunction) { + throw new Error("The assembled transaction does not match the requested contract action."); + } +} + +function dedupeValue(...values: (string | number | bigint)[]): string { + return values.map(String).join(":"); +} + +function runQueuedWrite( + signer: Signer, + label: string, + dedupeKey: string, + guard: () => Signer, + run: (attempt: 0 | 1) => Promise, +): Promise { + return getSubmitQueue().enqueue({ + id: queueId(), + address: signer.publicKey, + label, + dedupeKey, + run: async ({ attempt, signal }) => { + if (signal.aborted) throw new DOMException("The queued transaction was cancelled.", "AbortError"); + assertSignerCurrent(guard, signer.publicKey); + return run(attempt); + }, + }); +} + +async function fetchTradePreflightInput(marketId: bigint, holder: string, includeBalance: boolean): Promise { + const [view, config, ledgerNow, position, usdcBalance] = await Promise.all([ + fetchOnChainMarket(marketId), + fetchMarketConfig(), + fetchLedgerNow(), + marketCore() + .get_position({ market_id: marketId, holder }) + .then((tx) => simulated(tx)) + .catch((error: unknown) => { + throw new UserFacingError(describeError(error), error); + }), + includeBalance ? fetchUsdcBalanceIfAvailable(holder) : Promise.resolve(undefined), + ]); + return { market: view.market, config, ledgerNow, position, usdcBalance }; } /** @@ -225,23 +527,47 @@ export function useBuy() { const guard = useWriteGuard(); const invalidate = useInvalidateAfterTrade(); return useMutation({ - mutationFn: async ({ marketId, outcome, collateralIn, slippageBps }: BuyParams) => { + mutationFn: async (params: BuyParams) => { const signer = guard(); - if (collateralIn <= 0n) throw new UserFacingError({ kind: "contract", message: "Enter an amount greater than zero." }); - const id = toId(marketId); - const quote = simulated( - await marketCore().quote_buy({ market_id: id, outcome: Outcomes[outcome], collateral_in: collateralIn }), - ); - const minSharesOut = minOut(quote, slippageBps); - const tx = await marketCore(signer).buy({ - trader: signer.publicKey, - market_id: id, - outcome: Outcomes[outcome], - collateral_in: collateralIn, - min_shares_out: minSharesOut, + const id = toId(params.marketId); + let firstMinSharesOut: bigint | undefined; + let previousReview: ReviewModel | undefined; + return runQueuedWrite(signer, `Buy ${params.outcome} on #${id}`, dedupeValue("buy", id, params.outcome, params.collateralIn, params.slippageBps), guard, async (attempt) => { + const preflightInput = await fetchTradePreflightInput(id, signer.publicKey, true); + throwPreflight(preflightBuy(preflightInput, params.outcome, params.collateralIn)); + let prepared = await prepareBuy(signer, params); + if (prepared.quote === undefined) throw new Error("The buy quote was not recorded with the prepared transaction."); + let quote = prepared.quote; + let minSharesOut = prepared.derived.min_shares_out.value; + if (attempt === 0) firstMinSharesOut = minSharesOut; + if ( + attempt === 1 && + firstMinSharesOut !== undefined && + minSharesOut < firstMinSharesOut && + (firstMinSharesOut - minSharesOut) * 10_000n > firstMinSharesOut + ) { + const accepted = params.review ? true : await params.confirmBoundsChanged?.(firstMinSharesOut, minSharesOut); + if (!accepted) { + throw new UserFacingError({ + kind: "rejected", + message: "The quote changed while the transaction was retried. Review the new minimum and try again.", + }); + } + } + const reviewed = await reviewPreparedWrite( + prepared, + params.review, + () => prepareBuy(signer, params), + previousReview, + attempt === 1, + ); + prepared = reviewed.prepared; + if (reviewed.model) previousReview = reviewed.model; + quote = prepared.quote ?? quote; + minSharesOut = prepared.derived.min_shares_out.value; + const { value, txHash } = await submitPrepared(prepared); + return { sharesOut: value, quotedShares: quote, minSharesOut, txHash, holder: signer.publicKey }; }); - const { value, txHash } = await send(tx); - return { sharesOut: value, quotedShares: quote, minSharesOut, txHash, holder: signer.publicKey }; }, onSuccess: (d, v) => invalidate(String(v.marketId), d.holder), }); @@ -253,6 +579,7 @@ export interface SellParams { /** Exact number of shares to sell. */ shares: bigint; slippageBps: number; + review?: PreparedWriteReview; } /** @@ -265,28 +592,36 @@ export function useSell() { const guard = useWriteGuard(); const invalidate = useInvalidateAfterTrade(); return useMutation({ - mutationFn: async ({ marketId, outcome, shares, slippageBps }: SellParams) => { + mutationFn: async ({ marketId, outcome, shares, slippageBps, review }: SellParams) => { const signer = guard(); const id = toId(marketId); - const { market } = await fetchOnChainMarket(id); - const [rSold, rOther] = outcome === "Yes" ? [market.reserve_yes, market.reserve_no] : [market.reserve_no, market.reserve_yes]; - let collateralOut: bigint; - try { - collateralOut = collateralOutForShares(rSold, rOther, shares, BigInt(market.fee_bps)); - } catch { - throw new UserFacingError({ kind: "contract", message: "There isn't enough liquidity to sell that many shares." }); - } - // +1 covers the off-chain estimate's rounding; slippage covers price moves. - const maxSharesIn = maxIn(shares, slippageBps) + 1n; - const tx = await marketCore(signer).sell({ - trader: signer.publicKey, - market_id: id, - outcome: Outcomes[outcome], - collateral_out: collateralOut, - max_shares_in: maxSharesIn, + let previousReview: ReviewModel | undefined; + return runQueuedWrite(signer, `Sell ${outcome} on #${id}`, dedupeValue("sell", id, outcome, shares, slippageBps), guard, async (attempt) => { + const prepareFresh = async () => { + const input = await fetchTradePreflightInput(id, signer.publicKey, false); + const market = input.market; + if (!market) { + throwPreflight(preflightSell(input, outcome, 0n, shares)); + throw new Error("Market preflight unexpectedly succeeded without a market."); + } + const [rSold, rOther] = + outcome === "Yes" + ? [market.reserve_yes, market.reserve_no] + : [market.reserve_no, market.reserve_yes]; + const collateralOut = collateralOutForShares(rSold, rOther, shares, BigInt(market.fee_bps)); + const maxSharesIn = maxIn(shares, slippageBps) + 1n; + throwPreflight(preflightSell(input, outcome, collateralOut, shares)); + return prepareSell(signer, { marketId: id, outcome, collateralOut, maxSharesIn }); + }; + const initial = await prepareFresh(); + const reviewed = await reviewPreparedWrite(initial, review, prepareFresh, previousReview, attempt === 1); + if (reviewed.model) previousReview = reviewed.model; + const prepared = reviewed.prepared; + const collateralOut = prepared.derived.collateral_out.value; + const maxSharesIn = prepared.derived.max_shares_in.value; + const { value, txHash } = await submitPrepared(prepared); + return { sharesSold: value, collateralOut, maxSharesIn, txHash, holder: signer.publicKey }; }); - const { value, txHash } = await send(tx); - return { sharesSold: value, collateralOut, maxSharesIn, txHash, holder: signer.publicKey }; }, onSuccess: (d, v) => invalidate(String(v.marketId), d.holder), }); @@ -296,28 +631,252 @@ export function useRedeem() { const guard = useWriteGuard(); const invalidate = useInvalidateAfterTrade(); return useMutation({ - mutationFn: async ({ marketId }: { marketId: bigint | string }) => { + mutationFn: async ({ marketId, review }: { marketId: bigint | string; review?: PreparedWriteReview }) => { const signer = guard(); - const tx = await marketCore(signer).redeem({ holder: signer.publicKey, market_id: toId(marketId) }); - const { value, txHash } = await send(tx); - return { payout: value, txHash, holder: signer.publicKey }; + const id = toId(marketId); + return runQueuedWrite(signer, `Redeem market #${id}`, dedupeValue("redeem", id), guard, async (attempt) => { + return redeemOnce(signer, id, undefined, review, attempt); + }); }, onSuccess: (d, v) => invalidate(String(v.marketId), d.holder), }); } +export interface RedeemAllController { + discovery: RedeemDiscovery | null; + states: Record; + scanning: boolean; + running: boolean; + error: string | null; + scan: () => Promise; + redeemAll: (review?: PreparedWriteReview) => Promise; + retryFailed: (review?: PreparedWriteReview) => Promise; + skip: (marketId: string) => void; + cancel: () => void; +} + +export function useRedeemAll(): RedeemAllController { + const { address } = useWallet(); + const guard = useWriteGuard(); + const queryClient = useQueryClient(); + const [discoveryState, setDiscovery] = useState(null); + const [discoveryHolder, setDiscoveryHolder] = useState(address); + const [states, setStates] = useState>({}); + const [statesHolder, setStatesHolder] = useState(address); + const [scanning, setScanning] = useState(false); + const [running, setRunning] = useState(false); + const [error, setError] = useState(null); + const statesRef = useRef(states); + const holderRef = useRef(address); + const runningRef = useRef(false); + const cancelRef = useRef(false); + const discovery = discoveryHolder === address ? discoveryState : null; + const visibleStates = statesHolder === address ? states : {}; + + useEffect(() => { + holderRef.current = address; + runningRef.current = false; + cancelRef.current = true; + let restored: Record = {}; + let restoreError: string | null = null; + try { + if (address) { + const resume = loadRedeemAllResume(address); + restored = Object.fromEntries( + Object.entries(resume?.states ?? {}).map(([id, state]) => [ + id, + state.s === "signing" + ? { s: "queued" as const } + : state.s === "submitting" + ? { + s: "failed" as const, + error: state.hash + ? `Transaction ${state.hash} was submitted before this tab closed. Verify it on Stellar before retrying.` + : "The tab closed while the transaction was submitting. Verify the ledger before retrying.", + } + : state, + ]), + ); + } + } catch (cause) { + restoreError = describeError(cause).message; + } + statesRef.current = restored; + const reset = window.setTimeout(() => { + setDiscovery(null); + setDiscoveryHolder(address); + setError(restoreError); + setRunning(false); + setScanning(false); + setStates(restored); + setStatesHolder(address); + }, 0); + return () => window.clearTimeout(reset); + }, [address]); + + const commitStates = (holder: string, next: Record) => { + if (holderRef.current !== holder) return; + statesRef.current = next; + setStates(next); + setStatesHolder(holder); + const resume: RedeemAllResume = { holder, ids: Object.keys(next), states: next }; + saveRedeemAllResume(resume); + }; + + const scan = async () => { + setScanning(true); + setError(null); + try { + const signer = guard(); + const holder = signer.publicKey; + const count = simulated(await marketCore().market_count()); + const result = await discoverRedeemable(holder, count); + if (holderRef.current !== holder) return; + const next = { ...statesRef.current }; + for (const item of result.redeemables) { + const id = item.marketId.toString(); + if (!next[id] || next[id].s === "failed") next[id] = { s: "queued" }; + } + setDiscovery(result); + setDiscoveryHolder(holder); + commitStates(holder, next); + } catch (cause) { + setError(describeError(cause).message); + } finally { + setScanning(false); + } + }; + + const processItems = async (ids: string[], review?: PreparedWriteReview) => { + if (runningRef.current) return; + let signer: Signer; + try { + signer = guard(); + if (holderRef.current !== signer.publicKey) { + throw new UserFacingError({ kind: "disconnected", message: "Wait for the connected wallet to finish switching before redeeming." }); + } + } catch (cause) { + setError(describeError(cause).message); + return; + } + const holder = signer.publicKey; + runningRef.current = true; + cancelRef.current = false; + setRunning(true); + setError(null); + const redeemedIds: string[] = []; + try { + for (const id of ids) { + if (cancelRef.current || holderRef.current !== holder) break; + const previous = statesRef.current[id]; + if (previous?.s === "redeemed" || previous?.s === "skipped") continue; + const marketId = BigInt(id); + try { + const result = await runQueuedWrite( + signer, + `Redeem market #${id}`, + dedupeValue("redeem", id), + guard, + async (attempt) => + redeemOnce( + signer, + marketId, + () => commitStates(holder, { ...statesRef.current, [id]: { s: "signing" } }), + review, + attempt, + ), + ); + commitStates(holder, { ...statesRef.current, [id]: { s: "submitting", hash: result.txHash ?? undefined } }); + commitStates(holder, { + ...statesRef.current, + [id]: { s: "redeemed", payout: result.payout, hash: result.txHash }, + }); + redeemedIds.push(id); + } catch (cause) { + if (cancelRef.current && cause instanceof DOMException && cause.name === "AbortError") break; + const failure = cause instanceof UserFacingError ? cause.described : describeError(cause); + if (failure.kind === "rejected") { + commitStates(holder, { ...statesRef.current, [id]: { s: "queued" } }); + setError(failure.message); + cancelRef.current = true; + break; + } + commitStates(holder, { ...statesRef.current, [id]: { s: "failed", error: failure.message } }); + } + } + if (holderRef.current === holder && redeemedIds.length) { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["stellar", "markets"] }), + queryClient.invalidateQueries({ queryKey: stellarKeys.balance(holder) }), + ...redeemedIds.flatMap((id) => [ + queryClient.invalidateQueries({ queryKey: stellarKeys.market(id) }), + queryClient.invalidateQueries({ queryKey: stellarKeys.position(id, holder) }), + ]), + ]); + } + } catch (cause) { + setError(describeError(cause).message); + } finally { + runningRef.current = false; + if (holderRef.current === holder) setRunning(false); + } + }; + + const redeemAll = async (review?: PreparedWriteReview) => { + const ids = (discovery?.redeemables ?? []).map((item) => item.marketId.toString()); + await processItems(ids, review); + }; + + const retryFailed = async (review?: PreparedWriteReview) => { + const ids = Object.entries(statesRef.current) + .filter(([, item]) => item.s === "failed") + .map(([id]) => id); + await processItems(ids, review); + }; + + const skip = (marketId: string) => { + const holder = address; + if (!holder || holderRef.current !== holder || runningRef.current) return; + commitStates(holder, { ...statesRef.current, [marketId]: { s: "skipped" } }); + }; + + const cancel = () => { + cancelRef.current = true; + }; + + return { discovery, states: visibleStates, scanning, running, error, scan, redeemAll, retryFailed, skip, cancel }; +} + /** SEP-41 transfer on the USDC SAC, signed by the connected wallet. */ +export interface UsdcTransferParams { + to: string; + amount: bigint; + review?: PreparedWriteReview; +} + export function useUsdcTransfer() { const guard = useWriteGuard(); const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ to, amount }: { to: string; amount: bigint }) => { + mutationFn: async ({ to, amount, review }: UsdcTransferParams) => { const signer = guard(); if (amount <= 0n) throw new UserFacingError({ kind: "contract", message: "Enter an amount greater than zero." }); - const tx = await token.transfer({ from: signer.publicKey, to, amount }, signer); - const { txHash } = await send(tx); - if (!txHash) throw new UserFacingError({ kind: "unknown", message: "The payment was sent but no transaction hash came back." }); - return { txHash, holder: signer.publicKey }; + let previousReview: ReviewModel | undefined; + return runQueuedWrite(signer, "USDC transfer", dedupeValue("usdc-transfer", to, amount), guard, async (attempt) => { + let prepared = await prepareUsdcTransfer(signer, { to, amount }); + const reviewed = await reviewPreparedWrite( + prepared, + review, + () => prepareUsdcTransfer(signer, { to, amount }), + previousReview, + attempt === 1, + ); + prepared = reviewed.prepared; + if (reviewed.model) previousReview = reviewed.model; + const { txHash } = await submitPrepared(prepared); + if (!txHash) throw new UserFacingError({ kind: "unknown", message: "The payment was sent but no transaction hash came back." }); + return { txHash, holder: signer.publicKey }; + }); }, onSuccess: (d) => { queryClient.invalidateQueries({ queryKey: stellarKeys.balance(d.holder) }); diff --git a/lib/stellar/__tests__/errors.test.ts b/lib/stellar/__tests__/errors.test.ts index c2c333e..7f55f30 100644 --- a/lib/stellar/__tests__/errors.test.ts +++ b/lib/stellar/__tests__/errors.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { describeError } from "../errors"; +import { classifySequenceError, describeError } from "../errors"; describe("describeError", () => { it("maps market-core contract errors by code", () => { @@ -22,6 +22,19 @@ describe("describeError", () => { expect(describeError(new Error("User declined access")).kind).toBe("rejected"); }); + it("classifies txBadSeq from structured RPC results and SDK error text", () => { + const structured = { + sendTransactionResponse: { + errorResult: { + result: () => ({ switch: () => ({ name: "txBadSeq" }) }), + }, + }, + }; + expect(classifySequenceError(structured)).toBe(true); + expect(describeError(structured)).toMatchObject({ kind: "stale-sequence", code: "txBadSeq" }); + expect(describeError("Transaction failed: txBadSeq")).toMatchObject({ kind: "stale-sequence" }); + }); + it("recognises balance and trustline failures before contract codes", () => { // A SAC failure inside market_core.buy carries the token's own code. expect(describeError("Error(Contract, #10) ... balance is not sufficient to spend").kind).toBe("insufficient-balance"); diff --git a/lib/stellar/__tests__/preflight.test.ts b/lib/stellar/__tests__/preflight.test.ts new file mode 100644 index 0000000..8a5f6e5 --- /dev/null +++ b/lib/stellar/__tests__/preflight.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import type { Market, Position } from "../generated/market-core"; +import { MAX_SETS } from "../generated/fpmm"; +import { FRIENDLY, describeError } from "../errors"; +import { preflightBuy, preflightRedeem, preflightSell, type PreflightInput } from "../preflight"; + +const market = (overrides: Partial = {}): Market => + ({ + category: { tag: "Crypto", values: undefined }, + close_time: 100n, + creator: "GCREATOR", + fee_bps: 0, + fees_accrued: 0n, + lp_claimed: false, + meta_uri: "", + question_hash: Buffer.alloc(32), + reserve_no: 1_000_000n, + reserve_yes: 1_000_000n, + resolution_hash: Buffer.alloc(32), + sets_minted: 0n, + status: { tag: "Open", values: undefined }, + ...overrides, + }) as Market; + +const position = (yes = 0n, no = 0n): Position => ({ yes, no }); +const validTrade = (): PreflightInput => ({ + config: { paused: false }, + market: market(), + ledgerNow: 99n, + position: position(100n, 100n), + usdcBalance: 1_000_000n, +}); + +describe("preflightBuy", () => { + it("reports trading pause, closed markets, and close-time equality", () => { + expect(preflightBuy({ ...validTrade(), config: { paused: true } }, "Yes", 1n)).toMatchObject({ + ok: false, + failures: [{ code: "Paused", contractErrorNumber: 2 }], + }); + expect(preflightBuy({ ...validTrade(), market: market({ status: { tag: "Void", values: undefined } }) }, "Yes", 1n)) + .toMatchObject({ ok: false, failures: [{ code: "MarketNotOpen", contractErrorNumber: 8 }] }); + expect(preflightBuy({ ...validTrade(), ledgerNow: 100n }, "Yes", 1n)).toMatchObject({ + ok: false, + failures: [{ code: "MarketClosed", contractErrorNumber: 9 }], + }); + expect(preflightBuy({ ...validTrade(), ledgerNow: 99n }, "Yes", 1n).ok).toBe(true); + }); + + it("uses strict market-size boundary semantics and avoids rejecting stale near-boundary data", () => { + const equality = market({ sets_minted: MAX_SETS - 100n }); + expect(preflightBuy({ ...validTrade(), market: equality }, "Yes", 100n).ok).toBe(true); + expect(preflightBuy({ ...validTrade(), market: equality }, "Yes", 101n)).toMatchObject({ + ok: false, + failures: [{ code: "MarketTooLarge", contractErrorNumber: 15 }], + }); + expect(preflightBuy({ ...validTrade(), market: equality, marketIsStale: true }, "Yes", 101n)) + .toMatchObject({ ok: "unknown", missing: ["fresh market"] }); + expect( + preflightBuy( + { ...validTrade(), market: market({ status: { tag: "Void", values: undefined } }), marketIsStale: true }, + "Yes", + 1n, + ).ok, + ).toBe("unknown"); + }); + + it("rejects invalid amount and insufficient known balance, but allows unknown balance", () => { + expect(preflightBuy(validTrade(), "Yes", 0n)).toMatchObject({ + ok: false, + failures: [{ code: "InvalidAmount", contractErrorNumber: 4 }], + }); + expect(preflightBuy({ ...validTrade(), usdcBalance: 10n }, "Yes", 11n)).toMatchObject({ + ok: false, + failures: [{ code: "InsufficientBalance" }], + }); + expect(preflightBuy({ ...validTrade(), usdcBalance: undefined }, "Yes", 11n).ok).toBe("unknown"); + expect(preflightBuy({ ...validTrade(), market: undefined }, "Yes", 1n).ok).toBe("unknown"); + }); +}); + +describe("preflightSell", () => { + it("matches liquidity and holdings boundaries", () => { + expect(preflightSell(validTrade(), "Yes", 1_000_000n, 1n)).toMatchObject({ + ok: false, + failures: [{ code: "InsufficientLiquidity", contractErrorNumber: 14 }], + }); + expect(preflightSell(validTrade(), "Yes", 10n, 100n).ok).toBe(true); + expect(preflightSell(validTrade(), "Yes", 10n, 101n)).toMatchObject({ + ok: false, + failures: [{ code: "InsufficientShares", contractErrorNumber: 13 }], + }); + expect(preflightSell({ ...validTrade(), positionIsStale: true }, "Yes", 10n, 101n).ok).toBe("unknown"); + expect(preflightSell({ ...validTrade(), marketIsStale: true }, "Yes", 10n, 101n).ok).toBe(false); + }); +}); + +describe("preflightRedeem", () => { + it("does not apply the trading pause to redemptions", () => { + expect( + preflightRedeem({ + config: { paused: true }, + market: market({ status: { tag: "Resolved", values: [{ tag: "Yes", values: undefined }] } }), + position: position(1n), + }).ok, + ).toBe(true); + }); + + it("requires a final market and a non-zero position", () => { + expect(preflightRedeem({ market: market(), position: position(1n) })).toMatchObject({ + ok: false, + failures: [{ code: "NotFinal", contractErrorNumber: 11 }], + }); + expect( + preflightRedeem({ + market: market({ status: { tag: "Void", values: undefined } }), + position: position(), + }), + ).toMatchObject({ ok: false, failures: [{ code: "NothingToRedeem", contractErrorNumber: 16 }] }); + expect( + preflightRedeem({ + market: market(), + marketIsStale: true, + position: position(1n), + }).ok, + ).toBe("unknown"); + expect( + preflightRedeem({ + market: market({ status: { tag: "Void", values: undefined } }), + position: position(), + positionIsStale: true, + }).ok, + ).toBe("unknown"); + expect(preflightRedeem({}).ok).toBe("unknown"); + }); +}); + +it("shares friendly text with simulation errors", () => { + for (const [number, code] of [[2, "Paused"], [9, "MarketClosed"], [15, "MarketTooLarge"], [16, "NothingToRedeem"]] as const) { + expect(describeError(`Error(Contract, #${number})`).message).toBe(FRIENDLY[code]); + } + expect(preflightBuy(validTrade(), "Yes", 0n)).toMatchObject({ + ok: false, + failures: [{ code: "InvalidAmount", message: FRIENDLY.InvalidAmount }], + }); +}); diff --git a/lib/stellar/__tests__/redeem-all.test.ts b/lib/stellar/__tests__/redeem-all.test.ts new file mode 100644 index 0000000..8f9cbcd --- /dev/null +++ b/lib/stellar/__tests__/redeem-all.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Keypair } from "@stellar/stellar-sdk"; + +const decodeValue = vi.fn(); +vi.mock("../clients", () => ({ + marketCore: () => ({ spec: { scValToNative: decodeValue } }), +})); + +import { + discoverRedeemable, + loadRedeemAllResume, + marketLedgerKey, + positionLedgerKey, + REDEEM_ALL_STORAGE_KEY, + saveRedeemAllResume, + type ItemState, +} from "../redeem-all"; + +interface FakeEntry { + key: { toXDR(format: "base64"): string }; + val: { contractData(): { val(): { id: bigint; kind: "Position" | "Market" } } }; + liveUntilLedgerSeq?: number; +} + +function fakeEntry(key: FakeEntry["key"], kind: "Position" | "Market", id: bigint): FakeEntry { + return { + key, + val: { contractData: () => ({ val: () => ({ kind, id }) }) }, + } as FakeEntry; +} + +describe("redeem-all discovery", () => { + beforeEach(() => { + decodeValue.mockImplementation((value: { id: bigint; kind: string }) => + value.kind === "Position" + ? { yes: 1_000_000n, no: 500_000n } + : { + close_time: 0n, + sets_minted: 1n, + reserve_yes: 1n, + reserve_no: 1n, + status: + value.id % 3n === 0n + ? ["Resolved", [value.id % 2n ? "Yes" : "No"]] + : { tag: "Resolved", values: [{ tag: value.id % 2n ? "Yes" : "No", values: undefined }] }, + }, + ); + }); + + afterEach(() => vi.clearAllMocks()); + + it("creates distinct persistent Soroban keys for markets and account positions", () => { + const holder = Keypair.random().publicKey(); + const anotherHolder = Keypair.random().publicKey(); + expect(marketLedgerKey(3n).toXDR("base64")).not.toBe(marketLedgerKey(4n).toXDR("base64")); + expect(positionLedgerKey(3n, holder).toXDR("base64")).not.toBe( + positionLedgerKey(3n, anotherHolder).toXDR("base64"), + ); + expect(positionLedgerKey(3n, holder).toXDR("base64")).not.toBe(marketLedgerKey(3n).toXDR("base64")); + }); + + it("reads every ledger chunk and accumulates redeemables beyond the first chunk", async () => { + const holder = Keypair.random().publicKey(); + const count = 202n; + const keyToMarket = new Map(); + for (let id = 0n; id < count; id++) { + keyToMarket.set(positionLedgerKey(id, holder).toXDR("base64"), id); + keyToMarket.set(marketLedgerKey(id).toXDR("base64"), id); + } + let requestCount = 0; + const server = { + getLedgerEntries: vi.fn(async (...keys: FakeEntry["key"][]) => { + const isPositionRead = requestCount < 2; + requestCount += 1; + const entries = keys.map((key) => { + const id = keyToMarket.get(key.toXDR("base64")); + if (id === undefined) return null; + return fakeEntry(key, isPositionRead ? "Position" : "Market", id); + }).filter((entry): entry is FakeEntry => entry !== null); + return { entries, latestLedger: 600 }; + }), + }; + + const result = await discoverRedeemable(holder, count, server as never); + + expect(server.getLedgerEntries).toHaveBeenCalledTimes(4); + expect(server.getLedgerEntries.mock.calls.map(([...keys]) => keys.length)).toEqual([200, 2, 200, 2]); + expect(result.scannedMarkets).toBe(count); + expect(result.redeemables).toHaveLength(Number(count)); + expect(result.redeemables[0]).toMatchObject({ marketId: 0n, settlement: "no", expectedPayout: 500_000n }); + expect(result.redeemables[201]).toMatchObject({ marketId: 201n, settlement: "yes", expectedPayout: 1_000_000n }); + expect(result.scanLimited).toBe(false); + }); + + it("limits huge scans to the newest market IDs", async () => { + const server = { getLedgerEntries: vi.fn(async () => ({ entries: [], latestLedger: 1 })) }; + const result = await discoverRedeemable(Keypair.random().publicKey(), 50_000n, server as never); + expect(result.scannedMarkets).toBe(20_000n); + expect(result.totalMarkets).toBe(50_000n); + expect(result.scanLimited).toBe(true); + expect(server.getLedgerEntries).toHaveBeenCalledTimes(100); + }); +}); + +describe("redeem-all session resume", () => { + const data = new Map(); + const storage = { + getItem: (key: string) => data.get(key) ?? null, + setItem: (key: string, value: string) => data.set(key, value), + removeItem: (key: string) => void data.delete(key), + clear: () => data.clear(), + key: (index: number) => [...data.keys()][index] ?? null, + get length() { + return data.size; + }, + }; + + beforeEach(() => { + data.clear(); + vi.stubGlobal("sessionStorage", storage); + }); + + afterEach(() => vi.unstubAllGlobals()); + + it("stores and restores progress per connected wallet, including bigint payouts", () => { + const holder = Keypair.random().publicKey(); + const states: Record = { + "7": { s: "redeemed", payout: 1_250_000n, hash: "abc123" }, + "8": { s: "failed", error: "RPC unavailable" }, + }; + saveRedeemAllResume({ holder, ids: Object.keys(states), states }); + expect(loadRedeemAllResume(holder)).toEqual({ holder, ids: ["7", "8"], states }); + expect(loadRedeemAllResume(Keypair.random().publicKey())).toBeNull(); + expect(data.has(`${REDEEM_ALL_STORAGE_KEY}${holder}`)).toBe(true); + }); + + it("surfaces malformed saved progress instead of silently treating it as empty", () => { + const holder = Keypair.random().publicKey(); + data.set(`${REDEEM_ALL_STORAGE_KEY}${holder}`, "{"); + expect(() => loadRedeemAllResume(holder)).toThrow("Could not restore redemption progress"); + }); +}); diff --git a/lib/stellar/__tests__/submit-queue.test.ts b/lib/stellar/__tests__/submit-queue.test.ts new file mode 100644 index 0000000..4ac0ddf --- /dev/null +++ b/lib/stellar/__tests__/submit-queue.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from "vitest"; +import { SubmitQueue } from "../submit-queue"; + +const job = (id: string, address: string, run: (attempt: 0 | 1) => Promise) => ({ + id, + address, + label: id, + run: ({ attempt }: { attempt: 0 | 1 }) => run(attempt), +}); + +describe("SubmitQueue", () => { + it("runs jobs for the same address sequentially", async () => { + const queue = new SubmitQueue(); + const order: string[] = []; + let release!: () => void; + const first = queue.enqueue(job("first", "G1", async () => { + order.push("first-start"); + await new Promise((resolve) => (release = resolve)); + order.push("first-end"); + return "1"; + })); + const second = queue.enqueue(job("second", "G1", async () => { + order.push("second-start"); + return "2"; + })); + + expect(queue.getState("G1").waiting).toEqual([{ id: "second", label: "second" }]); + await vi.waitFor(() => expect(order).toEqual(["first-start"])); + release(); + await expect(Promise.all([first, second])).resolves.toEqual(["1", "2"]); + expect(order).toEqual(["first-start", "first-end", "second-start"]); + }); + + it("runs different accounts concurrently", async () => { + const queue = new SubmitQueue(); + let release!: () => void; + const first = queue.enqueue(job("first", "G1", async () => { + await new Promise((resolve) => (release = resolve)); + return "1"; + })); + const second = queue.enqueue(job("second", "G2", async () => "2")); + await expect(second).resolves.toBe("2"); + release(); + await expect(first).resolves.toBe("1"); + }); + + it("retries txBadSeq exactly once with attempt 1", async () => { + const queue = new SubmitQueue(); + const attempts: number[] = []; + const run = vi.fn(async (attempt: 0 | 1) => { + attempts.push(attempt); + if (attempt === 0) throw new Error("txBadSeq"); + return "sent"; + }); + await expect(queue.enqueue(job("buy", "G1", run))).resolves.toBe("sent"); + expect(attempts).toEqual([0, 1]); + }); + + it("does not retry a second stale sequence failure", async () => { + const queue = new SubmitQueue(); + const run = vi.fn(async () => { + throw new Error("txBadSeq"); + }); + await expect(queue.enqueue(job("buy", "G1", run))).rejects.toThrow("txBadSeq"); + expect(run).toHaveBeenCalledTimes(2); + }); + + it("deduplicates identical submissions within one second", async () => { + const queue = new SubmitQueue(); + const run = vi.fn(async () => "sent"); + const first = queue.enqueue({ ...job("first", "G1", run), dedupeKey: "buy:4:Yes:10" }); + const second = queue.enqueue({ ...job("second", "G1", run), dedupeKey: "buy:4:Yes:10" }); + await expect(Promise.all([first, second])).resolves.toEqual(["sent", "sent"]); + expect(run).toHaveBeenCalledTimes(1); + }); + + it("cancels waiting jobs but leaves a running job alone", async () => { + const queue = new SubmitQueue(); + let release!: () => void; + const running = queue.enqueue(job("running", "G1", async () => { + await new Promise((resolve) => (release = resolve)); + return "done"; + })); + const waiting = queue.enqueue(job("waiting", "G1", async () => "not run")); + await vi.waitFor(() => expect(queue.getState("G1").running?.id).toBe("running")); + expect(queue.cancel("running")).toBe(false); + expect(queue.cancel("waiting")).toBe(true); + await expect(waiting).rejects.toThrow(/cancelled/i); + release(); + await expect(running).resolves.toBe("done"); + }); +}); diff --git a/lib/stellar/__tests__/tx-decode.test.ts b/lib/stellar/__tests__/tx-decode.test.ts new file mode 100644 index 0000000..eac072c --- /dev/null +++ b/lib/stellar/__tests__/tx-decode.test.ts @@ -0,0 +1,116 @@ +import { Account, Address, Keypair, nativeToScVal, Operation, TransactionBuilder, xdr } from "@stellar/stellar-sdk"; +import type { AssembledTransaction } from "@stellar/stellar-sdk/contract"; +import { describe, expect, it } from "vitest"; +import { marketCore } from "../clients"; +import { CONTRACTS, NETWORK_PASSPHRASE } from "../config"; +import { formatUsdc, USDC_UNIT } from "../units"; +import { assertIntentMatches, describeAssembled } from "../tx-decode"; + +const spec = marketCore().spec; +const trader = Keypair.random().publicKey(); + +function assembled(functionName: string, contractId: string, args: xdr.ScVal[]): AssembledTransaction { + const func = xdr.HostFunction.hostFunctionTypeInvokeContract( + new xdr.InvokeContractArgs({ + contractAddress: new Address(contractId).toScAddress(), + functionName, + args, + }), + ); + const built = new TransactionBuilder(new Account(trader, "17"), { + fee: "100", + networkPassphrase: NETWORK_PASSPHRASE, + }) + .addOperation(Operation.invokeHostFunction({ func, auth: [] })) + .setTimeout(300) + .build(); + return { built } as AssembledTransaction; +} + +function buyTransaction(collateralIn: bigint, minSharesOut: bigint) { + return assembled( + "buy", + CONTRACTS.marketCore, + spec.funcArgsToScVals("buy", { + trader, + market_id: 42n, + outcome: { tag: "Yes", values: undefined }, + collateral_in: collateralIn, + min_shares_out: minSharesOut, + }), + ); +} + +describe("describeAssembled", () => { + it("decodes buy arguments using spec names and formats enum, amounts, and derived bounds", () => { + const collateralIn = 12_345_678_901_234_567n; + const minSharesOut = 9_876_543_210_987_654n; + const model = describeAssembled(buyTransaction(collateralIn, minSharesOut), spec, { + min_shares_out: { value: minSharesOut, from: "quote 10 shares, 1% slippage" }, + }); + + expect(model.fn).toBe("buy"); + expect(model.source).toBe(trader); + expect(model.sequence).toBe("18"); + expect(model.fee).toBe(`${formatUsdc(100n, { minDecimals: 7, maxDecimals: 7 })} XLM`); + expect(model.hash).toMatch(/^[0-9a-f]{64}$/); + expect(model.args).toMatchObject([ + { label: "Source account", type: "Address", raw: trader }, + { label: "Market id", type: "u64", raw: "42", display: "#42" }, + { label: "Outcome", type: "Outcome", raw: "Yes", display: "Yes" }, + { label: "Collateral in (USDC)", type: "i128", raw: collateralIn.toString(), display: `${formatUsdc(collateralIn, { minDecimals: 7, maxDecimals: 7 })} USDC` }, + { label: "Minimum shares out", type: "i128", raw: minSharesOut.toString(), derivedFrom: "quote 10 shares, 1% slippage" }, + ]); + expect(model.args[0].display).toContain(trader); + expect(model.validUntil).not.toBeNull(); + }); + + it("decodes the SEP-41 SAC transfer signature without converting i128 amounts to Number", () => { + const recipient = Keypair.random().publicKey(); + const amount = 9_007_199_254_740_993n * USDC_UNIT; + const tx = assembled("transfer", CONTRACTS.usdc, [ + new Address(trader).toScVal(), + new Address(recipient).toScVal(), + nativeToScVal(amount, { type: "i128" }), + ]); + const model = describeAssembled(tx, "sac"); + + expect(model.contractName).toBe("USDC token (Stellar Asset Contract)"); + expect(model.args).toMatchObject([ + { label: "From", raw: trader, type: "Address" }, + { label: "To", raw: recipient, type: "Address" }, + { label: "Amount (USDC)", raw: amount.toString(), display: `${formatUsdc(amount, { minDecimals: 7, maxDecimals: 7 })} USDC` }, + ]); + }); + + it("rejects mismatches between the decoded envelope and requested intent", () => { + const model = describeAssembled(buyTransaction(10n * USDC_UNIT, 5n * USDC_UNIT), spec); + expect(() => + assertIntentMatches(model, { + trader, + market_id: "42", + outcome: "Yes", + collateral_in: (11n * USDC_UNIT).toString(), + min_shares_out: (5n * USDC_UNIT).toString(), + }), + ).toThrow(/collateral_in.*does not match/i); + expect(() => + assertIntentMatches(model, { + trader, + market_id: "42", + outcome: "Yes", + collateral_in: (10n * USDC_UNIT).toString(), + min_shares_out: (5n * USDC_UNIT).toString(), + }), + ).not.toThrow(); + expect(() => + assertIntentMatches({ ...model, source: Keypair.random().publicKey() }, { + trader, + market_id: "42", + outcome: "Yes", + collateral_in: (10n * USDC_UNIT).toString(), + min_shares_out: (5n * USDC_UNIT).toString(), + }), + ).toThrow(/source account does not match/i); + }); +}); diff --git a/lib/stellar/errors.ts b/lib/stellar/errors.ts index 8aa78cb..10cab1f 100644 --- a/lib/stellar/errors.ts +++ b/lib/stellar/errors.ts @@ -13,7 +13,7 @@ const TABLES: Record> = { }; /** Plain-language messages for contract error names, from the user's point of view. */ -const FRIENDLY: Record = { +export const FRIENDLY: Record = { // market-core Paused: "Trading is paused right now. Try again later.", InvalidAmount: "Enter an amount greater than zero.", @@ -33,7 +33,7 @@ const FRIENDLY: Record = { TradeCapExceeded: "This trade is above the treasury's per-trade limit.", MarketCapExceeded: "The treasury has reached its limit for this market.", DailyCapExceeded: "The treasury has reached its daily limit.", - InsufficientBalance: "The treasury doesn't have enough funds for this.", + InsufficientBalance: "You don't have enough USDC for this.", // resolver MarketNotConfigured: "This market's resolution rule hasn't been revealed yet.", SpecHashMismatch: "That resolution rule doesn't match what this market committed to.", @@ -44,7 +44,16 @@ const FRIENDLY: Record = { }; export interface DescribedError { - kind: "rejected" | "insufficient-balance" | "no-trustline" | "contract" | "wrong-network" | "disconnected" | "network" | "unknown"; + kind: + | "rejected" + | "insufficient-balance" + | "no-trustline" + | "contract" + | "wrong-network" + | "disconnected" + | "network" + | "stale-sequence" + | "unknown"; message: string; /** Contract error name, e.g. "SlippageExceeded", when known. */ code?: string; @@ -74,6 +83,13 @@ function safeJson(v: unknown): string { export function describeError(err: unknown, contract: ContractName = "marketCore"): DescribedError { const text = textOf(err); + if (classifySequenceError(err)) { + return { + kind: "stale-sequence", + code: "txBadSeq", + message: "Your account sequence changed while this transaction was being prepared. Please try again.", + }; + } if (/user (rejected|declined|denied)|rejected by (the )?user|request (was )?rejected|declined|cancell?ed by user|user closed/i.test(text)) { return { kind: "rejected", message: "You cancelled the request in your wallet." }; } @@ -106,6 +122,28 @@ export function describeError(err: unknown, contract: ContractName = "marketCore return { kind: "unknown", message: "Something went wrong. Please try again." }; } +/** True when a submission failed because the source account sequence is stale. */ +export function classifySequenceError(err: unknown): boolean { + if (err instanceof UserFacingError && err.described.code === "txBadSeq") return true; + if (typeof err === "object" && err !== null) { + const root = err as Record; + const response = root.sendTransactionResponse; + const errorResult = + response && typeof response === "object" + ? (response as Record).errorResult + : root.errorResult; + if (errorResult && typeof errorResult === "object") { + const result = errorResult as { result?: () => { switch?: () => { name?: string } } }; + try { + if (result.result?.().switch?.().name === "txBadSeq") return true; + } catch { + // Fall through to SDK error text when the response is not an XDR result. + } + } + } + return /txBadSeq|tx_bad_seq|bad sequence number|sequence number is too low/i.test(textOf(err)); +} + /** Thrown by write hooks so callers get a readable message in `error.message`. */ export class UserFacingError extends Error { constructor(public readonly described: DescribedError, public readonly original?: unknown) { diff --git a/lib/stellar/preflight.ts b/lib/stellar/preflight.ts new file mode 100644 index 0000000..4b78052 --- /dev/null +++ b/lib/stellar/preflight.ts @@ -0,0 +1,166 @@ +import type { Market, Position } from "./generated/market-core"; +import { Errors } from "./generated/market-core"; +import { calcBuy, calcSell, MAX_SETS } from "./generated/fpmm"; +import { FRIENDLY } from "./errors"; +import type { OutcomeTag } from "./clients"; + +export type PreflightCode = + | "Paused" + | "MarketNotFound" + | "MarketNotOpen" + | "MarketClosed" + | "NotFinal" + | "InvalidAmount" + | "InsufficientLiquidity" + | "MarketTooLarge" + | "InsufficientShares" + | "NothingToRedeem" + | "InsufficientBalance"; + +export interface PreflightFailure { + code: PreflightCode; + message: string; + contractErrorNumber?: number; +} + +export type Preflight = + | { ok: true } + | { ok: false; failures: PreflightFailure[] } + | { ok: "unknown"; missing: string[] }; + +export interface PreflightInput { + config?: { paused: boolean }; + market?: Market; + marketIsStale?: boolean; + /** Set only when the on-chain read explicitly returned MarketNotFound. */ + marketNotFound?: boolean; + ledgerNow?: bigint; + position?: Position; + positionIsStale?: boolean; + usdcBalance?: bigint; +} + +const ERROR_NUMBERS = new Map( + Object.entries(Errors).map(([number, value]) => [value.message, Number(number)] as const), +); + +function failure(code: PreflightCode): PreflightFailure { + return { + code, + message: FRIENDLY[code] ?? `The contract would reject this (${code}).`, + ...(ERROR_NUMBERS.has(code) ? { contractErrorNumber: ERROR_NUMBERS.get(code) } : {}), + }; +} + +function result(failures: PreflightFailure[], missing: string[]): Preflight { + if (failures.length) return { ok: false, failures }; + return missing.length ? { ok: "unknown", missing: [...new Set(missing)] } : { ok: true }; +} + +function commonTradeFailures(input: PreflightInput, missing: string[]): PreflightFailure[] { + const failures: PreflightFailure[] = []; + if (input.config?.paused) failures.push(failure("Paused")); + else if (!input.config) missing.push("protocol status"); + + if (input.marketNotFound) failures.push(failure("MarketNotFound")); + else if (input.marketIsStale) missing.push("fresh market"); + else if (!input.market) missing.push("market"); + else if (input.market.status.tag !== "Open") failures.push(failure("MarketNotOpen")); + + if (input.market && !input.marketIsStale && input.ledgerNow === undefined) missing.push("ledger time"); + else if ( + input.market && + !input.marketIsStale && + input.ledgerNow !== undefined && + input.ledgerNow >= input.market.close_time + ) { + failures.push(failure("MarketClosed")); + } + return failures; +} + +export function preflightBuy(input: PreflightInput, outcome: OutcomeTag, collateralIn: bigint): Preflight { + const missing: string[] = []; + const failures = commonTradeFailures(input, missing); + let mathResult: ReturnType | undefined; + + if (collateralIn <= 0n) { + failures.push(failure("InvalidAmount")); + } else if (input.market && !input.marketIsStale) { + const [rBought, rOther] = + outcome === "Yes" + ? [input.market.reserve_yes, input.market.reserve_no] + : [input.market.reserve_no, input.market.reserve_yes]; + try { + mathResult = calcBuy(rBought, rOther, collateralIn, BigInt(input.market.fee_bps)); + } catch (error) { + if (error instanceof Error && error.message.includes("invalid input")) failures.push(failure("InvalidAmount")); + else missing.push("market reserves"); + } + + if (mathResult) { + const newSets = input.market.sets_minted + mathResult.net; + const difference = newSets - MAX_SETS; + if (difference > 0n) { + if (input.marketIsStale && difference === 1n) missing.push("fresh market size"); + else failures.push(failure("MarketTooLarge")); + } + } + } else if (!input.marketIsStale) { + missing.push("market reserves"); + } + + if (input.usdcBalance === undefined) missing.push("USDC balance"); + else if (input.usdcBalance < collateralIn) failures.push(failure("InsufficientBalance")); + + return result(failures, missing); +} + +export function preflightSell( + input: PreflightInput, + outcome: OutcomeTag, + collateralOut: bigint, + sharesIn: bigint, +): Preflight { + const missing: string[] = []; + const failures = commonTradeFailures(input, missing); + if (input.market && !input.marketIsStale) { + const [rSold, rOther] = + outcome === "Yes" + ? [input.market.reserve_yes, input.market.reserve_no] + : [input.market.reserve_no, input.market.reserve_yes]; + try { + calcSell(rSold, rOther, collateralOut, BigInt(input.market.fee_bps)); + } catch (error) { + const message = error instanceof Error ? error.message : ""; + if (message.includes("insufficient liquidity")) failures.push(failure("InsufficientLiquidity")); + else if (message.includes("invalid input")) failures.push(failure("InvalidAmount")); + else missing.push("market reserves"); + } + } else if (!input.marketIsStale) { + missing.push("market reserves"); + } + if (input.position === undefined) missing.push("position"); + else if ((outcome === "Yes" ? input.position.yes : input.position.no) < sharesIn) { + if (input.positionIsStale) missing.push("fresh position"); + else failures.push(failure("InsufficientShares")); + } + return result(failures, missing); +} + +export function preflightRedeem(input: PreflightInput): Preflight { + const missing: string[] = []; + const failures: PreflightFailure[] = []; + if (input.marketNotFound) failures.push(failure("MarketNotFound")); + else if (input.marketIsStale) missing.push("fresh market"); + else if (!input.market) missing.push("market"); + else if (input.market.status.tag === "Open") failures.push(failure("NotFinal")); + + if (input.position === undefined) missing.push("position"); + else if (input.position.yes === 0n && input.position.no === 0n) { + if (input.positionIsStale) missing.push("fresh position"); + else failures.push(failure("NothingToRedeem")); + } + + return result(failures, missing); +} diff --git a/lib/stellar/redeem-all.ts b/lib/stellar/redeem-all.ts new file mode 100644 index 0000000..e03d21a --- /dev/null +++ b/lib/stellar/redeem-all.ts @@ -0,0 +1,233 @@ +import { Address, nativeToScVal, rpc, xdr } from "@stellar/stellar-sdk"; +import { marketCore } from "./clients"; +import { CONTRACTS, RPC_URL } from "./config"; +import { settleValue, type Settlement } from "./generated/fpmm"; +import type { Market, Position } from "./generated/market-core"; + +export const REDEEM_ALL_STORAGE_KEY = "oracledesk_redeem_all:"; +export const LEDGER_ENTRY_CHUNK_SIZE = 200; +export const MAX_REDEEM_SCAN_MARKETS = 20_000n; + +export interface Redeemable { + marketId: bigint; + settlement: Settlement; + yes: bigint; + no: bigint; + expectedPayout: bigint; + archived: boolean; +} + +export interface RedeemDiscovery { + redeemables: Redeemable[]; + scannedMarkets: bigint; + totalMarkets: bigint; + scanLimited: boolean; +} + +export type ItemState = + | { s: "queued" } + | { s: "signing" } + | { s: "submitting"; hash?: string } + | { s: "redeemed"; payout: bigint; hash: string | null; note?: string } + | { s: "skipped" } + | { s: "failed"; error: string }; + +export interface RedeemAllResume { + holder: string; + ids: string[]; + states: Record; +} + +const rpcServer = new rpc.Server(RPC_URL, { allowHttp: RPC_URL.startsWith("http://") }); + +export function marketLedgerKey(marketId: bigint): xdr.LedgerKey { + const key = xdr.ScVal.scvVec([xdr.ScVal.scvSymbol("Market"), nativeToScVal(marketId, { type: "u64" })]); + return xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ + contract: new Address(CONTRACTS.marketCore).toScAddress(), + key, + durability: xdr.ContractDataDurability.persistent(), + }), + ); +} + +export function positionLedgerKey(marketId: bigint, holder: string): xdr.LedgerKey { + const key = xdr.ScVal.scvVec([ + xdr.ScVal.scvSymbol("Position"), + nativeToScVal(marketId, { type: "u64" }), + new Address(holder).toScVal(), + ]); + return xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ + contract: new Address(CONTRACTS.marketCore).toScAddress(), + key, + durability: xdr.ContractDataDurability.persistent(), + }), + ); +} + +function chunks(values: T[], size: number): T[][] { + const output: T[][] = []; + for (let i = 0; i < values.length; i += size) output.push(values.slice(i, i + size)); + return output; +} + +function readUdt(name: string, scVal: xdr.ScVal): T { + const type = xdr.ScSpecTypeDef.scSpecTypeUdt(new xdr.ScSpecTypeUdt({ name })); + return marketCore().spec.scValToNative(scVal, type); +} + +function isPosition(value: unknown): value is Position { + if (!value || typeof value !== "object") return false; + const position = value as Record; + return typeof position.yes === "bigint" && typeof position.no === "bigint"; +} + +function isMarket(value: unknown): value is Market { + if (!value || typeof value !== "object") return false; + const market = value as Record; + const status = market.status; + const validStatus = + (Array.isArray(status) && ["Open", "Resolved", "Void"].includes(String(status[0]))) || + Boolean(status && typeof status === "object" && "tag" in status && + ["Open", "Resolved", "Void"].includes(String((status as { tag: unknown }).tag))); + return ( + typeof market.close_time === "bigint" && + typeof market.sets_minted === "bigint" && + typeof market.reserve_yes === "bigint" && + typeof market.reserve_no === "bigint" && + validStatus + ); +} + +function settlementOf(status: Market["status"]): Settlement | null { + const parts = Array.isArray(status) + ? status + : status && typeof status === "object" && "tag" in status + ? [status.tag, ...("values" in status && Array.isArray(status.values) ? status.values : [])] + : []; + if (parts[0] === "Void") return "void"; + if (parts[0] !== "Resolved") return null; + const outcome = parts[1]; + const tag = + typeof outcome === "string" + ? outcome + : Array.isArray(outcome) + ? outcome[0] + : outcome && typeof outcome === "object" && "tag" in outcome + ? outcome.tag + : null; + if (tag === "Yes") return "yes"; + if (tag === "No") return "no"; + return null; +} + +function isItemState(value: unknown): value is ItemState { + if (!value || typeof value !== "object" || !("s" in value)) return false; + const state = value as Record; + if (state.s === "queued" || state.s === "signing" || state.s === "skipped") return true; + if (state.s === "submitting") return state.hash === undefined || typeof state.hash === "string"; + if (state.s === "redeemed") { + return typeof state.payout === "bigint" && (state.hash === null || typeof state.hash === "string"); + } + return state.s === "failed" && typeof state.error === "string"; +} + +export async function discoverRedeemable( + holder: string, + totalMarkets: bigint, + server: rpc.Server = rpcServer, +): Promise { + const scannedMarkets = totalMarkets > MAX_REDEEM_SCAN_MARKETS ? MAX_REDEEM_SCAN_MARKETS : totalMarkets; + const startId = totalMarkets - scannedMarkets; + const ids = Array.from({ length: Number(scannedMarkets) }, (_, index) => startId + BigInt(index)); + const positionKeys = ids.map((id) => ({ id, key: positionLedgerKey(id, holder) })); + const positions = new Map(); + + for (const positionChunk of chunks(positionKeys, LEDGER_ENTRY_CHUNK_SIZE)) { + const response = await server.getLedgerEntries(...positionChunk.map(({ key }) => key)); + const idByKey = new Map(positionChunk.map(({ id, key }) => [key.toXDR("base64"), id])); + for (const entry of response.entries) { + const id = idByKey.get(entry.key.toXDR("base64")); + if (id === undefined || id < 0n) continue; + const position = readUdt("Position", entry.val.contractData().val()); + if (!isPosition(position) || (position.yes === 0n && position.no === 0n)) continue; + positions.set(entry.key.toXDR("base64"), { id, position, liveUntilLedgerSeq: entry.liveUntilLedgerSeq }); + } + } + + const candidatePositions = [...positions.values()]; + const marketKeys = candidatePositions.map((entry) => marketLedgerKey(entry.id)); + const markets = new Map(); + let latestLedger = 0; + for (const keyChunk of chunks(marketKeys, LEDGER_ENTRY_CHUNK_SIZE)) { + const response = await server.getLedgerEntries(...keyChunk); + latestLedger = Math.max(latestLedger, response.latestLedger); + for (const entry of response.entries) { + const key = entry.key.toXDR("base64"); + const market = readUdt("Market", entry.val.contractData().val()); + if (isMarket(market)) markets.set(key, { market, liveUntilLedgerSeq: entry.liveUntilLedgerSeq }); + } + } + + const redeemables: Redeemable[] = []; + for (const item of candidatePositions) { + const marketEntry = markets.get(marketLedgerKey(item.id).toXDR("base64")); + if (!marketEntry) continue; + const settlement = settlementOf(marketEntry.market.status); + if (!settlement) continue; + const archived = + (item.liveUntilLedgerSeq !== undefined && item.liveUntilLedgerSeq < latestLedger) || + (marketEntry.liveUntilLedgerSeq !== undefined && marketEntry.liveUntilLedgerSeq < latestLedger); + redeemables.push({ + marketId: item.id, + settlement, + yes: item.position.yes, + no: item.position.no, + expectedPayout: settleValue(settlement, item.position.yes, item.position.no), + archived, + }); + } + return { redeemables, scannedMarkets, totalMarkets, scanLimited: totalMarkets > scannedMarkets }; +} + +export function loadRedeemAllResume(holder: string): RedeemAllResume | null { + if (typeof sessionStorage === "undefined") return null; + try { + const value = sessionStorage.getItem(`${REDEEM_ALL_STORAGE_KEY}${holder}`); + if (!value) return null; + const parsed: unknown = JSON.parse(value, (key, item: unknown) => { + if (key === "payout" && typeof item === "string") return BigInt(item); + return item; + }); + if (!parsed || typeof parsed !== "object") throw new Error("Stored redemption progress is malformed."); + const resume = parsed as Partial; + if ( + resume.holder !== holder || + !Array.isArray(resume.ids) || + !resume.ids.every((id) => typeof id === "string" && /^\d+$/.test(id)) || + !resume.states || + typeof resume.states !== "object" || + Array.isArray(resume.states) || + !resume.ids.every((id) => Object.hasOwn(resume.states as object, id)) || + !Object.entries(resume.states).every(([id, state]) => /^\d+$/.test(id) && isItemState(state)) + ) { + throw new Error("Stored redemption progress does not match this wallet."); + } + return resume as RedeemAllResume; + } catch (error) { + throw new Error(`Could not restore redemption progress: ${String(error)}`); + } +} + +export function saveRedeemAllResume(value: RedeemAllResume): void { + if (typeof sessionStorage === "undefined") return; + try { + sessionStorage.setItem( + `${REDEEM_ALL_STORAGE_KEY}${value.holder}`, + JSON.stringify(value, (key, item: unknown) => (key === "payout" && typeof item === "bigint" ? item.toString() : item)), + ); + } catch (error) { + throw new Error(`Could not save redemption progress: ${String(error)}`); + } +} diff --git a/lib/stellar/submit-queue.ts b/lib/stellar/submit-queue.ts new file mode 100644 index 0000000..894b1b2 --- /dev/null +++ b/lib/stellar/submit-queue.ts @@ -0,0 +1,306 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import { classifySequenceError } from "./errors"; + +const LOCK_PREFIX = "oracledesk-submit:"; +const LEASE_MS = 10_000; +const HEARTBEAT_MS = 2_000; + +export interface QueueRunContext { + attempt: 0 | 1; + signal: AbortSignal; +} + +export interface QueueJob { + id: string; + address: string; + label: string; + /** Same address + key requests within one second share the original promise. */ + dedupeKey?: string; + run(context: QueueRunContext): Promise; +} + +export interface QueueEntryView { + id: string; + label: string; +} + +export interface QueueState { + address: string; + running: QueueEntryView | null; + waiting: QueueEntryView[]; + lockWaiting: boolean; +} + +interface QueuedJob { + job: QueueJob; + controller: AbortController; + resolve(value: T): void; + reject(reason: unknown): void; + cancelled: boolean; + promise: Promise; +} + +interface LeaseRecord { + owner: string; + expiresAt: number; +} + +const EMPTY_STATES = new Map(); +const EMPTY_STATE = (address: string): QueueState => { + let state = EMPTY_STATES.get(address); + if (!state) { + state = { address, running: null, waiting: [], lockWaiting: false }; + EMPTY_STATES.set(address, state); + } + return state; +}; + +function makeTabId(): string { + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; +} + +function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new DOMException("The queued transaction was cancelled.", "AbortError")); + return; + } + const timer = setTimeout(resolve, ms); + signal.addEventListener( + "abort", + () => { + clearTimeout(timer); + reject(new DOMException("The queued transaction was cancelled.", "AbortError")); + }, + { once: true }, + ); + }); +} + +async function withBrowserLock( + address: string, + signal: AbortSignal, + run: () => Promise, + onWaiting: (waiting: boolean) => void, +): Promise { + if (typeof navigator === "undefined") return run(); + + if (navigator.locks) { + onWaiting(true); + try { + return await navigator.locks.request( + `${LOCK_PREFIX}${address}`, + { mode: "exclusive", signal }, + async () => { + onWaiting(false); + return run(); + }, + ); + } finally { + onWaiting(false); + } + } + + return withLease(address, signal, run, onWaiting); +} + +/** + * Best-effort fallback for browsers without Web Locks. localStorage provides + * the lease, while BroadcastChannel lets other tabs observe its heartbeat. + */ +async function withLease( + address: string, + signal: AbortSignal, + run: () => Promise, + onWaiting: (waiting: boolean) => void, +): Promise { + if (typeof localStorage === "undefined") return run(); + + const tabId = getTabId(); + const key = `${LOCK_PREFIX}${address}`; + const channel = typeof BroadcastChannel === "undefined" ? null : new BroadcastChannel("oracledesk-submit"); + const readLease = (): LeaseRecord | null => { + try { + const value = localStorage.getItem(key); + return value ? (JSON.parse(value) as LeaseRecord) : null; + } catch { + return null; + } + }; + const writeLease = () => { + const lease = { owner: tabId, expiresAt: Date.now() + LEASE_MS }; + localStorage.setItem(key, JSON.stringify(lease)); + channel?.postMessage({ address, ...lease }); + return readLease()?.owner === tabId; + }; + + try { + onWaiting(true); + while (!signal.aborted) { + const current = readLease(); + if (!current || current.expiresAt <= Date.now()) { + if (writeLease()) break; + } + await sleep(200, signal); + } + if (signal.aborted) throw new DOMException("The queued transaction was cancelled.", "AbortError"); + onWaiting(false); + + const heartbeat = setInterval(() => { + if (readLease()?.owner === tabId) writeLease(); + }, HEARTBEAT_MS); + try { + return await run(); + } finally { + clearInterval(heartbeat); + if (readLease()?.owner === tabId) localStorage.removeItem(key); + channel?.postMessage({ address, owner: tabId, expiresAt: 0 }); + } + } finally { + channel?.close(); + } +} + +let tabId: string | undefined; +function getTabId(): string { + tabId ??= makeTabId(); + return tabId; +} + +export class SubmitQueue { + private readonly pending = new Map(); + private readonly running = new Map(); + private readonly listeners = new Set<() => void>(); + private readonly deduplicated = new Map }>(); + private readonly snapshots = new Map(); + private readonly lockWaiting = new Set(); + + enqueue(job: QueueJob): Promise { + const now = Date.now(); + const dedupeKey = job.dedupeKey ? `${job.address}:${job.dedupeKey}` : null; + if (dedupeKey) { + const previous = this.deduplicated.get(dedupeKey); + if (previous && previous.until > now) return previous.promise as Promise; + } + + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + const queued: QueuedJob = { job, controller: new AbortController(), resolve, reject, cancelled: false, promise }; + const jobs = this.pending.get(job.address) ?? []; + jobs.push(queued); + this.pending.set(job.address, jobs); + if (dedupeKey) { + this.deduplicated.set(dedupeKey, { until: now + 1_000, promise }); + setTimeout(() => { + if (this.deduplicated.get(dedupeKey)?.promise === promise) this.deduplicated.delete(dedupeKey); + }, 1_000); + } + this.notify(); + void this.drain(job.address); + return promise; + } + + /** Removes a job only while it is still waiting; a running wallet request is never cancelled. */ + cancel(id: string): boolean { + for (const [address, jobs] of this.pending) { + const index = jobs.findIndex((entry) => entry.job.id === id); + if (index < 0) continue; + const [entry] = jobs.splice(index, 1); + entry.cancelled = true; + entry.controller.abort(); + entry.reject(new DOMException("The queued transaction was cancelled.", "AbortError")); + if (!jobs.length) this.pending.delete(address); + this.notify(); + return true; + } + return false; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + getState(address: string): QueueState { + const state = this.snapshots.get(address); + if (state) return state; + const empty = EMPTY_STATE(address); + this.snapshots.set(address, empty); + return empty; + } + + private view(entry: QueuedJob | undefined): QueueEntryView | null { + return entry ? { id: entry.job.id, label: entry.job.label } : null; + } + + private notify(): void { + const addresses = new Set([...this.pending.keys(), ...this.running.keys(), ...this.snapshots.keys(), ...this.lockWaiting]); + for (const address of addresses) { + const running = this.view(this.running.get(address)); + const waiting = (this.pending.get(address) ?? []).map((entry) => this.view(entry) as QueueEntryView); + const previous = this.snapshots.get(address); + if ( + previous?.running?.id !== running?.id || + previous?.waiting.map((entry) => entry.id).join("|") !== waiting.map((entry) => entry.id).join("|") || + previous?.lockWaiting !== this.lockWaiting.has(address) + ) { + this.snapshots.set(address, { address, running, waiting, lockWaiting: this.lockWaiting.has(address) }); + } + } + for (const listener of this.listeners) listener(); + } + + private async drain(address: string): Promise { + if (this.running.has(address)) return; + const jobs = this.pending.get(address); + const entry = jobs?.shift(); + if (!entry) { + this.pending.delete(address); + return; + } + if (!jobs?.length) this.pending.delete(address); + this.running.set(address, entry); + this.notify(); + + try { + const result = await withBrowserLock(address, entry.controller.signal, async () => { + try { + return await entry.job.run({ attempt: 0, signal: entry.controller.signal }); + } catch (error) { + if (!classifySequenceError(error)) throw error; + return entry.job.run({ attempt: 1, signal: entry.controller.signal }); + } + }, (waiting) => { + if (waiting) this.lockWaiting.add(address); + else this.lockWaiting.delete(address); + this.notify(); + }); + entry.resolve(result); + } catch (error) { + entry.reject(error); + } finally { + this.running.delete(address); + this.notify(); + void this.drain(address); + } + } +} + +let singleton: SubmitQueue | undefined; +export function getSubmitQueue(): SubmitQueue { + singleton ??= new SubmitQueue(); + return singleton; +} + +export function useSubmitQueueState(address: string | null | undefined): QueueState { + const queue = getSubmitQueue(); + const subscribe = (listener: () => void) => queue.subscribe(listener); + const getSnapshot = () => (address ? queue.getState(address) : EMPTY_STATE("")); + return useSyncExternalStore(subscribe, getSnapshot, () => EMPTY_STATE(address ?? "")); +} diff --git a/lib/stellar/tx-decode.ts b/lib/stellar/tx-decode.ts new file mode 100644 index 0000000..132b8f7 --- /dev/null +++ b/lib/stellar/tx-decode.ts @@ -0,0 +1,198 @@ +import { Address, scValToNative, xdr } from "@stellar/stellar-sdk"; +import type { AssembledTransaction, Spec } from "@stellar/stellar-sdk/contract"; +import { CONTRACTS, NETWORK_LABEL } from "./config"; +import { formatUsdc, shortAddress } from "./units"; + +export interface ReviewField { + label: string; + type: string; + display: string; + raw: string; + derivedFrom?: string; +} + +export interface ReviewModel { + network: string; + source: string; + sequence: string; + fee: string; + validFrom: string | null; + validUntil: string | null; + contractId: string; + contractName: string; + fn: string; + args: ReviewField[]; + hash: string; +} + +export type DerivedFields = Record; + +const ARG_LABELS: Record = { + trader: "Source account", + holder: "Source account", + market_id: "Market id", + outcome: "Outcome", + collateral_in: "Collateral in (USDC)", + min_shares_out: "Minimum shares out", + collateral_out: "Collateral out (USDC)", + max_shares_in: "Maximum shares in", + from: "From", + to: "To", + amount: "Amount (USDC)", +}; + +function stringValue(value: string | Buffer): string { + return Buffer.isBuffer(value) ? value.toString("utf8") : value; +} + +function enumCases(spec: Spec, name: string): Set { + const entry = spec.entries.find( + (candidate) => + (candidate.switch().name === "scSpecEntryUdtEnumV0" && + stringValue(candidate.udtEnumV0().name()) === name) || + (candidate.switch().name === "scSpecEntryUdtUnionV0" && + stringValue(candidate.udtUnionV0().name()) === name), + ); + if (!entry) return new Set(); + if (entry.switch().name === "scSpecEntryUdtEnumV0") { + return new Set(entry.udtEnumV0().cases().map((entryCase) => stringValue(entryCase.name()))); + } + return new Set(entry.udtUnionV0().cases().map((entryCase) => stringValue(entryCase.value().name()))); +} + +function enumTag(spec: Spec, name: string, native: unknown): string { + const cases = enumCases(spec, name); + let tag: unknown; + if (Array.isArray(native) && native.length >= 1) tag = native[0]; + else if (native && typeof native === "object" && "tag" in native) tag = (native as { tag: unknown }).tag; + else if (typeof native === "string") tag = native; + if (typeof tag !== "string" || !cases.has(tag)) { + throw new Error(`The ${name} enum value could not be decoded from the contract spec.`); + } + return tag; +} + +function typeName(spec: Spec, definition: xdr.ScSpecTypeDef): string { + const typeSwitch = definition.switch().name; + if (typeSwitch === "scSpecTypeUdt") return stringValue(definition.udt().name()); + const primitive = typeSwitch.replace(/^scSpecType/, ""); + return primitive === "Address" ? primitive : primitive.toLowerCase(); +} + +function rawForValue( + spec: Spec | "sac", + type: string, + typeDef: xdr.ScSpecTypeDef | null, + scVal: xdr.ScVal, +): string { + if (type === "Address") return Address.fromScVal(scVal).toString(); + const native = spec === "sac" ? scValToNative(scVal) : spec.scValToNative(scVal, typeDef as xdr.ScSpecTypeDef); + if (typeDef?.switch().name === "scSpecTypeUdt") { + return enumTag(spec as Spec, typeDef.udt().name().toString(), native); + } + if (typeof native === "bigint" || typeof native === "string" || typeof native === "number") return String(native); + if (native && typeof native === "object" && "toString" in native) { + return (native as { toString(): string }).toString(); + } + throw new Error(`The ${type} argument could not be decoded.`); +} + +function formatValue(name: string, type: string, raw: string): string { + if (type === "Address") return `${raw} (${shortAddress(raw)})`; + if (type === "i128") return `${formatUsdc(BigInt(raw), { minDecimals: 7, maxDecimals: 7 })} USDC`; + if (type === "u64" && name === "market_id") return `#${raw}`; + return raw; +} + +function contractDisplay(contractId: string): string { + if (contractId === CONTRACTS.marketCore) return "Market core"; + if (contractId === CONTRACTS.usdc) return "USDC token (Stellar Asset Contract)"; + return "Contract"; +} + +export function describeAssembled( + tx: AssembledTransaction, + spec: Spec | "sac", + derived: DerivedFields = {}, +): ReviewModel { + const built = tx.built; + if (!built) throw new Error("The transaction must be simulated before it can be reviewed."); + const operations = built.operations; + if (operations.length !== 1 || operations[0].type !== "invokeHostFunction") { + throw new Error("Only a single contract invocation can be reviewed."); + } + const hostFunction = operations[0].func; + if (hostFunction.switch().name !== "hostFunctionTypeInvokeContract") { + throw new Error("The assembled operation is not a contract invocation."); + } + const invocation = hostFunction.invokeContract(); + const contractId = Address.fromScAddress(invocation.contractAddress()).toString(); + const fn = stringValue(invocation.functionName()); + const args = invocation.args(); + + let fields: ReviewField[]; + if (spec === "sac") { + if (contractId !== CONTRACTS.usdc || fn !== "transfer" || args.length !== 3) { + throw new Error("The SAC invocation does not match the known SEP-41 transfer signature."); + } + fields = [ + { name: "from", type: "Address", scVal: args[0] }, + { name: "to", type: "Address", scVal: args[1] }, + { name: "amount", type: "i128", scVal: args[2] }, + ].map(({ name, type, scVal }) => { + const raw = rawForValue("sac", type, null, scVal); + return { + label: ARG_LABELS[name] ?? name, + type, + display: formatValue(name, type, raw), + raw, + ...(derived[name] ? { derivedFrom: derived[name].from } : {}), + }; + }); + } else { + const inputs = spec.getFunc(fn).inputs(); + if (inputs.length !== args.length) throw new Error("Decoded argument count does not match the contract spec."); + fields = inputs.map((input, index) => { + const name = stringValue(input.name()); + const typeDef = input.type(); + const type = typeName(spec, typeDef); + const raw = rawForValue(spec, type, typeDef, args[index]); + return { + label: ARG_LABELS[name] ?? name, + type, + display: formatValue(name, type, raw), + raw, + ...(derived[name] ? { derivedFrom: derived[name].from } : {}), + }; + }); + } + + const bounds = built.timeBounds; + return { + network: NETWORK_LABEL, + source: built.source, + sequence: built.sequence, + fee: `${formatUsdc(BigInt(built.fee), { minDecimals: 7, maxDecimals: 7 })} XLM`, + validFrom: bounds?.minTime ?? null, + validUntil: bounds?.maxTime ?? null, + contractId, + contractName: contractDisplay(contractId), + fn, + args: fields, + hash: built.hash().toString("hex"), + }; +} + +/** Refuse to display a confirmation action if the envelope no longer matches the caller's intent. */ +export function assertIntentMatches(model: ReviewModel, intent: Record): void { + const expectedSource = intent.trader ?? intent.holder ?? intent.from; + if (expectedSource && model.source !== expectedSource) { + throw new Error("The assembled transaction source account does not match the requested action."); + } + for (const [name, expected] of Object.entries(intent)) { + const field = model.args.find((arg) => arg.label === (ARG_LABELS[name] ?? name)); + if (!field || field.raw !== expected) { + throw new Error(`The assembled transaction's ${name} argument does not match the requested action.`); + } + } +}