Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion app/markets/quick-view/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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%

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -297,6 +304,13 @@ function MarketQuickViewContent() {
</span>
{balance.data !== undefined && <span>Balance {formatUsdc(balance.data, { maxDecimals: 2, grouping: true })}</span>}
</div>
{breakdown && (
<div className="grid grid-cols-3 gap-2 text-[10px] font-data-mono text-on-surface-variant" aria-label="Trade breakdown">
<span>Fee {formatUsdc(breakdown.fee, { maxDecimals: 4 })} USDC</span>
<span>Impact {(Number(breakdown.priceImpactBps) / 100).toFixed(2)}%</span>
<span>Net {formatUsdc(breakdown.net, { maxDecimals: 4 })} USDC</span>
</div>
)}
{!isOpen && view && (
<p className="text-[11px] text-on-surface-variant">This market is closed to trading.</p>
)}
Expand Down
5 changes: 5 additions & 0 deletions lib/hooks/useStellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -46,6 +47,10 @@ export function simulated<T>(tx: AssembledTransaction<unknown>, contract: Contra
async function send<T>(tx: AssembledTransaction<unknown>, contract: ContractName = "marketCore"): Promise<{ value: T; txHash: string | null }> {
// Surface simulation failures before asking the wallet to sign.
simulated<T>(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;
Expand Down
39 changes: 39 additions & 0 deletions lib/stellar/__tests__/reliability.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
19 changes: 19 additions & 0 deletions lib/stellar/archival.ts
Original file line number Diff line number Diff line change
@@ -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<T>(build: () => Promise<AssembledTransaction<T>>, restore: () => Promise<void>): Promise<AssembledTransaction<T>> {
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();
}
}
28 changes: 28 additions & 0 deletions lib/stellar/authorization.ts
Original file line number Diff line number Diff line change
@@ -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<T>(tx: AssembledTransaction<T>, expectedAddress?: string): AuthorizationInspection {
const entries = tx.simulation && "auth" in tx.simulation
? (tx.simulation.auth as readonly xdr.SorobanAuthorizationEntry[])
: [];
return inspectAuthorizationEntries(entries, expectedAddress);
}
20 changes: 20 additions & 0 deletions lib/stellar/buy-breakdown.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading