From e3bfcb1de98900db161a408f2bbd18cd2245fc75 Mon Sep 17 00:00:00 2001 From: Samuel Mmaduabuchi Date: Sat, 17 Oct 2026 02:50:34 +0100 Subject: [PATCH] feat: harden trade quotes and transaction recovery --- app/markets/quick-view/page.tsx | 16 +++++++++- lib/hooks/useStellar.ts | 5 +++ lib/stellar/__tests__/reliability.test.ts | 39 +++++++++++++++++++++++ lib/stellar/archival.ts | 19 +++++++++++ lib/stellar/authorization.ts | 28 ++++++++++++++++ lib/stellar/buy-breakdown.ts | 20 ++++++++++++ 6 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 lib/stellar/__tests__/reliability.test.ts create mode 100644 lib/stellar/archival.ts create mode 100644 lib/stellar/authorization.ts create mode 100644 lib/stellar/buy-breakdown.ts 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 }; +}