diff --git a/.a.swp b/.a.swp
deleted file mode 100644
index 5e258018..00000000
Binary files a/.a.swp and /dev/null differ
diff --git a/.capability.swp b/.capability.swp
deleted file mode 100644
index 0286241f..00000000
Binary files a/.capability.swp and /dev/null differ
diff --git a/.changelog/unreleased/security-audit-followups.md b/.changelog/unreleased/security-audit-followups.md
new file mode 100644
index 00000000..36854876
--- /dev/null
+++ b/.changelog/unreleased/security-audit-followups.md
@@ -0,0 +1,8 @@
+---
+type: security
+area: trade
+pr: TBD
+breaking: false
+---
+
+Fixed a crash when opening the order confirmation dialog, which now shows the active network; TanStack Start was updated to clear a critical server-side XSS advisory.
diff --git a/.changelog/unreleased/security-remove-third-party-oracle.md b/.changelog/unreleased/security-remove-third-party-oracle.md
new file mode 100644
index 00000000..1702b192
--- /dev/null
+++ b/.changelog/unreleased/security-remove-third-party-oracle.md
@@ -0,0 +1,8 @@
+---
+type: security
+area: trade
+pr: TBD
+breaking: false
+---
+
+Trade prices no longer come from an unowned third-party price proxy; Pyth Hermes is now the primary price source, with Binance and GMX as fallbacks.
diff --git a/.colored.swp b/.colored.swp
deleted file mode 100644
index 1a2a4681..00000000
Binary files a/.colored.swp and /dev/null differ
diff --git a/.for.swp b/.for.swp
deleted file mode 100644
index 1187de8f..00000000
Binary files a/.for.swp and /dev/null differ
diff --git a/.gitignore b/.gitignore
index e57f4c17..6a0b2600 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,4 +50,6 @@ fix.md
test-results/
# Generated OG images
apps/docs/public/og/
-.nitro-static/og/
\ No newline at end of file
+.nitro-static/og/
+# Editor swap files
+*.swp
diff --git a/.has.swp b/.has.swp
deleted file mode 100644
index 608322b1..00000000
Binary files a/.has.swp and /dev/null differ
diff --git a/.if.swp b/.if.swp
deleted file mode 100644
index bbaac6f8..00000000
Binary files a/.if.swp and /dev/null differ
diff --git a/.prompt,.swp b/.prompt,.swp
deleted file mode 100644
index add1c79c..00000000
Binary files a/.prompt,.swp and /dev/null differ
diff --git a/.terminal.swp b/.terminal.swp
deleted file mode 100644
index ac0ef7b8..00000000
Binary files a/.terminal.swp and /dev/null differ
diff --git a/.the.swp b/.the.swp
deleted file mode 100644
index 8110e362..00000000
Binary files a/.the.swp and /dev/null differ
diff --git a/.uncomment.swp b/.uncomment.swp
deleted file mode 100644
index da4f6af3..00000000
Binary files a/.uncomment.swp and /dev/null differ
diff --git a/apps/docs/.gitignore b/apps/docs/.gitignore
index a11303ce..aff729c5 100644
--- a/apps/docs/.gitignore
+++ b/apps/docs/.gitignore
@@ -4,3 +4,6 @@
# DX-050: version snapshots are reproducible via scripts/snapshot-version.ts
# and are not authored content — see that script's header comment.
content-versions/
+
+# Lighthouse CI generated reports and assertion results
+.lighthouseci/
diff --git a/apps/docs/content/concepts/oracles.mdx b/apps/docs/content/concepts/oracles.mdx
index bded5e46..54cfaf0f 100644
--- a/apps/docs/content/concepts/oracles.mdx
+++ b/apps/docs/content/concepts/oracles.mdx
@@ -1,7 +1,7 @@
---
title: Oracles
description: Where SO4 prices come from, the order the app falls back through sources, how staleness is judged, and what happens when a source fails.
-updated: 2026-08-31
+updated: 2026-10-15
status: beta
---
@@ -11,9 +11,8 @@ implemented in `apps/web/src/features/trade/lib/oracle.ts`; this page describes
what that file does today, not what the README summary says.
> The README and the DX1 content map describe the intended design as "Binance
-> primary, GMX fallback". The shipped `fetchTokenPrices` puts the SO4 oracle
-> service first and Pyth second, with Binance and GMX below them. This page
-> follows the code.
+> primary, GMX fallback". The shipped `fetchTokenPrices` puts Pyth first, with
+> Binance and GMX below it. This page follows the code.
## Sources, in priority order
@@ -22,11 +21,10 @@ returns the first that produces at least one price:
| # | Source | Endpoint | Notes |
| - | ------ | -------- | ----- |
-| 1 | **SO4 oracle service** | `oracle.biscotti-proxy-worker.workers.dev/prices` | The primary source. A hosted aggregator; its own comments say it combines Binance, Coinbase, and Pyth. Returns a `min` and `max` price per token, scaled by `10^30`, with a Unix-seconds timestamp. |
-| 2 | **Pyth Hermes** | via `fetchPythAttestations` (`apps/web/src/features/trade/lib/pyth.ts`) | Used only if source 1 threw or returned nothing. Binance display prices are layered on top to fill any symbol Pyth did not return. |
-| 3 | **Binance REST** | `api.binance.com/api/v3/ticker/bookTicker` | Bid becomes the min price, ask becomes the max price. |
-| 4 | **GMX oracle** | `${ENV.ORACLE_URL}/prices/tickers` | Last networked source. Prices are decoded with GMX v2 scaling: `usdPrice × 10^(30 − tokenDecimals)`. |
-| 5 | **Built-in dummy prices** | none | A hard-coded `DUMMY_PRICES` array. Reached only if every source above failed. Prices are static and wrong; the UI has no live data at this point. |
+| 1 | **Pyth Hermes** | via `fetchPythAttestations` (`apps/web/src/features/trade/lib/pyth.ts`) | The primary source. Binance display prices are layered on top to fill any symbol Pyth did not return. |
+| 2 | **Binance REST** | `api.binance.com/api/v3/ticker/bookTicker` | Used only if Pyth threw or returned nothing. Bid becomes the min price, ask becomes the max price. |
+| 3 | **GMX oracle** | `${ENV.ORACLE_URL}/prices/tickers` | Last networked source. Prices are decoded with GMX v2 scaling: `usdPrice × 10^(30 − tokenDecimals)`. |
+| 4 | **Built-in dummy prices** | none | A hard-coded `DUMMY_PRICES` array. Reached only if every source above failed. Prices are static and wrong; the UI has no live data at this point. |
The four tracked markets are BTC, ETH, XLM, and USDC, mapped from the test token
symbols `TWBTC`, `TETH`, `TXLM`, `TUSDC`.
@@ -37,8 +35,7 @@ Each source is wrapped in its own `try`/`catch`. The app moves to the next
source when the current one:
- throws — a network error, or a non-200 HTTP response, or
-- returns an empty result — for source 1, an empty map; for source 2, no Pyth
- attestations.
+- returns an empty result — for source 1, no Pyth attestations.
There is no health scoring, no retry, and no circuit breaker. A source that
succeeds once is not preferred later; every call re-walks the list from the
@@ -63,7 +60,7 @@ last value it has with the red dot.
## Mark price versus execution price
-Sources 1, 3, and 4 return a `min` and a `max` price — the two sides of a
+Every networked source returns a `min` and a `max` price — the two sides of a
spread. The displayed mark price and the price used for position valuation and
the liquidation check are derived from these; which side is used depends on the
direction of the position and the operation. The exact selection lives in the
@@ -75,16 +72,16 @@ If you are integrating, read those before assuming a single price.
| Situation | What the code does today |
| --------- | ------------------------ |
-| SO4 oracle service outage | Falls through to Pyth, then Binance, then GMX. |
+| Pyth Hermes outage | Falls through to Binance, then GMX. |
| All networked sources fail | Serves the static `DUMMY_PRICES`. The values are placeholders and the staleness indicator will read stale. There is no banner telling the user the feed is dead — this is unhandled. |
| One source returns a price far from the others | Nothing. `fetchTokenPrices` takes the first source that answers; it does not cross-check sources for deviation. Deviation checks, if any, happen in the contract at execution time, not in the price feed. |
| Price older than 30 seconds | Marked `stale` in the UI. Trading is not blocked client-side. |
## Worked example
-The SO4 oracle service returns `{ symbol: "TWBTC", min: 7.87e34, max: 7.871e34,
-timestamp: 1_760_000_000 }`. `oracle.ts` divides by `1e30`, giving a min of
-78,700 and a max of 78,710 USD, with `updatedAt = 1_760_000_000 × 1000` ms. If
+Pyth Hermes returns a BTC attestation with a min of 78,700 and a max of
+78,710 USD, published at Unix time 1,760,000,000 seconds, so `oracle.ts` sets
+`updatedAt = 1_760_000_000 × 1000` ms. If
the current time is 1,760,000,020,000 ms, the price is 20 seconds old, so the
trade page shows a yellow `warning` dot. At 1,760,000,035,000 ms it is 35
seconds old and the dot turns red.
@@ -95,8 +92,7 @@ From source to liquidation check, the value flows like this:
```mermaid caption="Oracle price ingestion pipeline and liquidation check flow"
graph TD
- SO4[SO4 oracle service] --> TP[TokenPrice
minPrice, maxPrice, updatedAt, source]
- Pyth[Pyth Hermes] --> TP
+ Pyth[Pyth Hermes] --> TP[TokenPrice
minPrice, maxPrice, updatedAt, source]
Binance[Binance REST] --> TP
GMX[GMX oracle] --> TP
Dummy[DUMMY_PRICES] --> TP
diff --git a/apps/docs/lighthouserc.json b/apps/docs/lighthouserc.json
index c8af36de..9dd92ff8 100644
--- a/apps/docs/lighthouserc.json
+++ b/apps/docs/lighthouserc.json
@@ -3,7 +3,8 @@
"collect": {
"numberOfRuns": 3,
"startServerCommand": "bun run dev",
- "startServerReadyPattern": "Listening on http://localhost:3000",
+ "startServerReadyPattern": "Listening on: http://localhost:3000",
+ "startServerReadyTimeout": 120000,
"url": [
"http://localhost:3000/",
"http://localhost:3000/concepts/risk",
diff --git a/apps/docs/package.json b/apps/docs/package.json
index a4cea204..185c6f26 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -43,9 +43,9 @@
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/bun": "^1.3.0",
- "@vitest/coverage-v8": "^3.2.0",
- "@vitest/expect": "^3.2.7",
- "vitest": "3",
+ "@vitest/coverage-v8": "^4.1.12",
+ "@vitest/expect": "^4.1.12",
+ "vitest": "^4.1.12",
"vitest-axe": "^0.1.0",
"@types/mdx": "^2.0.14",
"@types/react": "^19.2.18",
diff --git a/apps/s03-indexer/.github/workflows/cli-deploy.yml b/apps/s03-indexer/.github/workflows/cli-deploy.yml
index 658d2c6c..89d1515b 100644
--- a/apps/s03-indexer/.github/workflows/cli-deploy.yml
+++ b/apps/s03-indexer/.github/workflows/cli-deploy.yml
@@ -30,5 +30,10 @@ jobs:
- name: repo
run: echo ${{github.repository}}
- name: Publish and Deploy
+ # Inputs go through env vars, not ${{ }} interpolation, so a crafted
+ # projectName cannot inject shell commands into this step.
+ env:
+ PROJECT_NAME: ${{ github.event.inputs.projectName }}
+ REPOSITORY: ${{ github.repository }}
run: |
- sh .github/workflows/scripts/publish-deploy.sh -o ${{github.repository}} -p ${{github.event.inputs.projectName}} -e ${{secrets.ENDPOINT}}
+ sh .github/workflows/scripts/publish-deploy.sh -o "$REPOSITORY" -p "$PROJECT_NAME" -e "$ENDPOINT"
diff --git a/apps/web/package.json b/apps/web/package.json
index 664de9b4..6f108b38 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -9,7 +9,8 @@
"preview": "vite preview",
"lint": "eslint",
"format": "prettier --write \"**/*.{ts,tsx}\"",
- "typecheck": "tsc --noEmit"
+ "typecheck": "tsc --noEmit",
+ "test:cache-headers": "bun test scripts/nitro-cache-headers.test.ts"
},
"dependencies": {
"@creit.tech/stellar-wallets-kit": "^2.4.0",
@@ -21,7 +22,7 @@
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.100.9",
"@tanstack/react-router": "^1.132.0",
- "@tanstack/react-start": "^1.132.0",
+ "@tanstack/react-start": "~1.168.61",
"@tanstack/router-plugin": "^1.132.0",
"@workspace/ui": "workspace:*",
"lightweight-charts": "^5.2.0",
@@ -44,16 +45,17 @@
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
- "@vitest/expect": "^3.2.7",
- "@vitest/ui": "3",
+ "@vitest/expect": "^4.1.12",
+ "@vitest/ui": "^4.1.12",
"axe-core": "^4.12.1",
"eslint": "^9.39.2",
"happy-dom": "^20.10.6",
"msw": "^2.14.6",
"qrcode.react": "^4.2.0",
+ "rou3": "^1.0.0",
"typescript": "^5.9.3",
"vite": "^7.3.2",
- "vitest": "3",
+ "vitest": "^4.1.12",
"vitest-axe": "^0.1.0"
}
}
diff --git a/apps/web/scripts/nitro-cache-headers.test.ts b/apps/web/scripts/nitro-cache-headers.test.ts
new file mode 100644
index 00000000..aa6e5554
--- /dev/null
+++ b/apps/web/scripts/nitro-cache-headers.test.ts
@@ -0,0 +1,103 @@
+/**
+ * Cache-headers integration check (OB-125 follow-up).
+ *
+ * Requires a production build first:
+ * bun run --cwd apps/web build
+ * bun run --cwd apps/web test:cache-headers
+ *
+ * Spawns the built Nitro server and verifies the route rules from
+ * src/shared/lib/cache-headers.ts against real HTTP responses:
+ * - hashed Vite assets under /assets (where fonts and images land) get the
+ * immutable cache header through directory rules — extension globs such as
+ * `/**\/*.woff2` are rejected by rou3/h3 2.x;
+ * - non-hashed public files keep revalidating instead of being pinned.
+ *
+ * Uses node:http rather than fetch because bunfig.toml preloads happy-dom,
+ * whose fetch enforces browser CORS rules.
+ */
+import { request } from "node:http"
+import { readdir } from "node:fs/promises"
+import { join } from "node:path"
+import { afterAll, beforeAll, expect, test } from "bun:test"
+import { spawn } from "bun"
+import type { Subprocess } from "bun"
+
+const PORT = 3212
+const IMMUTABLE = "public, max-age=31536000, immutable"
+const REVALIDATING = "public, no-cache, max-age=0, must-revalidate"
+
+const appRoot = join(import.meta.dirname, "..")
+
+let server: Subprocess
+
+type HttpResponse = {
+ status: number
+ headers: Record | undefined>
+}
+
+function httpGet(path: string): Promise {
+ return new Promise((resolve, reject) => {
+ const req = request(
+ { host: "localhost", port: PORT, path, method: "GET" },
+ (res) => {
+ res.resume()
+ res.on("end", () =>
+ resolve({ status: res.statusCode ?? 0, headers: res.headers }),
+ )
+ },
+ )
+ req.on("error", reject)
+ req.end()
+ })
+}
+
+async function findAsset(extension: string): Promise {
+ const entries = await readdir(join(appRoot, ".output/public/assets"))
+ return entries.find((entry) => entry.endsWith(extension)) ?? null
+}
+
+beforeAll(async () => {
+ server = spawn(["node", join(appRoot, ".output/server/index.mjs")], {
+ env: { ...process.env, PORT: String(PORT) },
+ stdout: "ignore",
+ stderr: "ignore",
+ })
+
+ for (let i = 0; i < 100; i++) {
+ try {
+ await httpGet("/")
+ return
+ } catch {
+ await Bun.sleep(100)
+ }
+ }
+ throw new Error(
+ "built Nitro server did not start — run `bun run --cwd apps/web build` first",
+ )
+})
+
+afterAll(() => {
+ server.kill()
+})
+
+test("hashed .woff2 font requests get the immutable cache header", async () => {
+ const font = await findAsset(".woff2")
+ expect(font).not.toBeNull()
+
+ const res = await httpGet(`/assets/${font}`)
+ expect(res.status).toBe(200)
+ expect(res.headers["cache-control"]).toBe(IMMUTABLE)
+})
+
+test("hashed .png requests get the immutable cache header", async () => {
+ // This build emits no images, but the /assets/** rule covers everything
+ // Vite hashes, so a synthetic path exercises the same rule.
+ const res = await httpGet("/assets/token-icon-4f2a91.png")
+ expect(res.headers["cache-control"]).toBe(IMMUTABLE)
+})
+
+test("non-hashed public files stay revalidating", async () => {
+ const res = await httpGet("/favicon.ico")
+ expect(res.status).toBe(200)
+ expect(res.headers["cache-control"]).toBe(REVALIDATING)
+})
diff --git a/apps/web/setup-tests.ts b/apps/web/setup-tests.ts
index 8fb0988f..fbb1a7db 100644
--- a/apps/web/setup-tests.ts
+++ b/apps/web/setup-tests.ts
@@ -22,24 +22,24 @@ afterAll(() => server.close());
process.env.VITE_NETWORK = 'testnet';
process.env.VITE_RPC_URL = 'https://soroban-testnet.stellar.org';
process.env.VITE_HORIZON_URL = 'https://horizon-testnet.stellar.org';
-process.env.VITE_CONTRACT_EXCHANGE_ROUTER = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4';
-process.env.VITE_CONTRACT_SYNTHETICS_READER = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSE2';
-process.env.VITE_CONTRACT_DATA_STORE = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSDS';
-process.env.VITE_CONTRACT_ORDER_VAULT = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSOV';
-process.env.VITE_CONTRACT_STAKING_ROUTER = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSSR';
-process.env.VITE_CONTRACT_GLV_ROUTER = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSGR';
-process.env.VITE_CONTRACT_VESTING_ROUTER = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSVR';
-process.env.VITE_CONTRACT_REFERRAL_STORAGE = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSRS';
-process.env.VITE_CONTRACT_MARKET_FACTORY = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSMF';
-process.env.VITE_CONTRACT_DEPOSIT_HANDLER = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSDH';
-process.env.VITE_CONTRACT_WITHDRAWAL_HANDLER = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSWH';
-process.env.VITE_CONTRACT_ORACLE = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSO';
-process.env.VITE_CONTRACT_ORDER_HANDLER = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSOH';
-process.env.VITE_FAUCET = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSF';
-process.env.VITE_TOKEN_TUSDC = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABTUSDC';
-process.env.VITE_TOKEN_TWBTC = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABTWBTC';
-process.env.VITE_TOKEN_TETH = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABTETH';
-process.env.VITE_TOKEN_TXLM = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABTXLM';
-process.env.VITE_MARKET_TOKEN_TWBTC_TUSDC = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABMT1';
-process.env.VITE_MARKET_TOKEN_TETH_TUSDC = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABMT2';
-process.env.VITE_MARKET_TOKEN_TXLM_TUSDC = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABMT3';
+process.env.VITE_CONTRACT_EXCHANGE_ROUTER = 'CAASUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP7';
+process.env.VITE_CONTRACT_SYNTHETICS_READER = 'CABCUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSYU';
+process.env.VITE_CONTRACT_DATA_STORE = 'CABSUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABDKN';
+process.env.VITE_CONTRACT_ORDER_VAULT = 'CACCUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA7WT';
+process.env.VITE_CONTRACT_STAKING_ROUTER = 'CACSUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOEK';
+process.env.VITE_CONTRACT_GLV_ROUTER = 'CADCUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB4TB';
+process.env.VITE_CONTRACT_VESTING_ROUTER = 'CADSUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABNBY';
+process.env.VITE_CONTRACT_REFERRAL_STORAGE = 'CAECUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANKM';
+process.env.VITE_CONTRACT_MARKET_FACTORY = 'CAESUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4YV';
+process.env.VITE_CONTRACT_DEPOSIT_HANDLER = 'CAFCUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABOP6';
+process.env.VITE_CONTRACT_WITHDRAWAL_HANDLER = 'CAFSUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB75H';
+process.env.VITE_CONTRACT_ORACLE = 'CAGCUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADBZ';
+process.env.VITE_CONTRACT_ORDER_HANDLER = 'CAGSUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASTA';
+process.env.VITE_FAUCET = 'CAHCUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAEL';
+process.env.VITE_TOKEN_TUSDC = 'CAHSUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABRWS';
+process.env.VITE_TOKEN_TWBTC = 'CAICUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABATC';
+process.env.VITE_TOKEN_TETH = 'CAISUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABRB3';
+process.env.VITE_TOKEN_TXLM = 'CAJCUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADWQ';
+process.env.VITE_MARKET_TOKEN_TWBTC_TUSDC = 'CAJSUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASEJ';
+process.env.VITE_MARKET_TOKEN_TETH_TUSDC = 'CAKCUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABOYX';
+process.env.VITE_MARKET_TOKEN_TXLM_TUSDC = 'CAKSUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB7KO';
diff --git a/apps/web/src/features/changelog/hooks/use-release-hash.test.tsx b/apps/web/src/features/changelog/hooks/use-release-hash.test.tsx
index de7189c1..6f415c87 100644
--- a/apps/web/src/features/changelog/hooks/use-release-hash.test.tsx
+++ b/apps/web/src/features/changelog/hooks/use-release-hash.test.tsx
@@ -10,6 +10,7 @@ function TestRelease({ ready = true }: { ready?: boolean }) {
afterEach(() => {
window.history.replaceState(null, "", "/")
vi.restoreAllMocks()
+ vi.unstubAllGlobals()
})
describe("useReleaseHash", () => {
@@ -17,9 +18,11 @@ describe("useReleaseHash", () => {
window.history.replaceState(null, "", "/changelog#v0-4-0")
const scrollIntoView = vi.fn()
Element.prototype.scrollIntoView = scrollIntoView
- vi.spyOn(window, "matchMedia").mockReturnValue({
- matches: false,
- } as MediaQueryList)
+ // jsdom does not implement matchMedia, so stub it rather than spy on it.
+ vi.stubGlobal(
+ "matchMedia",
+ vi.fn().mockReturnValue({ matches: false }),
+ )
const { container } = render()
const release = container.querySelector("#v0-4-0")
@@ -35,9 +38,11 @@ describe("useReleaseHash", () => {
window.history.replaceState(null, "", "/changelog#v0-4-0")
const scrollIntoView = vi.fn()
Element.prototype.scrollIntoView = scrollIntoView
- vi.spyOn(window, "matchMedia").mockReturnValue({
- matches: true,
- } as MediaQueryList)
+ // jsdom does not implement matchMedia, so stub it rather than spy on it.
+ vi.stubGlobal(
+ "matchMedia",
+ vi.fn().mockReturnValue({ matches: true }),
+ )
render()
diff --git a/apps/web/src/features/pools/components/pool-deposit-dialog.test.tsx b/apps/web/src/features/pools/components/pool-deposit-dialog.test.tsx
index 41286f5e..11b79ec9 100644
--- a/apps/web/src/features/pools/components/pool-deposit-dialog.test.tsx
+++ b/apps/web/src/features/pools/components/pool-deposit-dialog.test.tsx
@@ -3,8 +3,11 @@ import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { PoolTransactionDialog } from "./pool-transaction-dialog"
+import type { Mock } from "vitest"
import type { PoolMarketConfig } from "../data/markets"
+type DialogProps = React.ComponentProps
+
vi.mock("@/lib/contracts", () => ({
getTokenClient: () => ({
balance: vi.fn().mockResolvedValue(100_000_000n),
@@ -69,8 +72,8 @@ const FAKE_ACCOUNT = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"
describe("PoolTransactionDialog — deposit mode (#328)", () => {
let queryClient: QueryClient
- let onClose: ReturnType
- let onQueued: ReturnType
+ let onClose: Mock
+ let onQueued: Mock>
beforeEach(() => {
queryClient = new QueryClient({
diff --git a/apps/web/src/features/pools/components/pool-withdraw-dialog.test.tsx b/apps/web/src/features/pools/components/pool-withdraw-dialog.test.tsx
index 5b8ef3f8..48353e58 100644
--- a/apps/web/src/features/pools/components/pool-withdraw-dialog.test.tsx
+++ b/apps/web/src/features/pools/components/pool-withdraw-dialog.test.tsx
@@ -3,8 +3,11 @@ import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { PoolTransactionDialog } from "./pool-transaction-dialog"
+import type { Mock } from "vitest"
import type { PoolMarketConfig } from "../data/markets"
+type DialogProps = React.ComponentProps
+
vi.mock("@/lib/contracts", () => ({
getTokenClient: () => ({
balance: vi.fn().mockResolvedValue(0n),
@@ -73,8 +76,8 @@ const USER_GM_BALANCE = 100_000_000n
describe("PoolTransactionDialog — withdraw mode (#329)", () => {
let queryClient: QueryClient
- let onClose: ReturnType
- let onQueued: ReturnType
+ let onClose: Mock
+ let onQueued: Mock>
beforeEach(() => {
queryClient = new QueryClient({
diff --git a/apps/web/src/features/trade/components/chart/TVChartContainer.tsx b/apps/web/src/features/trade/components/chart/TVChartContainer.tsx
index 09a8462a..14e47629 100644
--- a/apps/web/src/features/trade/components/chart/TVChartContainer.tsx
+++ b/apps/web/src/features/trade/components/chart/TVChartContainer.tsx
@@ -17,7 +17,6 @@ import { useOracleCandles } from "../../hooks/useOracleCandles"
import { useLiveBar } from "../../hooks/useLiveBar"
import { usePositions } from "../../hooks/usePositions"
import { useOrders } from "../../hooks/useOrders"
-import { useWalletStore } from "@/features/wallet/store/wallet-store"
import {
buildCandleOptions,
buildChartOptions,
@@ -26,6 +25,7 @@ import {
} from "../../lib/chart-theme"
import type { CandlestickData, IChartApi, IPriceLine, ISeriesApi, UTCTimestamp } from "lightweight-charts"
import type { OhlcBar } from "../../lib/oracle"
+import { useWalletStore } from "@/features/wallet/store/wallet-store"
import { formatUsd } from "@/shared/lib/format"
import { ENV } from "@/app/config/env"
diff --git a/apps/web/src/features/trade/components/orderbook/DepthLadder.tsx b/apps/web/src/features/trade/components/orderbook/DepthLadder.tsx
index 4b968bc4..a5cded55 100644
--- a/apps/web/src/features/trade/components/orderbook/DepthLadder.tsx
+++ b/apps/web/src/features/trade/components/orderbook/DepthLadder.tsx
@@ -1,9 +1,9 @@
+import { useState } from "react"
import { cn } from "@workspace/ui/lib/utils"
-import { useOrderBook } from "../../hooks/useOrderBook"
-import type {OrderBookLevel} from "../../hooks/useOrderBook";
-import { formatUsd } from "@/shared/lib/format"
+import { useOrderBook } from "../../hooks/useOrderBook"
import { useRecentTrades } from "../../hooks/useRecentTrades"
-import { useState } from "react"
+import type { OrderBookLevel } from "../../hooks/useOrderBook"
+import { formatUsd } from "@/shared/lib/format"
type Props = {
symbol: string | undefined
@@ -162,13 +162,11 @@ function ColumnHeaders({ compact }: { compact: boolean }) {
function SpreadRow({
spread,
spreadPct,
- midPrice,
referencePrice,
referencePriceLabel,
}: {
spread: number | null
spreadPct: number | null
- midPrice: number | null
referencePrice: number | null
referencePriceLabel: string
}) {
@@ -217,7 +215,7 @@ export function DepthLadder({ symbol, compact = false }: Props) {
const { trades } = useRecentTrades(symbol)
// OB-054: Determine reference price — prefer last trade, fall back to mid price
- const lastTradePrice = trades[0]?.price ?? null
+ const lastTradePrice = trades.length > 0 ? trades[0].price : null
const referencePrice = lastTradePrice ?? midPrice
const referencePriceLabel = lastTradePrice !== null ? "Last" : midPrice !== null ? "Mid" : "Mark"
@@ -322,7 +320,6 @@ export function DepthLadder({ symbol, compact = false }: Props) {
diff --git a/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.test.tsx b/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.test.tsx
index b9dcafed..c2b3394f 100644
--- a/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.test.tsx
+++ b/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.test.tsx
@@ -15,8 +15,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { cleanup, render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { formatUsd } from "../../lib/trade-math"
+import type * as ContractsModule from "@/lib/contracts"
+import type * as DataStoreModule from "../../lib/data-store"
// `vi.mock` factories are hoisted above module-level declarations, so anything
// they close over has to be created inside `vi.hoisted` to avoid a TDZ error.
@@ -64,15 +67,33 @@ vi.mock("../../lib/stellar", () => ({ createSwapOrder, sendBatchOrderTxn }))
vi.mock("@/lib/soroban/simulate", () => ({
estimateFee: vi.fn(() => Promise.resolve({ total: "0.5" })),
}))
-vi.mock("@/lib/contracts/exchange-router-client", () => ({
- buildCreateOrderTransaction: vi.fn(() => Promise.resolve({})),
- buildBatchOrderTransaction: vi.fn(() => Promise.resolve({})),
-}))
+// The dialog imports the build helpers from the `@/lib/contracts` barrel, so
+// that is the module to replace — mocking a submodule the barrel never reads
+// would leave the real (RPC-simulating) implementation in place.
+vi.mock("@/lib/contracts", async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ buildCreateOrderTransaction: vi.fn(() => Promise.resolve({})),
+ buildBatchOrderTransaction: vi.fn(() => Promise.resolve({})),
+ }
+})
vi.mock("../../lib/order-encoding", () => ({
toCreateOrderParams: (o: unknown) => o,
toDecreaseOrderParams: (o: unknown) => o,
}))
+// ── Fee config: deterministic, no RPC simulation ──────────────────────────────
+vi.mock("../../lib/data-store", async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ fetchFeeConfig: vi.fn(() =>
+ Promise.resolve({ ...actual.DEFAULT_FEE_CONFIG }),
+ ),
+ }
+})
+
// ── Base UI dialog: lightweight stand-ins (render children when open) ─────────
vi.mock("@workspace/ui/components/dialog", () => ({
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
@@ -110,6 +131,11 @@ function makeTradeState() {
triggerPrice: "",
leverage: 10,
sidecarOrders: [],
+ advanced: {
+ advancedDisplay: false,
+ limitOrTPSL: false,
+ slippagePct: 0.3,
+ },
clearSidecarOrders: vi.fn(() => {}),
tradeFlags: {
isLong: true,
@@ -127,17 +153,22 @@ type DialogProps = React.ComponentProps
function renderDialog(overrides: Partial = {}) {
const onClose = vi.fn(() => {})
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ })
render(
-
+
+
+
)
return { onClose }
}
@@ -174,6 +205,14 @@ describe("ConfirmationDialog (#227)", () => {
expect(rowValue("Entry price")).toBe(formatUsd(ENTRY_PRICE)) // price
})
+ it("shows the active network in the dialog header", async () => {
+ renderDialog()
+
+ // OB-077: the header renders ENV.NETWORK from @/app/config/env.
+ // VITE_NETWORK is pinned to "testnet" in vitest.config.ts.
+ expect(await screen.findByText("Network: testnet")).toBeInTheDocument()
+ })
+
it("runs the order flow and closes when Confirm is clicked", async () => {
const user = userEvent.setup()
const { onClose } = renderDialog()
diff --git a/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.tsx b/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.tsx
index 7444813a..1fba2485 100644
--- a/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.tsx
+++ b/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState, useRef } from "react"
+import { useEffect, useMemo, useRef, useState } from "react"
import {
Dialog,
DialogContent,
@@ -24,6 +24,7 @@ import { useMarketRiskParams } from "../../hooks/useMarketRiskParams"
import { validateExecutionRequest } from "../../lib/execution-support"
import type { DecreaseOrderParams, IncreaseOrderParams } from "../../lib/stellar"
import type { useTradeState } from "../../hooks/useTradeState"
+import { ENV } from "@/app/config/env"
import { applyReferralCode } from "@/features/referrals/lib/referrals"
import {
buildBatchOrderTransaction,
@@ -132,8 +133,8 @@ export function ConfirmationDialog({
sizeUsd,
entryPrice,
leverage,
- triggerPrice: triggerPrice ?? "",
- collateralAmount: fromAmount ?? "",
+ triggerPrice,
+ collateralAmount: fromAmount,
}
if (!inputSnapshotRef.current) {
diff --git a/apps/web/src/features/trade/hooks/useSourceFreshness.test.ts b/apps/web/src/features/trade/hooks/useSourceFreshness.test.ts
index 80a6e7b6..1f68c0ee 100644
--- a/apps/web/src/features/trade/hooks/useSourceFreshness.test.ts
+++ b/apps/web/src/features/trade/hooks/useSourceFreshness.test.ts
@@ -146,11 +146,10 @@ describe("useSourceFreshness", () => {
})
it("clears the view when the symbol goes away", () => {
+ const initialProps: { symbol: string | undefined } = { symbol: "BTC" }
const { result, rerender } = renderHook(
({ symbol }: { symbol: string | undefined }) => useSourceFreshness(symbol),
- {
- initialProps: { symbol: "BTC" as string | undefined },
- },
+ { initialProps },
)
expect(result.current).not.toBeNull()
rerender({ symbol: undefined })
diff --git a/apps/web/src/features/trade/lib/oracle.test.ts b/apps/web/src/features/trade/lib/oracle.test.ts
index 1a4403ba..58476f3d 100644
--- a/apps/web/src/features/trade/lib/oracle.test.ts
+++ b/apps/web/src/features/trade/lib/oracle.test.ts
@@ -1,238 +1,38 @@
-import { describe, expect, it, vi, afterEach } from "vitest"
-import { fetchTokenPrices, validateAndSortCandles } from "./oracle"
-
-// ---------------------------------------------------------------------------
-// Helpers
-// ---------------------------------------------------------------------------
-
-function makeSo4Ticker(symbol: string, min = 80_000, max = 80_050) {
- return { token: `addr_${symbol}`, symbol, min, max, timestamp: Math.floor(Date.now() / 1000) }
-}
-
-function makeBinanceTicker(symbol: string, bid: string, ask: string) {
- return { symbol, bidPrice: bid, askPrice: ask }
-}
-
-function makeGmxTicker(symbol: string, min: string, max: string) {
- return { tokenSymbol: symbol, tokenAddress: `addr_${symbol}`, minPrice: min, maxPrice: max, updatedAt: Date.now() }
-}
-
-// ---------------------------------------------------------------------------
-// fetchTokenPrices — fallback chain
-// ---------------------------------------------------------------------------
-
-describe("fetchTokenPrices fallback chain", () => {
- afterEach(() => {
- vi.restoreAllMocks()
- })
-
- it("returns SO4 oracle prices when the primary source succeeds", async () => {
- vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
- ok: true,
- json: async () => [
- makeSo4Ticker("TWBTC", 80_000e30, 80_050e30),
- makeSo4Ticker("TETH", 2_300e30, 2_302e30),
- makeSo4Ticker("TXLM", 0.167e30, 0.1672e30),
- makeSo4Ticker("TUSDC", 0.9998e30, 1.0002e30),
- ],
- }))
+import { HttpResponse, http } from "msw"
+import { describe, expect, it, vi } from "vitest"
+import { server } from "../../../../test/msw/server"
+import { fetchTokenPrices } from "./oracle"
+
+vi.mock("./pyth", () => ({
+ fetchPythAttestations: vi.fn(async (symbols: Array) =>
+ symbols.map((symbol) => ({
+ symbol,
+ minPrice: 100,
+ maxPrice: 101,
+ publishTimeMs: 1_700_000_000_000,
+ priceUpdateData: new Uint8Array(),
+ })),
+ ),
+}))
+
+describe("fetchTokenPrices", () => {
+ it("never queries the third-party workers.dev price proxy and uses Pyth as primary", async () => {
+ let proxyHits = 0
+ server.use(
+ http.get("https://oracle.biscotti-proxy-worker.workers.dev/*", () => {
+ proxyHits += 1
+ return HttpResponse.json([
+ { token: "C", symbol: "TWBTC", min: 1e35, max: 1e35, timestamp: 1 },
+ ])
+ }),
+ http.get("https://api.binance.com/*", () => new HttpResponse(null, { status: 503 })),
+ )
const prices = await fetchTokenPrices()
- expect(prices.length).toBeGreaterThan(0)
- expect(prices.some((p) => p.source === "so4")).toBe(true)
- expect(prices.every((p) => p.source !== "fallback")).toBe(true)
- })
-
- it("falls through to Pyth when SO4 oracle fails", async () => {
- let callCount = 0
- vi.stubGlobal("fetch", vi.fn().mockImplementation((url: string) => {
- callCount++
- // SO4 oracle fails
- if (typeof url === "string" && url.includes("biscotti-proxy")) {
- return Promise.reject(new Error("SO4 down"))
- }
- // Pyth Hermes succeeds
- if (typeof url === "string" && url.includes("hermes.pyth")) {
- return Promise.resolve({
- ok: true,
- json: async () => ({
- parsed: [
- { id: "btc_id", price: { price: "8000000", expo: -2, publish_time: Math.floor(Date.now() / 1000) }, ema_price: { price: "8000000", expo: -2 } },
- ],
- }),
- })
- }
- // Binance succeeds (for display prices)
- return Promise.resolve({
- ok: true,
- json: async () => [
- makeBinanceTicker("BTCUSDT", "79990", "80010"),
- makeBinanceTicker("ETHUSDT", "2299", "2301"),
- makeBinanceTicker("XLMUSDT", "0.166", "0.168"),
- makeBinanceTicker("USDCUSDT", "0.9997", "1.0003"),
- ],
- })
- }))
-
- const prices = await fetchTokenPrices()
- // Should not use SO4 source since it failed
- expect(prices.every((p) => p.source !== "so4")).toBe(true)
- })
-
- it("falls through to Binance when SO4 and Pyth both fail", async () => {
- vi.stubGlobal("fetch", vi.fn().mockImplementation((url: string) => {
- if (typeof url === "string" && url.includes("biscotti-proxy")) {
- return Promise.reject(new Error("SO4 down"))
- }
- if (typeof url === "string" && url.includes("hermes.pyth")) {
- return Promise.reject(new Error("Pyth down"))
- }
- // Binance succeeds
- return Promise.resolve({
- ok: true,
- json: async () => [
- makeBinanceTicker("BTCUSDT", "79990", "80010"),
- makeBinanceTicker("ETHUSDT", "2299", "2301"),
- makeBinanceTicker("XLMUSDT", "0.166", "0.168"),
- makeBinanceTicker("USDCUSDT", "0.9997", "1.0003"),
- ],
- })
- }))
-
- const prices = await fetchTokenPrices()
- expect(prices.some((p) => p.source === "binance")).toBe(true)
- expect(prices.every((p) => p.source !== "so4")).toBe(true)
- expect(prices.every((p) => p.source !== "pyth")).toBe(true)
- })
-
- it("falls through to GMX oracle when SO4, Pyth, and Binance all fail", async () => {
- vi.stubGlobal("fetch", vi.fn().mockImplementation((url: string) => {
- if (typeof url === "string" && url.includes("biscotti-proxy")) {
- return Promise.reject(new Error("SO4 down"))
- }
- if (typeof url === "string" && url.includes("hermes.pyth")) {
- return Promise.reject(new Error("Pyth down"))
- }
- if (typeof url === "string" && url.includes("binance.com")) {
- return Promise.reject(new Error("Binance down"))
- }
- // GMX oracle succeeds
- return Promise.resolve({
- ok: true,
- json: async () => [
- makeGmxTicker("BTC", "80000000000000000000000000000000000", "80050000000000000000000000000000000"),
- makeGmxTicker("ETH", "2300000000000000000000000000000000", "2302000000000000000000000000000000"),
- ],
- })
- }))
-
- const prices = await fetchTokenPrices()
- expect(prices.some((p) => p.source === "gmx")).toBe(true)
- })
-
- it("returns dummy fallback prices when all sources fail", async () => {
- vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("All sources down")))
-
- const prices = await fetchTokenPrices()
-
- expect(prices.length).toBeGreaterThan(0)
- expect(prices.every((p) => p.source === "fallback")).toBe(true)
- // Dummy prices are reasonable values
+ expect(proxyHits).toBe(0)
const btc = prices.find((p) => p.symbol === "BTC")
- expect(btc).toBeDefined()
- expect(btc!.minPrice).toBeGreaterThan(0)
- })
-
- it("falls back to dummy when SO4 returns an empty array", async () => {
- vi.stubGlobal("fetch", vi.fn().mockImplementation((url: string) => {
- if (typeof url === "string" && url.includes("biscotti-proxy")) {
- // Empty response — no tickers
- return Promise.resolve({ ok: true, json: async () => [] })
- }
- return Promise.reject(new Error("other sources down"))
- }))
-
- const prices = await fetchTokenPrices()
- expect(prices.every((p) => p.source === "fallback")).toBe(true)
- })
-
- it("falls back gracefully when SO4 returns a non-ok HTTP status", async () => {
- vi.stubGlobal("fetch", vi.fn().mockImplementation((url: string) => {
- if (typeof url === "string" && url.includes("biscotti-proxy")) {
- return Promise.resolve({ ok: false, status: 503 })
- }
- return Promise.reject(new Error("other sources down"))
- }))
-
- const prices = await fetchTokenPrices()
- // Should reach dummy prices without throwing
- expect(Array.isArray(prices)).toBe(true)
- expect(prices.length).toBeGreaterThan(0)
- })
-})
-
-// ---------------------------------------------------------------------------
-// validateAndSortCandles
-// ---------------------------------------------------------------------------
-
-describe("validateAndSortCandles", () => {
- const bar = (time: number, open = 100, high = 105, low = 95, close = 102) => ({
- time, open, high, low, close,
- })
-
- it("returns an empty array for non-array input", () => {
- // @ts-expect-error intentional bad input
- expect(validateAndSortCandles(null)).toEqual([])
- // @ts-expect-error intentional bad input
- expect(validateAndSortCandles("bad")).toEqual([])
- })
-
- it("sorts candles chronologically", () => {
- const result = validateAndSortCandles([bar(300), bar(100), bar(200)])
- expect(result.map((c) => c.time)).toEqual([100, 200, 300])
- })
-
- it("drops candles with non-positive prices", () => {
- const result = validateAndSortCandles([
- bar(100),
- { time: 200, open: -1, high: 105, low: 95, close: 102 },
- { time: 300, open: 100, high: 105, low: 95, close: 0 },
- ])
- expect(result.length).toBe(1)
- expect(result[0]?.time).toBe(100)
- })
-
- it("drops candles where high < low", () => {
- const invalid = { time: 100, open: 100, high: 90, low: 95, close: 100 }
- expect(validateAndSortCandles([invalid])).toEqual([])
- })
-
- it("drops candles where high < open or high < close", () => {
- const highLtOpen = { time: 100, open: 110, high: 105, low: 95, close: 100 }
- const highLtClose = { time: 200, open: 100, high: 105, low: 95, close: 110 }
- expect(validateAndSortCandles([highLtOpen])).toEqual([])
- expect(validateAndSortCandles([highLtClose])).toEqual([])
- })
-
- it("drops duplicate timestamps, keeping the first encountered", () => {
- const result = validateAndSortCandles([bar(100), bar(100, 101)])
- expect(result.length).toBe(1)
- expect(result[0]?.open).toBe(100)
- })
-
- it("converts millisecond timestamps to seconds", () => {
- const msBar = { time: 100_000 * 1000, open: 100, high: 105, low: 95, close: 102 }
- const result = validateAndSortCandles([msBar])
- expect(result[0]?.time).toBe(100_000)
- })
-
- it("drops candles with non-finite or zero timestamps", () => {
- const bad = [
- { time: NaN, open: 100, high: 105, low: 95, close: 102 },
- { time: 0, open: 100, high: 105, low: 95, close: 102 },
- { time: -100, open: 100, high: 105, low: 95, close: 102 },
- ]
- expect(validateAndSortCandles(bad)).toEqual([])
+ expect(btc?.source).toBe("pyth")
+ expect(btc?.minPrice).toBe(100)
})
})
diff --git a/apps/web/src/features/trade/lib/oracle.ts b/apps/web/src/features/trade/lib/oracle.ts
index 5a1985a8..22abcd41 100644
--- a/apps/web/src/features/trade/lib/oracle.ts
+++ b/apps/web/src/features/trade/lib/oracle.ts
@@ -1,4 +1,4 @@
-// Oracle price feed — SO4 oracle (primary), Pyth Hermes (on-chain), Binance REST (display/candles).
+// Oracle price feed — Pyth Hermes (primary, on-chain), Binance REST (display/candles), GMX (last resort).
//
// Symbol conventions used in this file:
// base symbol — "BTC", "ETH", "XLM", "USDC" (used by external APIs)
@@ -11,7 +11,7 @@ import { ENV } from "../../../app/config/env"
import { fetchPythAttestations } from "./pyth"
import { formatPct } from "@/shared/lib/format"
-export type PriceSource = "so4" | "pyth" | "binance" | "gmx" | "fallback"
+export type PriceSource = "pyth" | "binance" | "gmx" | "fallback"
export type TokenPrice = {
symbol: string
@@ -104,39 +104,6 @@ function pctStr(pct: number): string {
return formatPct(pct)
}
-// ─── SO4 Oracle (primary price source) ───────────────────────────────────────
-
-const SO4_ORACLE_URL = "https://oracle.biscotti-proxy-worker.workers.dev"
-
-type So4OracleTicker = {
- token: string // contract address
- symbol: string // TWBTC, TETH, TXLM, TUSDC
- min: number // usdPrice × 10^30 (parsed as float — precision loss at low digits is acceptable)
- max: number
- timestamp: number // Unix seconds
-}
-
-async function fetchSo4OraclePrices(): Promise