diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29fbbaa..de6abb9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,31 +1,43 @@ -name: Continuous Integration +name: CI on: pull_request: - types: - - opened - - reopened - - synchronize + +permissions: + contents: read concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: - check: + checks: + name: Lint, build and test runs-on: ubuntu-latest + steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: jdx/mise-action@v2 + - name: Install mise + run: | + curl https://mise.run | MISE_VERSION=v2026.5.6 sh + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + echo "$HOME/.local/share/mise/shims" >> "$GITHUB_PATH" + export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH" + mise install - - run: pnpm install --frozen-lockfile + - name: Install dependencies + run: pnpm install --frozen-lockfile - name: Install Playwright Browsers run: pnpm exec playwright install --with-deps - - run: pnpm run lint + - name: Lint + run: pnpm lint - - run: pnpm run build + - name: Build + run: pnpm build - - run: pnpm run test + - name: Test + run: pnpm test diff --git a/.gitignore b/.gitignore index c2283c8..a4df2dc 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ dist-ssr opensrc/ .turbo -*.tsbuildinfo \ No newline at end of file +*.tsbuildinfo +.pnpm-store/ \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..e56fd6d --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule ".external/effect-smol"] + path = .external/effect-smol + url = https://github.com/Effect-TS/effect-smol.git diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..47a9d36 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,25 @@ +# AGENTS.md + +Instructions for AI coding agents working with this codebase. + +## Commands + +After each code change, run these commands to confirm that nothing is broken + +- To lint and typecheck, run: `pnpm turbo lint --filter=@yieldxyz/perps-{package}`, +- For code formatting, run: `pnpm turbo format --filter=@yieldxyz/perps-{package}`, +- If a file is added, removed, or changed in `routes` folder, to generate route tree run: `pnpm turbo generate-routes --filter=@yieldxyz/perps-{package}` + +## General rules + +- Don't mix components/UI and logic - separate those two in hooks and components files +- All state handling should be done with `@effect-atom/atom-react` +- Use `effect` as much as possible. Don't use async/await +- Math/finance calculations and functions are placed in `packages/common/src/lib/math.ts` +- For manipulating array and records use `effect/Array` and `effect/Record` +- Don't use hooks `useMemo` or `useCallback` as we're using react-compiler +- During refactoring, avoid re-exporting functionality. Update imports in file thats importing this functionality + +## Source code references + +- Check `.external/effect-smol` for all references related to effect. Do not look up web for docs. They are not available yet as this is beta version \ No newline at end of file diff --git a/apps/dashboard-react-example/.env.example b/apps/dashboard-react-example/.env.example new file mode 100644 index 0000000..1398a4d --- /dev/null +++ b/apps/dashboard-react-example/.env.example @@ -0,0 +1,4 @@ +VITE_PERPS_BASE_URL= +VITE_PERPS_API_KEY= +VITE_REOWN_PROJECT_ID= +VITE_MORALIS_API_KEY= diff --git a/apps/dashboard-react-example/index.html b/apps/dashboard-react-example/index.html new file mode 100644 index 0000000..7724a0c --- /dev/null +++ b/apps/dashboard-react-example/index.html @@ -0,0 +1,12 @@ + + + + + + Perps Dashboard React Example + + +
+ + + diff --git a/apps/dashboard-react-example/package.json b/apps/dashboard-react-example/package.json new file mode 100644 index 0000000..f3dea79 --- /dev/null +++ b/apps/dashboard-react-example/package.json @@ -0,0 +1,22 @@ +{ + "name": "@yieldxyz/perps-dashboard-react-example", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 3102", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@yieldxyz/perps-dashboard": "workspace:*", + "react": "catalog:", + "react-dom": "catalog:" + }, + "devDependencies": { + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" + } +} diff --git a/apps/dashboard-react-example/src/main.tsx b/apps/dashboard-react-example/src/main.tsx new file mode 100644 index 0000000..7f1ec9e --- /dev/null +++ b/apps/dashboard-react-example/src/main.tsx @@ -0,0 +1,19 @@ +import { Dashboard, type PerpsConfig } from "@yieldxyz/perps-dashboard"; +import "@yieldxyz/perps-dashboard/styles.css"; +import { createRoot } from "react-dom/client"; +import "./styles.css"; + +const config: PerpsConfig = { + perpsBaseUrl: import.meta.env.VITE_PERPS_BASE_URL, + perpsApiKey: import.meta.env.VITE_PERPS_API_KEY, + reownProjectId: import.meta.env.VITE_REOWN_PROJECT_ID || undefined, + moralisApiKey: import.meta.env.VITE_MORALIS_API_KEY, +}; + +const rootElement = document.getElementById("root"); + +if (!rootElement) { + throw new Error("Root element not found"); +} + +createRoot(rootElement).render(); diff --git a/apps/dashboard-react-example/src/styles.css b/apps/dashboard-react-example/src/styles.css new file mode 100644 index 0000000..cc764a9 --- /dev/null +++ b/apps/dashboard-react-example/src/styles.css @@ -0,0 +1,14 @@ +html { + background: #121314; +} + +body { + margin: 0; + min-width: 320px; + background: #121314; +} + +#root { + min-height: 100vh; + background: #121314; +} diff --git a/apps/dashboard-react-example/tsconfig.json b/apps/dashboard-react-example/tsconfig.json new file mode 100644 index 0000000..772e4bf --- /dev/null +++ b/apps/dashboard-react-example/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/apps/dashboard-react-example/vite.config.ts b/apps/dashboard-react-example/vite.config.ts new file mode 100644 index 0000000..58676f7 --- /dev/null +++ b/apps/dashboard-react-example/vite.config.ts @@ -0,0 +1,6 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], +}); diff --git a/apps/dashboard-vanilla-example/.env.example b/apps/dashboard-vanilla-example/.env.example new file mode 100644 index 0000000..1398a4d --- /dev/null +++ b/apps/dashboard-vanilla-example/.env.example @@ -0,0 +1,4 @@ +VITE_PERPS_BASE_URL= +VITE_PERPS_API_KEY= +VITE_REOWN_PROJECT_ID= +VITE_MORALIS_API_KEY= diff --git a/apps/dashboard-vanilla-example/index.html b/apps/dashboard-vanilla-example/index.html new file mode 100644 index 0000000..d30459a --- /dev/null +++ b/apps/dashboard-vanilla-example/index.html @@ -0,0 +1,12 @@ + + + + + + Perps Dashboard Vanilla Example + + +
+ + + diff --git a/apps/dashboard-vanilla-example/package.json b/apps/dashboard-vanilla-example/package.json new file mode 100644 index 0000000..7680a90 --- /dev/null +++ b/apps/dashboard-vanilla-example/package.json @@ -0,0 +1,16 @@ +{ + "name": "@yieldxyz/perps-dashboard-vanilla-example", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 3103", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@yieldxyz/perps-dashboard": "workspace:*" + }, + "devDependencies": { + "vite": "catalog:" + } +} diff --git a/apps/dashboard-vanilla-example/src/main.js b/apps/dashboard-vanilla-example/src/main.js new file mode 100644 index 0000000..f881073 --- /dev/null +++ b/apps/dashboard-vanilla-example/src/main.js @@ -0,0 +1,16 @@ +import { mountDashboard } from "@yieldxyz/perps-dashboard/vanilla"; +import "@yieldxyz/perps-dashboard/styles.css"; +import "./styles.css"; + +const config = { + perpsBaseUrl: import.meta.env.VITE_PERPS_BASE_URL, + perpsApiKey: import.meta.env.VITE_PERPS_API_KEY, + reownProjectId: import.meta.env.VITE_REOWN_PROJECT_ID || undefined, + moralisApiKey: import.meta.env.VITE_MORALIS_API_KEY, +}; + +const dashboard = mountDashboard("#app", { config }); + +if (import.meta.hot) { + import.meta.hot.dispose(() => dashboard.unmount()); +} diff --git a/apps/dashboard-vanilla-example/src/styles.css b/apps/dashboard-vanilla-example/src/styles.css new file mode 100644 index 0000000..86521f5 --- /dev/null +++ b/apps/dashboard-vanilla-example/src/styles.css @@ -0,0 +1,14 @@ +html { + background: #121314; +} + +body { + margin: 0; + min-width: 320px; + background: #121314; +} + +#app { + min-height: 100vh; + background: #121314; +} diff --git a/apps/dashboard-vanilla-example/vite.config.js b/apps/dashboard-vanilla-example/vite.config.js new file mode 100644 index 0000000..6150f9a --- /dev/null +++ b/apps/dashboard-vanilla-example/vite.config.js @@ -0,0 +1,3 @@ +import { defineConfig } from "vite"; + +export default defineConfig({}); diff --git a/apps/widget-react-example/.env.example b/apps/widget-react-example/.env.example new file mode 100644 index 0000000..1398a4d --- /dev/null +++ b/apps/widget-react-example/.env.example @@ -0,0 +1,4 @@ +VITE_PERPS_BASE_URL= +VITE_PERPS_API_KEY= +VITE_REOWN_PROJECT_ID= +VITE_MORALIS_API_KEY= diff --git a/apps/widget-react-example/index.html b/apps/widget-react-example/index.html new file mode 100644 index 0000000..1f8080e --- /dev/null +++ b/apps/widget-react-example/index.html @@ -0,0 +1,12 @@ + + + + + + Perps Widget React Example + + +
+ + + diff --git a/apps/widget-react-example/package.json b/apps/widget-react-example/package.json new file mode 100644 index 0000000..7359d1d --- /dev/null +++ b/apps/widget-react-example/package.json @@ -0,0 +1,22 @@ +{ + "name": "@yieldxyz/perps-widget-react-example", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 3100", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@yieldxyz/perps-widget": "workspace:*", + "react": "catalog:", + "react-dom": "catalog:" + }, + "devDependencies": { + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" + } +} diff --git a/apps/widget-react-example/src/main.tsx b/apps/widget-react-example/src/main.tsx new file mode 100644 index 0000000..debf9b8 --- /dev/null +++ b/apps/widget-react-example/src/main.tsx @@ -0,0 +1,20 @@ +import { type PerpsConfig, Widget } from "@yieldxyz/perps-widget"; +import "@yieldxyz/perps-widget/styles.css"; +import { createRoot } from "react-dom/client"; +import "./styles.css"; + +const config: PerpsConfig = { + perpsBaseUrl: "https://perps.yield.xyz", + perpsApiKey: "e2d627cf-2ae3-4775-9fbc-76819c7cae38", + reownProjectId: "29e1b718ad16983a0705cf24d5b5b747", + moralisApiKey: + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJub25jZSI6IjhlNTk0NDE0LTg3NGMtNDZlMC1iMWNlLWU5ZjYzMjY1YWExMiIsIm9yZ0lkIjoiNDI2Nzk1IiwidXNlcklkIjoiNDM4OTk2IiwidHlwZSI6IlBST0pFQ1QiLCJ0eXBlSWQiOiI2YzMwMDY3Yi1kNDEyLTQwYjYtYTQ4OS0zYjEwM2ExNThjOGMiLCJpYXQiOjE3Mzc0NTA4NzcsImV4cCI6NDg5MzIxMDg3N30.pd8sKdRHdtlqoZVS7wb8Jyy2GLhhr95X8yW64W_gSC0", +}; + +const rootElement = document.getElementById("root"); + +if (!rootElement) { + throw new Error("Root element not found"); +} + +createRoot(rootElement).render(); diff --git a/apps/widget-react-example/src/styles.css b/apps/widget-react-example/src/styles.css new file mode 100644 index 0000000..582501e --- /dev/null +++ b/apps/widget-react-example/src/styles.css @@ -0,0 +1,24 @@ +html { + background: #131517; +} + +body { + margin: 0; + min-width: 320px; + background: #131517; +} + +#root { + min-height: 100vh; + display: flex; + justify-content: center; + align-items: flex-start; + padding: 30px 16px; + box-sizing: border-box; +} + +@media (min-width: 768px) { + #root { + padding: 32px; + } +} diff --git a/apps/widget-react-example/tsconfig.json b/apps/widget-react-example/tsconfig.json new file mode 100644 index 0000000..772e4bf --- /dev/null +++ b/apps/widget-react-example/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/apps/widget-react-example/vite.config.ts b/apps/widget-react-example/vite.config.ts new file mode 100644 index 0000000..58676f7 --- /dev/null +++ b/apps/widget-react-example/vite.config.ts @@ -0,0 +1,6 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], +}); diff --git a/apps/widget-vanilla-example/.env.example b/apps/widget-vanilla-example/.env.example new file mode 100644 index 0000000..1398a4d --- /dev/null +++ b/apps/widget-vanilla-example/.env.example @@ -0,0 +1,4 @@ +VITE_PERPS_BASE_URL= +VITE_PERPS_API_KEY= +VITE_REOWN_PROJECT_ID= +VITE_MORALIS_API_KEY= diff --git a/apps/widget-vanilla-example/index.html b/apps/widget-vanilla-example/index.html new file mode 100644 index 0000000..88efde6 --- /dev/null +++ b/apps/widget-vanilla-example/index.html @@ -0,0 +1,12 @@ + + + + + + Perps Widget Vanilla Example + + +
+ + + diff --git a/apps/widget-vanilla-example/package.json b/apps/widget-vanilla-example/package.json new file mode 100644 index 0000000..032c5f4 --- /dev/null +++ b/apps/widget-vanilla-example/package.json @@ -0,0 +1,16 @@ +{ + "name": "@yieldxyz/perps-widget-vanilla-example", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 3101", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@yieldxyz/perps-widget": "workspace:*" + }, + "devDependencies": { + "vite": "catalog:" + } +} diff --git a/apps/widget-vanilla-example/src/main.js b/apps/widget-vanilla-example/src/main.js new file mode 100644 index 0000000..1aa0457 --- /dev/null +++ b/apps/widget-vanilla-example/src/main.js @@ -0,0 +1,17 @@ +import { mountWidget } from "@yieldxyz/perps-widget/vanilla"; +import "@yieldxyz/perps-widget/styles.css"; +import "./styles.css"; + +const config = { + perpsBaseUrl: "https://perps.yield.xyz", + perpsApiKey: "e2d627cf-2ae3-4775-9fbc-76819c7cae38", + reownProjectId: "29e1b718ad16983a0705cf24d5b5b747" || undefined, + moralisApiKey: + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJub25jZSI6IjhlNTk0NDE0LTg3NGMtNDZlMC1iMWNlLWU5ZjYzMjY1YWExMiIsIm9yZ0lkIjoiNDI2Nzk1IiwidXNlcklkIjoiNDM4OTk2IiwidHlwZSI6IlBST0pFQ1QiLCJ0eXBlSWQiOiI2YzMwMDY3Yi1kNDEyLTQwYjYtYTQ4OS0zYjEwM2ExNThjOGMiLCJpYXQiOjE3Mzc0NTA4NzcsImV4cCI6NDg5MzIxMDg3N30.pd8sKdRHdtlqoZVS7wb8Jyy2GLhhr95X8yW64W_gSC0", +}; + +const widget = mountWidget("#app", { config }); + +if (import.meta.hot) { + import.meta.hot.dispose(() => widget.unmount()); +} diff --git a/apps/widget-vanilla-example/src/styles.css b/apps/widget-vanilla-example/src/styles.css new file mode 100644 index 0000000..943f19e --- /dev/null +++ b/apps/widget-vanilla-example/src/styles.css @@ -0,0 +1,24 @@ +html { + background: #131517; +} + +body { + margin: 0; + min-width: 320px; + background: #131517; +} + +#app { + min-height: 100vh; + display: flex; + justify-content: center; + align-items: flex-start; + padding: 30px 16px; + box-sizing: border-box; +} + +@media (min-width: 768px) { + #app { + padding: 32px; + } +} diff --git a/apps/widget-vanilla-example/vite.config.js b/apps/widget-vanilla-example/vite.config.js new file mode 100644 index 0000000..6150f9a --- /dev/null +++ b/apps/widget-vanilla-example/vite.config.js @@ -0,0 +1,3 @@ +import { defineConfig } from "vite"; + +export default defineConfig({}); diff --git a/mise.lock b/mise.lock new file mode 100644 index 0000000..2d16fd4 --- /dev/null +++ b/mise.lock @@ -0,0 +1,37 @@ +# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html + +[[tools.node]] +version = "24.15.0" +backend = "core:node" + +[tools.node."platforms.linux-arm64"] +checksum = "sha256:73afc234d558c24919875f51c2d1ea002a2ada4ea6f83601a383869fefa64eed" +url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-linux-arm64.tar.gz" + +[tools.node."platforms.linux-arm64-musl"] +checksum = "sha256:31e98aa960a067da91edffd5d93bc46657b5d2a8029612c359f5f2ac0060152a" +url = "https://unofficial-builds.nodejs.org/download/release/v24.15.0/node-v24.15.0-linux-arm64-musl.tar.gz" + +[tools.node."platforms.linux-x64"] +checksum = "sha256:44836872d9aec49f1e6b52a9a922872db9a2b02d235a616a5681b6a85fec8d89" +url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-linux-x64.tar.gz" + +[tools.node."platforms.linux-x64-musl"] +checksum = "sha256:f55af5bd489c5347b113ca6594cae00a54b30ba57ac5875324311bfc6f4762e3" +url = "https://unofficial-builds.nodejs.org/download/release/v24.15.0/node-v24.15.0-linux-x64-musl.tar.gz" + +[tools.node."platforms.macos-arm64"] +checksum = "sha256:372331b969779ab5d15b949884fc6eaf88d5afe87bde8ba881d6400b9100ffc4" +url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-darwin-arm64.tar.gz" + +[tools.node."platforms.macos-x64"] +checksum = "sha256:ffd5ee293467927f3ee731a553eb88fd1f48cf74eebc2d74a6babe4af228673b" +url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-darwin-x64.tar.gz" + +[tools.node."platforms.windows-x64"] +checksum = "sha256:cc5149eabd53779ce1e7bdc5401643622d0c7e6800ade18928a767e940bb0e62" +url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-win-x64.zip" + +[[tools."npm:pnpm"]] +version = "10.33.2" +backend = "npm:pnpm" diff --git a/mise.toml b/mise.toml index 3f1baa1..921b2eb 100644 --- a/mise.toml +++ b/mise.toml @@ -1,4 +1,4 @@ [tools] -node="24" -pnpm="10" \ No newline at end of file +node="24.15.0" +"npm:pnpm"="10.33.2" \ No newline at end of file diff --git a/package.json b/package.json index b58535c..73198b1 100644 --- a/package.json +++ b/package.json @@ -17,11 +17,15 @@ "prepare": "husky" }, "devDependencies": { - "turbo": "catalog:", "@biomejs/biome": "catalog:", - "@effect/language-service": "catalog:", "@commitlint/cli": "catalog:", "@commitlint/config-conventional": "catalog:", - "husky": "catalog:" + "@effect/language-service": "catalog:", + "husky": "catalog:", + "json-schema-typed": "^8.0.2", + "openapi-typescript": "^7.13.0", + "swagger2openapi": "^7.0.8", + "turbo": "catalog:", + "yaml": "^2.8.4" } } diff --git a/packages/common/package.json b/packages/common/package.json index cdf0d07..f04af27 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -10,7 +10,7 @@ "test": "vitest run", "copy-assets": "cp -r src/assets dist/src && cp -r src/styles dist/src/styles", "lint": "biome check . && tsc -b", - "format": "biome format --write .", + "format": "biome check --write .", "generate-client-factory": "tsx --env-file .env scripts/generate-client-factory.ts", "generate-tradingview-symbols": "tsx --env-file .env scripts/generate-tradingview-symbols/index.ts" }, @@ -55,11 +55,7 @@ }, "dependencies": { "@base-ui/react": "catalog:", - "@effect-atom/atom-react": "catalog:", - "@effect/experimental": "catalog:", - "@effect/platform": "catalog:", "@effect/platform-node": "catalog:", - "@ledgerhq/wallet-api-client": "catalog:", "@lucas-barake/effect-form-react": "catalog:", "@nktkas/hyperliquid": "catalog:", "@reown/appkit": "catalog:", @@ -82,12 +78,13 @@ "tailwindcss": "catalog:", "tw-animate-css": "catalog:", "viem": "catalog:", - "wagmi": "catalog:" + "wagmi": "catalog:", + "@effect/atom-react": "catalog:" }, "devDependencies": { + "@rolldown/plugin-babel": "catalog:", "@tanstack/devtools-vite": "catalog:", "@tanstack/router-cli": "catalog:", - "@tim-smart/openapi-gen": "catalog:", "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", @@ -102,6 +99,7 @@ "vite": "catalog:", "vite-plugin-node-polyfills": "catalog:", "vitest": "catalog:", - "vitest-browser-react": "catalog:" + "vitest-browser-react": "catalog:", + "@effect/openapi-generator": "catalog:" } } diff --git a/packages/common/scripts/generate-client-factory.ts b/packages/common/scripts/generate-client-factory.ts index 0b8351e..e46dcb4 100644 --- a/packages/common/scripts/generate-client-factory.ts +++ b/packages/common/scripts/generate-client-factory.ts @@ -1,119 +1,138 @@ +import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { - Command, - CommandExecutor, - FetchHttpClient, - FileSystem, - HttpClient, - Path, -} from "@effect/platform"; -import { NodeContext, NodeRuntime } from "@effect/platform-node"; -import { Config, Effect, Layer } from "effect"; +import { NodeRuntime, NodeServices } from "@effect/platform-node"; +import { Effect, Fiber, FileSystem, Layer, Stream } from "effect"; +import { FetchHttpClient, HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; const fetchOpenApiSpecs = Effect.gen(function* () { const client = yield* HttpClient.HttpClient; const fs = yield* FileSystem.FileSystem; - const perpsDocsUrl = yield* Config.string("PERPS_DOCS_URL"); - const perpsJsonPath = yield* perpsOpenApiJsonPath; + const perpsDocsUrl = process.env.PERPS_DOCS_URL; + if (!perpsDocsUrl) { + return yield* Effect.fail(new Error("PERPS_DOCS_URL is not set")); + } yield* client.get(perpsDocsUrl).pipe( Effect.andThen((response) => response.text), - Effect.andThen((txt) => fs.writeFileString(perpsJsonPath, txt)), + Effect.andThen((txt) => fs.writeFileString(perpsOpenApiJsonPath, txt)), ); }); const generateClientFactory = Effect.gen(function* () { - const ce = yield* CommandExecutor.CommandExecutor; const fs = yield* FileSystem.FileSystem; - const output = yield* ce.string( - Command.make( - "pnpm", - "openapi-gen", - "-n", - "SKClient", - "-t", - "-s", - yield* perpsOpenApiJsonPath, - ), - ); - - const outputSchemas = yield* ce.string( - Command.make( - "pnpm", - "openapi-gen", - "-n", - "SKClient", - "-s", - yield* perpsOpenApiJsonPath, - ), - ); - - if (yield* fs.exists(yield* generateClientPath)) { - yield* fs.remove(yield* generateClientPath); - } - yield* fs.writeFileString(yield* generateClientPath, output); - - if (yield* fs.exists(yield* clientSchemasPath)) { - yield* fs.remove(yield* clientSchemasPath); - } - yield* fs.writeFileString(yield* clientSchemasPath, outputSchemas); - - yield* fs.remove(yield* perpsOpenApiJsonPath); -}); - -const formatClientFactory = Effect.gen(function* () { - const ce = yield* CommandExecutor.CommandExecutor; - - yield* ce.exitCode( - Command.make("biome", "format", "--write", yield* generateClientPath), - ); - yield* ce.exitCode( - Command.make("biome", "format", "--write", yield* clientSchemasPath), - ); + const output = yield* runCommand("pnpm", [ + "openapigen", + "-n", + "SKClient", + "-f", + "httpclient-type-only", + "-s", + perpsOpenApiJsonPath, + "-p", + openApiPatch, + ]); + + const outputSchemas = yield* runCommand("pnpm", [ + "openapigen", + "-n", + "SKClient", + "-f", + "httpclient", + "-s", + perpsOpenApiJsonPath, + "-p", + openApiPatch, + ]); + + const clientSchemas = sanitizeGeneratedSource(outputSchemas); + + yield* fs.writeFileString(generateClientPath, output); + yield* fs.writeFileString(clientSchemasPath, clientSchemas); + yield* fs.remove(perpsOpenApiJsonPath, { force: true }); }); const program = Effect.gen(function* () { yield* fetchOpenApiSpecs; yield* generateClientFactory; - yield* formatClientFactory; }); -const layer = Layer.mergeAll(FetchHttpClient.layer, NodeContext.layer); - -program.pipe(Effect.scoped, Effect.provide(layer), NodeRuntime.runMain); - -const __dirname = Path.Path.pipe( - Effect.andThen((p) => p.dirname(fileURLToPath(import.meta.url))), +program.pipe( + Effect.provide(Layer.mergeAll(FetchHttpClient.layer, NodeServices.layer)), + NodeRuntime.runMain, ); -const perpsOpenApiJsonPath = Effect.all({ - p: Path.Path, - __dirname, -}).pipe(Effect.andThen(({ p, __dirname }) => p.join(__dirname, "perps.json"))); +const __dirname = dirname(fileURLToPath(import.meta.url)); -const generateClientPath = Effect.all({ - p: Path.Path, +const perpsOpenApiJsonPath = join(__dirname, "perps.json"); + +const generateClientPath = join( __dirname, -}).pipe( - Effect.andThen(({ p, __dirname }) => - p.join( - __dirname, - "..", - "src", - "services", - "api-client", - "client-factory.ts", - ), - ), + "..", + "src", + "services", + "api-client", + "client-factory.ts", ); -const clientSchemasPath = Effect.all({ - p: Path.Path, +const clientSchemasPath = join( __dirname, -}).pipe( - Effect.andThen(({ p, __dirname }) => - p.join(__dirname, "..", "src", "services", "api-client", "api-schemas.ts"), - ), + "..", + "src", + "services", + "api-client", + "api-schemas.ts", ); + +const openApiPatch = JSON.stringify([ + { + op: "replace", + path: "/components/schemas/ArgumentSchemaPropertyDto/properties/items", + value: { + type: "object", + description: "Items schema (for arrays)", + }, + }, +]); + +const sanitizeGeneratedSource = (source: string) => + source.replaceAll( + '"examples": [["open","close","updateLeverage"]]', + '"examples": ["open"]', + ); + +const runCommand = (command: string, args: ReadonlyArray) => + Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const process = yield* spawner.spawn(ChildProcess.make(command, args)); + const stdoutFiber = yield* process.stdout.pipe( + Stream.decodeText(), + Stream.mkString, + Effect.forkChild, + ); + const stderrFiber = yield* process.stderr.pipe( + Stream.decodeText(), + Stream.mkString, + Effect.forkChild, + ); + const exitCode = yield* process.exitCode; + const stdout = yield* Fiber.join(stdoutFiber); + const stderr = yield* Fiber.join(stderrFiber); + + if (stderr.length > 0) { + yield* Effect.sync(() => console.error(stderr)); + } + + const exitCodeNumber = Number(exitCode); + if (exitCodeNumber !== 0) { + return yield* Effect.fail( + new Error(stderr || `${command} exited with code ${exitCodeNumber}`), + ); + } + + return stdout; + }), + ); diff --git a/packages/common/scripts/generate-tradingview-symbols/index.ts b/packages/common/scripts/generate-tradingview-symbols/index.ts index 12abbcd..f2f0b79 100644 --- a/packages/common/scripts/generate-tradingview-symbols/index.ts +++ b/packages/common/scripts/generate-tradingview-symbols/index.ts @@ -1,13 +1,20 @@ import { writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { + Array as _Array, + Config, + Context, + Effect, + Layer, + Logger, +} from "effect"; import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse, -} from "@effect/platform"; -import { Array as _Array, Config, Effect, Layer, Logger } from "effect"; +} from "effect/unstable/http"; import { type BaseSymbolSchema, byProviderAndCurrency, @@ -24,11 +31,10 @@ import { // ----------------------------------------------------------------------------- // HttpClient Services // ----------------------------------------------------------------------------- -class PerpsClient extends Effect.Service()( +class PerpsClient extends Context.Service()( "perps/scripts/audit-tradingview-symbols/PerpsClient", { - dependencies: [FetchHttpClient.layer], - effect: Effect.gen(function* () { + make: Effect.gen(function* () { const baseUrl = yield* Config.string("PERPS_BASE_URL"); const apiKey = yield* Config.string("PERPS_API_KEY"); const client = yield* HttpClient.HttpClient; @@ -43,13 +49,16 @@ class PerpsClient extends Effect.Service()( ); }), }, -) {} +) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(FetchHttpClient.layer), + ); +} -class TradingViewClient extends Effect.Service()( +class TradingViewClient extends Context.Service()( "perps/scripts/audit-tradingview-symbols/TradingViewClient", { - dependencies: [FetchHttpClient.layer], - effect: Effect.gen(function* () { + make: Effect.gen(function* () { const client = yield* HttpClient.HttpClient; return client.pipe( @@ -68,7 +77,11 @@ class TradingViewClient extends Effect.Service()( ); }), }, -) {} +) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(FetchHttpClient.layer), + ); +} // ----------------------------------------------------------------------------- // API Functions @@ -127,15 +140,15 @@ const getAllMarketsForProvider = Effect.fn(function* (providerId: string) { }); const totalPages = Math.ceil(firstPage.total / DEFAULT_LIMIT); - const restPages = yield* Effect.allSuccesses( + const restPages = yield* Effect.all( Array.from({ length: totalPages - 1 }).map((_, index) => getMarketsPage({ providerId, offset: (index + 1) * DEFAULT_LIMIT, limit: DEFAULT_LIMIT, - }), + }).pipe(Effect.option), ), - ); + ).pipe(Effect.map(_Array.getSomes)); return [ ...(firstPage.items ?? []), @@ -258,9 +271,9 @@ const program = Effect.gen(function* () { }); const layer = Layer.mergeAll( - PerpsClient.Default, - TradingViewClient.Default, - Logger.pretty, + PerpsClient.layer, + TradingViewClient.layer, + Logger.layer([Logger.consolePretty()]), ); program.pipe(Effect.provide(layer), Effect.runPromise); diff --git a/packages/common/scripts/generate-tradingview-symbols/utils.ts b/packages/common/scripts/generate-tradingview-symbols/utils.ts index 8396f6a..749722d 100644 --- a/packages/common/scripts/generate-tradingview-symbols/utils.ts +++ b/packages/common/scripts/generate-tradingview-symbols/utils.ts @@ -7,7 +7,7 @@ export const DEFAULT_LIMIT = 50; // ----------------------------------------------------------------------------- export const ProviderId = Schema.String.pipe(Schema.brand("ProviderId")); -export const CompareCurrencySchema = Schema.Literal("USD", "USDC", "USDT"); +export const CompareCurrencySchema = Schema.Literals(["USD", "USDC", "USDT"]); export const currencyPriorityRecord: Record< typeof CompareCurrencySchema.Type, @@ -24,7 +24,7 @@ export const TokenDto = Schema.Struct({ symbol: BaseSymbolSchema }); export const ProviderDto = Schema.Struct({ id: Schema.String, - name: Schema.optionalWith(Schema.String, { nullable: true }), + name: Schema.optional(Schema.NullOr(Schema.String)), }); export const MarketDto = Schema.Struct({ @@ -38,7 +38,7 @@ export const MarketsResponse = Schema.Struct({ total: Schema.Number, offset: Schema.Number, limit: Schema.Number, - items: Schema.optionalWith(Schema.Array(MarketDto), { nullable: true }), + items: Schema.optional(Schema.NullOr(Schema.Array(MarketDto))), }); export const ProvidersResponse = Schema.Array(ProviderDto); @@ -53,7 +53,7 @@ export const TradingViewSearchResponse = Schema.Struct({ ), }); -const CheckTradingViewSymbolResult = Schema.Union( +const CheckTradingViewSymbolResult = Schema.Union([ Schema.Struct({ status: Schema.Literal("match"), perpsSymbol: Schema.String, @@ -68,7 +68,7 @@ const CheckTradingViewSymbolResult = Schema.Union( status: Schema.Literal("error"), perpsSymbol: Schema.String, }), -); +]); // ----------------------------------------------------------------------------- // Error Types @@ -100,12 +100,11 @@ export const normalizeSymbol = (symbol: T): T => export const makeResult = Schema.decodeSync(CheckTradingViewSymbolResult); const byCurrency = Order.mapInput( - Order.number, + Order.Number, (val: (typeof TradingViewSearchResponse.Type)["symbols"][number]) => { const compareSymbol = Schema.decodeUnknownOption( Schema.TemplateLiteralParser( - Schema.String, - Schema.Literal("USD", "USDC", "USDT"), + Schema.TemplateLiteral([Schema.String, CompareCurrencySchema]).parts, ), )(val.symbol).pipe( Option.map((v) => v[1]), @@ -121,7 +120,7 @@ const byCurrency = Order.mapInput( ); const byProvider = Order.mapInput( - Order.number, + Order.Number, (val: (typeof TradingViewSearchResponse.Type)["symbols"][number]) => Record.get(exchangePriorityRecord, val.provider_id).pipe( Option.getOrElse(() => 999), @@ -135,7 +134,7 @@ export const compareSymbolsFromBaseSymbol = ( ) => { const compareSymbols = Schema.decodeSync( Schema.Array( - Schema.TemplateLiteral(BaseSymbolSchema, CompareCurrencySchema), + Schema.TemplateLiteral([BaseSymbolSchema, CompareCurrencySchema]), ), )( CompareCurrencySchema.literals.map( diff --git a/packages/common/src/atoms/actions-atoms.ts b/packages/common/src/atoms/actions-atoms.ts index 4cfeb2a..d26eccd 100644 --- a/packages/common/src/atoms/actions-atoms.ts +++ b/packages/common/src/atoms/actions-atoms.ts @@ -1,67 +1,7 @@ -import { Reactivity } from "@effect/experimental/Reactivity"; -import { Atom, type Result } from "@effect-atom/atom-react"; -import { Effect, Stream } from "effect"; -import type { SignTransactionsState, WalletConnected } from "../domain/wallet"; -import type { ActionDto } from "../services/api-client/client-factory"; -import { runtimeAtom } from "../services/runtime"; -import { portfolioReactivityKeysArray } from "./portfolio-atoms"; +import { Schema } from "effect"; +import * as Atom from "effect/unstable/reactivity/Atom"; +import { Action } from "../domain"; -export const actionAtom = Atom.make(null); +export const decodeAction = Schema.decodeSync(Action); -const getActionAtom = Atom.make( - Effect.fn(function* (ctx) { - const action = ctx.get(actionAtom); - if (!action) { - return yield* Effect.dieMessage("No action found"); - } - return action; - }), -); - -type SignActionAtoms = ( - arg: (action: ActionDto) => Effect.Effect< - { - stream: Stream.Stream; - retry: Effect.Effect; - }, - never, - never - >, -) => { - machineStreamAtom: Atom.Atom>; - retryMachineAtom: Atom.AtomResultFn; -}; - -export const signActionAtoms: SignActionAtoms = Atom.family( - (signTransactions: WalletConnected["signTransactions"]) => { - const machineAtom = runtimeAtom.atom((ctx) => - ctx - .result(getActionAtom) - .pipe(Effect.andThen((action) => signTransactions(action))), - ); - - const machineStreamAtom = runtimeAtom.atom((ctx) => - ctx.result(machineAtom).pipe( - Effect.map((val) => val.stream), - Stream.unwrap, - Stream.takeUntil((v) => v.isDone), - Stream.onDone(() => - Reactivity.pipe( - Effect.andThen((reactivity) => - reactivity.invalidate(portfolioReactivityKeysArray), - ), - ), - ), - ), - ); - - const retryMachineAtom = runtimeAtom.fn((_, ctx) => - ctx.result(machineAtom).pipe(Effect.andThen((val) => val.retry)), - ); - - return { - machineStreamAtom, - retryMachineAtom, - }; - }, -); +export const actionAtom = Atom.make(null); diff --git a/packages/common/src/atoms/close-position-atoms.ts b/packages/common/src/atoms/close-position-atoms.ts index e72d916..102b14c 100644 --- a/packages/common/src/atoms/close-position-atoms.ts +++ b/packages/common/src/atoms/close-position-atoms.ts @@ -1,11 +1,13 @@ -import { Atom, Registry, Result } from "@effect-atom/atom-react"; import { Number as _Number, Effect } from "effect"; +import * as Result from "effect/unstable/reactivity/AsyncResult"; +import * as Atom from "effect/unstable/reactivity/Atom"; +import * as Registry from "effect/unstable/reactivity/AtomRegistry"; +import type { Position } from "../domain"; import type { WalletConnected } from "../domain/wallet"; import { getCloseCalculations } from "../lib/math"; import { ApiClientService } from "../services/api-client"; -import type { PositionDto } from "../services/api-client/api-schemas"; import { runtimeAtom } from "../services/runtime"; -import { actionAtom } from "./actions-atoms"; +import { actionAtom, decodeAction } from "./actions-atoms"; import { selectedProviderAtom } from "./providers-atoms"; export const SLIDER_STOPS = [0, 25, 50, 75, 100]; @@ -17,10 +19,7 @@ export const closePercentageAtom = Atom.writable( ); export const submitCloseAtom = runtimeAtom.fn( - Effect.fn(function* (args: { - position: PositionDto; - wallet: WalletConnected; - }) { + Effect.fn(function* (args: { position: Position; wallet: WalletConnected }) { const client = yield* ApiClientService; const registry = yield* Registry.AtomRegistry; @@ -29,7 +28,7 @@ export const submitCloseAtom = runtimeAtom.fn( .pipe(Result.getOrElse(() => null)); if (!selectedProvider) { - return yield* Effect.dieMessage("No selected provider"); + return yield* Effect.die(new Error("No selected provider")); } const closePercentage = registry.get(closePercentageAtom); @@ -39,18 +38,20 @@ export const submitCloseAtom = runtimeAtom.fn( : getCloseCalculations(args.position, closePercentage); const action = yield* client.ActionsControllerExecuteAction({ - providerId: selectedProvider.id, - address: args.wallet.currentAccount.address, - action: "close", - args: { - marketId: args.position.marketId, - side: args.position.side, - ...(closeCalculations && { - size: closeCalculations.closeSizeInMarketPrice, - }), + payload: { + providerId: selectedProvider.id, + address: args.wallet.currentAccount.address, + action: "close", + args: { + marketId: args.position.marketId, + side: args.position.side, + ...(closeCalculations && { + size: closeCalculations.closeSizeInMarketPrice, + }), + }, }, }); - registry.set(actionAtom, action); + registry.set(actionAtom, decodeAction(action)); }), ); diff --git a/packages/common/src/atoms/config-atom.ts b/packages/common/src/atoms/config-atom.ts index 86681ed..f81d1de 100644 --- a/packages/common/src/atoms/config-atom.ts +++ b/packages/common/src/atoms/config-atom.ts @@ -1,4 +1,6 @@ import { ConfigService } from "../services/config"; import { runtimeAtom } from "../services/runtime"; -export const configAtom = runtimeAtom.atom(ConfigService); +export const configAtom = runtimeAtom.atom( + ConfigService.useSync((config) => config), +); diff --git a/packages/common/src/atoms/edit-position-atoms.ts b/packages/common/src/atoms/edit-position-atoms.ts index 3885548..4943f40 100644 --- a/packages/common/src/atoms/edit-position-atoms.ts +++ b/packages/common/src/atoms/edit-position-atoms.ts @@ -1,18 +1,16 @@ -import { Registry, Result } from "@effect-atom/atom-react"; import { Effect, flow, Match, Option } from "effect"; import { defined } from "effect/Match"; +import * as Result from "effect/unstable/reactivity/AsyncResult"; +import * as Registry from "effect/unstable/reactivity/AtomRegistry"; import type { TPOrSLConfiguration, TPOrSLSettings, } from "../components/molecules/tp-sl-dialog"; +import type { Position } from "../domain"; import type { WalletConnected } from "../domain/wallet"; import { ApiClientService } from "../services/api-client"; -import type { - ArgumentsDto, - PositionDto, -} from "../services/api-client/api-schemas"; import { runtimeAtom } from "../services/runtime"; -import { actionAtom } from "./actions-atoms"; +import { actionAtom, decodeAction } from "./actions-atoms"; import { selectedProviderAtom } from "./providers-atoms"; export const tpSlArgument = flow( @@ -34,7 +32,7 @@ export const editSLTPAtom = runtimeAtom.fn( stopLossOrderId, takeProfitOrderId, }: { - position: PositionDto; + position: Position; wallet: WalletConnected; tpOrSLSettings: TPOrSLSettings; stopLossOrderId?: string; @@ -48,16 +46,20 @@ export const editSLTPAtom = runtimeAtom.fn( .pipe(Result.getOrElse(() => null)); if (!selectedProvider) { - return yield* Effect.dieMessage("No selected provider"); + return yield* Effect.die(new Error("No selected provider")); } - const newStopLossPrice: ArgumentsDto["stopLossPrice"] = tpSlArgument( - tpOrSLSettings.stopLoss, - ); + type TpSlActionArgs = { + stopLossPrice?: number; + takeProfitPrice?: number; + stopLossOrderId?: string; + takeProfitOrderId?: string; + orderId?: string; + }; - const newTakeProfitPrice: ArgumentsDto["takeProfitPrice"] = tpSlArgument( - tpOrSLSettings.takeProfit, - ); + const newStopLossPrice = tpSlArgument(tpOrSLSettings.stopLoss); + + const newTakeProfitPrice = tpSlArgument(tpOrSLSettings.takeProfit); const actionArgs = Match.value({ newStopLossPrice, @@ -65,7 +67,7 @@ export const editSLTPAtom = runtimeAtom.fn( }).pipe( Match.withReturnType<{ action: "setTpAndSl" | "takeProfit" | "stopLoss"; - args: ArgumentsDto; + args: TpSlActionArgs; } | null>(), Match.when( { newStopLossPrice: defined, newTakeProfitPrice: defined }, @@ -97,19 +99,21 @@ export const editSLTPAtom = runtimeAtom.fn( ); if (!actionArgs) { - return yield* Effect.dieMessage("No TP/SL settings provided"); + return yield* Effect.die(new Error("No TP/SL settings provided")); } const action = yield* client.ActionsControllerExecuteAction({ - providerId: selectedProvider.id, - address: wallet.currentAccount.address, - action: actionArgs.action, - args: { - marketId: position.marketId, - ...actionArgs.args, + payload: { + providerId: selectedProvider.id, + address: wallet.currentAccount.address, + action: actionArgs.action, + args: { + marketId: position.marketId, + ...actionArgs.args, + }, }, }); - registry.set(actionAtom, action); + registry.set(actionAtom, decodeAction(action)); }), ); diff --git a/packages/common/src/atoms/external-lifecycle-event-listener.ts b/packages/common/src/atoms/external-lifecycle-event-listener.ts new file mode 100644 index 0000000..4fe252c --- /dev/null +++ b/packages/common/src/atoms/external-lifecycle-event-listener.ts @@ -0,0 +1,7 @@ +import { Stream } from "effect"; +import { EventsService } from "../services/events"; +import { runtimeAtom } from "../services/runtime"; + +export const lifecycleEventAtom = runtimeAtom.atom( + Stream.unwrap(EventsService.useSync((service) => service.stream)), +); diff --git a/packages/common/src/atoms/external-wallet-source.ts b/packages/common/src/atoms/external-wallet-source.ts new file mode 100644 index 0000000..65b066c --- /dev/null +++ b/packages/common/src/atoms/external-wallet-source.ts @@ -0,0 +1,6 @@ +import * as Atom from "effect/unstable/reactivity/Atom"; +import type { ExternalWalletSource } from "../domain"; + +export const externalWalletSourceAtom = Atom.make( + null, +); diff --git a/packages/common/src/atoms/hyperliquid-atoms.ts b/packages/common/src/atoms/hyperliquid-atoms.ts index 2e7c15a..9db62c0 100644 --- a/packages/common/src/atoms/hyperliquid-atoms.ts +++ b/packages/common/src/atoms/hyperliquid-atoms.ts @@ -2,7 +2,7 @@ import { Stream } from "effect"; import { HyperliquidService, runtimeAtom } from "../services"; export const midPriceAtom = runtimeAtom.atom( - HyperliquidService.use((service) => service.subscribeMidPrice).pipe( - Stream.unwrapScoped, - ), + Stream.unwrap( + HyperliquidService.use((service) => service.subscribeMidPrice), + ).pipe(Stream.scoped), ); diff --git a/packages/common/src/atoms/index.ts b/packages/common/src/atoms/index.ts index 26fe657..dd1af2a 100644 --- a/packages/common/src/atoms/index.ts +++ b/packages/common/src/atoms/index.ts @@ -2,13 +2,18 @@ export * from "./actions-atoms"; export * from "./close-position-atoms"; export * from "./config-atom"; export * from "./edit-position-atoms"; +export * from "./external-lifecycle-event-listener"; +export * from "./external-wallet-source"; export * from "./hyperliquid-atoms"; export * from "./markets-atoms"; export * from "./order-form-atoms"; export * from "./orders-pending-actions-atom"; +export * from "./perps-config-atom"; export * from "./portfolio-atoms"; export * from "./position-pending-actions-atom"; export * from "./providers-atoms"; +export * from "./reactivity-keys"; export * from "./tokens-atoms"; -export * from "./utils"; +export * from "./transaction-execution-atoms"; +export * from "./wallet-adapter-atoms"; export * from "./wallet-atom"; diff --git a/packages/common/src/atoms/markets-atoms.ts b/packages/common/src/atoms/markets-atoms.ts index 1c28c4b..b4ea7ed 100644 --- a/packages/common/src/atoms/markets-atoms.ts +++ b/packages/common/src/atoms/markets-atoms.ts @@ -1,4 +1,3 @@ -import { Atom, AtomRef } from "@effect-atom/atom-react"; import { Array as _Array, Data, @@ -7,44 +6,60 @@ import { pipe, Record, Schedule, + Schema, Stream, } from "effect"; +import * as Atom from "effect/unstable/reactivity/Atom"; +import * as AtomRef from "effect/unstable/reactivity/AtomRef"; +import { + Market, + MarketId, + type Provider, + updateMarketMarkPrice, +} from "../domain"; import { ApiClientService } from "../services/api-client"; -import type { ProviderDto } from "../services/api-client/api-schemas"; import { runtimeAtom } from "../services/runtime"; import { midPriceAtom } from "./hyperliquid-atoms"; import { selectedProviderAtom } from "./providers-atoms"; const DEFAULT_LIMIT = 50; -const getAllMarkets = Effect.fn(function* (selectedProvider: ProviderDto) { +const getAllMarkets = Effect.fn(function* (selectedProvider: Provider) { const client = yield* ApiClientService; const firstPage = yield* client.MarketsControllerGetMarkets({ - providerId: selectedProvider.id, - limit: DEFAULT_LIMIT, - offset: 0, + params: { + providerId: selectedProvider.id as "hyperliquid" | "hyperliquid-xyz", + limit: DEFAULT_LIMIT, + offset: 0, + }, }); const totalPages = Math.ceil(firstPage.total / DEFAULT_LIMIT); - const restPages = yield* Effect.allSuccesses( + const restPages = yield* Effect.all( Array.from({ length: totalPages - 1, }).map((_, index) => - client.MarketsControllerGetMarkets({ - providerId: selectedProvider.id, - offset: (index + 1) * DEFAULT_LIMIT, - limit: DEFAULT_LIMIT, - }), + client + .MarketsControllerGetMarkets({ + params: { + providerId: selectedProvider.id as + | "hyperliquid" + | "hyperliquid-xyz", + offset: (index + 1) * DEFAULT_LIMIT, + limit: DEFAULT_LIMIT, + }, + }) + .pipe(Effect.option), ), { concurrency: "unbounded" }, - ); + ).pipe(Effect.map(_Array.getSomes)); - return [ + return yield* Schema.decodeEffect(Schema.Array(Market))([ ...(firstPage.items ?? []), ...restPages.flatMap((page) => page.items ?? []), - ]; + ]); }); export const marketsAtom = runtimeAtom.atom( @@ -82,7 +97,7 @@ export const marketAtom = Atom.family((marketId: string) => runtimeAtom.atom( Effect.fn(function* (ctx) { const markets = yield* ctx.result(marketsAtom); - const record = Record.get(markets, marketId); + const record = Record.get(markets, Schema.decodeSync(MarketId)(marketId)); if (record._tag === "None") { return yield* new MarketNotFoundError(); @@ -98,22 +113,26 @@ export const refreshMarketsAtom = runtimeAtom.atom( const selectedProvider = yield* ctx.result(selectedProviderAtom); yield* Stream.fromSchedule( - Schedule.forever.pipe(Schedule.addDelay(() => Duration.minutes(1))), + Schedule.forever.pipe( + Schedule.addDelay(() => Effect.succeed(Duration.minutes(1))), + ), ).pipe( Stream.mapEffect(() => getAllMarkets(selectedProvider)), Stream.tap((markets) => ctx.result(marketsAtom).pipe( - Effect.tap((prevMarkets) => { - markets.forEach((market) => { - const prevMarket = Record.get(prevMarkets, market.id); - - if (prevMarket._tag === "None") { - return; - } - - prevMarket.value.set(market); - }); - }), + Effect.tap((prevMarkets) => + Effect.sync(() => { + markets.forEach((market) => { + const prevMarket = Record.get(prevMarkets, market.id); + + if (prevMarket._tag === "None") { + return; + } + + prevMarket.value.set(market); + }); + }), + ), ), ), Stream.runDrain, @@ -146,10 +165,7 @@ export const updateMarketsMidPriceAtom = runtimeAtom.atom((ctx) => return; } - marketRef.value.update((market) => ({ - ...market, - markPrice: parsed, - })); + marketRef.value.update((market) => updateMarketMarkPrice(market, parsed)); }); }), ); diff --git a/packages/common/src/atoms/order-form-atoms.ts b/packages/common/src/atoms/order-form-atoms.ts index ae438cd..342e05a 100644 --- a/packages/common/src/atoms/order-form-atoms.ts +++ b/packages/common/src/atoms/order-form-atoms.ts @@ -1,8 +1,14 @@ -import { Atom, Registry, Result } from "@effect-atom/atom-react"; import { FormBuilder, FormReact } from "@lucas-barake/effect-form-react"; import { Number as _Number, Effect, Schema } from "effect"; +import * as Result from "effect/unstable/reactivity/AsyncResult"; +import * as Atom from "effect/unstable/reactivity/Atom"; +import * as Registry from "effect/unstable/reactivity/AtomRegistry"; import { AmountField, type TPOrSLSettings } from "../components"; -import { isWalletConnected, type WalletConnected } from "../domain"; +import { + isWalletConnected, + type Market, + type WalletConnected, +} from "../domain"; import { calcBaseAmountFromUsd, calculateMargin, @@ -15,13 +21,8 @@ import { round, valueFromPercent, } from "../lib"; -import { - ApiClientService, - ApiSchemas, - type ApiTypes, - runtimeAtom, -} from "../services"; -import { actionAtom } from "./actions-atoms"; +import { ApiClientService, runtimeAtom } from "../services"; +import { actionAtom, decodeAction } from "./actions-atoms"; import { tpSlArgument } from "./edit-position-atoms"; import { selectedProviderBalancesAtom } from "./portfolio-atoms"; import { selectedProviderAtom } from "./providers-atoms"; @@ -32,12 +33,12 @@ export const ORDER_SLIDER_STOPS = [0, 25, 50, 75, 100]; // Types export type OrderType = "market" | "limit"; -export type OrderSide = ApiTypes.PositionSide; +export type OrderSide = "long" | "short"; // Schemas -export const LeverageRangesSchema = Schema.Data( - ApiSchemas.MarketDto.fields.leverageRange, -).pipe(Schema.brand("LeverageRange")); +export const LeverageRangesSchema = Schema.Array(Schema.Number).pipe( + Schema.brand("LeverageRange"), +); // Order Type Atom export const orderTypeAtom = Atom.make("market"); @@ -84,8 +85,12 @@ export const orderFormAtom = Atom.family( .addField( "Amount", Schema.NumberFromString.pipe( - Schema.annotations({ message: () => "Invalid amount" }), - Schema.greaterThan(0, { message: () => "Must be greater than 0" }), + Schema.annotate({ message: "Invalid amount" }), + Schema.check( + Schema.isGreaterThan(0, { + message: "Must be greater than 0", + }), + ), ), ) .refineEffect((values) => @@ -96,7 +101,7 @@ export const orderFormAtom = Atom.family( .pipe(Result.getOrElse(() => null)); if (!isWalletConnected(wallet)) { - return yield* Effect.dieMessage("No wallet"); + return yield* Effect.die(new Error("No wallet")); } const providerBalance = registry @@ -104,7 +109,7 @@ export const orderFormAtom = Atom.family( .pipe(Result.getOrElse(() => null)); if (!providerBalance) { - return { path: ["Amount"], message: "Missing provider balance" }; + return { path: ["Amount"], issue: "Missing provider balance" }; } const leverage = registry.get(leverageAtom(leverageRanges)); @@ -116,7 +121,7 @@ export const orderFormAtom = Atom.family( if (requiredMargin > providerBalance.availableBalance) { return { path: ["Amount"], - message: "Insufficient balance", + issue: "Insufficient balance", }; } }), @@ -132,8 +137,8 @@ export const orderFormAtom = Atom.family( side, }: { wallet: WalletConnected; - market: ApiSchemas.MarketDto; - side: ApiTypes.PositionSide; + market: Market; + side: OrderSide; }, { decoded }, ) => @@ -146,7 +151,7 @@ export const orderFormAtom = Atom.family( .pipe(Result.getOrElse(() => null)); if (!selectedProvider) { - return yield* Effect.dieMessage("No selected provider"); + return yield* Effect.die(new Error("No selected provider")); } const leverage = registry.get(leverageAtom(leverageRanges)); @@ -160,22 +165,24 @@ export const orderFormAtom = Atom.family( orderType === "limit" ? registry.get(limitPriceAtom) : undefined; const action = yield* client.ActionsControllerExecuteAction({ - providerId: selectedProvider.id, - address: wallet.currentAccount.address, - action: "open", - args: { - marketId: market.id, - side, - size: decoded.Amount.toString(), - marginMode: "isolated", - ...(stopLossPrice && { stopLossPrice }), - ...(takeProfitPrice && { takeProfitPrice }), - ...(leverage && { leverage }), - ...(limitPrice && { limitPrice }), + payload: { + providerId: selectedProvider.id, + address: wallet.currentAccount.address, + action: "open", + args: { + marketId: market.id, + side, + size: decoded.Amount.toString(), + marginMode: "isolated", + ...(stopLossPrice && { stopLossPrice }), + ...(takeProfitPrice && { takeProfitPrice }), + ...(leverage && { leverage }), + ...(limitPrice && { limitPrice }), + }, }, }); - registry.set(actionAtom, action); + registry.set(actionAtom, decodeAction(action)); }), }); @@ -194,8 +201,8 @@ export const orderFormAtom = Atom.family( export const getOrderCalculations = ( amount: number, leverage: number, - market: ApiSchemas.MarketDto, - side: ApiTypes.PositionSide, + market: Market, + side: OrderSide, ) => { const cryptoAmount = calcBaseAmountFromUsd({ usdAmount: amount, diff --git a/packages/common/src/atoms/orders-pending-actions-atom.ts b/packages/common/src/atoms/orders-pending-actions-atom.ts index ebda7bc..009ec88 100644 --- a/packages/common/src/atoms/orders-pending-actions-atom.ts +++ b/packages/common/src/atoms/orders-pending-actions-atom.ts @@ -1,9 +1,11 @@ -import { Atom, Registry, Result } from "@effect-atom/atom-react"; import { Effect } from "effect"; +import * as Result from "effect/unstable/reactivity/AsyncResult"; +import * as Atom from "effect/unstable/reactivity/Atom"; +import * as Registry from "effect/unstable/reactivity/AtomRegistry"; import type { WalletAccount } from "../domain/wallet"; import { ApiClientService } from "../services/api-client"; import { runtimeAtom } from "../services/runtime"; -import { actionAtom } from "./actions-atoms"; +import { actionAtom, decodeAction } from "./actions-atoms"; import { selectedProviderAtom } from "./providers-atoms"; export const cancelOrderAtom = Atom.family((orderId: string) => @@ -20,20 +22,22 @@ export const cancelOrderAtom = Atom.family((orderId: string) => .pipe(Result.getOrElse(() => null)); if (!selectedProvider) { - return yield* Effect.dieMessage("No selected provider"); + return yield* Effect.die(new Error("No selected provider")); } const action = yield* client.ActionsControllerExecuteAction({ - providerId: selectedProvider.id, - address: args.walletAddress, - action: "cancelOrder", - args: { - orderId, - marketId: args.marketId, + payload: { + providerId: selectedProvider.id, + address: args.walletAddress, + action: "cancelOrder", + args: { + orderId, + marketId: args.marketId, + }, }, }); - registry.set(actionAtom, action); + registry.set(actionAtom, decodeAction(action)); }), ), ); diff --git a/packages/common/src/atoms/perps-config-atom.ts b/packages/common/src/atoms/perps-config-atom.ts new file mode 100644 index 0000000..454f52f --- /dev/null +++ b/packages/common/src/atoms/perps-config-atom.ts @@ -0,0 +1,4 @@ +import * as Atom from "effect/unstable/reactivity/Atom"; +import type { PerpsConfig } from "../services/config"; + +export const perpsConfigAtom = Atom.make(null); diff --git a/packages/common/src/atoms/portfolio-atoms.ts b/packages/common/src/atoms/portfolio-atoms.ts index 265c6f6..12aa4f0 100644 --- a/packages/common/src/atoms/portfolio-atoms.ts +++ b/packages/common/src/atoms/portfolio-atoms.ts @@ -1,24 +1,28 @@ -import { Atom, AtomRef } from "@effect-atom/atom-react"; -import { Duration, Effect, Option, Record, Schema } from "effect"; -import { WalletAccountAddress } from "../domain"; +import { + Array as _Array, + Duration, + Effect, + Option, + Record, + Schema, +} from "effect"; +import * as Atom from "effect/unstable/reactivity/Atom"; +import * as AtomRef from "effect/unstable/reactivity/AtomRef"; +import { + Balance, + MarketId, + Order, + Position, + updatePositionMarkPrice, + WalletAddress, +} from "../domain"; import type { WalletAccount } from "../domain/wallet"; import { ApiClientService } from "../services/api-client"; import { runtimeAtom, withReactivity } from "../services/runtime"; import { midPriceAtom } from "./hyperliquid-atoms"; import { marketsBySymbolAtom } from "./markets-atoms"; import { providersAtom, selectedProviderAtom } from "./providers-atoms"; -import { withRefreshAfter } from "./utils"; - -export const portfolioReactivityKeys = { - positions: "positions", - orders: "orders", - providersBalances: "providersBalances", - selectedProviderBalances: "selectedProviderBalances", -} as const; - -export const portfolioReactivityKeysArray = Object.values( - portfolioReactivityKeys, -); +import { portfolioReactivityKeys } from "./reactivity-keys"; export const positionsAtom = Atom.family( (walletAddress: WalletAccount["address"]) => @@ -28,10 +32,14 @@ export const positionsAtom = Atom.family( const client = yield* ApiClientService; const selectedProvider = yield* ctx.result(selectedProviderAtom); - const positions = yield* client.PortfolioControllerGetPositions({ - address: walletAddress, - providerId: selectedProvider.id, - }); + const positions = yield* client + .PortfolioControllerGetPositions({ + payload: { + address: walletAddress, + providerId: selectedProvider.id, + }, + }) + .pipe(Effect.andThen(Schema.decodeEffect(Schema.Array(Position)))); return Record.fromIterableBy( positions.map((position) => AtomRef.make(position)), @@ -41,7 +49,7 @@ export const positionsAtom = Atom.family( ) .pipe( withReactivity([portfolioReactivityKeys.positions]), - withRefreshAfter(Duration.minutes(1)), + Atom.withRefresh(Duration.minutes(1)), Atom.keepAlive, ), ); @@ -54,15 +62,19 @@ export const ordersAtom = Atom.family( const client = yield* ApiClientService; const selectedProvider = yield* ctx.result(selectedProviderAtom); - return yield* client.PortfolioControllerGetOrders({ - address: walletAddress, - providerId: selectedProvider.id, - }); + return yield* client + .PortfolioControllerGetOrders({ + payload: { + address: walletAddress, + providerId: selectedProvider.id, + }, + }) + .pipe(Effect.andThen(Schema.decodeEffect(Schema.Array(Order)))); }), ) .pipe( withReactivity([portfolioReactivityKeys.orders]), - withRefreshAfter(Duration.minutes(1)), + Atom.withRefresh(Duration.minutes(1)), Atom.keepAlive, ), ); @@ -75,20 +87,25 @@ export const providersBalancesAtom = Atom.family( const providers = yield* get.result(providersAtom); const client = yield* ApiClientService; - return yield* Effect.allSuccesses( + return yield* Effect.all( providers.map((provider) => - client.PortfolioControllerGetBalances({ - address: walletAddress, - providerId: provider.id, - }), + client + .PortfolioControllerGetBalances({ + payload: { + address: walletAddress, + providerId: provider.id, + }, + }) + .pipe(Effect.andThen(Schema.decodeEffect(Balance))) + .pipe(Effect.option), ), { concurrency: "unbounded" }, - ); + ).pipe(Effect.map(_Array.getSomes)); }), ) .pipe( withReactivity([portfolioReactivityKeys.providersBalances]), - withRefreshAfter(Duration.minutes(1)), + Atom.withRefresh(Duration.minutes(1)), Atom.keepAlive, ), ); @@ -101,15 +118,19 @@ export const selectedProviderBalancesAtom = Atom.family( const selectedProvider = yield* ctx.result(selectedProviderAtom); const client = yield* ApiClientService; - return yield* client.PortfolioControllerGetBalances({ - address: walletAddress, - providerId: selectedProvider.id, - }); + return yield* client + .PortfolioControllerGetBalances({ + payload: { + address: walletAddress, + providerId: selectedProvider.id, + }, + }) + .pipe(Effect.andThen(Schema.decodeEffect(Balance))); }), ) .pipe( withReactivity([portfolioReactivityKeys.selectedProviderBalances]), - withRefreshAfter(Duration.minutes(1)), + Atom.withRefresh(Duration.minutes(1)), Atom.keepAlive, ), ); @@ -133,19 +154,18 @@ export const updatePositionsMidPriceAtom = Atom.family( const positionRef = Record.get(positions, marketRef.value.value.id); if (positionRef._tag === "None") return; - positionRef.value.update((position) => ({ - ...position, - markPrice: Number(price), - })); + positionRef.value.update((position) => + updatePositionMarkPrice(position, Number(price)), + ); }); }), ), ); export const GetCurrentPositionRefArgs = Schema.Struct({ - marketId: Schema.String, - address: WalletAccountAddress, -}).pipe(Schema.Data, Schema.brand("GetCurrentPositionRefArgs")); + marketId: MarketId, + address: WalletAddress, +}).pipe(Schema.brand("GetCurrentPositionRefArgs")); export const makeGetCurrentPositionRefArgs = Schema.decodeSync( GetCurrentPositionRefArgs, diff --git a/packages/common/src/atoms/position-pending-actions-atom.ts b/packages/common/src/atoms/position-pending-actions-atom.ts index 16458fa..bd0baf5 100644 --- a/packages/common/src/atoms/position-pending-actions-atom.ts +++ b/packages/common/src/atoms/position-pending-actions-atom.ts @@ -1,10 +1,11 @@ -import { Registry, Result } from "@effect-atom/atom-react"; import { Effect } from "effect"; +import * as Result from "effect/unstable/reactivity/AsyncResult"; +import * as Registry from "effect/unstable/reactivity/AtomRegistry"; +import type { Position } from "../domain"; import type { WalletConnected } from "../domain/wallet"; import { ApiClientService } from "../services/api-client"; -import type { PositionDto } from "../services/api-client/api-schemas"; import { runtimeAtom } from "../services/runtime"; -import { actionAtom } from "./actions-atoms"; +import { actionAtom, decodeAction } from "./actions-atoms"; import { selectedProviderAtom } from "./providers-atoms"; export type UpdateMarginMode = "add" | "remove"; @@ -15,7 +16,7 @@ export const updateLeverageAtom = runtimeAtom.fn( wallet, newLeverage, }: { - position: PositionDto; + position: Position; wallet: WalletConnected; newLeverage: number; }) { @@ -27,20 +28,22 @@ export const updateLeverageAtom = runtimeAtom.fn( .pipe(Result.getOrElse(() => null)); if (!selectedProvider) { - return yield* Effect.dieMessage("No selected provider"); + return yield* Effect.die(new Error("No selected provider")); } const action = yield* client.ActionsControllerExecuteAction({ - providerId: selectedProvider.id, - address: wallet.currentAccount.address, - action: "updateLeverage", - args: { - marketId: position.marketId, - leverage: newLeverage, - marginMode: "isolated", + payload: { + providerId: selectedProvider.id, + address: wallet.currentAccount.address, + action: "updateLeverage", + args: { + marketId: position.marketId, + leverage: newLeverage, + marginMode: "isolated", + }, }, }); - registry.set(actionAtom, action); + registry.set(actionAtom, decodeAction(action)); }), ); diff --git a/packages/common/src/atoms/providers-atoms.ts b/packages/common/src/atoms/providers-atoms.ts index 0765114..6a953e6 100644 --- a/packages/common/src/atoms/providers-atoms.ts +++ b/packages/common/src/atoms/providers-atoms.ts @@ -1,7 +1,8 @@ -import { Atom, Result } from "@effect-atom/atom-react"; -import { Array as _Array, Data, Effect } from "effect"; +import { Array as _Array, Data, Effect, Schema } from "effect"; +import * as Result from "effect/unstable/reactivity/AsyncResult"; +import * as Atom from "effect/unstable/reactivity/Atom"; +import { Provider } from "../domain"; import { ApiClientService } from "../services/api-client"; -import type { ProviderDto } from "../services/api-client/api-schemas"; import { runtimeAtom, withReactivity } from "../services/runtime"; export const providersReactivityKeys = { @@ -14,8 +15,10 @@ export const providersReactivityKeysArray = Object.values( export const providersAtom = runtimeAtom .atom( - ApiClientService.pipe( - Effect.andThen((client) => client.ProvidersControllerGetProviders()), + ApiClientService.use((client) => + client + .ProvidersControllerGetProviders(undefined) + .pipe(Effect.andThen(Schema.decodeEffect(Schema.Array(Provider)))), ), ) .pipe(withReactivity([providersReactivityKeys.providers]), Atom.keepAlive); @@ -40,5 +43,5 @@ const initialProviderAtom = runtimeAtom.atom( export const selectedProviderAtom = Atom.writable( (ctx) => ctx.get(initialProviderAtom), - (ctx, value: ProviderDto) => ctx.setSelf(Result.success(value)), + (ctx, value: Provider) => ctx.setSelf(Result.success(value)), ).pipe(Atom.keepAlive); diff --git a/packages/common/src/atoms/reactivity-keys.ts b/packages/common/src/atoms/reactivity-keys.ts new file mode 100644 index 0000000..6b48e1f --- /dev/null +++ b/packages/common/src/atoms/reactivity-keys.ts @@ -0,0 +1,10 @@ +export const portfolioReactivityKeys = { + positions: "positions", + orders: "orders", + providersBalances: "providersBalances", + selectedProviderBalances: "selectedProviderBalances", +} as const; + +export const portfolioReactivityKeysArray = Object.values( + portfolioReactivityKeys, +); diff --git a/packages/common/src/atoms/tokens-atoms.ts b/packages/common/src/atoms/tokens-atoms.ts index 0b96e8c..974c4be 100644 --- a/packages/common/src/atoms/tokens-atoms.ts +++ b/packages/common/src/atoms/tokens-atoms.ts @@ -1,7 +1,14 @@ -import { HttpClientRequest, HttpClientResponse } from "@effect/platform"; -import { Atom } from "@effect-atom/atom-react"; import { EvmNetworks } from "@stakekit/common"; -import { Array as _Array, Effect, Option, pipe, Record, Schema } from "effect"; +import { + Array as _Array, + Result as _Result, + Effect, + pipe, + Record, + Schema, +} from "effect"; +import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import * as Atom from "effect/unstable/reactivity/Atom"; import type { TokenBalance } from "../domain/types"; import type { WalletAccount } from "../domain/wallet"; import { ConfigService } from "../services/config"; @@ -105,8 +112,8 @@ export const moralisTokenBalancesAtom = Atom.family( Effect.map((response) => _Array.filterMap(response.result, (token) => !token.usd_price - ? Option.none() - : Option.some({ + ? _Result.failVoid + : _Result.succeed({ price: token.usd_price, amount: token.balance_formatted, token: { @@ -124,7 +131,11 @@ export const moralisTokenBalancesAtom = Atom.family( ), ), (effects) => - Effect.allSuccesses(effects, { concurrency: "unbounded" }).pipe( + Effect.all( + effects.map((effect) => effect.pipe(Effect.option)), + { concurrency: "unbounded" }, + ).pipe( + Effect.map(_Array.getSomes), Effect.map((res) => pipe( Record.fromIterableBy(res, (r) => r.network), @@ -133,8 +144,6 @@ export const moralisTokenBalancesAtom = Atom.family( ), ), ), - - Effect.ensureSuccessType(), ); }), ) diff --git a/packages/common/src/atoms/transaction-execution-atoms.ts b/packages/common/src/atoms/transaction-execution-atoms.ts new file mode 100644 index 0000000..95027fb --- /dev/null +++ b/packages/common/src/atoms/transaction-execution-atoms.ts @@ -0,0 +1,288 @@ +import { + Array as _Array, + Cause, + Duration, + Effect, + Match, + Option, + Ref, + Schedule, + Schema, + Stream, +} from "effect"; +import * as Atom from "effect/unstable/reactivity/Atom"; +import { + Action, + makeLifecycleActionContext, + makeLifecycleTransactionContext, +} from "../domain"; +import { EIP712Tx, Transaction } from "../domain/transactions"; +import { + type SignTransactionsState, + TransactionFailedError, + TransactionNotConfirmedError, +} from "../domain/wallet"; +import { ApiClientService } from "../services/api-client"; +import { EventsService } from "../services/events"; +import { runtimeAtom } from "../services/runtime"; +import { WalletAdapterService } from "../services/wallet-adapter"; + +const makeInitialState = (action: Action): SignTransactionsState => ({ + action, + transactions: action.transactions, + currentTxIndex: 0, + step: null, + error: null, + txHash: null, + isDone: false, +}); + +const advanceToNextTransaction = ( + state: SignTransactionsState, + updatedAction: Action, +): SignTransactionsState => { + const isDone = + updatedAction.status === "SUCCESS" || + state.currentTxIndex === state.transactions.length - 1; + + const transactions = _Array.map(state.transactions, (tx) => + _Array + .findFirst(updatedAction.transactions, (newTx) => newTx.id === tx.id) + .pipe(Option.getOrElse(() => tx)), + ); + + if (isDone) { + return { + ...state, + action: updatedAction, + isDone: true, + transactions, + error: null, + step: null, + txHash: null, + }; + } + + return { + ...state, + action: updatedAction, + currentTxIndex: state.currentTxIndex + 1, + transactions, + isDone: false, + error: null, + step: "sign" as const, + txHash: null, + }; +}; + +export const makeTransactionExecution = (action: Action) => + Effect.gen(function* () { + const apiClient = yield* ApiClientService; + const events = yield* EventsService; + const walletAdapter = yield* WalletAdapterService; + const stateRef = yield* Ref.make(makeInitialState(action)); + + return Effect.gen(function* () { + const state = yield* Ref.get(stateRef); + const tx = yield* Effect.fromOption( + _Array.get(state.transactions, state.currentTxIndex), + ).pipe(Effect.orDie); + + const walletState = walletAdapter.getState(); + + if (walletState.status === "disconnected") { + return yield* Effect.die(new Error("Wallet is disconnected")); + } + + if (!tx.signablePayload) { + const result = advanceToNextTransaction(state, state.action); + + yield* Ref.set(stateRef, result); + + if (result.isDone) { + yield* events.publish({ + type: "action.completed", + action: makeLifecycleActionContext(result.action), + }); + } + + return result; + } + + const result: SignTransactionsState = yield* Match.value(state.step).pipe( + Match.when(null, () => + Effect.succeed({ + ...state, + error: null, + step: "sign" as const, + txHash: null, + }), + ), + Match.when("sign", () => + Effect.gen(function* () { + yield* events.publish({ + type: "transaction.signing_started", + action: makeLifecycleActionContext(state.action), + transaction: makeLifecycleTransactionContext(tx), + }); + + const decodedTx = yield* Schema.decodeUnknownEffect(Transaction)( + tx.signablePayload, + ).pipe(Effect.orDie); + + const signPayload = Schema.is(EIP712Tx)(decodedTx) + ? walletAdapter.signTypedData({ + account: walletState.currentAccount.address, + transaction: decodedTx, + }) + : walletAdapter.sendTransaction({ + account: walletState.currentAccount.address, + transaction: decodedTx, + }); + + const payload = yield* signPayload; + + yield* events.publish({ + type: "transaction.submitted", + action: makeLifecycleActionContext(state.action), + transaction: makeLifecycleTransactionContext(tx), + result: + tx.signingFormat === "EIP712_TYPED_DATA" + ? { type: "signedPayload", signedPayload: payload } + : { type: "transactionHash", transactionHash: payload }, + }); + + return { + ...state, + error: null, + step: "submit" as const, + txHash: payload, + }; + }), + ), + Match.when("submit", () => { + const payload = state.txHash; + + if (!payload) { + return Effect.fail(new TransactionFailedError()); + } + + return apiClient + .TransactionsControllerSubmitTransaction(tx.id, { + payload: + tx.signingFormat === "EIP712_TYPED_DATA" + ? { signedPayload: payload } + : { transactionHash: payload }, + }) + .pipe( + Effect.map(() => ({ + ...state, + error: null, + step: "check" as const, + txHash: payload, + })), + ); + }), + Match.when("check", () => + Effect.gen(function* () { + const updatedAction = yield* apiClient + .ActionsControllerGetAction(state.action.id, undefined) + .pipe( + Effect.andThen((res) => + Schema.decodeUnknownEffect(Action)(res), + ), + Effect.andThen((res) => + res.status === "SUCCESS" + ? Effect.succeed(res) + : Effect.fromOption( + _Array.findFirst( + res.transactions, + (newTx) => newTx.id === tx.id, + ), + ).pipe( + Effect.catch(() => + Effect.die( + new Error("Transaction not found in response"), + ), + ), + Effect.andThen((newTx) => + Match.value(newTx.status).pipe( + Match.when( + (status) => + status === "CONFIRMED" || + status === "BROADCASTED", + () => Effect.void, + ), + Match.when( + (status) => + status === "NOT_FOUND" || status === "FAILED", + () => Effect.fail(new TransactionFailedError()), + ), + Match.orElse(() => + Effect.fail(new TransactionNotConfirmedError()), + ), + ), + ), + Effect.as(res), + ), + ), + Effect.retry({ + while: (error) => + error._tag === "TransactionNotConfirmedError", + times: 20, + schedule: Schedule.spaced(Duration.seconds(2)), + }), + ); + + return advanceToNextTransaction(state, updatedAction); + }), + ), + Match.exhaustive, + ); + + yield* Ref.set(stateRef, result); + + if (result.isDone) { + yield* events.publish({ + type: "action.completed", + action: makeLifecycleActionContext(result.action), + }); + } + + return result; + }).pipe( + Effect.catch((error) => + Effect.gen(function* () { + const failedState = yield* Ref.updateAndGet(stateRef, (state) => ({ + ...state, + error, + })); + + yield* events.publish({ + type: "action.failed", + action: makeLifecycleActionContext(failedState.action), + error: Cause.pretty(Cause.fail(error)), + }); + + return failedState; + }), + ), + ); + }); + +export const transactionExecutionAtoms = Atom.family((action: Action) => { + const machineAtom = runtimeAtom.atom(makeTransactionExecution(action)); + + const machineStreamAtom = runtimeAtom.atom((ctx) => + ctx.result(machineAtom).pipe( + Effect.flatten, + Stream.fromEffect, + Stream.repeat(Schedule.forever), + Stream.takeUntil((state) => state.isDone), + ), + ); + + return { + machineStreamAtom, + }; +}); diff --git a/packages/common/src/atoms/utils.ts b/packages/common/src/atoms/utils.ts deleted file mode 100644 index e890b41..0000000 --- a/packages/common/src/atoms/utils.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Atom } from "@effect-atom/atom-react"; -import { type Duration, Effect, Schedule } from "effect"; -import { dual } from "effect/Function"; - -export const withRefreshAfter = dual< - (refreshAfter: Duration.Duration) => (atom: Atom.Atom) => Atom.Atom, - (atom: Atom.Atom, refreshAfter: Duration.Duration) => Atom.Atom ->(2, (atom, refreshAfter) => { - return Atom.transform(atom, (ctx) => { - const cancelRefresh = Effect.sleep(refreshAfter).pipe( - Effect.andThen(Effect.sync(() => ctx.refresh(atom))), - Effect.repeat({ - schedule: Schedule.forever.pipe(Schedule.addDelay(() => refreshAfter)), - }), - Effect.runCallback, - ); - - ctx.addFinalizer(cancelRefresh); - - return ctx.get(atom); - }); -}); diff --git a/packages/common/src/atoms/wallet-adapter-atoms.ts b/packages/common/src/atoms/wallet-adapter-atoms.ts new file mode 100644 index 0000000..9c8c2bc --- /dev/null +++ b/packages/common/src/atoms/wallet-adapter-atoms.ts @@ -0,0 +1,40 @@ +import { Effect, Option, Stream } from "effect"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import type { ChainId, WalletAddress } from "../domain"; +import { WalletAdapterService } from "../services"; +import { runtimeAtom } from "../services/runtime"; + +export const walletAdapterAtom = runtimeAtom.atom( + Effect.gen(function* () { + return yield* WalletAdapterService; + }), +); + +export const walletAdapterStateAtom = runtimeAtom.atom( + Stream.unwrap(WalletAdapterService.useSync((service) => service.changes)), +); + +export const walletAdapterModeAtom = Atom.make((context) => { + const walletAdapter = context.get(walletAdapterAtom); + + return AsyncResult.value(walletAdapter).pipe( + Option.map((adapter) => adapter.mode), + Option.getOrElse(() => "browser" as const), + ); +}); + +export const switchWalletAdapterAccountAtom = runtimeAtom.fn( + Effect.fn(function* (address: WalletAddress) { + const walletAdapter = yield* WalletAdapterService; + + return yield* walletAdapter.switchAccount(address); + }), +); + +export const switchWalletAdapterChainAtom = runtimeAtom.fn( + Effect.fn(function* (chainId: ChainId) { + const walletAdapter = yield* WalletAdapterService; + + return yield* walletAdapter.switchChain(chainId); + }), +); diff --git a/packages/common/src/atoms/wallet-atom.ts b/packages/common/src/atoms/wallet-atom.ts index 86fa6f7..1232aa6 100644 --- a/packages/common/src/atoms/wallet-atom.ts +++ b/packages/common/src/atoms/wallet-atom.ts @@ -1,34 +1,67 @@ -import { Atom } from "@effect-atom/atom-react"; -import { Effect, Stream } from "effect"; +import { Stream } from "effect"; +import * as Atom from "effect/unstable/reactivity/Atom"; +import type { Wallet, WalletConnected } from "../domain/wallet"; import type { - BrowserWalletConnected, - LedgerWalletConnected, -} from "../domain/wallet"; + PerpsWalletAdapter, + WalletAdapterState, +} from "../domain/wallet-adapter"; import { runtimeAtom } from "../services/runtime"; -import { WalletService } from "../services/wallet/wallet-service"; +import { WalletAdapterService } from "../services/wallet-adapter"; + +const makeWallet = ( + adapter: PerpsWalletAdapter, + state: WalletAdapterState, +): Wallet => { + if (state.status === "disconnected") { + return { + type: adapter.mode, + status: "disconnected", + }; + } + + return { + type: adapter.mode, + status: "connected", + currentAccount: state.currentAccount, + accounts: state.accounts, + switchAccount: ({ account }) => adapter.switchAccount(account.address), + }; +}; + +const accountsAreEqual = ( + a: WalletConnected["accounts"], + b: WalletConnected["accounts"], +) => + a.length === b.length && + a.every((account, index) => { + const other = b[index]; + + return ( + other && + account.address === other.address && + account.id === other.id && + account.label === other.label + ); + }); export const walletAtom = runtimeAtom.atom( - WalletService.pipe( - Effect.andThen((ws) => ws.walletStream), - Stream.unwrap, + Stream.unwrap( + WalletAdapterService.useSync((adapter) => + adapter.changes.pipe(Stream.map((state) => makeWallet(adapter, state))), + ), + ).pipe( Stream.changesWith((a, b) => { - if (a.status !== b.status) { + if (a.type !== b.type || a.status !== b.status) { return false; } if (a.status === "connected" && b.status === "connected") { - const addressMatch = - a.currentAccount.address === b.currentAccount.address; - - if (a.type === "ledger" && b.type === "ledger") { - const aAccount = a.currentAccount; - const bAccount = b.currentAccount; - return addressMatch && aAccount.id === bAccount.id; - } - - if (a.type === "browser") { - return addressMatch; - } + return ( + a.currentAccount.address === b.currentAccount.address && + a.currentAccount.id === b.currentAccount.id && + a.currentAccount.label === b.currentAccount.label && + accountsAreEqual(a.accounts, b.accounts) + ); } return true; @@ -36,10 +69,6 @@ export const walletAtom = runtimeAtom.atom( ), ); -export const switchLedgerAccountAtom = Atom.family( - (wallet: LedgerWalletConnected) => runtimeAtom.fn(wallet.switchAccount), -); - -export const switchBrowserAccountAtom = Atom.family( - (wallet: BrowserWalletConnected) => runtimeAtom.fn(wallet.switchAccount), +export const switchWalletAccountAtom = Atom.family((wallet: WalletConnected) => + runtimeAtom.fn(wallet.switchAccount), ); diff --git a/packages/common/src/components/molecules/Chart/index.tsx b/packages/common/src/components/molecules/Chart/index.tsx index a6b06be..831ecc4 100644 --- a/packages/common/src/components/molecules/Chart/index.tsx +++ b/packages/common/src/components/molecules/Chart/index.tsx @@ -1,4 +1,4 @@ -import { useAtomValue } from "@effect-atom/atom-react"; +import { useAtomValue } from "@effect/atom-react"; import { Option } from "effect"; import { TriangleAlertIcon } from "lucide-react"; import { useLayoutEffect, useRef, useState } from "react"; diff --git a/packages/common/src/components/molecules/Chart/state.ts b/packages/common/src/components/molecules/Chart/state.ts index 66bfd7c..3a3a3ea 100644 --- a/packages/common/src/components/molecules/Chart/state.ts +++ b/packages/common/src/components/molecules/Chart/state.ts @@ -1,5 +1,5 @@ -import { Atom } from "@effect-atom/atom-react"; import { Array as _Array, Option, Record, Schema } from "effect"; +import * as Atom from "effect/unstable/reactivity/Atom"; import tradingViewSymbols from "../../../assets/tradingview-symbols.json" with { type: "json", }; diff --git a/packages/common/src/components/molecules/address-switcher.tsx b/packages/common/src/components/molecules/address-switcher.tsx index 10b02a4..226c850 100644 --- a/packages/common/src/components/molecules/address-switcher.tsx +++ b/packages/common/src/components/molecules/address-switcher.tsx @@ -1,39 +1,26 @@ -import { useAtomSet } from "@effect-atom/atom-react"; +import { useAtomSet } from "@effect/atom-react"; import { useDisconnect } from "@reown/appkit/react"; -import { Match } from "effect"; import { Check, ChevronDown, Copy, LogOut, Wallet } from "lucide-react"; import type React from "react"; import { useEffect, useRef, useState } from "react"; -import { - switchBrowserAccountAtom, - switchLedgerAccountAtom, -} from "../../atoms/wallet-atom"; -import type { - BrowserWalletConnected, - LedgerWalletConnected, -} from "../../domain/wallet"; -import { - isBrowserWalletConnected, - isLedgerWalletConnected, - type WalletConnected, -} from "../../domain/wallet"; +import { switchWalletAccountAtom } from "../../atoms/wallet-atom"; +import type { WalletAccount, WalletConnected } from "../../domain/wallet"; +import { isBrowserWalletConnected } from "../../domain/wallet"; import { cn, truncateAddress } from "../../lib/utils"; import { Button } from "../ui/button"; import { Dialog } from "../ui/dialog"; import { Text } from "../ui/text"; -const LedgerAccountList = ({ +const WalletAccountList = ({ wallet, onAccountSwitch, }: { - wallet: LedgerWalletConnected; + wallet: WalletConnected; onAccountSwitch: () => void; }) => { - const switchAccount = useAtomSet(switchLedgerAccountAtom(wallet)); + const switchAccount = useAtomSet(switchWalletAccountAtom(wallet)); - const handleAccountSwitch = ( - account: LedgerWalletConnected["accounts"][number], - ) => { + const handleAccountSwitch = (account: WalletAccount) => { switchAccount({ account }); onAccountSwitch(); }; @@ -47,57 +34,7 @@ const LedgerAccountList = ({ return ( - ); - }); -}; - -const BrowserAccountList = ({ - wallet, - onAccountSwitch, -}: { - wallet: BrowserWalletConnected; - onAccountSwitch: () => void; -}) => { - const switchAccount = useAtomSet(switchBrowserAccountAtom(wallet)); - - const handleAccountSwitch = ( - account: BrowserWalletConnected["accounts"][number], - ) => { - switchAccount({ account }); - onAccountSwitch(); - }; - - return wallet.accounts.map((account) => { - const isCurrentAccount = account.address === wallet.currentAccount.address; - - return ( -