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
new file mode 100644
index 0000000..7ffca6c
--- /dev/null
+++ b/lib/stellar/archival.ts
@@ -0,0 +1,19 @@
+import type { AssembledTransaction } from '@stellar/stellar-sdk/contract';
+
+export const isArchivedStateError = (error: unknown): boolean => /archiv|missing ledger entry|ttl|entry.?not.?found/i.test(String(error));
+
+/** Re-simulate after restoring archived entries through the supplied backend/client. */
+export async function simulateWithRestore(build: () => Promise>, restore: () => Promise): Promise> {
+ 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();
+ return build();
+ }
+}
diff --git a/lib/stellar/authorization.ts b/lib/stellar/authorization.ts
new file mode 100644
index 0000000..45cd0fd
--- /dev/null
+++ b/lib/stellar/authorization.ts
@@ -0,0 +1,28 @@
+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; }
+
+/** Inspect authorization entries before a wallet prompt; never sign unexpected addresses/functions. */
+export function inspectAuthorizationEntries(entries: readonly xdr.SorobanAuthorizationEntry[], expectedAddress?: string): AuthorizationInspection {
+ const signers: string[] = [];
+ const functions: string[] = [];
+ const warnings: string[] = [];
+ for (const entry of entries) {
+ try {
+ const address = entry.credentials().switch().name === 'sorobanCredentialsAddress' ? Address.fromScAddress(entry.credentials().address().address()).toString() : undefined;
+ if (address) signers.push(address);
+ functions.push(String(entry.rootInvocation().function()));
+ if (expectedAddress && address && address !== expectedAddress) warnings.push(`Unexpected signer ${address}`);
+ } catch { warnings.push('Unable to decode an authorization entry'); }
+ }
+ 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 };
+}