diff --git a/app/markets/quick-view/page.tsx b/app/markets/quick-view/page.tsx index 63c6a06..d095ab5 100644 --- a/app/markets/quick-view/page.tsx +++ b/app/markets/quick-view/page.tsx @@ -12,6 +12,7 @@ import { useBuy, useOnChainMarket, usePosition, useQuoteBuy, useUsdcBalance } fr import { useWallet } from "@/lib/contexts/WalletContext"; import { explorerTx } from "@/lib/stellar/config"; import { formatUsdc, minOut, tryParseUsdc } from "@/lib/stellar/units"; +import { buyBreakdown } from "@/lib/stellar/buy-breakdown"; const SLIPPAGE_BPS = 100; // 1% @@ -77,11 +78,17 @@ function MarketQuickViewContent() { const amountRaw = tryParseUsdc(amountInput); const debouncedAmount = useDebounced(amountRaw, 400); const quote = useQuoteBuy(onChainMarketId, side, debouncedAmount); + const view = chain.data; + const breakdown = view && debouncedAmount && debouncedAmount > 0n + ? (() => { + const [bought, other] = side === "Yes" ? [view.market.reserve_yes, view.market.reserve_no] : [view.market.reserve_no, view.market.reserve_yes]; + try { return buyBreakdown(bought, other, debouncedAmount, BigInt(view.market.fee_bps)); } catch { return null; } + })() + : null; // 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"; - const view = chain.data; const fromChain = Boolean(view); const backendProb = market ? Math.round((market.currentYesProb ?? market.initialYesProb) * 100) : 50; const yesProb = view ? Math.round(view.yesBps / 100) : backendProb; @@ -297,6 +304,13 @@ function MarketQuickViewContent() { {balance.data !== undefined && Balance {formatUsdc(balance.data, { maxDecimals: 2, grouping: true })}} + {breakdown && ( +
+ Fee {formatUsdc(breakdown.fee, { maxDecimals: 4 })} USDC + Impact {(Number(breakdown.priceImpactBps) / 100).toFixed(2)}% + Net {formatUsdc(breakdown.net, { maxDecimals: 4 })} USDC +
+ )} {!isOpen && view && (

This market is closed to trading.

)} diff --git a/lib/hooks/useStellar.ts b/lib/hooks/useStellar.ts index 574738f..a385a7f 100644 --- a/lib/hooks/useStellar.ts +++ b/lib/hooks/useStellar.ts @@ -9,6 +9,7 @@ import { collateralOutForShares, priceYesBps } from "@/lib/stellar/generated/fpm import type { 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 { inspectTransactionAuthorization } from "@/lib/stellar/authorization"; /** Query keys all start with "stellar" so a disconnect can drop them in one go. */ export const stellarKeys = { @@ -46,6 +47,10 @@ export function simulated(tx: AssembledTransaction, contract: Contra async function send(tx: AssembledTransaction, contract: ContractName = "marketCore"): Promise<{ value: T; txHash: string | null }> { // Surface simulation failures before asking the wallet to sign. simulated(tx, contract); + const authorization = inspectTransactionAuthorization(tx); + if (!authorization.valid) { + throw new UserFacingError({ kind: "contract", message: "The wallet authorization does not match this transaction." }); + } try { const sent = await tx.signAndSend(); const value = sent.result as unknown; diff --git a/lib/stellar/__tests__/reliability.test.ts b/lib/stellar/__tests__/reliability.test.ts new file mode 100644 index 0000000..4277676 --- /dev/null +++ b/lib/stellar/__tests__/reliability.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { buyBreakdown } from "../buy-breakdown"; +import { isArchivedStateError, simulateWithRestore } from "../archival"; +import { isExpiredTransaction, isFeeSurge, transactionRecoveryAdvice } from "../transaction-recovery"; + +describe("buy breakdown", () => { + it("reports fee, net collateral and price impact from FPMM math", () => { + const result = buyBreakdown(1_000_000n, 1_000_000n, 100_000n, 100n); + expect(result.fee).toBe(1_000n); + expect(result.net).toBe(99_000n); + expect(result.sharesOut).toBeGreaterThan(0n); + expect(result.priceImpactBps).toBeGreaterThan(0n); + }); +}); + +describe("transaction recovery", () => { + it("classifies fee surges and expiry with actionable advice", () => { + expect(isFeeSurge("tx_bad_seq")).toBe(true); + expect(transactionRecoveryAdvice("tx_bad_seq").kind).toBe("fee-bump"); + expect(isExpiredTransaction("tx_too_late")).toBe(true); + expect(transactionRecoveryAdvice("tx_too_late").kind).toBe("rebuild"); + }); +}); + +describe("archived state recovery", () => { + it("restores and rebuilds when simulation exposes an archived entry", async () => { + let attempts = 0; + const build = async () => { + attempts += 1; + if (attempts === 1) throw new Error("archived ledger entry"); + return {} as never; + }; + // Build failures occur before an assembled transaction exists, so callers + // can also use this predicate around their restore endpoint. + expect(isArchivedStateError("archived ledger entry")).toBe(true); + await expect(simulateWithRestore(build, async () => undefined)).resolves.toBeDefined(); + expect(attempts).toBe(2); + }); +}); diff --git a/lib/stellar/archival.ts b/lib/stellar/archival.ts index e9c7566..7ffca6c 100644 --- a/lib/stellar/archival.ts +++ b/lib/stellar/archival.ts @@ -4,8 +4,13 @@ export const isArchivedStateError = (error: unknown): boolean => /archiv|missing /** Re-simulate after restoring archived entries through the supplied backend/client. */ export async function simulateWithRestore(build: () => Promise>, restore: () => Promise): Promise> { - const tx = await build(); - try { tx.simulation; return tx; } + try { + const tx = await build(); + // `simulation` is only metadata; reading `result` forces the SDK to + // surface expired/missing state errors from the simulation. + void tx.result; + return tx; + } catch (error) { if (!isArchivedStateError(error)) throw error; await restore(); diff --git a/lib/stellar/authorization.ts b/lib/stellar/authorization.ts index 0f0f82f..45cd0fd 100644 --- a/lib/stellar/authorization.ts +++ b/lib/stellar/authorization.ts @@ -1,4 +1,5 @@ import { Address, xdr } from '@stellar/stellar-sdk'; +import type { AssembledTransaction } from '@stellar/stellar-sdk/contract'; export interface AuthorizationInspection { count: number; signers: string[]; functions: string[]; warnings: string[]; valid: boolean; } @@ -17,3 +18,11 @@ export function inspectAuthorizationEntries(entries: readonly xdr.SorobanAuthori } return { count: entries.length, signers, functions, warnings, valid: warnings.length === 0 }; } + +/** Decode the authorization entries returned by Soroban simulation. */ +export function inspectTransactionAuthorization(tx: AssembledTransaction, expectedAddress?: string): AuthorizationInspection { + const entries = tx.simulation && "auth" in tx.simulation + ? (tx.simulation.auth as readonly xdr.SorobanAuthorizationEntry[]) + : []; + return inspectAuthorizationEntries(entries, expectedAddress); +} diff --git a/lib/stellar/buy-breakdown.ts b/lib/stellar/buy-breakdown.ts new file mode 100644 index 0000000..c185085 --- /dev/null +++ b/lib/stellar/buy-breakdown.ts @@ -0,0 +1,20 @@ +import { calcBuy, type BuyQuote } from "./generated/fpmm"; + +export interface BuyBreakdown extends BuyQuote { + /** Price impact in basis points, measured against the pre-trade marginal price. */ + priceImpactBps: bigint; +} + +/** Calculate the same fee, output and price impact used by the FPMM locally. */ +export function buyBreakdown( + reserveBought: bigint, + reserveOther: bigint, + collateralIn: bigint, + feeBps: bigint, +): BuyBreakdown { + const quote = calcBuy(reserveBought, reserveOther, collateralIn, feeBps); + const before = reserveBought * 10_000n / (reserveBought + reserveOther); + const after = quote.newReserveBought * 10_000n / + (quote.newReserveBought + quote.newReserveOther); + return { ...quote, priceImpactBps: after > before ? after - before : before - after }; +}