diff --git a/.gitattributes b/.gitattributes
deleted file mode 100644
index af3ad128..00000000
--- a/.gitattributes
+++ /dev/null
@@ -1,4 +0,0 @@
-/.yarn/** linguist-vendored
-/.yarn/releases/* binary
-/.yarn/plugins/**/* binary
-/.pnp.* binary linguist-generated
diff --git a/.gitignore b/.gitignore
index 7eec523b..462414e0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,268 +1,50 @@
-# production
-/build
-
-# misc
-.DS_Store
-*.pem
-.idea
-
-# debug
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-.pnpm-debug.log*
-
-# local env files
-.env*.local
+# Dependencies and package-manager state
+node_modules/
+.pnpm-store/
+*.tgz
-# typescript
+# Build output and caches
+build/
+dist/
+.next/
+out/
+.turbo/
+.cache/
+coverage/
+.nyc_output/
+playwright-report/
+test-results/
+*.lcov
*.tsbuildinfo
next-env.d.ts
+# Environment and local configuration
+.env
+.env.*
+!.env.example
+*.local
-# compiled output
-lib
-**/lib
-
-# OS
-.DS_Store
-
-# Tests
-coverage
-**/coverage
-.nyc_output
-
-.data
-ormconfig.json
-
-## Terraform
-**/.terraform/**/
-
-.terraform.lock.hcl
-.terraform
-# .tfstate files
-*.tfstate
-*.tfstate.*
-*.tfvars
-
-# Logs
-logs
+# Logs and diagnostics
+logs/
*.log
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-lerna-debug.log*
-.pnpm-debug.log*
-
-# Diagnostic reports (https://nodejs.org/api/report.html)
+*-debug.log*
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
-# Runtime data
-pids
-*.pid
-*.seed
-*.pid.lock
-
-# Directory for instrumented libs generated by jscoverage/JSCover
-lib-cov
-
-# Coverage directory used by tools like istanbul
-*.lcov
-
-# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
-.grunt
-
-# Bower dependency directory (https://bower.io/)
-bower_components
-
-# node-waf configuration
-.lock-wscript
-
-# Compiled binary addons (https://nodejs.org/api/addons.html)
-build/Release
-
-# Dependency directories
-node_modules/
-jspm_packages/
-
-# Snowpack dependency directory (https://snowpack.dev/)
-web_modules/
-
-# TypeScript cache
-*.tsbuildinfo
-
-# Optional npm cache directory
-.npm
-
-# Optional eslint cache
-.eslintcache
-
-# Optional stylelint cache
-.stylelintcache
-
-# Microbundle cache
-.rpt2_cache/
-.rts2_cache_cjs/
-.rts2_cache_es/
-.rts2_cache_umd/
-
-# Optional REPL history
-.node_repl_history
-
-# Output of 'npm pack'
-*.tgz
-
-# Yarn Integrity file
-.yarn-integrity
-
-# dotenv environment variable files
-.env
-.env.development.local
-.env.test.local
-.env.production.local
-.env.local
-
-# parcel-bundler cache (https://parceljs.org/)
-.cache
-.parcel-cache
-
-# Next.js build output
-.next
-out
-
-# Nuxt.js build / generate output
-.nuxt
-dist
-
-# Gatsby files
-.cache/
-# Comment in the public line in if your project uses Gatsby and not Next.js
-# https://nextjs.org/blog/next-9-1#public-directory-support
-# public
-
-# vuepress build output
-.vuepress/dist
-
-# vuepress v2.x temp and cache directory
-.temp
-.cache
-
-# Docusaurus cache and generated files
-.docusaurus
-
-# Serverless directories
-.serverless/
-
-# FuseBox cache
-.fusebox/
-
-# DynamoDB Local files
-.dynamodb/
-
-# TernJS port file
-.tern-port
-
-# Stores VSCode versions used for testing VSCode extensions
-.vscode-test
-
-# yarn v2
-.yarn/cache
-.yarn/unplugged
-.yarn/build-state.yml
-.yarn/install-state.gz
-.pnp.*
-
-.yarn/*
-!.yarn/patches
-!.yarn/plugins
-!.yarn/releases
-!.yarn/sdks
-!.yarn/versions
-
-# Swap the comments on the following lines if you don't wish to use zero-installs
-# Documentation here: https://yarnpkg.com/features/zero-installs
-!.yarn/cache
-#.pnp.*
-
-# Yarn Not-Zero-Installs
-# https://yarnpkg.com/getting-started/qa/\#which-files-should-be-gitignored
-.pnp.*
-.yarn/*
-!.yarn/patches
-!.yarn/plugins
-!.yarn/releases
-!.yarn/sdks
-!.yarn/versions
-.turbo
+# Local vendored reference repositories
+@repos/
+# Editors and operating systems
.DS_Store
-
-# Logs
-logs
-*.log
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-pnpm-debug.log*
-lerna-debug.log*
-
-node_modules
-dist
-dist-ssr
-*.local
-
-# Editor directories and files
+.idea/
.vscode/*
!.vscode/extensions.json
-.idea
-.DS_Store
-*.suo
-*.ntvs*
-*.njsproj
-*.sln
+!.vscode/settings.json
*.sw?
-
-# dependencies
-/node_modules
-/.pnp
-.pnp.js
-.yarn/install-state.gz
-
-# testing
-/coverage
-
-# next.js
-/.next/
-/out/
-
-# production
-/build
-
-# misc
-.DS_Store
-*.pem
-
-# debug
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-
-# local env files
-.env*.local
-
-# vercel
-.vercel
-
-# typescript
-*.tsbuildinfo
-next-env.d.ts
-
+# Credentials and certificates
*.key
*.pem
*.p12
*.pfx
*.crt
-*.cer
-
-.pnpm-store
\ No newline at end of file
+*.cer
\ No newline at end of file
diff --git a/.rev-dep.config.jsonc b/.rev-dep.config.jsonc
index 5691d0be..232369a1 100644
--- a/.rev-dep.config.jsonc
+++ b/.rev-dep.config.jsonc
@@ -1,15 +1,16 @@
{
- "$schema": "https://github.com/jayu/rev-dep/blob/master/config-schema/1.7.schema.json?raw=true",
- "configVersion": "1.7",
+ "configVersion": "1.11",
+ "$schema": "https://raw.githubusercontent.com/jayu/rev-dep/master/config-schema/1.11.schema.json",
"rules": [
{
"path": "packages/widget",
"prodEntryPoints": [
"src/index.package.ts",
"src/index.bundle.ts",
+ "src/public-api/index.package.ts",
+ "src/public-api/index.bundle.ts",
"src/main.tsx",
"src/translation/i18next.d.ts",
- "src/types/purify-extend.d.ts",
"src/types/window.d.ts",
"src/vite-env.d.ts",
"postcss.config.js",
@@ -18,16 +19,119 @@
],
"devEntryPoints": [
"scripts/generate-effect-openapi.ts",
- "tests/utils/setup.ts",
+ "tests/utils/setup.browser.ts",
+ "tests/utils/setup.dom.ts",
"tests/**/*.test.ts",
"tests/**/*.test.tsx"
],
- "orphanFilesDetection": {
- "enabled": true
+ "moduleBoundaries": [
+ {
+ "name": "app",
+ "pattern": "src/app/**",
+ "allow": [
+ "src/app/**",
+ "src/features/**",
+ "src/services/**",
+ "src/domain/**",
+ "src/shared/**",
+ "src/public-api/**",
+ "src/translation/**"
+ ]
+ },
+ {
+ "name": "features",
+ "pattern": "src/features/**",
+ "allow": [
+ "src/features/**",
+ "src/app/config/**",
+ "src/app/runtime/**",
+ "src/services/**",
+ "src/domain/**",
+ "src/shared/**",
+ "src/public-api/**"
+ ]
+ },
+ {
+ "name": "services",
+ "pattern": "src/services/**",
+ "allow": [
+ "src/services/**",
+ "src/domain/**",
+ "src/shared/**",
+ "src/generated/**",
+ "src/public-api/**"
+ ]
+ },
+ {
+ "name": "domain",
+ "pattern": "src/domain/**",
+ "allow": [
+ "src/domain/**",
+ "src/generated/**",
+ "src/public-api/**",
+ "src/shared/**"
+ ]
+ },
+ {
+ "name": "shared",
+ "pattern": "src/shared/**",
+ "allow": ["src/shared/**", "src/domain/**"]
+ },
+ {
+ "name": "generated",
+ "pattern": "src/generated/**",
+ "allow": ["src/generated/**"]
+ },
+ {
+ "name": "translation",
+ "pattern": "src/translation/**",
+ "allow": ["src/translation/**", "src/shared/**"]
+ },
+ {
+ "name": "public-api",
+ "pattern": "src/public-api/**",
+ "allow": ["src/public-api/**"]
+ }
+ ],
+ "restrictedDirectImportersDetection": {
+ "enabled": true,
+ "files": ["src/generated/api/**"],
+ "allowImporters": [
+ "src/domain/borrow/**",
+ "src/domain/schema/**",
+ "src/services/api/**",
+ "scripts/**",
+ "tests/generated/**"
+ ]
},
+ "orphanFilesDetection": [
+ {
+ "enabled": true
+ },
+ {
+ // check for runtime files that are only reachable from tests
+ "enabled": true,
+ "validEntryPoints": [
+ "src/index.package.ts",
+ "src/index.bundle.ts",
+ "src/public-api/index.package.ts",
+ "src/public-api/index.bundle.ts",
+ "src/main.tsx",
+ "src/translation/i18next.d.ts",
+ "src/types/window.d.ts",
+ "src/vite-env.d.ts",
+ "postcss.config.js",
+ "public/mockServiceWorker.js",
+ "vite/*.ts",
+ "scripts/generate-effect-openapi.ts",
+ "tests/**/*.ts",
+ "tests/**/*.tsx"
+ ],
+ "graphExclude": ["tests/**/*.ts", "tests/**/*.tsx"]
+ }
+ ],
"unusedExportsDetection": {
- "enabled": true,
- "ignoreFiles": ["src/types/yield-api-schema.d.ts"]
+ "enabled": true
},
"unusedNodeModulesDetection": {
"enabled": true,
@@ -35,6 +139,7 @@
"@effect/openapi-generator",
"@effect/platform-node",
"babel-plugin-react-compiler",
+ "jsdom",
"playwright",
"postcss",
"swagger2openapi",
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 49e9f9c6..4e103a2a 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,21 +1,16 @@
{
- "typescript.preferences.importModuleSpecifier": "relative",
+ "js/ts.preferences.importModuleSpecifier": "relative",
+ "js/ts.preferences.autoImportFileExcludePatterns": ["@repos/**"],
"editor.codeActionsOnSave": {
"source.organizeImports.biome": "explicit"
},
-
- "typescript.preferences.autoImportFileExcludePatterns": ["repos/**"],
- "javascript.preferences.autoImportFileExcludePatterns": ["repos/**"],
-
"files.exclude": {
- "repos/**": true
+ "@repos/**": true
},
-
"files.watcherExclude": {
- "repos/**": true
+ "@repos/**": true
},
-
"search.exclude": {
- "repos/**": true
+ "@repos/**": true
}
}
diff --git a/AGENTS.md b/AGENTS.md
index 57d73946..db9b5389 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -16,20 +16,34 @@
- `packages/widget/src/hooks/*` — feature and API hooks.
- `packages/widget/src/domain/*` — shared domain types/helpers.
- `packages/widget/src/translation/*` — i18n resources (`English`, `French`).
-- `packages/widget/tests/*` — Vitest browser tests (MSW-backed).
+- `packages/widget/tests/*` — Vitest Node and browser tests; browser tests use the `.browser.test.*` suffix and MSW.
- `packages/examples/*` — integration examples (`with-vite`, `with-vite-bundled`, `with-nextjs`, `with-cdn-script`).
## Commands Agents Should Use
+### Package manager and dependency installation
+
+- Run all pnpm commands through the version pinned by mise: `mise exec -- pnpm ...`.
+- Do not install dependencies unless a dependency manifest or lockfile changed, `node_modules` is missing or invalid, or the requested work otherwise requires it.
+- When sandboxed, request approval to run dependency-installing or dependency-modifying commands outside the sandbox so pnpm can use the normal global store.
+- Do not fall back to a sandboxed install or create a project-local `.pnpm-store`. If approval is denied, report the blocked installation instead of changing the store configuration.
+
### From repo root (all workspaces via Turbo)
-- `pnpm build` — build all packages.
-- `pnpm lint` — lint/type-check all packages.
-- `pnpm test` — run all workspace tests.
-- `pnpm format` — run formatting checks/tasks.
-- `pnpm check-hygiene` — check unused deps, unresolved imports, circular deps, etc.
+
+- `mise exec -- pnpm build` — build all packages.
+- `mise exec -- pnpm lint` — lint/type-check all packages.
+- `mise exec -- pnpm test` — run all workspace tests.
+- `mise exec -- pnpm format` — run formatting checks/tasks.
+- `mise exec -- pnpm check-hygiene` — check unused deps, unresolved imports, circular deps, etc.
### Focused widget commands (recommended for most tasks)
-- `pnpm --filter @stakekit/widget {command}`
+
+- `mise exec -- pnpm --filter @stakekit/widget {command}`
+- `mise exec -- pnpm --filter @stakekit/widget test:unit` — run the fast Node test project.
+- `mise exec -- pnpm --filter @stakekit/widget test:dom` — run React/DOM tests in jsdom.
+- `mise exec -- pnpm --filter @stakekit/widget test:browser` — run the Chromium test project.
+- `mise exec -- pnpm --filter @stakekit/widget test:changed` — run affected Node + jsdom tests.
+- `mise exec -- pnpm --filter @stakekit/widget test:changed:all` — run all affected projects, including Chromium.
## Agent Working Guidelines (short)
- Keep public API compatibility in `src/index.package.ts` and `src/index.bundle.ts`.
@@ -44,7 +58,7 @@
- React Query defaults are in `packages/widget/src/providers/query-client/index.tsx`.
- App-level config/env mapping is in `packages/widget/src/config/index.ts`.
- Test bootstrapping + MSW worker setup:
- - `packages/widget/tests/utils/setup.ts`
+ - `packages/widget/tests/utils/setup.browser.ts`
- `packages/widget/tests/mocks/worker.ts`
## Vendored Repositories
diff --git a/TODO.md b/TODO.md
new file mode 100644
index 00000000..bc27fa4b
--- /dev/null
+++ b/TODO.md
@@ -0,0 +1 @@
+- fix ast linting
\ No newline at end of file
diff --git a/agent-patterns/README.md b/agent-patterns/README.md
new file mode 100644
index 00000000..edbd0e9d
--- /dev/null
+++ b/agent-patterns/README.md
@@ -0,0 +1,47 @@
+# Effect Agent Patterns
+
+These guides describe the Effect APIs used by this repository. They target the
+vendored Effect snapshot under `@repos/effect`; unstable APIs can change, so the
+vendored source wins whenever a guide and a signature disagree.
+
+## Pick The Smallest Relevant Guide
+
+| Work involves | Read |
+| --- | --- |
+| Reactive state, async resources, hydration, or atom lifetimes | `effect-atoms.md` |
+| Outgoing HTTP requests, generated clients, retries, or response bodies | `effect-http-client.md` |
+| Polling, pagination, callback sources, queues, or incremental processing | `effect-stream.md` |
+
+Read more than one guide when a boundary crosses modules. In particular:
+
+- An HTTP response body exposed as a stream needs both the HTTP and Stream
+ guides.
+- An atom backed by a stream needs both the Atom and Stream guides.
+- An atom-backed HTTP query needs all three when it incrementally consumes the
+ response.
+
+## Source-Checking Workflow
+
+Before writing Effect code:
+
+1. Read `@repos/effect/LLMS.md`.
+2. Read the relevant guide from this directory.
+3. Inspect the exact exported signature and nearby implementation in
+ `@repos/effect/packages`.
+4. Inspect the corresponding vendored tests for lifecycle or concurrency
+ behavior that the type signature cannot express.
+
+Use `rg --no-ignore` when searching `@repos/effect`, because the vendored clone
+may be ignored by Git locally. Do not use `node_modules` or web documentation as
+the authority for Effect behavior in this repository.
+
+## Repository Rules Still Apply
+
+- Match nearby imports and project abstractions before introducing a new one.
+- Keep generated API clients generated. Put hand-written behavior in transport
+ or service layers rather than editing generated files.
+- Prefer `Effect.gen` and named `Effect.fn` functions for domain operations.
+- Keep service requirements in the type until the application boundary provides
+ the layer.
+- Test interruption, finalization, and time-dependent behavior when they are part
+ of correctness, not only the happy-path value.
diff --git a/agent-patterns/effect-atoms.md b/agent-patterns/effect-atoms.md
index e0b801e4..fcaefeb9 100644
--- a/agent-patterns/effect-atoms.md
+++ b/agent-patterns/effect-atoms.md
@@ -1,28 +1,51 @@
# Effect Atom Patterns
-Use this when writing or changing code that uses Effect's unstable atom
-reactivity APIs. The source of truth reviewed for these patterns is the vendored
-Effect repo: `@repos/effect/LLMS.md`,
-`@repos/effect/packages/effect/src/unstable/reactivity/Atom.ts`,
-`AtomRegistry.ts`, `AsyncResult.ts`, `Reactivity.ts`, `Hydration.ts`,
-`AtomHttpApi.ts`, `AtomRpc.ts`, and the tests in
-`@repos/effect/packages/effect/test/reactivity`.
+Use this guide for Effect's unstable reactivity APIs: local state, derived
+state, async resources, mutations, pagination, invalidation, persistence, and
+hydration.
+
+The source of truth is the vendored Effect repository, especially:
+
+- `@repos/effect/packages/effect/src/unstable/reactivity/Atom.ts`
+- `@repos/effect/packages/effect/src/unstable/reactivity/AtomRegistry.ts`
+- `@repos/effect/packages/effect/src/unstable/reactivity/AsyncResult.ts`
+- `@repos/effect/packages/effect/src/unstable/reactivity/Reactivity.ts`
+- `@repos/effect/packages/effect/src/unstable/reactivity/Hydration.ts`
+- `@repos/effect/packages/effect/src/unstable/reactivity/AtomHttpApi.ts`
+- `@repos/effect/packages/effect/src/unstable/reactivity/AtomRpc.ts`
+- `@repos/effect/packages/effect/test/reactivity`
## Imports
-Import atom APIs from the unstable reactivity barrel.
+Match nearby code. The reactivity barrel is convenient when several modules are
+used together; module imports are useful for namespace-style or type-only
+imports.
```ts
-import { Effect, Layer, Schema, Stream } from "effect"
-import { AsyncResult, Atom, AtomRegistry, Hydration } from "effect/unstable/reactivity"
+import { Effect, Option, Schema, Stream } from "effect"
+import {
+ AsyncResult,
+ Atom,
+ AtomRegistry,
+ Hydration
+} from "effect/unstable/reactivity"
```
-## Registry Owns State
+## Mental Model
+
+An `Atom` is a stable description of how to read or write a value. An
+`AtomRegistry` owns the mutable runtime state:
-An `Atom` is a value description. An `AtomRegistry` owns cached values,
-dependency edges, subscriptions, running fibers, stream scopes, and disposal.
-Use one registry per isolated lifetime: UI root, request, route boundary, or
-test.
+- cached values and serializable preloads
+- parent/child dependency edges
+- subscriptions and mounted nodes
+- running fibers, stream scopes, and finalizers
+- idle timers and disposal
+
+The same atom can hold different values in different registries. Use one
+registry per isolated lifetime, such as a UI root or test. Prefer
+`AtomRegistry.layer` when an Effect scope owns that lifetime; its finalizer
+disposes the registry.
```ts
const count = Atom.make(0)
@@ -33,31 +56,25 @@ registry.set(count, 21)
registry.get(doubled) // 42
```
-The same atom object can have different values in different registries. After
-`registry.dispose()`, later atom access is an error.
+A bare `registry.get(atom)` does not mount the atom. An unobserved node is
+eligible for disposal on a later scheduler turn. This matters for async work:
+mount or subscribe when the computation must remain alive.
-## Keep Atom Identity Stable
+After `registry.dispose()`, creating or accessing nodes throws. `reset()` clears
+nodes but leaves the registry reusable; `dispose()` is terminal.
-Do not create parameterized atoms inline during reads or renders. Atom identity
-is the cache key unless the atom is serializable. Use `Atom.family` for
-parameterized atoms so the same input returns the same atom object.
+## Choose The Right Shape
-```ts
-const userAtom = Atom.family((id: string) =>
- UserClient.runtime.atom(UserClient.use((client) => client.getUser({ id }))).pipe(
- Atom.withLabel(`user:${id}`)
- )
-)
-```
-
-Use `Atom.withLabel` on important atoms. It only adds diagnostic metadata and
-does not change behavior.
-
-## Choose The Right Constructor
-
-Use `Atom.make(value)` for writable local state, `Atom.make((get) => value)` for
-derived synchronous state, and `Atom.make(effectOrStream)` for read atoms that
-produce `AsyncResult`.
+| Need | API | Exposed value |
+| --- | --- | --- |
+| Writable local state | `Atom.make(initial)` | `A` |
+| Synchronous derivation | `Atom.make((get) => value)` | `A` |
+| Async or stream resource | `Atom.make(effectOrStream)` | `AsyncResult` |
+| Synchronous command | `Atom.fnSync` | `Option` or configured initial `A` |
+| Async command/mutation | `Atom.fn` | `AsyncResult` |
+| Incremental page/chunk loading | `Atom.pull` | `PullResult` |
+| Existing mutable Effect state | `Atom.subscriptionRef` | writable resource atom |
+| Parameterized atom | `Atom.family` | same shape returned by the family |
```ts
const search = Atom.make("")
@@ -72,57 +89,97 @@ const users = Atom.make((get) =>
)
```
-Use `Atom.fn` for command-style effects that run when written to. Use
-`Atom.fnSync` for synchronous commands. Before the first write, `Atom.fn`
-returns `AsyncResult.initial()` unless `initialValue` is supplied, and
-`Atom.fnSync` returns `Option.none()` unless `initialValue` is supplied.
+Use `Atom.fn` for work that starts only when a value is written. Before the
+first write it is `AsyncResult.initial()` unless `initialValue` is supplied.
+The default latest write wins: refreshing the command atom disposes its prior
+lifetime and interrupts its computation.
```ts
-const saveUser = Atom.fn<{ readonly id: string; readonly name: string }>()(
- Effect.fn("saveUser")(function*(input) {
- return yield* UserApi.use((api) => api.save(input))
- })
-)
+const saveUser = Atom.fn<{
+ readonly id: string
+ readonly name: string
+}>()(Effect.fn("saveUser")(function*(input) {
+ return yield* UserApi.use((api) => api.save(input))
+}))
registry.set(saveUser, { id: "1", name: "Ada" })
```
-Write `Atom.Reset` to clear an `Atom.fn` result back to its initial state and
-`Atom.Interrupt` to interrupt the current computation. Set `{ concurrent: true }`
-only when multiple writes should run at the same time; the default interrupts
-and replaces the previous run.
+Write `Atom.Reset` to restore the initial command state and `Atom.Interrupt` to
+interrupt the active computation. Use `{ concurrent: true }` only when writes
+may overlap and a single shared result atom is still the correct interface. If
+callers need individually correlated results, model the operation as a normal
+Effect instead.
+
+## Keep Identity Stable
+
+Registry caching normally uses atom identity. Do not create parameterized atoms
+inline during reads or React renders. Use `Atom.family` so equal inputs return
+the same live atom object.
+
+```ts
+const userAtom = Atom.family((id: string) =>
+ UserRuntime.atom(
+ UserApi.use((api) => api.getUser(id))
+ ).pipe(Atom.withLabel(`user:${id}`))
+)
+```
+
+`Atom.family` uses Effect hashing/equality and weakly holds produced objects when
+the platform supports `WeakRef`. Prefer primitive ids or immutable Effect data
+as family keys; mutating an object used as a hashed key breaks lookup
+assumptions.
+
+Serializable atoms are different: their serialization key becomes the registry
+key. Two atom objects with the same serializable key alias the same registry
+node. Keys must therefore be unique and their schemas compatible throughout one
+registry.
+
+Use `Atom.withLabel` on important atoms for diagnostics. It does not change
+runtime behavior.
## Read Dependencies Deliberately
-Inside an atom read, `get(atom)` records a dependency. When that dependency
-changes, the current atom is invalidated. Use `get.once(atom)` when you need the
-current value without creating a dependency edge.
+In a normal atom read:
-Use `get.result(asyncAtom)` to await an `AsyncResult` atom from another effect
-atom. It waits while the result is `Initial`; pass `{ suspendOnWaiting: true }`
-when stale values marked `waiting` should also suspend.
+- `get(atom)` or `get.get(atom)` records a dependency.
+- `get.once(atom)` reads without a dependency edge.
+- `get.result(asyncAtom)` records a dependency and suspends the current atom
+ effect while the result is `Initial`.
+- `get.resultOnce(asyncAtom)` waits once without making it a reactive
+ dependency.
+
+Pass `{ suspendOnWaiting: true }` when stale values marked `waiting` must also
+suspend.
```ts
const enrichedUser = Atom.make((get) =>
Effect.gen(function*() {
- const user = yield* get.result(userAtom("1"), { suspendOnWaiting: true })
+ const user = yield* get.result(userAtom("1"), {
+ suspendOnWaiting: true
+ })
return { ...user, displayName: user.name.toUpperCase() }
})
)
```
-In `Atom.fn` bodies, `get.result` behaves as a one-shot wait instead of a normal
-dependency read. If the command should rerun from another atom changing, read
-that atom with `get(atom)` as part of the command trigger or use reactivity keys.
+`Atom.fn` and `Atom.fnSync` are commands, not reactive derivations. Reads through
+their `FnContext` are one-shot and do **not** add dependency edges. A command
+reruns only when written again. Use a normal derived atom when work should rerun
+because another atom changed, or pass the current value as part of the command
+input.
-## Handle AsyncResult As State
+Use `get.subscribe`, `get.mount`, and `get.addFinalizer` only for explicit
+lifecycle integration. Their cleanup is tied to the current atom lifetime.
+
+## Treat AsyncResult As A State Machine
`AsyncResult` has three variants: `Initial`, `Success`, and `Failure`. The
`waiting` flag is an overlay, not a fourth variant. A waiting success or failure
-can still contain the previous usable value.
+can retain useful stale data while a refresh runs.
Prefer `AsyncResult.matchWithWaiting`, `AsyncResult.builder`, or explicit
-refinements instead of assuming any non-success is a loading state.
+refinements instead of equating every non-success with loading.
```ts
const view = AsyncResult.matchWithWaiting(result, {
@@ -133,33 +190,42 @@ const view = AsyncResult.matchWithWaiting(result, {
})
```
-`AsyncResult.value(result)` and `AsyncResult.getOrElse(result, fallback)` may
-return a previous success stored inside a failure. Inspect
-`AsyncResult.cause(result)` or `AsyncResult.error(result)` when current failure
-versus stale data matters.
+`AsyncResult.value(result)` and `AsyncResult.getOrElse(result, fallback)` can
+return a previous success stored inside a failure. This is useful for keeping
+already loaded pages or stale server data visible. Inspect
+`AsyncResult.cause(result)` or `AsyncResult.error(result)` when the current
+failure must be shown separately from the fallback value.
+
+Do not drop the `waiting` flag when mapping UI state. It is the signal that a
+displayed success or failure is stale and a new computation is active.
-## Manage Lifetime Explicitly
+## Make Lifetime A Product Decision
-Unobserved atoms are auto-disposed by default. That means local state can reset,
-effects can restart, streams can resubscribe, and finalizers can run after the
-last listener or dependent child disappears.
+Unobserved atoms auto-dispose by default. Disposal can reset local state,
+interrupt effects, close stream scopes, remove subscriptions, and run
+finalizers.
-Use the narrowest lifetime tool that matches the behavior:
+Use the narrowest lifetime tool that matches the requirement:
-- `Atom.keepAlive` keeps an atom cached even when unobserved.
-- `Atom.setIdleTTL(duration)` keeps an unused atom around for a finite idle time.
-- `Atom.autoDispose` restores default disposal on a copied atom.
-- `registry.mount(atom)` keeps an atom alive until the returned release function
- is called.
-- `Atom.mount(atom)` keeps an atom alive for the current Effect scope.
+- `registry.subscribe(atom, listener)` observes until its release callback runs.
+- `registry.mount(atom)` keeps it alive until its release callback runs.
+- `Atom.mount(atom)` keeps it alive for the current Effect scope.
+- `Atom.setIdleTTL(duration)` retains it for a finite unobserved interval.
+- `Atom.keepAlive` retains it for the entire registry lifetime.
+- `Atom.autoDispose` removes a copied atom's `keepAlive` behavior.
-Always release `registry.subscribe` and `registry.mount` callbacks when
-integrating with external callback-based code.
+Always release registry subscriptions and mounts. Be especially careful with a
+high-cardinality `Atom.family`: combining it with `keepAlive` or a long TTL can
+turn a weak family into an effectively unbounded registry cache.
-## Batch Related Writes
+Registry `initialValues` seed a node but do not mount it. The seeded value is
+still subject to normal disposal and recomputation.
-Use `Atom.batch` when multiple synchronous writes should invalidate dependents
-and notify listeners once after the final state is known.
+## Batch Related Synchronous Writes
+
+Use `Atom.batch` when several synchronous writes form one logical state change.
+Dependents can rebuild from the latest values inside the batch, but listeners
+are notified after the outermost batch commits.
```ts
Atom.batch(() => {
@@ -168,23 +234,21 @@ Atom.batch(() => {
})
```
-Reads inside a batch can still rebuild from the latest written state, but
-listeners are notified after the batch commits.
+`Atom.batch` is not an Effect transaction and does not wait for async work. Use
+it only around synchronous registry operations.
## Use Runtime Atoms For Services
-Use `Atom.runtime(layer)` or `Atom.context({ memoMap })` when atom effects need
-Effect services. The runtime builds the layer with a memo map, provides
-`AtomRegistry`, `Scope`, `Scheduler`, and `Reactivity`, and exposes
-`runtime.atom`, `runtime.fn`, `runtime.pull`, and `runtime.subscriptionRef`.
+Plain async atoms may require only `Scope` and `AtomRegistry`. Use an atom
+runtime when effects also require application services.
```ts
const UserRuntime = Atom.runtime(UserApi.layer)
const user = Atom.family((id: string) =>
- UserRuntime.atom(UserApi.use((api) => api.getUser(id)), {
- initialValue: { id, name: "Loading" }
- })
+ UserRuntime.atom(
+ UserApi.use((api) => api.getUser(id))
+ )
)
const saveUser = UserRuntime.fn(
@@ -195,49 +259,114 @@ const saveUser = UserRuntime.fn(
)
```
-Use registry `initialValues` with `Atom.initialValue(runtime.layer, testLayer)`
-to replace runtime services in tests.
+`Atom.runtime(layer)` uses the default shared `Layer.MemoMap`.
+`Atom.context({ memoMap })` creates a factory with an explicit memoization
+boundary and exposes `addGlobalLayer`. The runtime supplies `AtomRegistry`,
+`Scope`, `Scheduler`, and `Reactivity` while building its layer.
-## Invalidate Server State With Reactivity Keys
+In tests, replace a runtime layer with registry initial values:
-Use `Atom.withReactivity(keys)` for reads that should refresh after matching
-invalidations. Use `runtime.fn(..., { reactivityKeys })`,
-`Reactivity.mutation(effect, keys)`, or `Reactivity.invalidate(keys)` for writes
-that should trigger those refreshes after success.
+```ts
+const registry = AtomRegistry.make({
+ initialValues: [
+ Atom.initialValue(UserRuntime.layer, UserApi.testLayer)
+ ]
+})
+```
+
+Mount the resource under test so the seeded runtime and async work are not
+disposed between assertions.
+
+## Model Server Resources With SWR And TTL
-Keys can be a flat array or a record. Prefer stable primitive keys or stable ids;
-non-primitive keys are matched by `Hash.hash`.
+`Atom.swr` adds stale-while-revalidate behavior to an `AsyncResult` atom.
+`staleTime` controls freshness; `revalidateOnMount` and `revalidateOnFocus`
+control automatic refresh triggers.
```ts
-const user = UserRuntime.atom(UserApi.use((api) => api.getUser("1"))).pipe(
- UserRuntime.factory.withReactivity({ users: ["1"] })
+const usersResource = UserRuntime.atom(
+ UserApi.use((api) => api.listUsers())
+).pipe(
+ Atom.swr({
+ staleTime: "30 seconds",
+ revalidateOnMount: true,
+ revalidateOnFocus: true,
+ focusSignal: appFocusSignal
+ }),
+ Atom.setIdleTTL("5 minutes")
)
+```
-const saveUser = UserRuntime.fn(
- (input: User) => UserApi.use((api) => api.saveUser(input)),
- { reactivityKeys: { users: ["1"] } }
+Important details:
+
+- A manual registry refresh is forceful even while data is fresh.
+- A stale success or failure keeps its previous success while revalidation
+ runs.
+- Focus revalidation requires both `revalidateOnFocus` and a `focusSignal`.
+- `true` respects `staleTime`; `"always"` forces a refresh on every signal.
+- Freshness uses `AsyncResult` timestamps and host `Date.now`.
+
+Apply resource policy combinators before `Atom.serializable`. Transforming an
+atom can intentionally remove its serializable marker; serialize the final
+public resource that should cross registries.
+
+## Invalidate Server State With Reactivity Keys
+
+Attach keys to reads with `Atom.withReactivity` or
+`runtime.factory.withReactivity`. Invalidate them through
+`runtime.fn(..., { reactivityKeys })`, `Reactivity.mutation`, or
+`Reactivity.invalidate`.
+
+```ts
+const user = UserRuntime.atom(
+ UserApi.use((api) => api.getUser("1"))
+).pipe(
+ UserRuntime.factory.withReactivity({ users: ["1"] })
)
```
+Key matching is hash based, not deep structural matching. Prefer primitives or
+immutable Effect data. Record keys have hierarchical behavior:
+`{ users: ["1"] }` registers both `"users"` and `"users:1"`. Consequently,
+two records with the same `users` group overlap even when their ids differ. Use
+a flat composite key such as `["users:1"]` when invalidation must be strictly
+per id.
+
+`Reactivity.mutation(effect, keys)` invalidates only after an Effect succeeds.
+A stream-returning runtime command uses stream finalization to invalidate, so it
+also invalidates when that stream ends through failure or interruption. Choose
+the command shape with that distinction in mind.
+
## Streams, Pulls, And SubscriptionRefs
-An `Atom.make(stream)` stores the latest emitted item in an `AsyncResult`. An
-empty stream completes as `NoSuchElementError`. Failures preserve the latest
-previous success when possible.
+`Atom.make(stream)` subscribes while the atom is alive and stores the latest
+emitted item as an `AsyncResult`. A stream that completes without emitting fails
+with `NoSuchElementError`. A later failure preserves a previous success when one
+exists.
-Use `Atom.pull(stream)` or `runtime.pull(stream)` for paginated or incremental
-streams that should advance only when the atom is written to. It accumulates
-items by default; pass `{ disableAccumulation: true }` when each pull should
-replace the previous batch.
+Use `Atom.pull` for demand-driven pagination. The first chunk is pulled when the
+atom is read; each write requests another chunk.
+
+```ts
+const pages = Atom.pull(
+ Stream.paginate(initialCursor, fetchPage)
+)
+```
+
+With default accumulation, the success value contains `{ items, done }` and
+`done` becomes true after the terminal pull. Pass
+`{ disableAccumulation: true }` when each pull should replace the prior batch or
+when retaining the entire history would be too expensive. In that mode, a
+terminal pull with no new items can surface `NoSuchElementError` instead of a
+final accumulated `{ done: true }` value. Avoid issuing another write while the
+pull result is already waiting unless overlapping pulls are intentional.
Use `Atom.subscriptionRef(refOrEffect)` when state already lives in a
-`SubscriptionRef`; writes to the atom update the ref.
+`SubscriptionRef`; atom writes update the ref.
## Persistence And Hydration
-Mark atoms that cross registry boundaries with `Atom.serializable({ key,
-schema })`. Only serializable atoms are included in `Hydration.dehydrate`, and
-`Hydration.hydrate` must run before the matching atoms are first read.
+Mark only state that crosses registry boundaries as serializable.
```ts
const user = userAtom("1").pipe(
@@ -254,53 +383,66 @@ const state = Hydration.dehydrate(serverRegistry)
Hydration.hydrate(clientRegistry, state)
```
-Stable keys matter more than atom identity during hydration. The target atom must
-use a compatible schema. `Hydration.dehydrate(..., { encodeInitialAs: "promise" })`
-uses a live JavaScript promise for initial async results, so do not serialize
-that form across JSON or processes.
-
-For browser URL state, `Atom.searchParam` requires synchronous schemas with no
-Effect context. For storage-backed state, use `Atom.kvs` with an atom runtime
-that provides `KeyValueStore`.
-
-## Remote API Helpers
-
-Use `AtomHttpApi.Service` or `AtomRpc.Service` when typed HTTP API or RPC
-clients should participate in atom caching, invalidation, and hydration.
-
-- Queries return `Atom>` for non-streaming endpoints.
-- Mutations return `AtomResultFn`.
-- `reactivityKeys` connect successful mutations to query refreshes.
-- `timeToLive` maps to `Atom.setIdleTTL` for finite durations and
- `Atom.keepAlive` for infinite durations.
-- `serializationKey` is required for serializable queries, and should uniquely
- identify the endpoint plus request.
-- RPC streaming queries return pull atoms and are not serializable query atoms.
+Hydrate before the target atom is first read. Hydration preloads encoded values
+by key; it does not replace an already-built node. Serialization uses
+synchronous JSON codecs, so schemas must not require Effect services.
-## Testing Patterns
+`dehydrate` ignores `AsyncResult.Initial` by default. The other modes are:
-Use `it.effect` for Effect-based tests, `AtomRegistry.make()` for an isolated
-cache, fake timers or `TestClock` for delayed atoms, and explicit
-`registry.mount(atom)` when async work must stay alive during the test.
+- `"value-only"` encodes the initial value itself.
+- `"promise"` includes a live promise that later updates the target registry.
-```ts
-it.effect("refreshes after mutation", () =>
- Effect.gen(function*() {
- const registry = AtomRegistry.make()
- const unmount = registry.mount(user)
+The promise mode is process-local JavaScript state. Do not JSON-serialize it or
+send it across a network/process boundary.
- registry.set(saveUser, { id: "1", name: "Grace" })
- yield* Effect.yieldNow
+For browser URL state, `Atom.searchParam` requires a synchronous schema with no
+context. For storage state, use `Atom.kvs` with a runtime providing
+`KeyValueStore`.
- const result = registry.get(user)
- assert(AsyncResult.isSuccess(result))
- assert.strictEqual(result.value.name, "Grace")
+## Remote API Helpers
- unmount()
- }))
-```
+Use `AtomHttpApi.Service` or `AtomRpc.Service` when a typed HTTP API or RPC
+client should participate directly in atom caching and invalidation.
-Prefer `yield* Effect.yieldNow`, fake timer advancement, or `TestClock` over
-real sleeps. Assert `AsyncResult` variants and `waiting` flags directly. For
-lifetime behavior, assert node disposal by reading again after yielding, or use
-`keepAlive` / `mount` when state should persist.
+- Non-streaming queries return `Atom>`.
+- Mutations return `AtomResultFn`.
+- `reactivityKeys` connect commands to query refreshes.
+- `timeToLive` maps finite values to idle TTL and infinity to `keepAlive`.
+- Serializable queries require a unique `serializationKey` per endpoint and
+ request.
+- Streaming RPC queries return pull atoms and are not serializable query atoms.
+
+Prefer the project's generated client plus hand-written transport/service layer
+when those helpers would duplicate existing abstractions.
+
+## Testing
+
+Create a fresh registry per test and dispose it or release every mount. Test the
+state machine, not only the final success:
+
+- initial and waiting state
+- stale success during refresh
+- failure with and without previous success
+- interruption or latest-write-wins behavior
+- finalizers and disposal
+- pagination completion and accumulated items
+
+Use regular Vitest tests for purely imperative registry logic. Run effectful
+assertions with the repository's existing `Effect.runPromise` pattern. Mount
+async atoms before yielding or advancing time.
+
+Registry idle TTL, `Atom.swr`, `Atom.debounce`, and several focus/storage helpers
+use host timers or `Date.now`; control those with the test runner's fake timers.
+Use `TestClock` only when the Effect inside the runtime is explicitly receiving
+the test clock service.
+
+## Review Checklist
+
+- Atom and serializable identities are stable and unique.
+- Command reads are not mistaken for reactive dependencies.
+- Async UI preserves both stale values and the `waiting` flag.
+- Every subscription or mount has a matching release.
+- High-cardinality families have a bounded cache policy.
+- Reactivity keys have the intended broad or per-id overlap.
+- Hydration happens before first read and uses a synchronous compatible schema.
+- Time and finalization behavior are covered by tests.
diff --git a/agent-patterns/effect-http-client.md b/agent-patterns/effect-http-client.md
index 7d6ad916..50b3fa2d 100644
--- a/agent-patterns/effect-http-client.md
+++ b/agent-patterns/effect-http-client.md
@@ -1,31 +1,54 @@
# Effect HttpClient Patterns
-Use this when writing project code that talks to external HTTP APIs with Effect.
-The source of truth reviewed for these patterns is the vendored Effect repo:
-`repos/effect/LLMS.md`, `repos/effect/ai-docs/src/50_http-client/10_basics.ts`,
-and the `repos/effect/packages/effect/src/unstable/http/HttpClient*.ts` modules
-plus their tests.
+Use this guide for outgoing HTTP APIs: request construction, generated clients,
+middleware, retries, rate limits, response decoding, streaming bodies, and
+tests.
-## Imports
+The source of truth is the vendored Effect repository, especially:
-Prefer the unstable HTTP barrel unless nearby code imports individual modules.
+- `@repos/effect/packages/effect/src/unstable/http/HttpClient.ts`
+- `@repos/effect/packages/effect/src/unstable/http/HttpClientRequest.ts`
+- `@repos/effect/packages/effect/src/unstable/http/HttpClientResponse.ts`
+- `@repos/effect/packages/effect/src/unstable/http/HttpClientError.ts`
+- `@repos/effect/packages/effect/src/unstable/http/HttpIncomingMessage.ts`
+- `@repos/effect/packages/effect/src/unstable/http/FetchHttpClient.ts`
+- `@repos/effect/packages/effect/src/unstable/persistence/RateLimiter.ts`
+- `@repos/effect/packages/effect/test/unstable/http`
+
+Read `effect-stream.md` as well when a request or response body is streamed.
+
+## Imports And Transport
+
+Match nearby code. The unstable HTTP barrel is convenient for hand-written
+services; generated clients in this repository use module imports.
```ts
import { Context, Effect, flow, Layer, Schedule, Schema, Stream } from "effect"
-import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
+import {
+ FetchHttpClient,
+ HttpClient,
+ HttpClientRequest,
+ HttpClientResponse
+} from "effect/unstable/http"
import { RateLimiter } from "effect/unstable/persistence"
```
-Platform implementations are provided as layers. Use `FetchHttpClient.layer` for
-portable fetch-based code unless the runtime has a more specific layer nearby
-(`NodeHttpClient.layerFetch`, `NodeHttpClient.layerNodeHttp`,
-`NodeHttpClient.layerUndici`, `BrowserHttpClient.layerXMLHttpRequest`, etc.).
+An `HttpClient` describes request preprocessing and response postprocessing. A
+platform layer supplies the low-level transport:
+
+- `FetchHttpClient.layer` for browsers, edge runtimes, and portable Fetch code
+- a nearby Node or browser-specific client when its transport behavior is
+ required
-## Build API Clients As Services
+Fetch behavior such as CORS, credentials, redirect defaults, and streaming
+support still depends on the runtime. Do not assume all platform layers behave
+identically.
-Wrap API-specific HTTP behavior in a `Context.Service` layer. Acquire
-`HttpClient.HttpClient` once, then apply shared middleware such as base URL,
-headers, status filtering, retries, tracing spans, and error mapping.
+## Put API Policy In A Service Boundary
+
+Generated code should describe endpoints. Hand-written transport or service
+layers should own base URLs, authentication, retry policy, observability, and
+domain error mapping.
```ts
class Todo extends Schema.Class("Todo")({
@@ -57,27 +80,29 @@ export class TodoApi extends Context.Service new TodoApiError({ cause })),
- Effect.withSpan("TodoApi.getTodo")
+ Effect.mapError((cause) => new TodoApiError({ cause }))
)
})
return TodoApi.of({ getTodo })
})
- ).pipe(
- Layer.provide(FetchHttpClient.layer)
- )
+ ).pipe(Layer.provide(FetchHttpClient.layer))
}
-export class TodoApiError extends Schema.TaggedErrorClass()("TodoApiError", {
- cause: Schema.Defect
-}) {}
+export class TodoApiError extends Schema.TaggedErrorClass()(
+ "TodoApiError",
+ { cause: Schema.Defect() }
+) {}
```
-## Requests Are Immutable Values
+Acquire and configure the shared client once while building the service. Keep
+endpoint functions small and map library errors into domain errors at this
+boundary. Do not leak authentication headers or raw response bodies into logs.
+
+## Build Immutable Requests
Use `HttpClientRequest` constructors and combinators instead of hand-building
-fetch options. Each combinator returns a new request.
+Fetch options. Every combinator returns a new request.
```ts
const request = HttpClientRequest.post("/todos").pipe(
@@ -90,9 +115,13 @@ const request = HttpClientRequest.post("/todos").pipe(
const response = yield* client.execute(request)
```
-Prefer schema-backed encoders when a schema exists. `schemaBodyJson` and
-`bodyJson` fail in the Effect error channel; `bodyJsonUnsafe` may throw during
-JSON encoding and is best reserved for generated code or already-safe payloads.
+Choose body encoding deliberately:
+
+- `schemaBodyJson(schema)(value)` validates/encodes through a schema and can
+ require encoding services.
+- `bodyJson(value)` catches JSON encoding failures as `HttpBodyError`.
+- `bodyJsonUnsafe(value)` is synchronous but may throw. Reserve it for generated
+ code or values whose serializability is already guaranteed.
```ts
const createTodo = (input: typeof NewTodo.Type) =>
@@ -103,47 +132,92 @@ const createTodo = (input: typeof NewTodo.Type) =>
)
```
-Use `setUrlParams` when replacing query values and `appendUrlParams` when
-multiple values with the same key must be preserved. Passing a `URL` to a
-request constructor extracts search parameters and hash into structured request
-fields.
+Use `setUrlParams` to replace values and `appendUrlParams` to preserve repeated
+keys. Passing a `URL` to a request constructor extracts its query and hash into
+the request's structured fields.
+
+Be careful with middleware that sets headers: later `setHeader` calls replace
+the same header. Prefer one clearly owned authentication transform.
-## Status Codes Are Not Errors By Default
+## Decide Status Policy Before Decoding
-`HttpClient` succeeds with an `HttpClientResponse` for non-2xx statuses. Choose
-one of these explicitly:
+HTTP status is data by default. `HttpClient` succeeds with a response for 4xx
+and 5xx statuses unless a filter turns them into failures.
+
+Use a client-wide filter only when every endpoint has the same status contract:
```ts
-// Simple API: fail on anything outside 2xx.
-const json = yield* client.get("/todos/1").pipe(
- Effect.flatMap(HttpClientResponse.filterStatusOk),
+const okClient = client.pipe(HttpClient.filterStatusOk)
+
+const todo = yield* okClient.get("/todos/1").pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo))
)
```
+For typed non-2xx responses, do not apply `filterStatusOk` before matching.
+Branch on the raw response first:
+
```ts
-// Typed API: branch by exact status or status class.
const result = yield* client.post("/todos").pipe(
Effect.flatMap(HttpClientResponse.matchStatus({
201: HttpClientResponse.schemaBodyJson(Todo),
400: (response) =>
- Effect.flatMap(
- HttpClientResponse.schemaBodyJson(ApiProblem)(response),
- (problem) => Effect.fail(new BadRequest({ problem }))
+ HttpClientResponse.schemaBodyJson(ApiProblem)(response).pipe(
+ Effect.flatMap((problem) => Effect.fail(new BadRequest({ problem })))
),
- "5xx": (response) => Effect.fail(new RemoteServiceUnavailable({ status: response.status })),
- orElse: (response) => Effect.fail(new UnexpectedStatus({ status: response.status }))
+ "5xx": (response) =>
+ Effect.fail(new RemoteServiceUnavailable({ status: response.status })),
+ orElse: (response) =>
+ Effect.fail(new UnexpectedStatus({ status: response.status }))
}))
)
```
-Use `HttpClient.filterStatusOk` on the client when every request made by that
-client expects 2xx responses. Use `HttpClientResponse.matchStatus` when the API
-has typed non-2xx responses.
+Exact status handlers win over status-class handlers. Always include `orElse`
+so new or undocumented statuses have an explicit path.
+
+## Know The Error Layers
+
+There is not one universal HTTP error type. The operation determines the error
+channel:
-## Decode Responses Deliberately
+| Operation | Typical failure |
+| --- | --- |
+| Request schema/JSON encoding before execution | `HttpBodyError` |
+| Transport, URL construction, status filtering, body reading | `HttpClientError` |
+| Schema validation of decoded body/headers | `SchemaError` |
+| Hand-written API boundary | mapped domain error |
-Body readers are effects and can fail with `HttpClientError`:
+`HttpClientError` wraps a more specific `reason`:
+
+- `TransportError`, `EncodeError`, and `InvalidUrlError` have no response.
+- `StatusCodeError`, `DecodeError`, and `EmptyBodyError` include a response.
+
+Catch the outer `HttpClientError`, then inspect `reason._tag`. Catching
+`StatusCodeError` directly with `Effect.catchTag` does not work on a normal
+client call because it is nested.
+
+```ts
+const recovered = client.get("/todos/1").pipe(
+ Effect.flatMap(HttpClientResponse.filterStatusOk),
+ Effect.catchTag("HttpClientError", (error) => {
+ if (
+ error.reason._tag === "StatusCodeError" &&
+ error.response?.status === 404
+ ) {
+ return Effect.succeedNone
+ }
+ return Effect.fail(error)
+ })
+)
+```
+
+This handler does not catch `SchemaError` from a later schema decoder. Map both
+at the service boundary when the public API exposes one domain error.
+
+## Decode A Body Once, Deliberately
+
+Raw body readers are effects and can fail with `HttpClientError`:
```ts
yield* response.text
@@ -152,7 +226,7 @@ yield* response.arrayBuffer
yield* response.urlParamsBody
```
-Prefer schema decoders for JSON and headers:
+Prefer schema decoders at trust boundaries:
```ts
yield* HttpClientResponse.schemaBodyJson(Todo)(response)
@@ -160,72 +234,84 @@ yield* HttpClientResponse.schemaJson(ResponseEnvelope)(response)
yield* HttpClientResponse.schemaNoBody(HeaderOnlyResponse)(response)
```
-Use `schemaBodyJson` for body-only JSON. Use `schemaJson` when the schema covers
-the whole `{ status, headers, body }` response shape. `response.json` parses an
-empty text body as `null`. `response.stream` fails if there is no body.
+- `schemaBodyJson` decodes only the JSON body.
+- `schemaJson` decodes `{ status, headers, body }` together.
+- `schemaNoBody` decodes status and headers without consuming a body.
+- `response.json` treats an empty text body as `null`.
+- `response.stream` fails with `EmptyBodyError` when there is no body.
-## Error Handling
+Text and array-buffer access are cached by the Web response wrapper, but a body
+stream is a live consumable resource. Do not inspect a body through `text` or
+`json` and then expect to stream the same body, or stream it and then decode it.
+Choose buffered decoding or streaming at the endpoint boundary.
-The public failure is usually `HttpClientError.HttpClientError`. Inspect
-`error.reason._tag` for the precise cause:
+## Understand Middleware Ordering
-- `TransportError`, `EncodeError`, and `InvalidUrlError` happen before a
- response exists.
-- `StatusCodeError`, `DecodeError`, and `EmptyBodyError` include the response.
+Request transforms and response transforms compose differently:
-Do not try to catch `StatusCodeError` directly with `Effect.catchTag` on a normal
-client call; the outer tag is `HttpClientError`. Catch `HttpClientError` and
-branch on `reason._tag`, or map errors at the API service boundary.
+- `mapRequest` appends preprocessing; successive calls execute in pipe order.
+- `mapRequestInput` prepends preprocessing before transforms already installed.
+- Each later response combinator wraps the response behavior built before it.
```ts
-const recovered = client.get("/todos/1").pipe(
- Effect.flatMap(HttpClientResponse.filterStatusOk),
- Effect.catchTag("HttpClientError", (error) => {
- if (error.reason._tag === "StatusCodeError" && error.response?.status === 404) {
- return Effect.succeedNone
- }
- return Effect.fail(error)
- })
+const client = baseClient.pipe(
+ HttpClient.mapRequest(
+ HttpClientRequest.prependUrl("https://api.example.com")
+ ),
+ HttpClient.followRedirects(),
+ HttpClient.filterStatusOk,
+ HttpClient.retryTransient({ times: 3 })
)
```
-## Middleware Ordering
+Ordering consequences:
+
+- Put `followRedirects` before `filterStatusOk`; otherwise a visible 3xx can be
+ rejected before redirect handling sees it.
+- Put `tap` before a status filter to inspect every response, including non-2xx.
+ Put it after the filter to observe accepted responses only; use `tapError` for
+ rejected ones.
+- Middleware inside a retry wrapper runs for every attempt. Middleware added
+ after the retry sees only the final outcome.
+- `filterStatusOk` may appear before `retryTransient`: transient
+ `StatusCodeError` reasons are recognized. Without a filter, transient raw
+ responses are also recognized by the default response retry mode.
-`HttpClient` middleware transforms a client and is usually read left to right in
-the `pipe`.
+Use `mapRequestInput` rarely. Most base URLs, headers, and auth belong in normal
+`mapRequest` transforms whose order is visible in the pipe.
+
+## Retry Only Safe Operations
+
+`retryTransient` retries timeouts, transport errors, and these statuses:
+`408`, `429`, `500`, `502`, `503`, and `504`. Its default mode is
+`"errors-and-responses"`.
```ts
-const client = baseClient.pipe(
- HttpClient.mapRequest(HttpClientRequest.prependUrl("https://api.example.com")),
- HttpClient.mapRequest(HttpClientRequest.bearerToken(token)),
- HttpClient.filterStatusOk,
- HttpClient.retryTransient({ times: 3 })
+const retried = client.pipe(
+ HttpClient.retryTransient({
+ schedule: Schedule.exponential("100 millis"),
+ times: 3
+ })
)
```
-Use `mapRequest` for transformations that should run after existing request
-preprocessing. Use `mapRequestInput` only when a transformation must run before
-previously installed request middleware.
-
-Useful middleware:
-
-- `mapRequest` / `mapRequestEffect` for base URLs, headers, auth, and request
- normalization.
-- `filterStatus` / `filterStatusOk` for turning unacceptable statuses into
- failures.
-- `retryTransient` for transport failures, timeouts, and transient statuses
- (`408`, `429`, `500`, `502`, `503`, `504`). The default `retryOn` is
- `"errors-and-responses"`.
-- `followRedirects()` for following 3xx `location` headers, defaulting to 10
- redirects.
-- `withCookiesRef` for cookie jars across requests.
-- `tap`, `tapError`, and `tapRequest` for logging/metrics without changing the
- response.
+`times` counts retries, not total attempts. `times: 3` can execute four total
+attempts. A custom `while` predicate adds errors to the built-in transient set;
+it does not replace that set, and it is ignored in `"response-only"` mode.
+
+The client does **not** check whether the HTTP method is idempotent. The same
+policy retries GET and POST. Before applying retries to mutations, require an
+API-supported idempotency key or other proof that replay cannot duplicate a
+side effect. Prefer separate read and mutation clients when their retry policies
+differ.
+
+Also confirm the request body can be replayed. A live stream or external handle
+may not be safe to execute again even when the method is idempotent.
## Rate Limiting
-Use `HttpClient.withRateLimiter` with the `RateLimiter` service when requests
-must share a limit by key.
+`HttpClient.withRateLimiter` coordinates requests that share a key through the
+`RateLimiter` service.
```ts
const limited = client.pipe(
@@ -238,73 +324,116 @@ const limited = client.pipe(
)
```
-By default, it inspects common rate-limit headers such as `ratelimit-limit`,
-`x-ratelimit-limit`, `ratelimit-remaining`, `retry-after`, and reset headers.
-It also retries `429` responses, including `HttpClientError` values wrapping a
-429 `StatusCodeError`, by sending the retry through the limiter again. Set
-`disableResponseInspection: true` only when response headers should not affect
-future limits.
+Pick keys with intentional cardinality: per upstream, account, or endpoint.
+Avoid accidentally including unique query data when all requests should share a
+limit.
+
+By default the middleware learns from common `RateLimit-*`, `X-RateLimit-*`, and
+`Retry-After` headers. It sends 429 responses, including filtered 429
+`HttpClientError` values, back through the limiter. This 429 loop is not bounded
+by a `times` option, so use an enclosing timeout/cancellation policy when the
+upstream may remain rate-limited indefinitely.
+
+`disableResponseInspection: true` stops header-based learning. It does not turn
+off 429 retries; those still pass through the configured limiter.
+
+Provide both the limiter and its store layer at the application/test boundary.
+Use `TestClock` in rate-limit tests rather than real waits.
-## Streaming And Scope
+## Stream Bodies And Control Scope
-For response bodies, prefer `HttpClientResponse.stream(effect)` or unwrap
-`response.stream` and consume it with `Stream` combinators.
+Convert a response effect directly into a byte stream:
```ts
const text = yield* client.get("/events").pipe(
- Effect.map((response) => response.stream),
- Stream.unwrap,
+ HttpClientResponse.stream,
Stream.decodeText(),
Stream.mkString
)
```
-Non-scoped responses are tied to an abort controller so interrupted body reads
-and early-ending streams abort the underlying request. If a request lifetime
-must be controlled by a surrounding `Scope`, apply `HttpClient.withScope`.
+Keep the body incremental for large or open-ended responses. Do not use
+`Stream.mkString`, `runCollect`, or `mkUint8Array` unless the body is known to be
+finite and bounded.
-## Tracing
+Normal responses use an abort controller. Interrupting a body read or ending a
+response stream early aborts the underlying request. Apply
+`HttpClient.withScope` when the request lifetime must instead be attached to an
+explicit surrounding `Scope`; closing that scope aborts it.
+
+## Tracing And Inspection
HttpClient creates client spans by default and records method, URL, status, and
-redacted headers. Use `Effect.withSpan` around domain operations and
-`Effect.annotateCurrentSpan` for request-specific attributes.
+redacted headers. Wrap domain operations in named `Effect.fn` functions or
+`Effect.withSpan`, and add safe request identifiers with
+`Effect.annotateCurrentSpan`.
-Context references can tune tracing:
+Context references can tune client tracing:
- `HttpClient.TracerDisabledWhen` disables spans for matching requests.
- `HttpClient.TracerPropagationEnabled` controls outgoing trace headers.
-- `HttpClient.SpanNameGenerator` customizes span names.
+- `HttpClient.SpanNameGenerator` customizes client span names.
-## Testing Patterns
+Use `tap`, `tapError`, and `tapRequest` for logging or metrics without changing
+results. Body inspection is a real decode and can consume a streamed body; do
+it only for endpoints that use buffered bodies. Keep secrets in headers covered
+by Effect's redaction configuration.
-Use `it.effect`, `assert` / `strictEqual` helpers from `@effect/vitest`, and
-`TestClock` for time-dependent behavior. Avoid `Effect.runSync` in tests.
+## Testing
-Test API code with `HttpClient.make` and `HttpClientResponse.fromWeb` rather
-than real network calls.
+Use a fake `HttpClient.make` transport instead of the network. Build responses
+with `HttpClientResponse.fromWeb` and assert the fully preprocessed request when
+middleware behavior matters.
```ts
-it.effect("decodes todo", () =>
- Effect.gen(function*() {
- const client = HttpClient.make((request) =>
- Effect.succeed(
- HttpClientResponse.fromWeb(
- request,
- new Response(JSON.stringify({ id: 1, title: "Test", completed: false }), {
+import { expect, it } from "vitest"
+
+it("decodes a todo", async () => {
+ const client = HttpClient.make((request) =>
+ Effect.succeed(
+ HttpClientResponse.fromWeb(
+ request,
+ new Response(
+ JSON.stringify({
+ id: 1,
+ title: "Test",
+ completed: false
+ }),
+ {
status: 200,
headers: { "content-type": "application/json" }
- })
+ }
)
)
)
+ )
- const todo = yield* program.pipe(
+ const todo = await Effect.runPromise(
+ HttpClient.get("https://example.com/todos/1").pipe(
+ Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo)),
Effect.provide(Layer.succeed(HttpClient.HttpClient, client))
)
+ )
- strictEqual(todo.id, 1)
- }))
+ expect(todo.id).toBe(1)
+})
```
-For retry and rate-limit tests, count attempts in a `Ref`, fork the request, and
-advance `TestClock` instead of waiting in real time.
+Cover at least the status and decode cases in the public contract. For retry,
+redirect, timeout, and rate-limit behavior:
+
+- count attempts with `Ref`
+- fork with `startImmediately: true` when the operation must reach a sleep
+- provide `TestClock.layer()` and advance `TestClock` from `effect/testing`
+- assert interruption and abort signals when lifetime is part of correctness
+
+## Review Checklist
+
+- Generated clients remain generated; policy lives in hand-written layers.
+- Every endpoint explicitly filters or matches status.
+- Request encoding, `HttpClientError`, and `SchemaError` are mapped intentionally.
+- Middleware order matches which attempts/responses each observer should see.
+- Retried methods and bodies are safe to replay.
+- Rate-limit keys are bounded and persistent 429 behavior is cancellable.
+- Large or infinite bodies remain streamed and are consumed once.
+- Tests cover non-2xx, invalid payloads, timing, and cancellation as applicable.
diff --git a/agent-patterns/effect-stream.md b/agent-patterns/effect-stream.md
index a32eaef1..32bead04 100644
--- a/agent-patterns/effect-stream.md
+++ b/agent-patterns/effect-stream.md
@@ -1,14 +1,24 @@
# Effect Stream Patterns
-Use this when writing project code that models incremental, pull-based data with
-Effect `Stream`. The source of truth reviewed for these patterns is the vendored
-Effect repo: `@repos/effect/LLMS.md`,
-`@repos/effect/ai-docs/src/02_stream/*`, and
-`@repos/effect/packages/effect/src/Stream.ts`.
+Use this guide when data is incremental, pull-based, potentially large, or
+open-ended: polling, pagination, events, queues, byte streams, concurrent
+pipelines, and resource-backed producers.
+
+The source of truth is the vendored Effect repository, especially:
+
+- `@repos/effect/packages/effect/src/Stream.ts`
+- `@repos/effect/ai-docs/src/02_stream`
+- `@repos/effect/packages/effect/test/Stream.test.ts`
+- platform adapters such as
+ `@repos/effect/packages/platform-node-shared/src/NodeStream.ts`
+
+Read `effect-http-client.md` as well for HTTP response or request bodies, and
+`effect-atoms.md` when a stream backs an atom.
## Imports
-Prefer the stable `effect` barrel unless nearby code imports individual modules.
+Prefer the stable `effect` barrel unless nearby code uses module imports.
+Testing utilities have a separate entry point.
```ts
import {
@@ -19,52 +29,54 @@ import {
Queue,
Schedule,
Sink,
- Stream,
- TestClock
+ Stream
} from "effect"
+import { TestClock } from "effect/testing"
```
-For Node.js readable streams, use the platform adapter in Node-only code.
+For Node.js readable streams, use the platform adapter only in Node-specific
+code.
```ts
import { NodeStream } from "@effect/platform-node"
```
-## Streams Are Lazy Descriptions
+## Mental Model
-A `Stream.Stream` describes a sequence that can emit zero or more `A`
-values, fail with `E`, and require services from `R`. It does not run until it
-is consumed with `Stream.run*`, `Stream.run`, `Stream.toQueue`,
-`Stream.toReadableStream*`, or a similar destructor.
+`Stream.Stream` is a lazy description that can emit zero or more `A`
+values, fail with `E`, and require services `R`. It does not start until a
+destructor consumes it.
```ts
-const stream = Stream.fromEffect(loadConfig)
-
-const program = stream.pipe(
- Stream.map((config) => config.region),
- Stream.runCollect
+const regions = Stream.fromEffect(loadConfig).pipe(
+ Stream.map((config) => config.region)
)
-```
-
-Reusing the same stream value reruns the description for each consumer. If
-several consumers must observe one running producer, use `Stream.share`,
-`Stream.broadcast`, `Stream.broadcastN`, a `Queue`, or a `PubSub`.
-## Choose The Smallest Constructor
-
-Use the constructor that matches the source shape.
+const program = regions.pipe(Stream.runCollect)
+```
-- `Stream.empty`, `Stream.succeed`, `Stream.make`, `Stream.fromIterable` for
- in-memory values.
-- `Stream.fromEffect` for one effectful value.
-- `Stream.fromEffectSchedule` for polling an effect over a schedule.
-- `Stream.paginate` for cursor or page APIs.
-- `Stream.fromAsyncIterable` for existing async iterables.
-- `Stream.fromEventListener` for DOM-style event targets.
-- `Stream.callback` for callback APIs that need explicit queue control.
-- `Stream.fromQueue` and `Stream.fromPubSub` for Effect concurrency primitives.
-- `Stream.fromReadableStream` or `NodeStream.fromReadable` for web or Node
- readable streams.
+Running `program` twice runs the stream description twice. Reusing a stream
+value does not memoize or share a producer. Sharing requires an explicit scoped
+combinator such as `share`, `broadcast`, or `broadcastN`.
+
+## Choose The Smallest Source
+
+| Source shape | Constructor |
+| --- | --- |
+| In-memory values | `empty`, `succeed`, `make`, `fromIterable` |
+| One effectful value | `fromEffect` |
+| Effect repeated on a schedule | `fromEffectSchedule` |
+| Cursor/page API | `paginate` |
+| Existing async iterable | `fromAsyncIterable` |
+| DOM/EventTarget events | `fromEventListener` |
+| General callback API | `callback` |
+| Effect queue or pubsub | `fromQueue`, `fromPubSub` |
+| Web readable stream | `fromReadableStream` |
+| Node readable stream | `NodeStream.fromReadable` |
+
+Prefer a structure-preserving constructor over mutable state inside
+`Stream.callback`. Pagination should usually be `Stream.paginate`; polling
+should usually be `Stream.fromEffectSchedule`.
```ts
const jobs = Stream.paginate(0, (page) =>
@@ -77,42 +89,45 @@ const jobs = Stream.paginate(0, (page) =>
)
```
-Prefer `Stream.paginate` over building a mutable loop with `Stream.callback` for
-normal paginated APIs. Prefer `Stream.fromEffectSchedule` over hand-written
-sleep loops for polling.
+`paginate` emits each item from the iterable returned in the first tuple slot
+and continues only while the second slot is `Option.some(nextState)`. Derive the
+next cursor from validated source metadata, not from the number of domain items
+left after filtering or tolerant decoding.
-## Consume With Intent
+`fromEffectSchedule` runs the effect immediately, then follows the schedule.
+Bound it with `take` when the resulting stream must be finite.
-Every stream pipeline should end in a clear consumer.
+## End Every Pipeline With An Intentional Consumer
-```ts
-const writeEvents = events.pipe(
- Stream.runForEach((event) => EventStore.use((store) => store.write(event)))
-)
-```
+Common destructors:
-Use `Stream.runCollect` only for finite streams with bounded output. For large
-or infinite streams, prefer `Stream.runForEach`, `Stream.runFold`,
-`Stream.runFoldEffect`, `Stream.runDrain`, or `Stream.run(Sink...)`.
+| Need | Destructor |
+| --- | --- |
+| Execute an effect per element | `runForEach` |
+| Ignore values but run effects | `runDrain` |
+| Fold incrementally | `runFold`, `runFoldEffect`, `run(Sink...)` |
+| Read an optional edge | `runHead`, `runLast` |
+| Collect a bounded stream | `runCollect` |
+| Count without retaining values | `runCount` |
+| Concatenate bounded text/bytes | `mkString`, `mkUint8Array` |
```ts
-const firstTen = source.pipe(
- Stream.take(10),
- Stream.runCollect
-)
-
-const total = source.pipe(
- Stream.map((event) => event.value),
- Stream.run(Sink.sum)
+const writeEvents = events.pipe(
+ Stream.runForEach((event) =>
+ EventStore.use((store) => store.write(event))
+ )
)
```
-`Stream.runHead` and `Stream.runLast` return `Option`. `runLast` waits for the
-stream to complete, so do not use it on open-ended streams.
+Use `runCollect`, `mkString`, and `mkUint8Array` only when output is finite and
+bounded. Apply `take` first when a test or caller needs a bounded prefix.
-## Transform Per Element Or Per Chunk Deliberately
+`runHead` can stop after one element. `runLast` must wait for completion and
+therefore never finishes for a healthy infinite stream.
-Use element operators for ordinary domain logic.
+## Transform Elements, Arrays, And Windows Deliberately
+
+Use element operators for domain logic:
```ts
const enriched = orders.pipe(
@@ -121,36 +136,49 @@ const enriched = orders.pipe(
)
```
-Use chunk-aware operators only when chunk boundaries matter or performance is
-worth the extra complexity: `Stream.mapArray`, `Stream.mapArrayEffect`,
-`Stream.runForEachArray`, `Stream.grouped`, and `Stream.groupedWithin`.
+Use array-aware operators only when source chunking or batching matters:
+
+- `mapArray` and `mapArrayEffect` transform emitted non-empty arrays.
+- `runForEachArray` consumes arrays without flattening first.
+- `grouped(n)` creates size-bounded batches.
+- `groupedWithin(n, duration)` flushes on size or time.
+- `bufferArray` preserves source arrays; `buffer` buffers elements and destroys
+ the original chunking.
```ts
const batched = events.pipe(
Stream.groupedWithin(100, "1 second"),
- Stream.mapEffect((batch) => EventStore.use((store) => store.writeBatch(batch)))
+ Stream.mapEffect((batch) =>
+ EventStore.use((store) => store.writeBatch(batch))
+ )
)
```
-Do not insert `runCollect` in the middle of a pipeline just to batch values.
-Batch with stream operators so backpressure and interruption still work.
+Do not insert `runCollect` in the middle of a pipeline merely to batch values.
+That ends streaming, retains all prior elements, and changes interruption and
+backpressure behavior.
-## Bound Concurrency And Buffers
+## Bound Concurrency And Know Ordering
-`Stream.mapEffect`, `Stream.flatMap`, `Stream.mergeAll`, and related operators
-can run work concurrently. Choose a concrete concurrency limit for I/O and only
-use `"unbounded"` when the upstream is already tightly bounded.
+`mapEffect` defaults to sequential execution. With concurrent execution it
+preserves input order unless `{ unordered: true }` is set.
```ts
const results = ids.pipe(
- Stream.mapEffect((id) => RemoteApi.use((api) => api.fetch(id)), {
- concurrency: 8
- })
+ Stream.mapEffect(
+ (id) => RemoteApi.use((api) => api.fetch(id)),
+ { concurrency: 8 }
+ )
)
```
-Set `unordered: true` only when output order does not matter. Concurrent
-`Stream.mergeAll` emits values as they arrive.
+Use `unordered: true` only when output order is irrelevant and head-of-line
+blocking is undesirable.
+
+`flatMap` has different semantics: with concurrency greater than one, inner
+streams are merged and their values arrive in runtime order. It has no ordered
+concurrent mode. `mergeAll` likewise emits from whichever active stream
+produces first.
```ts
const merged = Stream.mergeAll(streams, {
@@ -159,21 +187,43 @@ const merged = Stream.mergeAll(streams, {
})
```
-When creating queues, pubsubs, callbacks, broadcasts, or shared streams, avoid
-unbounded capacity by default. Pick a bounded `capacity` and a strategy:
+Choose a concrete I/O concurrency limit. Use `"unbounded"` only when upstream
+cardinality is already tightly bounded and reviewed.
+
+When processing stateful updates, ask whether concurrent work is valid at all.
+Ordered output does not prevent effects themselves from running concurrently.
+
+## Treat Buffers As A Correctness Choice
+
+Bound queues, callbacks, pubsubs, shared streams, and explicit buffers by
+default. Choose the overflow strategy from the product semantics:
+
+- `"suspend"` applies backpressure and retains every value.
+- `"sliding"` drops older buffered values and keeps recent state.
+- `"dropping"` keeps older buffered values and drops new arrivals.
+
+```ts
+const buffered = source.pipe(
+ Stream.buffer({ capacity: 32, strategy: "suspend" })
+)
+```
+
+Backpressure works only when the producer can await an effectful offer. An
+external synchronous callback cannot pause for `Queue.offer`; with bounded
+callback sources, dropped/coalesced values must be acceptable or the external
+API must provide its own pause/resume mechanism.
-- `suspend` applies backpressure.
-- `sliding` keeps newer values and drops older buffered values.
-- `dropping` keeps older buffered values and drops newer values.
+Capacity is measured in elements for `buffer` and `toQueue`, but in emitted
+arrays for `bufferArray` and broadcast internals. Do not treat every
+`bufferSize` as the same unit without checking the signature and source.
-## Manage Scope And Cleanup
+## Manage Scope And Finalization
-Streams run resources for the duration of consumption. Use `Stream.scoped` when
-the stream requires `Scope`, and use `Stream.ensuring` or
-`Effect.acquireRelease` inside constructors to register finalizers.
+Streams hold acquired resources for the duration of one consumption. Use
+`Stream.scoped` to internalize a `Scope` requirement.
```ts
-const resourceStream = Stream.scoped(
+const connectionStream = Stream.scoped(
Stream.fromEffect(
Effect.acquireRelease(
Connection.open,
@@ -183,114 +233,185 @@ const resourceStream = Stream.scoped(
)
```
-`Stream.broadcast`, `Stream.broadcastN`, `Stream.share`, `Stream.toQueue`, and
-`Stream.toPubSub` return scoped effects. Acquire them inside `Effect.scoped` or
-inside a layer so the producer and subscribers are finalized.
+Use `Stream.ensuring` for a finalizer that should run after every consumption,
+regardless of success, failure, or interruption. Use `Effect.acquireRelease`
+when cleanup belongs to an acquired handle.
-```ts
-const program = Effect.scoped(
- Effect.gen(function*() {
- const shared = yield* updates.pipe(
- Stream.share({ capacity: 16, replay: 1 })
- )
+Scoped destructors and sharing operations include:
- yield* shared.pipe(Stream.take(1), Stream.runCollect)
- })
-)
-```
+- `Stream.toPull`
+- `Stream.toQueue` and `Stream.toPubSub*`
+- `Stream.broadcast`, `broadcastN`, and `share`
+
+Acquire them inside `Effect.scoped` or a layer. Closing the scope must stop the
+producer and release queues, subscriptions, and external handles.
-When bridging to web or async protocols, make sure cancellation closes the
-underlying handle. `Stream.fromReadableStream` cancels the reader by default.
-`NodeStream.fromReadable` can close Node streams when done.
+Web readable streams are canceled by default when their Effect stream
+finalizes. `NodeStream.fromReadable` can close Node streams on completion. Keep
+those defaults unless ownership belongs elsewhere.
## Bridge Callback APIs Safely
-Use `Stream.fromEventListener` for event targets when it fits. It registers and
-removes the listener for the stream lifetime.
+Use `fromEventListener` for EventTarget-like APIs. It removes the listener when
+the stream ends.
```ts
-const clicks = Stream.fromEventListener(button, "click", {
- passive: true,
- bufferSize: 16
-})
+const clicks = Stream.fromEventListener(
+ button,
+ "click",
+ { passive: true, bufferSize: 16 }
+)
```
-Use `Stream.callback` for custom callback APIs. Register cleanup with
-`Effect.acquireRelease`, and signal completion with the queue API instead of
-leaving consumers waiting forever.
+`fromEventListener` exposes `bufferSize` but not an overflow strategy. Its
+synchronous listener cannot suspend; when a bounded buffer is full, a new event
+may be rejected. Use `Stream.callback` when sliding or dropping behavior must be
+chosen explicitly.
+
+Use `Stream.callback` when registration, error, completion, or overflow behavior
+needs explicit control.
```ts
-const messages = Stream.callback((queue) =>
- Effect.acquireRelease(
- Effect.sync(() => {
- const unsubscribeMessage = socket.onMessage((message) => {
- Queue.offerUnsafe(queue, message)
- })
-
- const unsubscribeClose = socket.onClose(() => {
- Queue.endUnsafe(queue)
- })
-
- return { unsubscribeClose, unsubscribeMessage }
- }),
- ({ unsubscribeClose, unsubscribeMessage }) =>
+const messages = Stream.callback(
+ (queue) =>
+ Effect.acquireRelease(
Effect.sync(() => {
- unsubscribeMessage()
- unsubscribeClose()
- })
- ),
+ const unsubscribeMessage = socket.onMessage((message) => {
+ Queue.offerUnsafe(queue, message)
+ })
+
+ const unsubscribeClose = socket.onClose(() => {
+ Queue.endUnsafe(queue)
+ })
+
+ const unsubscribeError = socket.onError((error) => {
+ Queue.failCauseUnsafe(queue, Cause.fail(error))
+ })
+
+ return {
+ unsubscribeClose,
+ unsubscribeError,
+ unsubscribeMessage
+ }
+ }),
+ (subscriptions) =>
+ Effect.sync(() => {
+ subscriptions.unsubscribeMessage()
+ subscriptions.unsubscribeClose()
+ subscriptions.unsubscribeError()
+ })
+ ),
{ bufferSize: 64, strategy: "sliding" }
)
```
-Prefer effectful `Queue.offer` when producer code is already inside Effect and
-can honor backpressure. Use `Queue.offerUnsafe` only from external synchronous
-callbacks where an Effect cannot be yielded.
+Signal normal completion with `Queue.endUnsafe` and failure with
+`Queue.failCauseUnsafe(queue, Cause.fail(error))`; otherwise consumers can wait
+forever. Register every external cleanup through the supplied scope.
+
+Use effectful `Queue.offer` when producer code is already inside Effect and can
+honor backpressure. Use `offerUnsafe` only at a synchronous callback boundary,
+and choose an overflow policy that remains correct if it cannot enqueue.
+
+## Choose Sharing Semantics Explicitly
+
+Several consumers of a plain stream each run an independent producer. Use the
+sharing primitive that matches subscriber lifetime:
+
+| Need | Primitive |
+| --- | --- |
+| Dynamic, reference-counted subscribers | `Stream.share` |
+| Dynamic subscribers to one scoped pubsub producer | `Stream.broadcast` |
+| Fixed number of consumers that must all subscribe before start | `Stream.broadcastN` |
+| Competing work consumers | `Queue` / `Stream.toQueue` |
+| Every subscriber receives each published event | `PubSub` |
+
+`share` starts upstream for the first subscriber and stops it after the last
+subscriber leaves. A later subscriber restarts the source unless
+`idleTimeToLive` keeps it alive. `replay` controls what a late subscriber can
+receive from the shared pubsub; it does not turn the source into a permanent
+cache.
+
+```ts
+const program = Effect.scoped(
+ Effect.gen(function*() {
+ const shared = yield* updates.pipe(
+ Stream.share({ capacity: 16, replay: 1 })
+ )
+
+ yield* Effect.all([
+ shared.pipe(Stream.runForEach(handleForLeftConsumer)),
+ shared.pipe(Stream.runForEach(handleForRightConsumer))
+ ], { concurrency: "unbounded" })
+ })
+)
+```
+
+`broadcastN` is safer when exactly N consumers must observe a finite source: it
+does not start until all N returned streams are subscribed. For dynamic
+`broadcast` subscribers, use replay or an external readiness protocol if early
+values cannot be missed.
-## Use Queues And PubSubs For Boundaries
+## Use Queues And PubSubs At Ownership Boundaries
-Use `Queue` when one producer coordinates with one or more competing consumers
-that pull work. `Stream.toQueue` creates a scoped dequeue and signals completion
-with `Cause.Done`; stream failures fail the queue.
+`Stream.toQueue` creates a scoped dequeue, feeds it in a child fiber, ends it
+with `Cause.Done`, and fails it with the stream failure.
```ts
const program = Effect.scoped(
Effect.gen(function*() {
- const queue = yield* source.pipe(Stream.toQueue({ capacity: 32 }))
- const next = yield* Queue.take(queue)
- return next
+ const queue = yield* source.pipe(
+ Stream.toQueue({ capacity: 32 })
+ )
+
+ return yield* Queue.take(queue)
})
)
```
-Use `PubSub` when each subscriber should receive published events. Use
-`Stream.fromPubSub` or `Stream.broadcast` instead of manually copying events
-into several queues.
+Multiple queue consumers compete for values. Use a PubSub when every subscriber
+needs a copy. `Stream.fromQueue` treats `Cause.Done` as normal stream completion;
+other queue failures become stream failures.
+
+Prefer `Stream.broadcast` or `Stream.fromPubSub` over manually copying each
+event into multiple queues.
-## Provide Services At The Stream Boundary
+## Keep Service Requirements Until The Boundary
-Service requirements flow through stream types the same way they flow through
-`Effect`. Provide a layer to the stream or provide a larger program that runs
-the stream.
+Stream service requirements compose like Effect requirements. Provide the
+smallest layer at a stable application or test boundary.
```ts
-const stream = Stream.fromEffect(
+const users = Stream.fromEffect(
UserApi.use((api) => api.listUsers())
).pipe(
- Stream.flatMap(Stream.fromIterable),
- Stream.provide(UserApi.layer)
+ Stream.flatMap(Stream.fromIterable)
+)
+
+const program = users.pipe(
+ Stream.runForEach(renderUser),
+ Effect.provide(UserApi.layer)
)
```
-When converting to web APIs outside Effect, use the service-aware variants:
-`Stream.toReadableStreamEffect`, `Stream.toReadableStreamWith`,
-`Stream.toAsyncIterableEffect`, or `Stream.toAsyncIterableWith`.
+When converting a serviceful stream for non-Effect consumers, capture services
+explicitly with `toReadableStreamEffect`, `toReadableStreamWith`,
+`toAsyncIterableEffect`, or `toAsyncIterableWith`. The plain conversion
+variants work only when the stream requires no services.
+
+Cancellation of the returned readable stream or async iterator is what closes
+the Effect scope. Consumers that abandon a manual iterator must call `return()`;
+`for await...of` does this when the loop exits normally.
-## Handle Errors In The Pipeline
+## Recover At The Correct Level
-Use stream error combinators when recovery should continue as a stream:
-`Stream.catchTag`, `Stream.catchTags`, `Stream.catchIf`, `Stream.catchCause`,
-`Stream.mapError`, and `Stream.retry`.
+Use stream combinators when recovery should continue as a stream:
+
+- `catchTag`, `catchTags`, and `catchIf` for typed recovery
+- `catchCause` when defects/interruption are deliberately part of policy
+- `mapError` to translate the stream failure channel
+- `retry` to restart a failed source
+- `result` to expose successes and the first failure as values
```ts
const recovered = source.pipe(
@@ -303,15 +424,20 @@ const recovered = source.pipe(
)
```
-Use `Effect.catchTag` after a `Stream.run*` call when the whole consumed stream
-should fail or recover as one effect. Use `Stream.result` when downstream code
-needs successes and the first failure as values; the stream still ends after
-that failure.
+Use `Effect.catchTag` after `Stream.run*` when the entire consumption should be
+handled as one effect.
+
+`Stream.retry` restarts the source description. Values emitted before failure
+can be emitted again after the restart. Retry only when duplicated prefixes and
+reacquiring the source are safe, or add an explicit cursor/checkpoint protocol.
-## Decode And Encode Streaming Data
+`Stream.result` emits success results and then one failure result; it still ends
+after that first failure. It is not a way to resume the failed source.
-For byte streams, decode text before string operations and split lines with
-`Stream.splitLines` so delimiters spanning chunks are handled correctly.
+## Decode Incremental Bytes Incrementally
+
+Decode bytes before string operations, and use `splitLines` so delimiters split
+across source arrays are handled correctly.
```ts
const lines = responseBytes.pipe(
@@ -321,8 +447,8 @@ const lines = responseBytes.pipe(
)
```
-For NDJSON or Msgpack, use the encoding channels and schema-backed variants
-instead of hand-parsing inside `Stream.map`.
+For NDJSON and Msgpack, use the encoding channels and schema-backed variants
+instead of `JSON.parse` in `Stream.map`.
```ts
import { Ndjson } from "effect/unstable/encoding"
@@ -333,36 +459,61 @@ const events = bytes.pipe(
)
```
-Use `ignoreEmptyLines: true` for NDJSON inputs that may contain blank lines.
+Use `ignoreEmptyLines: true` only when blank lines are valid transport noise.
+Schema decoding should remain at the trust boundary so malformed input has a
+typed failure path.
-## Testing Patterns
+## Testing
-Use `it.effect` and consume the stream in the test. Bound infinite streams with
-`Stream.take`, and use `TestClock` for schedules, debounce, throttle, retries,
-or `groupedWithin`.
+A stream test must consume the stream. Bound infinite streams with `take` and
+test finalization as well as output.
```ts
-import { strictEqual } from "node:assert"
-import { Effect, Fiber, Schedule, Stream, TestClock } from "effect"
+import { Effect, Fiber, Schedule, Stream } from "effect"
+import { TestClock } from "effect/testing"
+import { expect, it } from "vitest"
+
+it("polls three times", async () => {
+ const values = await Effect.runPromise(
+ Effect.gen(function*() {
+ const fiber = yield* Stream.fromEffectSchedule(
+ Effect.succeed("tick"),
+ Schedule.spaced("1 second")
+ ).pipe(
+ Stream.take(3),
+ Stream.runCollect,
+ Effect.forkChild({ startImmediately: true })
+ )
+
+ yield* TestClock.adjust("2 seconds")
+
+ return yield* Fiber.join(fiber)
+ }).pipe(Effect.provide(TestClock.layer()))
+ )
-it.effect("polls three times", () =>
- Effect.gen(function*() {
- const fiber = yield* Stream.fromEffectSchedule(
- Effect.succeed("tick"),
- Schedule.spaced("1 second")
- ).pipe(
- Stream.take(3),
- Stream.runCollect,
- Effect.fork
- )
+ expect(values).toEqual(["tick", "tick", "tick"])
+})
+```
- yield* TestClock.adjust("2 seconds")
+For callback, queue, readable-stream, and sharing tests:
- const values = yield* Fiber.join(fiber)
- strictEqual(values.length, 3)
- }))
-```
+- start live consumers before publishing
+- assert overflow behavior with a deliberately slow consumer
+- close or interrupt the scope and assert external cleanup
+- test normal completion and source failure
+- use `TestClock` for schedules, debounce, throttle, retry, and time windows
+
+Avoid real sleeps. A time-controlled test may need a start-immediate fork so the
+stream reaches its scheduled suspension before the clock advances.
+
+## Review Checklist
-For queue and callback streams, assert finalization as well as emitted values.
-Fork consumers before publishing when the source is live, then interrupt or close
-the scope to verify cleanup.
+- The source constructor matches the real source shape.
+- Every collection is proven finite and bounded.
+- Concurrency and output ordering are both intentional.
+- Buffer capacity and overflow strategy match loss/backpressure requirements.
+- Every acquired handle and shared producer has a scoped finalizer.
+- Callback sources signal end/failure and tolerate unsafe-offer overflow.
+- Sharing semantics match dynamic, fixed, competing, or fan-out consumers.
+- Retry cannot duplicate unsafe work or already emitted values.
+- Tests consume the stream and cover time, failure, and cleanup behavior.
diff --git a/amplify.yml b/amplify.yml
index 8dbd9635..7aa7ee25 100644
--- a/amplify.yml
+++ b/amplify.yml
@@ -11,6 +11,7 @@ frontend:
- echo "VITE_YIELDS_API_URL=$VITE_YIELDS_API_URL" >> packages/widget/.env
- echo "VITE_API_URL=$VITE_API_URL" >> packages/widget/.env
- echo "VITE_API_KEY=$VITE_API_KEY" >> packages/widget/.env
+ - echo "VITE_FORCE_BORROW=$VITE_FORCE_BORROW" >> packages/widget/.env
build:
commands:
diff --git a/biome.json b/biome.json
index 21dc2774..01f916c2 100644
--- a/biome.json
+++ b/biome.json
@@ -15,7 +15,7 @@
"!**/region-iso-3166-codes.ts",
"!**/mockServiceWorker.js",
"!**/package.json",
- "!**/repos"
+ "!**/@repos"
]
},
"formatter": {
@@ -35,7 +35,7 @@
"linter": {
"enabled": true,
"rules": {
- "recommended": true,
+ "preset": "recommended",
"style": {
"noNonNullAssertion": "off",
"noParameterAssign": "error",
diff --git a/mise.lock b/mise.lock
index 51b14104..85c22ae7 100644
--- a/mise.lock
+++ b/mise.lock
@@ -41,5 +41,5 @@ version = "11.14.1"
backend = "npm:npm"
[[tools."npm:pnpm"]]
-version = "10.33.2"
+version = "11.12.0"
backend = "npm:pnpm"
diff --git a/mise.toml b/mise.toml
index 8cc2234b..119a523d 100644
--- a/mise.toml
+++ b/mise.toml
@@ -1,4 +1,4 @@
[tools]
node = "24.15.0"
"npm:npm" = "11.14.1"
-"npm:pnpm" = "10.33.2"
\ No newline at end of file
+"npm:pnpm" = "11.12.0"
\ No newline at end of file
diff --git a/package.json b/package.json
index 9487ca9e..2acca1de 100644
--- a/package.json
+++ b/package.json
@@ -6,28 +6,35 @@
],
"author": "Petar Todorovic (https://github.com)",
"license": "MIT",
+ "devEngines": {
+ "packageManager": {
+ "name": "pnpm",
+ "version": "11.12.0"
+ }
+ },
"scripts": {
"build": "turbo run build",
"lint": "turbo run lint",
+ "lint:ast": "ast-grep scan --off=no-react-memoization --error=no-suppress-all --error=unused-suppression packages/widget",
+ "test:ast": "ast-grep test --skip-snapshot-tests",
"test": "turbo run test",
"format": "turbo run format",
"clean": "turbo run clean",
"prepare": "husky",
- "check-hygiene": "rev-dep config run --list-all-issues",
+ "check-hygiene": "pnpm run check-hygiene:rev-dep && pnpm run check-hygiene:test-only-exports",
+ "check-hygiene:rev-dep": "rev-dep config run --list-all-issues",
+ "check-hygiene:test-only-exports": "tsx scripts/check-test-only-exports.ts",
"check": "turbo run lint && pnpm run check-hygiene && turbo run test && turbo run build"
},
"devDependencies": {
+ "@ast-grep/cli": "catalog:",
"@biomejs/biome": "catalog:",
"@commitlint/cli": "catalog:",
"@commitlint/config-conventional": "catalog:",
"husky": "catalog:",
+ "knip": "catalog:",
"rev-dep": "catalog:",
+ "tsx": "catalog:",
"turbo": "catalog:"
- },
- "pnpm": {
- "patchedDependencies": {
- "purify-ts": "patches/purify-ts.patch",
- "@stakekit/rainbowkit@2.2.11": "patches/@stakekit__rainbowkit@2.2.11.patch"
- }
}
}
diff --git a/packages/examples/with-nextjs/package.json b/packages/examples/with-nextjs/package.json
index 03dc0503..a6e77834 100644
--- a/packages/examples/with-nextjs/package.json
+++ b/packages/examples/with-nextjs/package.json
@@ -18,6 +18,7 @@
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
+ "@typescript/native": "catalog:",
"typescript": "catalog:"
}
}
diff --git a/packages/examples/with-vite-bundled/package.json b/packages/examples/with-vite-bundled/package.json
index 78a96980..93bd85fb 100644
--- a/packages/examples/with-vite-bundled/package.json
+++ b/packages/examples/with-vite-bundled/package.json
@@ -14,7 +14,7 @@
"@stakekit/widget": "workspace:*"
},
"devDependencies": {
- "typescript": "catalog:",
+ "@typescript/native": "catalog:",
"vite": "catalog:"
}
}
diff --git a/packages/examples/with-vite-bundled/src/typescript.svg b/packages/examples/with-vite-bundled/src/typescript.svg
deleted file mode 100644
index 351f16e6..00000000
--- a/packages/examples/with-vite-bundled/src/typescript.svg
+++ /dev/null
@@ -1,17 +0,0 @@
-
diff --git a/packages/examples/with-vite/package.json b/packages/examples/with-vite/package.json
index c947f694..c1b24fc5 100644
--- a/packages/examples/with-vite/package.json
+++ b/packages/examples/with-vite/package.json
@@ -18,7 +18,7 @@
"devDependencies": {
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
- "typescript": "catalog:",
+ "@typescript/native": "catalog:",
"vite": "catalog:",
"@vitejs/plugin-react": "catalog:"
}
diff --git a/packages/widget/.env.example b/packages/widget/.env.example
index 77baaa97..81fe7033 100644
--- a/packages/widget/.env.example
+++ b/packages/widget/.env.example
@@ -6,5 +6,6 @@ VITE_FORCE_WALLET_CONNECT_ONLY=false
VITE_ENABLE_MSW_MOCK=true
VITE_SHOW_QUERY_DEVTOOLS=true
VITE_FORCE_ADDRESS=
+VITE_FORCE_BORROW=
VITE_FORCE_DASHBOARD=
-VITE_APP_VARIANT=
\ No newline at end of file
+VITE_APP_VARIANT=
diff --git a/packages/widget/ARCHITECTURE.md b/packages/widget/ARCHITECTURE.md
new file mode 100644
index 00000000..f99b1db3
--- /dev/null
+++ b/packages/widget/ARCHITECTURE.md
@@ -0,0 +1,117 @@
+# Widget architecture
+
+## Module ownership
+
+Production code is organized by ownership rather than by React mechanism:
+
+- `src/app` owns public-input normalization, runtime construction, provider
+ composition, and classic/dashboard route composition.
+- `src/services` owns Effect services and side effects: API transport,
+ persistence, tracking, wallet integration, workflow execution, and borrow
+ execution.
+- `src/features/` owns feature state, resources, React adapters, and
+ screens. A feature exposes cross-feature collaboration only through an
+ intentional root entry such as `index.ts`, `support.ts`, `state.ts`, `ui.ts`,
+ or another narrowly named entrypoint.
+- `src/domain` owns framework-independent schemas, identifiers, and business
+ rules. Approved domain schema modules may adapt generated schemas, but domain
+ code must not depend on React, services, or features.
+- `src/shared` owns framework-neutral utilities and genuinely reusable React or
+ UI primitives. Shared modules must not depend on app, services, or features.
+
+The retired top-level `hooks`, `providers`, `pages`, `pages-dashboard`,
+`components`, `common`, `atoms`, and `borrow` ownership buckets must not be
+reintroduced. A hook belongs in its feature's `react` or `ui` area; a provider
+belongs in `app/composition` only when it composes the application or adapts a
+third-party tree-scoped contract.
+
+## Dependency direction and public entries
+
+The intended direction is:
+
+`public entry -> app composition/routes -> feature public entries -> app runtime -> services -> domain/shared`
+
+Feature-to-feature collaboration must use an explicit supported entrypoint;
+deep imports into another feature are forbidden. Services may depend on other
+services, domain, shared code, and generated clients where approved, but never
+on React or features. Rev-dep enforces module direction, public-entry usage,
+cycles, unresolved imports, and orphaned modules. Biome confines generated API
+imports, rejects React dependencies in services, and blocks retired
+architecture paths.
+
+## Effect services
+
+Each Effect service keeps its service definition and common layer builders
+colocated. Alternative implementations are additional layer builders on the
+service or layers defined next to the integrating adapter; contract and default
+implementation are not split into files without a concrete reason.
+
+React hooks and components invoke effects through feature-owned atoms. Network,
+persistence, tracking, wallet, polling, and transaction side effects belong in
+services rather than UI hooks. Generated runtime API clients are private to
+`services/api`; approved `domain/schema` and `domain/borrow` modules may import
+generated schema artifacts only.
+
+## Application runtime
+
+`src/app/runtime/app-runtime.ts` contains the only production `Atom.runtime`.
+Its fresh application layer composes bootstrap configuration, focused yield,
+legacy and borrow API services, rich errors, persistence, tracking, wallet,
+workflow execution, and borrow execution services. Borrow configuration is
+optional at construction time; invoking an unavailable borrow operation
+produces the typed error.
+
+All application atoms resolve dependencies through `appRuntime`. Feature-local
+atoms may own synchronous state directly, but must not construct another
+runtime or hide an independent service graph. Mounting a widget creates a new
+registry and lifecycle-sensitive service state; remounting therefore starts
+cleanly.
+
+## Effect Atom state conventions
+
+Effect atoms own application configuration, asynchronous resources, workflow
+state, mutations, and cross-feature read models. Resource keys must describe
+their complete input, failures remain typed, and mutation success refreshes
+only declared dependent resources. React hooks should be thin adapters over
+atoms or derived read models, not alternate state owners.
+
+Use React Context only when the value is inherently tree-scoped, such as a
+compound component, host DOM element, router history adapter, or required
+third-party provider. New page or application-state contexts are rejected by
+the architecture check.
+
+## Supported lifecycle
+
+The package supports one active StakeKit widget instance per document.
+Mounting multiple widgets concurrently on the same page is unsupported. A
+single widget may be unmounted and mounted again; registry-owned workflow and
+lifecycle state is recreated for that new mount.
+
+## React Context policy
+
+Effect atoms own widget configuration, feature workflow state, shared read
+models, and application lifecycle state. React Context is reserved for values
+whose meaning is the React subtree itself or for libraries that require their
+own provider.
+
+The remaining widget-owned contexts are intentional:
+
+- `CollapsibleContext`, `CopyTextContext`, `SelectModalContext`, and the amount
+ toggle context are private compound-component contracts. Their state belongs
+ to one component subtree.
+- `BackButtonContext` is a compound layout override that marks the dashboard
+ subtree in which a back button is rendered.
+- `CurrentLayoutContext` coordinates measurements between the active routed
+ page and its surrounding animated layout.
+- `SKLocationContext` adapts the current and previous React Router locations
+ for the routed subtree.
+- `RootElementContext` exposes the widget host element to portal and overlay
+ descendants in that host subtree.
+
+Effect Atom's registry context and the contexts supplied by i18next, TanStack
+Query, Wagmi, RainbowKit, and the Solana wallet adapters are third-party runtime
+contracts and remain at application composition boundaries.
+
+Page workflows must not introduce React contexts. Earn, position details,
+activity, completion, transaction flow, tracking, summary, configuration, and
+mount-animation state are registry-scoped atoms or models derived from atoms.
diff --git a/packages/widget/package.json b/packages/widget/package.json
index c26d1b74..af9f5aac 100644
--- a/packages/widget/package.json
+++ b/packages/widget/package.json
@@ -45,7 +45,15 @@
"start": "pnpm dev",
"build": "pnpm lint && pnpm clean && pnpm build:package && pnpm build:bundle && pnpm build:website && pnpm build:types",
"lint": "biome check . && tsc",
- "test": "vitest -c vite/vite.config.dev.ts --retry 2 run",
+ "test": "vitest -c vite/vitest.config.ts run",
+ "test:unit": "vitest -c vite/vitest.config.ts --project unit run",
+ "test:unit:watch": "vitest -c vite/vitest.config.ts --project unit",
+ "test:dom": "vitest -c vite/vitest.config.ts --project dom run",
+ "test:dom:watch": "vitest -c vite/vitest.config.ts --project dom",
+ "test:browser": "vitest -c vite/vitest.config.ts --project browser run",
+ "test:changed": "vitest -c vite/vitest.config.ts run --project unit --project dom --changed",
+ "test:changed:all": "vitest -c vite/vitest.config.ts run --changed",
+ "test:changed:unit": "vitest -c vite/vitest.config.ts run --project unit --changed",
"format": "biome check --write . --unsafe",
"build:website": "vite -c vite/vite.config.website build",
"build:package": "vite -c vite/vite.config.package.ts build",
@@ -54,7 +62,7 @@
"build:types": "tsc --project tsconfig.build.json",
"clean": "rm -rf dist",
"preview": "vite -c vite/vite.config.dev.ts preview --outDir dist/website",
- "gen:api": "tsx scripts/generate-effect-openapi.ts"
+ "gen:api": "node --import tsx scripts/generate-effect-openapi.ts"
},
"peerDependencies": {
"react": ">=18",
@@ -75,7 +83,9 @@
"@cosmos-kit/keplr": "catalog:",
"@cosmos-kit/leap": "catalog:",
"@cosmos-kit/walletconnect": "catalog:",
+ "@effect/atom-react": "catalog:",
"@effect/openapi-generator": "catalog:",
+ "@effect/platform-browser": "catalog:",
"@effect/platform-node": "catalog:",
"@faker-js/faker": "catalog:",
"@ledgerhq/wallet-api-client": "catalog:",
@@ -109,6 +119,7 @@
"@types/lodash.uniqwith": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
+ "@typescript/native": "catalog:",
"@vanilla-extract/css": "catalog:",
"@vanilla-extract/dynamic": "catalog:",
"@vanilla-extract/recipes": "catalog:",
@@ -116,8 +127,6 @@
"@vanilla-extract/vite-plugin": "catalog:",
"@vitejs/plugin-react": "catalog:",
"@vitest/browser-playwright": "catalog:",
- "@xstate/react": "catalog:",
- "@xstate/store": "catalog:",
"autoprefixer": "catalog:",
"babel-plugin-react-compiler": "catalog:",
"bignumber.js": "catalog:",
@@ -129,6 +138,7 @@
"eventemitter3": "catalog:",
"i18next": "catalog:",
"i18next-browser-languagedetector": "catalog:",
+ "jsdom": "catalog:",
"lodash.merge": "catalog:",
"lodash.uniqwith": "catalog:",
"mipd": "catalog:",
@@ -137,17 +147,13 @@
"msw": "catalog:",
"playwright": "catalog:",
"postcss": "catalog:",
- "purify-ts": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-i18next": "catalog:",
"react-loading-skeleton": "catalog:",
"react-router": "catalog:",
- "reselect": "catalog:",
- "rxjs": "catalog:",
"swagger2openapi": "catalog:",
"tsx": "catalog:",
- "typescript": "catalog:",
"unplugin-macros": "catalog:",
"viem": "catalog:",
"vite": "catalog:",
@@ -155,7 +161,6 @@
"vitest": "catalog:",
"vitest-browser-react": "catalog:",
"wagmi": "catalog:",
- "xstate": "catalog:",
"yaml": "catalog:",
"recharts": "catalog:"
},
diff --git a/packages/widget/public/mockServiceWorker.js b/packages/widget/public/mockServiceWorker.js
index 57451a30..0c970efc 100644
--- a/packages/widget/public/mockServiceWorker.js
+++ b/packages/widget/public/mockServiceWorker.js
@@ -7,114 +7,114 @@
* - Please do NOT modify this file.
*/
-const PACKAGE_VERSION = "2.12.2";
-const INTEGRITY_CHECKSUM = "4db4a41e972cec1b64cc569c66952d82";
-const IS_MOCKED_RESPONSE = Symbol("isMockedResponse");
-const activeClientIds = new Set();
+const PACKAGE_VERSION = '2.15.0'
+const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e'
+const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
+const activeClientIds = new Set()
-addEventListener("install", () => {
- self.skipWaiting();
-});
+addEventListener('install', function () {
+ self.skipWaiting()
+})
-addEventListener("activate", (event) => {
- event.waitUntil(self.clients.claim());
-});
+addEventListener('activate', function (event) {
+ event.waitUntil(self.clients.claim())
+})
-addEventListener("message", async (event) => {
- const clientId = Reflect.get(event.source || {}, "id");
+addEventListener('message', async function (event) {
+ const clientId = Reflect.get(event.source || {}, 'id')
if (!clientId || !self.clients) {
- return;
+ return
}
- const client = await self.clients.get(clientId);
+ const client = await self.clients.get(clientId)
if (!client) {
- return;
+ return
}
const allClients = await self.clients.matchAll({
- type: "window",
- });
+ type: 'window',
+ })
switch (event.data) {
- case "KEEPALIVE_REQUEST": {
+ case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
- type: "KEEPALIVE_RESPONSE",
- });
- break;
+ type: 'KEEPALIVE_RESPONSE',
+ })
+ break
}
- case "INTEGRITY_CHECK_REQUEST": {
+ case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
- type: "INTEGRITY_CHECK_RESPONSE",
+ type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
- });
- break;
+ })
+ break
}
- case "MOCK_ACTIVATE": {
- activeClientIds.add(clientId);
+ case 'MOCK_ACTIVATE': {
+ activeClientIds.add(clientId)
sendToClient(client, {
- type: "MOCKING_ENABLED",
+ type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
- });
- break;
+ })
+ break
}
- case "CLIENT_CLOSED": {
- activeClientIds.delete(clientId);
+ case 'CLIENT_CLOSED': {
+ activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
- return client.id !== clientId;
- });
+ return client.id !== clientId
+ })
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
- self.registration.unregister();
+ self.registration.unregister()
}
- break;
+ break
}
}
-});
+})
-addEventListener("fetch", (event) => {
- const requestInterceptedAt = Date.now();
+addEventListener('fetch', function (event) {
+ const requestInterceptedAt = Date.now()
// Bypass navigation requests.
- if (event.request.mode === "navigate") {
- return;
+ if (event.request.mode === 'navigate') {
+ return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (
- event.request.cache === "only-if-cached" &&
- event.request.mode !== "same-origin"
+ event.request.cache === 'only-if-cached' &&
+ event.request.mode !== 'same-origin'
) {
- return;
+ return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
- return;
+ return
}
- const requestId = crypto.randomUUID();
- event.respondWith(handleRequest(event, requestId, requestInterceptedAt));
-});
+ const requestId = crypto.randomUUID()
+ event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
+})
/**
* @param {FetchEvent} event
@@ -122,28 +122,38 @@ addEventListener("fetch", (event) => {
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
- const client = await resolveMainClient(event);
- const requestCloneForEvents = event.request.clone();
+ const client = await resolveMainClient(event)
+ const requestCloneForEvents = event.request.clone()
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
- );
+ )
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
- const serializedRequest = await serializeRequest(requestCloneForEvents);
+ const serializedRequest = await serializeRequest(requestCloneForEvents)
+
+ // Omit the body of server-sent event stream responses.
+ // Cloning such responses would prevent client-side stream cancelations
+ // from reaching the original stream (a teed stream only cancels its
+ // source once both of its branches cancel) and would buffer the
+ // entire stream into the unconsumed clone indefinitely.
+ const isEventStreamResponse = response.headers
+ .get('content-type')
+ ?.toLowerCase()
+ .startsWith('text/event-stream')
// Clone the response so both the client and the library could consume it.
- const responseClone = response.clone();
+ const responseClone = isEventStreamResponse ? null : response.clone()
sendToClient(
client,
{
- type: "RESPONSE",
+ type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
@@ -151,19 +161,21 @@ async function handleRequest(event, requestId, requestInterceptedAt) {
...serializedRequest,
},
response: {
- type: responseClone.type,
- status: responseClone.status,
- statusText: responseClone.statusText,
- headers: Object.fromEntries(responseClone.headers.entries()),
- body: responseClone.body,
+ type: response.type,
+ status: response.status,
+ statusText: response.statusText,
+ headers: Object.fromEntries(response.headers.entries()),
+ body: responseClone ? responseClone.body : null,
},
},
},
- responseClone.body ? [serializedRequest.body, responseClone.body] : [],
- );
+ responseClone && responseClone.body
+ ? [serializedRequest.body, responseClone.body]
+ : [],
+ )
}
- return response;
+ return response
}
/**
@@ -175,30 +187,30 @@ async function handleRequest(event, requestId, requestInterceptedAt) {
* @returns {Promise}
*/
async function resolveMainClient(event) {
- const client = await self.clients.get(event.clientId);
+ const client = await self.clients.get(event.clientId)
if (activeClientIds.has(event.clientId)) {
- return client;
+ return client
}
- if (client?.frameType === "top-level") {
- return client;
+ if (client?.frameType === 'top-level') {
+ return client
}
const allClients = await self.clients.matchAll({
- type: "window",
- });
+ type: 'window',
+ })
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
- return client.visibilityState === "visible";
+ return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
- return activeClientIds.has(client.id);
- });
+ return activeClientIds.has(client.id)
+ })
}
/**
@@ -211,36 +223,36 @@ async function resolveMainClient(event) {
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
- const requestClone = event.request.clone();
+ const requestClone = event.request.clone()
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
- const headers = new Headers(requestClone.headers);
+ const headers = new Headers(requestClone.headers)
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
- const acceptHeader = headers.get("accept");
+ const acceptHeader = headers.get('accept')
if (acceptHeader) {
- const values = acceptHeader.split(",").map((value) => value.trim());
+ const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
- (value) => value !== "msw/passthrough",
- );
+ (value) => value !== 'msw/passthrough',
+ )
if (filteredValues.length > 0) {
- headers.set("accept", filteredValues.join(", "));
+ headers.set('accept', filteredValues.join(', '))
} else {
- headers.delete("accept");
+ headers.delete('accept')
}
}
- return fetch(requestClone, { headers });
+ return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
- return passthrough();
+ return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
@@ -248,15 +260,15 @@ async function getResponse(event, client, requestId, requestInterceptedAt) {
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
- return passthrough();
+ return passthrough()
}
// Notify the client that a request has been intercepted.
- const serializedRequest = await serializeRequest(event.request);
+ const serializedRequest = await serializeRequest(event.request)
const clientMessage = await sendToClient(
client,
{
- type: "REQUEST",
+ type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
@@ -264,19 +276,19 @@ async function getResponse(event, client, requestId, requestInterceptedAt) {
},
},
[serializedRequest.body],
- );
+ )
switch (clientMessage.type) {
- case "MOCK_RESPONSE": {
- return respondWithMock(clientMessage.data);
+ case 'MOCK_RESPONSE': {
+ return respondWithMock(clientMessage.data)
}
- case "PASSTHROUGH": {
- return passthrough();
+ case 'PASSTHROUGH': {
+ return passthrough()
}
}
- return passthrough();
+ return passthrough()
}
/**
@@ -287,21 +299,21 @@ async function getResponse(event, client, requestId, requestInterceptedAt) {
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
- const channel = new MessageChannel();
+ const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
- if (event.data?.error) {
- return reject(event.data.error);
+ if (event.data && event.data.error) {
+ return reject(event.data.error)
}
- resolve(event.data);
- };
+ resolve(event.data)
+ }
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
- ]);
- });
+ ])
+ })
}
/**
@@ -314,17 +326,17 @@ function respondWithMock(response) {
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
- return Response.error();
+ return Response.error()
}
- const mockedResponse = new Response(response.body, response);
+ const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
- });
+ })
- return mockedResponse;
+ return mockedResponse
}
/**
@@ -345,5 +357,5 @@ async function serializeRequest(request) {
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
- };
+ }
}
diff --git a/packages/widget/scripts/generate-effect-openapi.ts b/packages/widget/scripts/generate-effect-openapi.ts
index 0512244a..10b42f22 100644
--- a/packages/widget/scripts/generate-effect-openapi.ts
+++ b/packages/widget/scripts/generate-effect-openapi.ts
@@ -10,16 +10,32 @@ type JsonPatchOperation = {
value: unknown;
};
+type SpecOutputConfig = {
+ format: "httpclient" | "httpclient-type-only";
+ outputPath: string;
+ schemaOnly?: boolean;
+};
+
type SpecConfig = {
name: string;
- outputPath: string;
+ outputs: ReadonlyArray;
patches: JsonPatchOperation[];
+ prepareSpec?: (contents: string) => string;
specFileName: string;
url: string;
};
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const widgetRoot = path.resolve(scriptDir, "..");
+const workspaceRoot = path.resolve(widgetRoot, "../..");
+const binaryPath = (root: string, name: string) =>
+ path.join(
+ root,
+ "node_modules/.bin",
+ `${name}${process.platform === "win32" ? ".CMD" : ""}`
+ );
+const openApiGeneratorBin = binaryPath(widgetRoot, "openapigen");
+const biomeBin = binaryPath(workspaceRoot, "biome");
const generatedFileHeader = [
"// @ts-nocheck",
"// biome-ignore-all lint: generated by Effect OpenAPI",
@@ -31,7 +47,17 @@ const specs: SpecConfig[] = [
name: "LegacyApi",
url: process.env.LEGACY_API_SPEC_URL ?? "https://api.stakek.it/docs.yaml",
specFileName: "legacy-api.yaml",
- outputPath: path.join(widgetRoot, "src/generated/api/legacy.ts"),
+ outputs: [
+ {
+ format: "httpclient-type-only",
+ outputPath: path.join(widgetRoot, "src/generated/api/legacy.ts"),
+ },
+ {
+ format: "httpclient",
+ outputPath: path.join(widgetRoot, "src/generated/api/legacy-schema.ts"),
+ schemaOnly: true,
+ },
+ ],
patches: [
// Upstream currently declares regionCode as an object, but production
// payloads and widget geo-block handling use it as a string.
@@ -47,7 +73,17 @@ const specs: SpecConfig[] = [
url:
process.env.YIELD_API_SPEC_URL ?? "https://api.stg.yield.xyz/docs.yaml",
specFileName: "yield-api.yaml",
- outputPath: path.join(widgetRoot, "src/generated/api/yield.ts"),
+ outputs: [
+ {
+ format: "httpclient-type-only",
+ outputPath: path.join(widgetRoot, "src/generated/api/yield.ts"),
+ },
+ {
+ format: "httpclient",
+ outputPath: path.join(widgetRoot, "src/generated/api/yield-schema.ts"),
+ schemaOnly: true,
+ },
+ ],
patches: [
// The spec marks these date-time fields as nullable, but openapigen beta
// currently drops null for nullable date-time strings. Replacing the
@@ -72,6 +108,29 @@ const specs: SpecConfig[] = [
},
],
},
+ {
+ name: "BorrowApi",
+ url:
+ process.env.BORROW_API_SPEC_URL ?? "https://borrow.yield.xyz/docs.json",
+ specFileName: "borrow-api.json",
+ outputs: [
+ {
+ format: "httpclient-type-only",
+ outputPath: path.join(widgetRoot, "src/generated/api/borrow-client.ts"),
+ },
+ {
+ format: "httpclient",
+ outputPath: path.join(widgetRoot, "src/generated/api/borrow.ts"),
+ schemaOnly: true,
+ },
+ ],
+ patches: [],
+ prepareSpec: (contents) =>
+ contents.replaceAll(
+ '"allOf":[{"$ref":"#/components/schemas/ArgumentSchemaPropertyDto"}]',
+ '"type":"object"'
+ ),
+ },
];
const run = async (
@@ -135,55 +194,102 @@ const fetchSpec = async (spec: SpecConfig) => {
return response.text();
};
+const extractSchemaOnlyOutput = (output: string, specName: string) => {
+ const clientMarker = "\nexport interface OperationConfig {";
+ const clientStart = output.indexOf(clientMarker);
+
+ if (clientStart === -1) {
+ throw new Error(
+ `${specName} schema generation did not contain the expected client marker`
+ );
+ }
+
+ return `${output
+ .slice(0, clientStart)
+ .replace(/^import \* as Data from "effect\/Data";?\r?\n/m, "")
+ .replace(/^import \* as Effect from "effect\/Effect";?\r?\n/m, "")
+ .replace(/^import type \{ SchemaError \} from "effect\/Schema";?\r?\n/m, "")
+ .replace(
+ /^import(?: type)? \* as HttpClient[^\n]+"effect\/unstable\/http\/[^\n]+\r?\n/gm,
+ ""
+ )
+ .trimEnd()}\n`;
+};
+
const generateSpec = async (spec: SpecConfig, tempDir: string) => {
const specPath = path.join(tempDir, spec.specFileName);
const patchPath = path.join(tempDir, `${spec.specFileName}.patch.json`);
console.log(`Fetching ${spec.name} spec from ${spec.url}`);
- await writeFile(specPath, await fetchSpec(spec));
+ const specContents = await fetchSpec(spec);
+ await writeFile(specPath, spec.prepareSpec?.(specContents) ?? specContents);
await writeFile(patchPath, `${JSON.stringify(spec.patches, null, 2)}\n`);
- await mkdir(path.dirname(spec.outputPath), { recursive: true });
-
- console.log(`Generating ${path.relative(widgetRoot, spec.outputPath)}`);
- const { stdout, stderr } = await run("pnpm", [
- "exec",
- "openapigen",
- "--spec",
- specPath,
- "--name",
- spec.name,
- "--format",
- "httpclient-type-only",
- "--patch",
- patchPath,
- ]);
-
- if (stderr.trim()) {
- console.warn(stderr.trim());
- }
- if (!stdout.trim()) {
- throw new Error(`${spec.name} generation produced empty output`);
- }
+ for (const output of spec.outputs) {
+ await mkdir(path.dirname(output.outputPath), { recursive: true });
+
+ console.log(
+ `Generating ${path.relative(widgetRoot, output.outputPath)} (${output.format})`
+ );
+ const { stdout, stderr } = await run(openApiGeneratorBin, [
+ "--spec",
+ specPath,
+ "--name",
+ spec.name,
+ "--format",
+ output.format,
+ "--patch",
+ patchPath,
+ ]);
+
+ if (stderr.trim()) {
+ console.warn(stderr.trim());
+ }
- await writeFile(spec.outputPath, `${generatedFileHeader}${stdout}`);
+ if (!stdout.trim()) {
+ throw new Error(
+ `${spec.name} ${output.format} generation produced empty output`
+ );
+ }
+
+ const generatedOutput = output.schemaOnly
+ ? extractSchemaOnlyOutput(stdout, spec.name)
+ : stdout;
+
+ await writeFile(
+ output.outputPath,
+ `${generatedFileHeader}${generatedOutput}`
+ );
+ }
};
const main = async () => {
const tempDir = await mkdtemp(path.join(tmpdir(), "stakekit-openapi-"));
+ const requestedSpecs = new Set(process.argv.slice(2));
+ const selectedSpecs = requestedSpecs.size
+ ? specs.filter((spec) => requestedSpecs.has(spec.name))
+ : specs;
+
+ if (!selectedSpecs.length) {
+ throw new Error(
+ `No OpenAPI specs matched: ${[...requestedSpecs].join(", ")}`
+ );
+ }
try {
- for (const spec of specs) {
+ for (const spec of selectedSpecs) {
await generateSpec(spec, tempDir);
}
- await run("pnpm", [
- "exec",
- "biome",
+ await run(biomeBin, [
"check",
"--write",
"--unsafe",
- ...specs.map((spec) => path.relative(widgetRoot, spec.outputPath)),
+ ...selectedSpecs.flatMap((spec) =>
+ spec.outputs.map((output) =>
+ path.relative(widgetRoot, output.outputPath)
+ )
+ ),
]);
} finally {
await rm(tempDir, { force: true, recursive: true });
diff --git a/packages/widget/src/App.tsx b/packages/widget/src/App.tsx
index 8bc4be7c..e6cc7831 100644
--- a/packages/widget/src/App.tsx
+++ b/packages/widget/src/App.tsx
@@ -1,29 +1,33 @@
import "@stakekit/rainbowkit/styles.css";
import "./translation";
-import "./utils/extend-purify";
-import "./styles/theme/global.css";
-import type { ComponentProps, RefObject } from "react";
+import "./shared/styles/theme/global.css";
+import type { ComponentProps } from "react";
import { createRef, useImperativeHandle, useState } from "react";
import ReactDOM from "react-dom/client";
import { createMemoryRouter, RouterProvider } from "react-router";
-import { preloadImages } from "./assets/images";
-import { Box } from "./components/atoms/box";
-import { Dashboard } from "./Dashboard";
-import { Providers } from "./providers";
-import { SettingsContextProvider, useSettings } from "./providers/settings";
-import type { SettingsProps, VariantProps } from "./providers/settings/types";
-import { appContainer } from "./style.css";
+import { Providers } from "./app/composition/providers";
+import { SKAtomRegistryProvider } from "./app/composition/providers/atom-runtime";
+import { normalizeWidgetConfig, useWidgetConfig } from "./app/config";
+import { ClassicRoutes, DashboardRoutes } from "./app/routes";
+import { appContainer } from "./features/widget-shell";
+import type {
+ BundledSKWidgetProps,
+ SKAppProps,
+ VariantProps,
+} from "./public-api/types";
+import { isLedgerDappBrowserProvider } from "./services/wallet/browser-environment";
+import { preloadImages } from "./shared/assets/images";
+import { Box } from "./shared/ui/primitives/box";
import { useLoadErrorTranslations } from "./translation";
-import { Widget } from "./Widget";
preloadImages();
const App = () => {
useLoadErrorTranslations();
- const { dashboardVariant } = useSettings();
+ const dashboardVariant = useWidgetConfig("dashboardVariant");
- return dashboardVariant ? : ;
+ return dashboardVariant ? : ;
};
const Root = () => (
@@ -32,7 +36,7 @@ const Root = () => (
);
-export type SKAppProps = SettingsProps & (VariantProps | { variant?: never });
+export type { BundledSKWidgetProps, SKAppProps } from "./public-api/types";
export const SKApp = (props: SKAppProps) => {
const variantProps: VariantProps =
@@ -43,24 +47,24 @@ export const SKApp = (props: SKAppProps) => {
const [router] = useState(() =>
createMemoryRouter([{ path: "*", Component: Root }])
);
+ const settings = normalizeWidgetConfig(
+ { ...props, ...variantProps },
+ { isLedgerLive: isLedgerDappBrowserProvider() }
+ );
return (
-
+
-
+
);
};
-export type BundledSKWidgetProps = SKAppProps & {
- ref?: RefObject<{ rerender: (newProps: BundledSKWidgetProps) => void }>;
-};
-
const BundledSKWidget = (_props: BundledSKWidgetProps) => {
const [props, setProps] = useState(_props);
diff --git a/packages/widget/src/Dashboard.tsx b/packages/widget/src/Dashboard.tsx
deleted file mode 100644
index 91fb2ca0..00000000
--- a/packages/widget/src/Dashboard.tsx
+++ /dev/null
@@ -1,118 +0,0 @@
-import { Route, Routes } from "react-router";
-import { GlobalModals } from "./components/molecules/global-modals";
-import { ConnectedCheck } from "./navigation/cheks/connected-check";
-// import { RewardsTabPage } from "./pages-dashboard/rewards";
-import { PendingCompletePage } from "./pages/complete/pages/pending-complete.page";
-import { StakeCompletePage } from "./pages/complete/pages/stake-complete.page";
-import { UnstakeCompletePage } from "./pages/complete/pages/unstake-complete.page";
-import { EarnPageContextProvider } from "./pages/details/earn-page/state/earn-page-context";
-import { EarnPageStateUsageBoundaryProvider } from "./pages/details/earn-page/state/earn-page-state-context";
-import { StakeReviewPage } from "./pages/review";
-import { PendingReviewPage } from "./pages/review/pages/pending-review.page";
-import { UnstakeReviewPage } from "./pages/review/pages/unstake-review.page";
-import { StakeStepsPage } from "./pages/steps";
-import { ActivityStepsPage } from "./pages/steps/pages/activity-steps.page";
-import { PendingStepsPage } from "./pages/steps/pages/pending-steps.page";
-import { UnstakeStepsPage } from "./pages/steps/pages/unstake-steps.page";
-import { ActivityTabPage } from "./pages-dashboard/activity";
-import { ActivityDetailsPage } from "./pages-dashboard/activity/activity-details.page";
-import { DashboardWrapper } from "./pages-dashboard/common/components/wrapper";
-import { OverviewPage } from "./pages-dashboard/overview";
-import { EarnPageContent } from "./pages-dashboard/overview/earn-page";
-import { ManagePage } from "./pages-dashboard/overview/manage.page";
-import { PositionDetailsPage } from "./pages-dashboard/position-details";
-import { PositionDetailsActions } from "./pages-dashboard/position-details/components/position-details-actions";
-import { PositionDetailsStakeActions } from "./pages-dashboard/position-details/components/position-details-stake-actions";
-import { DashboardProvider } from "./pages-dashboard/providers/dashboard-context";
-import { useSKLocation } from "./providers/location";
-
-const positionDetailsStakeFooterPath =
- /^\/positions\/[^/]+\/[^/]+(?:\/stake)?$/;
-
-export const shouldRegisterDashboardEarnFooterButton = (pathname: string) =>
- pathname === "/" || positionDetailsStakeFooterPath.test(pathname);
-
-export const Dashboard = () => {
- const { current } = useSKLocation();
- const registerEarnFooterButton = shouldRegisterDashboardEarnFooterButton(
- current.pathname
- );
-
- return (
-
-
-
-
- }>
- {/* Earn Tab */}
- }>
- } />
-
- }>
- } />
- } />
- } />
-
-
-
- {/* Manage Tab */}
- } />
-
- {/* Position Details */}
- }
- >
- } />
-
- {/* Staking */}
-
- } />
- } />
- } />
- } />
-
-
- }
- />
-
- {/* Unstaking */}
-
- } />
- } />
- } />
- } />
-
-
- {/* Pending Actions */}
-
- } />
- } />
- } />
-
-
-
- {/* Rewards Tab */}
- {/* } /> */}
-
- {/* Activity Tab */}
- }>
- } />
- }
- />
-
-
-
-
-
-
-
-
- );
-};
diff --git a/packages/widget/src/Widget.tsx b/packages/widget/src/Widget.tsx
deleted file mode 100644
index 00dfc49a..00000000
--- a/packages/widget/src/Widget.tsx
+++ /dev/null
@@ -1,183 +0,0 @@
-import { AnimatePresence, LayoutGroup, motion } from "motion/react";
-import { useEffect } from "react";
-import { Navigate, Route, Routes, useNavigate } from "react-router";
-import { GlobalModals } from "./components/molecules/global-modals";
-import { Header } from "./components/molecules/header";
-import { useDetailsMatch } from "./hooks/navigation/use-details-match";
-import { useHandleDeepLinks } from "./hooks/use-handle-deep-links";
-import { useInitParams } from "./hooks/use-init-params";
-import { usePrevious } from "./hooks/use-previous";
-import { useSavedRef } from "./hooks/use-saved-ref";
-import { useUnderMaintenance } from "./hooks/use-under-maintenance";
-import { ConnectedCheck } from "./navigation/cheks/connected-check";
-import { AnimationLayout } from "./navigation/containers/animation-layout";
-import { ActivityCompletePage } from "./pages/complete/pages/activity-complete.page";
-import { PendingCompletePage } from "./pages/complete/pages/pending-complete.page";
-import { StakeCompletePage } from "./pages/complete/pages/stake-complete.page";
-import { UnstakeCompletePage } from "./pages/complete/pages/unstake-complete.page";
-import { Layout } from "./pages/components/layout";
-import { headerContainer } from "./pages/components/layout/styles.css";
-import { PoweredBy } from "./pages/components/powered-by";
-import UnderMaintenance from "./pages/components/under-maintenance";
-import { AnimatedActivityPage } from "./pages/details/activity-page/activity.page";
-import { Details } from "./pages/details/details-page/details.page";
-import { AnimatedEarnPage } from "./pages/details/earn-page/earn.page";
-import { AnimatedPositionsPage } from "./pages/details/positions-page/positions.page";
-import { PositionDetailsPage } from "./pages/position-details";
-import { StakeReviewPage } from "./pages/review";
-import { ActionReviewPage } from "./pages/review/pages/action-review.page";
-import { PendingReviewPage } from "./pages/review/pages/pending-review.page";
-import { UnstakeReviewPage } from "./pages/review/pages/unstake-review.page";
-import { StakeStepsPage } from "./pages/steps";
-import { ActivityStepsPage } from "./pages/steps/pages/activity-steps.page";
-import { PendingStepsPage } from "./pages/steps/pages/pending-steps.page";
-import { UnstakeStepsPage } from "./pages/steps/pages/unstake-steps.page";
-import { useSKLocation } from "./providers/location";
-import { useSKWallet } from "./providers/sk-wallet";
-import { container } from "./style.css";
-import { MaybeWindow } from "./utils/maybe-window";
-
-export const Widget = () => {
- const underMaintenance = useUnderMaintenance();
-
- const { chain, address } = useSKWallet();
-
- const prevChain = usePrevious(chain);
- const prevAddress = usePrevious(address);
-
- const { current } = useSKLocation();
-
- const pathnameRef = useSavedRef(current.pathname);
- const navigateRef = useSavedRef(useNavigate());
-
- /**
- * On chain change, navigate to home page
- */
- useEffect(() => {
- if (
- pathnameRef.current !== "/" &&
- pathnameRef.current !== "/positions" &&
- pathnameRef.current !== "/activity" &&
- ((prevChain && chain !== prevChain) ||
- (prevAddress && address !== prevAddress))
- ) {
- MaybeWindow.ifJust((w) => {
- const url = new URL(w.location.href);
- const newUrl = new URL(w.location.origin);
- if (url.searchParams.has("embed")) {
- newUrl.searchParams.set("embed", "true");
- }
-
- w.history.pushState({}, w.document.title, newUrl.href);
- navigateRef.current("/", { replace: true });
- });
- }
- }, [chain, address, pathnameRef, navigateRef, prevChain, prevAddress]);
-
- const initTab = useInitParams().data?.tab;
-
- useEffect(() => {
- if (!initTab) return;
-
- navigateRef.current(initTab === "earn" ? "/" : "/positions");
- }, [initTab, navigateRef]);
-
- useHandleDeepLinks();
-
- const detailsMatch = useDetailsMatch();
-
- /**
- * Dont unmount details page with tabs
- * Handle position details pages in their own Routes
- */
- const key = detailsMatch ? "/" : current.key;
-
- if (underMaintenance) return ;
-
- return (
- <>
-
-
-
-
-
-
-
-
-
- }>
- {/* Home + Tabs */}
- }>
- } />
- }
- />
- } />
-
-
- }>
- {/* Activity flow */}
-
- } />
- }
- />
- }
- />
-
-
- {/* Stake flow */}
-
- } />
- } />
- } />
-
-
- {/* Unstake or pending actions flow */}
-
- } />
- }
- />
-
- {/* Unstaking */}
-
- } />
- } />
- }
- />
-
-
- {/* Pending Actions */}
-
- } />
- } />
- }
- />
-
-
-
-
- } />
-
-
-
-
-
-
-
-
-
-
- >
- );
-};
diff --git a/packages/widget/src/app/composition/providers/atom-runtime/index.tsx b/packages/widget/src/app/composition/providers/atom-runtime/index.tsx
new file mode 100644
index 00000000..c7fa924d
--- /dev/null
+++ b/packages/widget/src/app/composition/providers/atom-runtime/index.tsx
@@ -0,0 +1,71 @@
+import { RegistryProvider, useAtomSet } from "@effect/atom-react";
+import {
+ useConnection as useSolanaConnection,
+ useWallet as useSolanaWallet,
+} from "@solana/wallet-adapter-react";
+import { type PropsWithChildren, useLayoutEffect, useState } from "react";
+import { config } from "../../../../shared/config/widget-defaults";
+import {
+ useWidgetConfig,
+ type WidgetConfig,
+ widgetConfigAtom,
+} from "../../../config";
+import {
+ dynamicExternalProviderInputAtom,
+ normalizeDynamicExternalProviderInput,
+ normalizeSolanaWalletInput,
+ solanaWalletInputAtom,
+} from "../../../runtime/root-inputs";
+
+const WidgetConfigBinding = ({
+ children,
+ settings,
+}: PropsWithChildren<{ readonly settings: WidgetConfig }>) => {
+ const setWidgetConfig = useAtomSet(widgetConfigAtom);
+
+ useLayoutEffect(() => {
+ setWidgetConfig(settings);
+ }, [setWidgetConfig, settings]);
+
+ return children;
+};
+
+export const SKAtomRegistryProvider = ({
+ children,
+ settings,
+}: PropsWithChildren<{ readonly settings: WidgetConfig }>) => {
+ const [initialSettings] = useState(settings);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const SKRootInputProvider = ({ children }: PropsWithChildren) => {
+ const setDynamicWalletInput = useAtomSet(dynamicExternalProviderInputAtom);
+ const setSolanaInput = useAtomSet(solanaWalletInputAtom);
+ const externalProviders = useWidgetConfig("externalProviders");
+ const solanaConnection = useSolanaConnection();
+ const solanaWallet = useSolanaWallet();
+ const dynamicWalletInput =
+ normalizeDynamicExternalProviderInput(externalProviders);
+ const solanaInput = normalizeSolanaWalletInput({
+ connection: solanaConnection.connection,
+ wallets: solanaWallet.wallets,
+ });
+
+ useLayoutEffect(() => {
+ setDynamicWalletInput(dynamicWalletInput);
+ }, [dynamicWalletInput, setDynamicWalletInput]);
+
+ useLayoutEffect(() => {
+ setSolanaInput(solanaInput);
+ }, [setSolanaInput, solanaInput]);
+
+ return children;
+};
diff --git a/packages/widget/src/app/composition/providers/index.tsx b/packages/widget/src/app/composition/providers/index.tsx
new file mode 100644
index 00000000..3707868c
--- /dev/null
+++ b/packages/widget/src/app/composition/providers/index.tsx
@@ -0,0 +1,48 @@
+import type { ComponentProps, PropsWithChildren } from "react";
+import { StrictMode } from "react";
+import { I18nextProvider } from "react-i18next";
+import { WagmiConfigProvider } from "../../../features/wallet";
+import { CurrentLayoutProvider } from "../../../features/widget-shell";
+import { SKLocationProvider } from "../../../shared/react/location-history";
+import { RootElementProvider } from "../../../shared/react/root-element";
+import { i18nInstance } from "../../../translation";
+import { WidgetConfigEffects } from "../../config/widget-config-effects";
+import { SKRootInputProvider } from "./atom-runtime";
+import { MountAnimationEffects } from "./mount-animation";
+import { ThirdPartyQueryClientProvider } from "./query-client";
+import { RainbowProvider } from "./rainbow";
+import { SolanaProvider } from "./solana";
+import { ThemeWrapper } from "./theme-wrapper";
+
+export const Providers = ({
+ children,
+}: PropsWithChildren & ComponentProps) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/widget/src/app/composition/providers/mount-animation/index.tsx b/packages/widget/src/app/composition/providers/mount-animation/index.tsx
new file mode 100644
index 00000000..fa8a3098
--- /dev/null
+++ b/packages/widget/src/app/composition/providers/mount-animation/index.tsx
@@ -0,0 +1,30 @@
+import { useEffect, useRef } from "react";
+import { useMountAnimation } from "../../../../features/mount-animation";
+import { delayAPIRequests } from "../../../../services/api/delay-api-requests";
+import { useSKLocation } from "../../../../shared/react/location-history";
+import { useWidgetConfig } from "../../../config";
+
+const removeDelay = delayAPIRequests();
+
+export const MountAnimationEffects = () => {
+ const onMountAnimationComplete = useWidgetConfig("onMountAnimationComplete");
+ const callbackRef = useRef(onMountAnimationComplete);
+ callbackRef.current = onMountAnimationComplete;
+ const { current } = useSKLocation();
+ const { dispatch, state } = useMountAnimation();
+
+ useEffect(() => {
+ if (state.layout && state.earnPage) {
+ removeDelay();
+ callbackRef.current?.();
+ }
+ }, [state.earnPage, state.layout]);
+
+ useEffect(() => {
+ if (current.pathname !== "/") {
+ dispatch({ type: "all" });
+ }
+ }, [current.pathname, dispatch]);
+
+ return null;
+};
diff --git a/packages/widget/src/app/composition/providers/query-client/index.tsx b/packages/widget/src/app/composition/providers/query-client/index.tsx
new file mode 100644
index 00000000..6e023ca6
--- /dev/null
+++ b/packages/widget/src/app/composition/providers/query-client/index.tsx
@@ -0,0 +1,14 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { PropsWithChildren } from "react";
+import { useState } from "react";
+
+/** Third-party infrastructure for Wagmi/RainbowKit; widget API state is forbidden here. */
+export const ThirdPartyQueryClientProvider = ({
+ children,
+}: PropsWithChildren) => {
+ const [queryClient] = useState(() => new QueryClient());
+
+ return (
+ {children}
+ );
+};
diff --git a/packages/widget/src/app/composition/providers/rainbow-kit.tsx b/packages/widget/src/app/composition/providers/rainbow-kit.tsx
new file mode 100644
index 00000000..8762fa04
--- /dev/null
+++ b/packages/widget/src/app/composition/providers/rainbow-kit.tsx
@@ -0,0 +1,118 @@
+import type { DisclaimerComponent } from "@stakekit/rainbowkit";
+import { RainbowKitProvider, useChainModal } from "@stakekit/rainbowkit";
+import type { PropsWithChildren } from "react";
+import { useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import { shouldShowDisconnect } from "../../../domain/types/connectors";
+import { useTrackEvent } from "../../../features/tracking";
+import {
+ addLedgerAccountAtom,
+ useCloseChainModal,
+ useLedgerDisabledChain,
+ useSKWallet,
+} from "../../../features/wallet";
+import { isLedgerLiveConnector } from "../../../services/wallet/connectors/ledger/ledger-live-connector-meta";
+import { vars } from "../../../shared/styles/theme/contract.css";
+import { id } from "../../../shared/styles/theme/ids";
+import type { ConnectKitTheme } from "../../../shared/styles/tokens/connect-kit";
+import { connectKitTheme } from "../../../shared/styles/tokens/connect-kit";
+import { Text } from "../../../shared/ui/primitives/typography/text";
+import { useWidgetConfig } from "../../config";
+
+const finalTheme: ConnectKitTheme = {
+ ...connectKitTheme.lightMode,
+ colors: vars.color.connectKit, // ThemeWrapper applies final light/dark colors
+ radii: vars.borderRadius.connectKit,
+ fonts: { body: vars.font.body },
+};
+
+export const RainbowKitProviderWithTheme = ({
+ children,
+}: PropsWithChildren) => {
+ const { connector, connectorChains } = useSKWallet();
+
+ const portalContainer = useWidgetConfig("portalContainer");
+
+ const ledgerDisabledChains = useLedgerDisabledChain();
+
+ const trackEvent = useTrackEvent();
+
+ const addLedgerAccount = useAtomSet(addLedgerAccountAtom);
+ const { closeChainModal } = useCloseChainModal();
+
+ const { t, i18n } = useTranslation();
+
+ const hideAccountAndChainSelector = useWidgetConfig(
+ "hideAccountAndChainSelector"
+ );
+ const initialChain = useWidgetConfig("initialChain");
+
+ const chainIdsToUse = useMemo(
+ () => new Set(connectorChains.map((c) => c.id)),
+ [connectorChains]
+ );
+
+ const disabledChains = useMemo(
+ () =>
+ ledgerDisabledChains.map((c) => ({
+ ...c,
+ info: t("chain_modal.disabled_chain_info"),
+ })),
+ [ledgerDisabledChains, t]
+ );
+
+ const hideDisconnect = useMemo(
+ () => (connector ? !shouldShowDisconnect(connector) : true),
+ [connector]
+ );
+
+ const locale = useMemo(() => {
+ if (i18n.language === "fr" || i18n.language === "fr-FR") {
+ return i18n.language;
+ }
+
+ return "en";
+ }, [i18n.language]);
+
+ return (
+ {
+ trackEvent("addLedgerAccountClicked");
+ addLedgerAccount({
+ chain: disabledChain,
+ closeChainModal,
+ connector:
+ connector && isLedgerLiveConnector(connector) ? connector : null,
+ });
+ }}
+ locale={locale}
+ appInfo={{ disclaimer: Disclamer, appName: t("shared.stake_kit") }}
+ {...(hideAccountAndChainSelector && { avatar: null })}
+ showRecentTransactions={false}
+ initialChain={initialChain}
+ theme={finalTheme}
+ hideDisconnect={hideDisconnect}
+ dialogRoot={portalContainer}
+ >
+
+ {children}
+
+ );
+};
+
+const DisabledChainHandling = () => {
+ useCloseChainModal().setCloseChainModal = useChainModal().closeChainModal; // haaack
+
+ return null;
+};
+
+const Disclamer: DisclaimerComponent = () => {
+ const { t } = useTranslation();
+ return {t("chain_modal_disclaimer")};
+};
+
+import { useAtomSet } from "@effect/atom-react";
diff --git a/packages/widget/src/app/composition/providers/rainbow/index.tsx b/packages/widget/src/app/composition/providers/rainbow/index.tsx
new file mode 100644
index 00000000..04e99cb3
--- /dev/null
+++ b/packages/widget/src/app/composition/providers/rainbow/index.tsx
@@ -0,0 +1,51 @@
+import { useAtomSet } from "@effect/atom-react";
+import { AccountExtraInfoContext } from "@stakekit/rainbowkit";
+import type { PropsWithChildren } from "react";
+import type { Address } from "viem";
+import { useSKWallet } from "../../../../features/wallet";
+import {
+ WalletService,
+ type WalletSwitchAccountInput,
+} from "../../../../services/wallet/wallet-service";
+import { formatAddress } from "../../../../shared/lib/general";
+import { appRuntime } from "../../../runtime";
+import { RainbowKitProviderWithTheme } from "../rainbow-kit";
+
+const switchLedgerAccountAtom = appRuntime.fn(
+ (input: WalletSwitchAccountInput) =>
+ WalletService.use((wallet) => wallet.switchAccount(input))
+);
+
+export const RainbowProvider = ({ children }: PropsWithChildren) => {
+ const wallet = useSKWallet();
+ const switchAccount = useAtomSet(switchLedgerAccountAtom, {
+ mode: "promise",
+ });
+
+ const otherAddresses =
+ wallet.ledgerAccounts
+ ?.filter((account) => account.address !== wallet.address)
+ .map((account) => formatAddress(account.address) as Address) ?? [];
+
+ return (
+ {
+ const account = wallet.ledgerAccounts?.find(
+ (candidate) => formatAddress(candidate.address) === selectedAddress
+ );
+
+ if (account && wallet.isConnected) {
+ void switchAccount({
+ account,
+ connector: wallet.connector,
+ }).catch(() => undefined);
+ }
+ },
+ }}
+ >
+ {children}
+
+ );
+};
diff --git a/packages/widget/src/app/composition/providers/solana/index.tsx b/packages/widget/src/app/composition/providers/solana/index.tsx
new file mode 100644
index 00000000..ad1b571d
--- /dev/null
+++ b/packages/widget/src/app/composition/providers/solana/index.tsx
@@ -0,0 +1,38 @@
+import { WalletAdapterNetwork } from "@solana/wallet-adapter-base";
+import {
+ ConnectionProvider,
+ WalletProvider,
+} from "@solana/wallet-adapter-react";
+import {
+ PhantomWalletAdapter,
+ TrustWalletAdapter,
+ WalletConnectWalletAdapter,
+} from "@solana/wallet-adapter-wallets";
+import { clusterApiUrl } from "@solana/web3.js";
+import { type PropsWithChildren, useMemo } from "react";
+import { config } from "../../../../shared/config/widget-defaults";
+
+const network = WalletAdapterNetwork.Mainnet;
+
+const endpoint = clusterApiUrl(network);
+
+export const SolanaProvider = ({ children }: PropsWithChildren) => {
+ const wallets = useMemo(() => {
+ return config.env.isTestMode
+ ? []
+ : [
+ new PhantomWalletAdapter(),
+ new TrustWalletAdapter(),
+ new WalletConnectWalletAdapter({
+ network: WalletAdapterNetwork.Mainnet,
+ options: { projectId: config.walletConnectV2.projectId },
+ }),
+ ];
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/packages/widget/src/app/composition/providers/theme-wrapper.tsx b/packages/widget/src/app/composition/providers/theme-wrapper.tsx
new file mode 100644
index 00000000..6653374b
--- /dev/null
+++ b/packages/widget/src/app/composition/providers/theme-wrapper.tsx
@@ -0,0 +1,62 @@
+import { assignInlineVars } from "@vanilla-extract/dynamic";
+import merge from "lodash.merge";
+import type { PropsWithChildren } from "react";
+import { useMemo } from "react";
+import { vars } from "../../../shared/styles/theme/contract.css";
+import { rootSelector } from "../../../shared/styles/theme/ids";
+import { lightTheme } from "../../../shared/styles/theme/themes";
+import { getFineryThemeOverrides } from "../../../shared/styles/theme/variant-overrides/finery";
+import { portoThemeOverrides } from "../../../shared/styles/theme/variant-overrides/porto";
+import { utilaThemeOverrides } from "../../../shared/styles/theme/variant-overrides/utila";
+import type { RecursivePartial } from "../../../shared/types/utils";
+import type { WidgetConfig } from "../../config";
+import { useWidgetConfig } from "../../config";
+
+export const getThemeOverrides = ({
+ baseTheme,
+ variant,
+}: {
+ baseTheme: typeof lightTheme;
+ variant: WidgetConfig["variant"];
+}): RecursivePartial => {
+ if (variant === "utila") {
+ return utilaThemeOverrides;
+ }
+
+ if (variant === "finery") {
+ return getFineryThemeOverrides(baseTheme);
+ }
+
+ if (variant === "porto") {
+ return portoThemeOverrides;
+ }
+
+ return {};
+};
+
+export const ThemeWrapper = ({ children }: PropsWithChildren) => {
+ const theme = useWidgetConfig("theme");
+ const variant = useWidgetConfig("variant");
+
+ const finalTheme = useMemo(() => {
+ const baseTheme = merge(structuredClone(lightTheme), theme);
+ const overrides = getThemeOverrides({
+ baseTheme,
+ variant,
+ });
+
+ return merge(structuredClone(lightTheme), theme, overrides);
+ }, [theme, variant]);
+
+ return (
+ <>
+
+ {children}
+ >
+ );
+};
diff --git a/packages/widget/src/app/composition/providers/tracking/index.tsx b/packages/widget/src/app/composition/providers/tracking/index.tsx
new file mode 100644
index 00000000..a36d6884
--- /dev/null
+++ b/packages/widget/src/app/composition/providers/tracking/index.tsx
@@ -0,0 +1,62 @@
+import { RegistryProvider, useAtomSet } from "@effect/atom-react";
+import type { PropsWithChildren } from "react";
+import { useLayoutEffect, useState } from "react";
+import type { TrackingConfig } from "../../../../public-api/types";
+import {
+ normalizeWidgetConfig,
+ type WidgetConfig,
+ widgetConfigAtom,
+} from "../../../config";
+
+type TrackingProviderProps = PropsWithChildren<{
+ readonly tracking: TrackingConfig | undefined;
+ readonly variantTracking?: TrackingConfig;
+}>;
+
+const makeTrackingConfig = ({
+ tracking,
+ variantTracking,
+}: Omit): TrackingConfig => ({
+ trackEvent: (event, properties) => {
+ tracking?.trackEvent?.(event, properties);
+ variantTracking?.trackEvent?.(event, properties);
+ },
+ trackPageView: (page, properties) => {
+ tracking?.trackPageView?.(page, properties);
+ variantTracking?.trackPageView?.(page, properties);
+ },
+});
+
+const TrackingConfigBinding = ({
+ children,
+ settings,
+}: PropsWithChildren<{ readonly settings: WidgetConfig }>) => {
+ const setWidgetConfig = useAtomSet(widgetConfigAtom);
+
+ useLayoutEffect(() => {
+ setWidgetConfig(settings);
+ }, [setWidgetConfig, settings]);
+
+ return children;
+};
+
+export const TrackingContextProvider = ({
+ children,
+ tracking,
+ variantTracking,
+}: TrackingProviderProps) => {
+ const settings = normalizeWidgetConfig({
+ apiKey: "",
+ tracking: makeTrackingConfig({ tracking, variantTracking }),
+ variant: "default",
+ });
+ const [initialSettings] = useState(settings);
+
+ return (
+
+
+ {children}
+
+
+ );
+};
diff --git a/packages/widget/src/app/config/index.ts b/packages/widget/src/app/config/index.ts
new file mode 100644
index 00000000..ec1dd3e5
--- /dev/null
+++ b/packages/widget/src/app/config/index.ts
@@ -0,0 +1,11 @@
+export {
+ normalizeWidgetConfig,
+ type WidgetConfig,
+ widgetConfigAtom,
+ widgetConfigFieldAtom,
+} from "./settings";
+export { useWidgetConfig } from "./use-widget-config";
+export {
+ normalizeWidgetBootstrapConfig,
+ widgetBootstrapConfigAtom,
+} from "./widget-config";
diff --git a/packages/widget/src/app/config/settings.ts b/packages/widget/src/app/config/settings.ts
new file mode 100644
index 00000000..896110a5
--- /dev/null
+++ b/packages/widget/src/app/config/settings.ts
@@ -0,0 +1,96 @@
+import { Record as EffectRecord } from "effect";
+import * as Atom from "effect/unstable/reactivity/Atom";
+import type { PreferredTokenYieldsPerNetwork } from "../../domain/types/stake";
+import { normalizeDashboardYieldCategoryOrder } from "../../domain/types/yields";
+import type { SettingsProps, VariantProps } from "../../public-api/types";
+import { defaultWidgetBootstrapConfig } from "../../services/config/widget-config";
+import { config } from "../../shared/config/widget-defaults";
+import { selectAtom } from "../../shared/effect/select-atom";
+
+type TokenYieldPreferences = Exclude<
+ PreferredTokenYieldsPerNetwork[keyof PreferredTokenYieldsPerNetwork],
+ undefined
+>;
+
+type ResolvedSettingsProps = Omit<
+ SettingsProps,
+ "borrowEnabled" | "dashboardYieldCategoryOrder" | "yieldGrouping"
+> & {
+ readonly borrowEnabled: boolean;
+ readonly dashboardYieldCategoryOrder: NonNullable<
+ SettingsProps["dashboardYieldCategoryOrder"]
+ >;
+ readonly yieldGrouping: NonNullable;
+};
+
+type ZerionChainModal = Extract<
+ VariantProps,
+ { readonly variant: "zerion" }
+>["chainModal"];
+
+export type WidgetConfig = ResolvedSettingsProps & {
+ readonly chainModal?: ZerionChainModal;
+ readonly isLedgerLive: boolean;
+ readonly variant: VariantProps["variant"];
+};
+
+export const normalizeWidgetConfig = (
+ input: SettingsProps & VariantProps,
+ options: { readonly isLedgerLive?: boolean } = {}
+): WidgetConfig => {
+ const preferredTokenYieldsPerNetwork = input.preferredTokenYieldsPerNetwork
+ ? (EffectRecord.map(
+ input.preferredTokenYieldsPerNetwork as Readonly<
+ Record
+ >,
+ (tokenYields) =>
+ EffectRecord.mapKeys(
+ tokenYields as Readonly<
+ Record
+ >,
+ (tokenString) => tokenString.toLowerCase()
+ )
+ ) as PreferredTokenYieldsPerNetwork)
+ : undefined;
+
+ const wagmi =
+ !config.env.isTestMode && input.wagmi?.__customConnectors__
+ ? { ...input.wagmi, __customConnectors__: undefined }
+ : input.wagmi;
+
+ return {
+ ...input,
+ borrowEnabled: input.borrowEnabled ?? false,
+ dashboardYieldCategoryOrder: normalizeDashboardYieldCategoryOrder(
+ input.dashboardYieldCategoryOrder
+ ),
+ isLedgerLive: options.isLedgerLive ?? false,
+ preferredTokenYieldsPerNetwork,
+ wagmi,
+ yieldGrouping:
+ input.yieldGrouping ?? (input.dashboardVariant ? "category" : "flat"),
+ } as WidgetConfig;
+};
+
+const defaultWidgetConfig = normalizeWidgetConfig({
+ apiKey: defaultWidgetBootstrapConfig.api.apiKey,
+ baseUrl: defaultWidgetBootstrapConfig.api.baseUrl,
+ borrowApiUrl: defaultWidgetBootstrapConfig.api.borrowApiUrl,
+ disableInjectedProviderDiscovery:
+ defaultWidgetBootstrapConfig.wallet.disableInjectedProviderDiscovery,
+ variant: "default",
+ yieldsApiUrl: defaultWidgetBootstrapConfig.api.yieldsApiUrl,
+});
+
+export const widgetConfigAtom = Atom.make(
+ defaultWidgetConfig
+).pipe(Atom.keepAlive, Atom.withLabel("widgetConfigAtom"));
+
+const widgetConfigFieldAtomFamily = Atom.family((field: keyof WidgetConfig) =>
+ selectAtom(widgetConfigAtom, (settings) => settings[field])
+);
+
+export const widgetConfigFieldAtom = (
+ field: Field
+): Atom.Atom =>
+ widgetConfigFieldAtomFamily(field) as Atom.Atom;
diff --git a/packages/widget/src/app/config/use-widget-config.ts b/packages/widget/src/app/config/use-widget-config.ts
new file mode 100644
index 00000000..639c4022
--- /dev/null
+++ b/packages/widget/src/app/config/use-widget-config.ts
@@ -0,0 +1,6 @@
+import { useAtomValue } from "@effect/atom-react";
+import { type WidgetConfig, widgetConfigFieldAtom } from "./settings";
+
+export const useWidgetConfig = (
+ field: Field
+): WidgetConfig[Field] => useAtomValue(widgetConfigFieldAtom(field));
diff --git a/packages/widget/src/app/config/widget-config-effects.tsx b/packages/widget/src/app/config/widget-config-effects.tsx
new file mode 100644
index 00000000..84446117
--- /dev/null
+++ b/packages/widget/src/app/config/widget-config-effects.tsx
@@ -0,0 +1,44 @@
+import type { PropsWithChildren } from "react";
+import { useLayoutEffect } from "react";
+import { useTranslation } from "react-i18next";
+import utilaTranslations from "../../translation/English/utila-variant.json";
+import { useWidgetConfig } from "./use-widget-config";
+
+export const WidgetConfigEffects = ({ children }: PropsWithChildren) => {
+ const { i18n } = useTranslation();
+ const customTranslations = useWidgetConfig("customTranslations");
+ const language = useWidgetConfig("language");
+ const variant = useWidgetConfig("variant");
+
+ useLayoutEffect(() => {
+ if (language) {
+ void i18n.changeLanguage(language);
+ }
+ }, [i18n, language]);
+
+ useLayoutEffect(() => {
+ if (variant === "utila") {
+ i18n.addResourceBundle(
+ "en",
+ "translation",
+ utilaTranslations,
+ true,
+ true
+ );
+ }
+
+ if (customTranslations) {
+ Object.entries(customTranslations).forEach(([language, value]) => {
+ i18n.addResourceBundle(
+ language,
+ "translation",
+ value.translation,
+ true,
+ true
+ );
+ });
+ }
+ }, [customTranslations, i18n, variant]);
+
+ return children;
+};
diff --git a/packages/widget/src/app/config/widget-config.ts b/packages/widget/src/app/config/widget-config.ts
new file mode 100644
index 00000000..235a2bfc
--- /dev/null
+++ b/packages/widget/src/app/config/widget-config.ts
@@ -0,0 +1,48 @@
+import * as Atom from "effect/unstable/reactivity/Atom";
+import type { WidgetBootstrapConfigValue } from "../../services/config/widget-config";
+import { config } from "../../shared/config/widget-defaults";
+import { type WidgetConfig, widgetConfigAtom } from "./settings";
+
+export const normalizeWidgetBootstrapConfig = ({
+ isLedgerLive,
+ settings,
+}: {
+ readonly isLedgerLive: boolean;
+ readonly settings: WidgetConfig;
+}): WidgetBootstrapConfigValue => ({
+ api: {
+ apiKey: settings.apiKey,
+ baseUrl: settings.baseUrl ?? config.env.apiUrl,
+ borrowApiUrl: settings.borrowApiUrl ?? config.env.borrowApiUrl,
+ yieldsApiUrl: settings.yieldsApiUrl ?? config.env.yieldsApiUrl,
+ },
+ tracking: {
+ tracking: settings.tracking,
+ variant: settings.variant,
+ },
+ wallet: {
+ chainIconMapping: settings.chainIconMapping,
+ customConnectors: settings.wagmi?.__customConnectors__,
+ disableInjectedProviderDiscovery:
+ !!settings.disableInjectedProviderDiscovery,
+ externalProviderInitToken: settings.externalProviders?.initToken ?? null,
+ forceWalletConnectOnly: !!settings.wagmi?.forceWalletConnectOnly,
+ hasExternalProvider: settings.externalProviders !== undefined,
+ institutionalWallets: !!settings.institutionalWallets,
+ isLedgerLive,
+ isSafe: !!settings.isSafe,
+ mapWalletFn: settings.mapWalletFn,
+ mapWalletListFn: settings.mapWalletListFn,
+ tonConnectManifestUrl: settings.tonConnectManifestUrl,
+ variant: settings.variant,
+ },
+});
+
+export const widgetBootstrapConfigAtom = Atom.make((get) => {
+ const settings = get(widgetConfigAtom);
+
+ return normalizeWidgetBootstrapConfig({
+ isLedgerLive: settings.isLedgerLive,
+ settings,
+ });
+}).pipe(Atom.keepAlive, Atom.withLabel("widgetBootstrapConfigAtom"));
diff --git a/packages/widget/src/app/routes/classic-routes.tsx b/packages/widget/src/app/routes/classic-routes.tsx
new file mode 100644
index 00000000..394a5872
--- /dev/null
+++ b/packages/widget/src/app/routes/classic-routes.tsx
@@ -0,0 +1,232 @@
+import { useAtomValue } from "@effect/atom-react";
+import { AnimatePresence, LayoutGroup, motion } from "motion/react";
+import { useEffect } from "react";
+import { Navigate, Route, Routes, useNavigate } from "react-router";
+import { activityTransactionWorkflowKeyAtom } from "../../features/activity/state/selection";
+import { AnimatedActivityPage } from "../../features/activity/ui";
+import { AnimatedEarnPage } from "../../features/earn/ui";
+import { initParamsAtom } from "../../features/init-params";
+import { AnimatedPositionsPage } from "../../features/portfolio/ui";
+import { ClassicPositionDetailsPage } from "../../features/position-details/ui";
+import { enterTransactionWorkflowKeyAtom } from "../../features/transaction-flow/state/enter-request";
+import { exitTransactionWorkflowKeyAtom } from "../../features/transaction-flow/state/exit-request";
+import { pendingTransactionWorkflowKeyAtom } from "../../features/transaction-flow/state/pending-action-request";
+import {
+ ActionReviewPage,
+ ActivityCompletePage,
+ ActivityStepsPage,
+ PendingCompletePage,
+ PendingReviewPage,
+ PendingStepsPage,
+ StakeCompletePage,
+ StakeReviewPage,
+ StakeStepsPage,
+ UnstakeCompletePage,
+ UnstakeReviewPage,
+ UnstakeStepsPage,
+} from "../../features/transaction-flow/ui";
+import { useSKWallet } from "../../features/wallet";
+import {
+ AnimationLayout,
+ ClassicLayout,
+ container,
+ Header,
+ PoweredBy,
+} from "../../features/widget-shell";
+import {
+ Details,
+ GlobalModals,
+ headerContainer,
+ UnderMaintenance,
+ useUnderMaintenance,
+} from "../../features/widget-shell/screens";
+import { useSKLocation } from "../../shared/react/location-history";
+import { useDetailsMatch } from "../../shared/react/navigation/use-details-match";
+import { usePrevious } from "../../shared/react/use-previous";
+import { useSavedRef } from "../../shared/react/use-saved-ref";
+import { ClassicTransactionWorkflowGuard } from "./guards/classic-transaction-workflow";
+import { ConnectedCheck } from "./guards/connected-wallet";
+import { useHandleDeepLinks } from "./hooks/use-handle-deep-links";
+
+export const ClassicRoutes = () => {
+ const underMaintenance = useUnderMaintenance();
+
+ const { chain, address } = useSKWallet();
+
+ const prevChain = usePrevious(chain);
+ const prevAddress = usePrevious(address);
+
+ const { current } = useSKLocation();
+
+ const pathnameRef = useSavedRef(current.pathname);
+ const navigateRef = useSavedRef(useNavigate());
+
+ /**
+ * On chain change, navigate to home page
+ */
+ useEffect(() => {
+ if (
+ pathnameRef.current !== "/" &&
+ pathnameRef.current !== "/positions" &&
+ pathnameRef.current !== "/activity" &&
+ ((prevChain && chain !== prevChain) ||
+ (prevAddress && address !== prevAddress))
+ ) {
+ const url = new URL(window.location.href);
+ const newUrl = new URL(window.location.origin);
+ if (url.searchParams.has("embed")) {
+ newUrl.searchParams.set("embed", "true");
+ }
+
+ window.history.pushState({}, window.document.title, newUrl.href);
+ navigateRef.current("/", { replace: true });
+ }
+ }, [chain, address, pathnameRef, navigateRef, prevChain, prevAddress]);
+
+ const initParams = useAtomValue(initParamsAtom);
+ const initTab = initParams?.tab;
+
+ useEffect(() => {
+ if (!initTab) return;
+
+ navigateRef.current(initTab === "earn" ? "/" : "/positions");
+ }, [initTab, navigateRef]);
+
+ useHandleDeepLinks();
+
+ const detailsMatch = useDetailsMatch();
+
+ /**
+ * Dont unmount details page with tabs
+ * Handle position details pages in their own Routes
+ */
+ const key = detailsMatch ? "/" : current.key;
+
+ if (underMaintenance) return ;
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ }
+ >
+ {/* Home + Tabs */}
+ }>
+ } />
+ }
+ />
+ } />
+
+
+ }>
+ {/* Activity flow */}
+
+ } />
+
+ }
+ >
+ }
+ />
+ }
+ />
+
+
+
+ {/* Stake flow */}
+
+ } />
+
+ }
+ >
+ } />
+ }
+ />
+
+
+
+ {/* Unstake or pending actions flow */}
+
+ } />
+ }
+ />
+
+ {/* Unstaking */}
+
+ } />
+
+ }
+ >
+ } />
+ }
+ />
+
+
+
+ {/* Pending Actions */}
+
+ } />
+
+ }
+ >
+ } />
+ }
+ />
+
+
+
+
+
+ } />
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/packages/widget/src/app/routes/dashboard-overview.css.ts b/packages/widget/src/app/routes/dashboard-overview.css.ts
new file mode 100644
index 00000000..b602f759
--- /dev/null
+++ b/packages/widget/src/app/routes/dashboard-overview.css.ts
@@ -0,0 +1,12 @@
+import { style } from "@vanilla-extract/css";
+
+export const overviewPageContainer = style({
+ maxWidth: "380px",
+});
+
+export const earnDetailsWrapper = style({
+ alignSelf: "stretch",
+ minHeight: "620px",
+ minWidth: 0,
+ position: "relative",
+});
diff --git a/packages/widget/src/app/routes/dashboard-overview.tsx b/packages/widget/src/app/routes/dashboard-overview.tsx
new file mode 100644
index 00000000..ee97acec
--- /dev/null
+++ b/packages/widget/src/app/routes/dashboard-overview.tsx
@@ -0,0 +1,41 @@
+import { Outlet } from "react-router";
+import { EarnDetails } from "../../features/earn/ui";
+import {
+ AnimationPage,
+ BackButtonProvider,
+ TabPageContainer,
+ VerticalDivider,
+} from "../../features/widget-shell";
+import { Box } from "../../shared/ui/primitives/box";
+import {
+ earnDetailsWrapper,
+ overviewPageContainer,
+} from "./dashboard-overview.css";
+
+export const DashboardOverview = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/packages/widget/src/app/routes/dashboard-routes.tsx b/packages/widget/src/app/routes/dashboard-routes.tsx
new file mode 100644
index 00000000..c24a7c3a
--- /dev/null
+++ b/packages/widget/src/app/routes/dashboard-routes.tsx
@@ -0,0 +1,211 @@
+import { Navigate, Route, Routes } from "react-router";
+import { activityTransactionWorkflowKeyAtom } from "../../features/activity/state/selection";
+import { ActivityTabPage } from "../../features/activity/ui";
+import {
+ BorrowCompletePage,
+ BorrowConnectedWalletRoute,
+ BorrowFormPage,
+ BorrowLayout,
+ BorrowPositionActionPage,
+ BorrowPositionActionsPage,
+ BorrowPositionDetailsPage,
+ BorrowReviewPage,
+ BorrowStepsPage,
+ useBorrowFeatureEnabled,
+} from "../../features/borrow/ui-entry";
+import { EarnPageContent, EarnPageModelBinding } from "../../features/earn/ui";
+import { ManagePage } from "../../features/portfolio/ui";
+import {
+ DashboardPositionDetailsPage,
+ PositionDetailsActions,
+ PositionDetailsStakeActions,
+} from "../../features/position-details/ui";
+import { enterTransactionWorkflowKeyAtom } from "../../features/transaction-flow/state/enter-request";
+import { exitTransactionWorkflowKeyAtom } from "../../features/transaction-flow/state/exit-request";
+import { pendingTransactionWorkflowKeyAtom } from "../../features/transaction-flow/state/pending-action-request";
+// import { RewardsTabPage } from "../../domain/types/rewards";
+import {
+ ActivityDetailsPage,
+ ActivityStepsPage,
+ PendingCompletePage,
+ PendingReviewPage,
+ PendingStepsPage,
+ StakeCompletePage,
+ StakeReviewPage,
+ StakeStepsPage,
+ UnstakeCompletePage,
+ UnstakeReviewPage,
+ UnstakeStepsPage,
+} from "../../features/transaction-flow/ui";
+import { GlobalModals } from "../../features/widget-shell/screens";
+import { useSKLocation } from "../../shared/react/location-history";
+import { DashboardOverview } from "./dashboard-overview";
+import { DashboardShell } from "./dashboard-shell";
+import { ClassicTransactionWorkflowGuard } from "./guards/classic-transaction-workflow";
+import { ConnectedCheck } from "./guards/connected-wallet";
+
+const positionDetailsStakeFooterPath =
+ /^\/positions\/[^/]+\/[^/]+(?:\/stake)?$/;
+
+export const shouldRegisterDashboardEarnFooterButton = (pathname: string) =>
+ pathname === "/" || positionDetailsStakeFooterPath.test(pathname);
+
+export const DashboardRoutes = () => {
+ const { current } = useSKLocation();
+ const borrowFeatureEnabled = useBorrowFeatureEnabled();
+ const registerEarnFooterButton = shouldRegisterDashboardEarnFooterButton(
+ current.pathname
+ );
+ const borrowRoutes = borrowFeatureEnabled ? (
+ <>
+ }>
+ } />
+ }>
+ } />
+ } />
+ } />
+
+
+ }>
+ }
+ >
+ } />
+ }
+ />
+ } />
+ } />
+ } />
+
+
+ >
+ ) : (
+ <>
+ } />
+ }
+ />
+ >
+ );
+
+ return (
+ <>
+
+
+ }>
+ {/* Earn Tab */}
+ }>
+ } />
+
+ }>
+ } />
+
+ }
+ >
+ } />
+ } />
+
+
+
+
+ {/* Manage Tab */}
+ } />
+
+ {/* Borrow Tab */}
+ {borrowRoutes}
+
+ {/* Position Details */}
+ }
+ >
+ } />
+
+ {/* Staking */}
+
+ } />
+ } />
+
+ }
+ >
+ } />
+ } />
+
+
+
+ }
+ />
+
+ {/* Unstaking */}
+
+ } />
+ } />
+
+ }
+ >
+ } />
+ } />
+
+
+
+ {/* Pending Actions */}
+
+ } />
+
+ }
+ >
+ } />
+ } />
+
+
+
+
+ {/* Rewards Tab */}
+ {/* } /> */}
+
+ {/* Activity Tab */}
+ }>
+ } />
+
+ }
+ >
+ }
+ />
+
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/packages/widget/src/app/routes/dashboard-shell.tsx b/packages/widget/src/app/routes/dashboard-shell.tsx
new file mode 100644
index 00000000..eabf4d4f
--- /dev/null
+++ b/packages/widget/src/app/routes/dashboard-shell.tsx
@@ -0,0 +1,35 @@
+import { Outlet } from "react-router";
+import {
+ DashboardHeader,
+ dashboardOutletWrapper,
+ dashboardWrapper,
+ PoweredBy,
+} from "../../features/widget-shell";
+import { combineRecipeWithVariant } from "../../shared/styles/recipe-variant";
+import { Box } from "../../shared/ui/primitives/box";
+import { useWidgetConfig } from "../config";
+import { DashboardTabs } from "./dashboard-tabs";
+
+export const DashboardShell = () => {
+ const variant = useWidgetConfig("variant");
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/widget/src/app/routes/dashboard-tabs/index.tsx b/packages/widget/src/app/routes/dashboard-tabs/index.tsx
new file mode 100644
index 00000000..dfea713b
--- /dev/null
+++ b/packages/widget/src/app/routes/dashboard-tabs/index.tsx
@@ -0,0 +1,134 @@
+import { Match } from "effect";
+import { startsWith } from "effect/String";
+import { useNavigate } from "react-router";
+import type { DashboardYieldCategory } from "../../../domain/types/yields";
+import { isBorrowFeatureEnabled } from "../../../features/borrow/availability";
+import { useEarnPageModel } from "../../../features/earn/support";
+import { useTrackEvent } from "../../../features/tracking";
+import { Divider } from "../../../features/widget-shell";
+import { useSKLocation } from "../../../shared/react/location-history";
+import { combineRecipeWithVariant } from "../../../shared/styles/recipe-variant";
+import { Box } from "../../../shared/ui/primitives/box";
+import { useWidgetConfig } from "../../config";
+import {
+ divider,
+ tabsContainer,
+ tabsGroupDivider,
+ tabsWrapper,
+} from "./styles.css";
+import { DashboardTab } from "./tab";
+
+type RouteTab = "earn" | "borrow" | "manage" | "activity";
+
+const tabsMap: Record = {
+ activity: "/activity",
+ borrow: "/borrow",
+ earn: "/",
+ manage: "/manage",
+};
+
+export const DashboardTabs = () => {
+ const trackEvent = useTrackEvent();
+ const navigate = useNavigate();
+ const {
+ availableDashboardYieldCategories,
+ onDashboardYieldCategorySelect,
+ selectedDashboardYieldCategory,
+ } = useEarnPageModel();
+ const { current } = useSKLocation();
+
+ const selectedTab = Match.value(current.pathname).pipe(
+ Match.when(startsWith("/activity"), () => "activity" as const),
+ Match.when(startsWith("/borrow"), () => "borrow" as const),
+ Match.whenOr(
+ startsWith("/manage"),
+ startsWith("/positions"),
+ () => "manage" as const
+ ),
+ Match.orElse(() => "earn" as const)
+ );
+
+ const onRouteTabPress = (selected: RouteTab) => {
+ if (selectedTab === selected) return;
+ trackEvent("tabClicked", { selected });
+ navigate(tabsMap[selected]);
+ };
+
+ const onYieldCategoryPress = (category: DashboardYieldCategory) => {
+ if (selectedTab === "earn" && selectedDashboardYieldCategory === category) {
+ return;
+ }
+
+ trackEvent("tabClicked", { selected: category });
+ onDashboardYieldCategorySelect(category);
+ navigate("/");
+ };
+
+ const borrowEnabled = useWidgetConfig("borrowEnabled");
+ const dashboardVariant = useWidgetConfig("dashboardVariant");
+ const variant = useWidgetConfig("variant");
+ const yieldGrouping = useWidgetConfig("yieldGrouping");
+ const categoryGroupingEnabled = yieldGrouping === "category";
+ const borrowFeatureEnabled = isBorrowFeatureEnabled({
+ borrowEnabled,
+ dashboardVariant,
+ yieldGrouping,
+ });
+ const showGroupDivider =
+ categoryGroupingEnabled &&
+ (availableDashboardYieldCategories.length > 0 || borrowFeatureEnabled);
+
+ return (
+
+
+ {categoryGroupingEnabled ? (
+ availableDashboardYieldCategories.map((category) => (
+ onYieldCategoryPress(category)}
+ variant={category}
+ />
+ ))
+ ) : (
+ onRouteTabPress("earn")}
+ variant="earn"
+ />
+ )}
+
+ {borrowFeatureEnabled ? (
+ onRouteTabPress("borrow")}
+ variant="borrow"
+ />
+ ) : null}
+
+ {showGroupDivider ? : null}
+
+ onRouteTabPress("manage")}
+ variant="manage"
+ />
+ onRouteTabPress("activity")}
+ variant="activity"
+ />
+
+
+
+
+
+
+ );
+};
diff --git a/packages/widget/src/app/routes/dashboard-tabs/styles.css.ts b/packages/widget/src/app/routes/dashboard-tabs/styles.css.ts
new file mode 100644
index 00000000..3b2dc0d8
--- /dev/null
+++ b/packages/widget/src/app/routes/dashboard-tabs/styles.css.ts
@@ -0,0 +1,129 @@
+import { style } from "@vanilla-extract/css";
+import { recipe } from "@vanilla-extract/recipes";
+import { atoms } from "../../../shared/styles/theme/atoms.css";
+import { vars } from "../../../shared/styles/theme/contract.css";
+
+export const divider = style({
+ bottom: 0,
+ position: "absolute",
+ width: "100%",
+});
+
+export const tabsGroupDivider = style([
+ atoms({ background: "tabBorder", mx: "2" }),
+ { height: "24px", width: "1px" },
+]);
+
+export const tab = recipe({
+ base: {
+ alignItems: "center",
+ cursor: "pointer",
+ display: "flex",
+ flex: 1,
+ justifyContent: "center",
+ userSelect: "none",
+ },
+ variants: {
+ variant: {
+ default: { borderRadius: "8px", padding: "8px 16px" },
+ utila: { borderRadius: "8px", padding: "8px 16px" },
+ porto: { borderRadius: "8px", padding: "8px 16px" },
+ },
+ state: { active: {} },
+ },
+ compoundVariants: [
+ {
+ variants: { state: "active", variant: "default" },
+ style: [atoms({ background: "stakeSectionBackground" })],
+ },
+ {
+ variants: { state: "active", variant: "utila" },
+ style: [atoms({ background: "stakeSectionBackground" })],
+ },
+ {
+ variants: { state: "active", variant: "porto" },
+ style: [atoms({ background: "white" })],
+ },
+ ],
+});
+
+export const tabBorder = style([
+ atoms({
+ background: "tabBorder",
+ borderRadius: "full",
+ position: "absolute",
+ }),
+ { bottom: 0, height: "2.5px", left: 0, right: 0 },
+]);
+
+export const tabContainer = recipe({
+ base: { display: "flex", flexDirection: "column", position: "relative" },
+ variants: {
+ variant: {
+ default: {},
+ utila: {},
+ porto: {},
+ finery: { height: "80px" },
+ },
+ },
+ defaultVariants: { variant: "default" },
+});
+
+export const tabText = recipe({
+ base: {
+ fontSize: "16px",
+ fontWeight: "500",
+ lineHeight: "125%",
+ textAlign: "center",
+ },
+ variants: {
+ state: { selected: {} },
+ variant: {
+ default: [atoms({ fontWeight: "semibold" }), { fontSize: "14px" }],
+ utila: [atoms({ fontWeight: "semibold" }), { fontSize: "14px" }],
+ porto: [
+ atoms({ color: "textMuted", fontWeight: "semibold" }),
+ { fontSize: "14px" },
+ ],
+ },
+ },
+ compoundVariants: [
+ {
+ variants: { state: "selected", variant: "porto" },
+ style: { color: vars.color.background },
+ },
+ ],
+ defaultVariants: { variant: "default" },
+});
+
+export const tabsContainer = recipe({
+ base: [
+ atoms({
+ alignItems: "center",
+ display: "flex",
+ justifyContent: "center",
+ zIndex: "simple",
+ }),
+ { gap: "24px" },
+ ],
+ variants: {
+ variant: {
+ default: { gap: "4px", padding: "8px 24px" },
+ utila: { gap: "4px", padding: "8px 24px" },
+ porto: { gap: "4px", padding: "16px 24px" },
+ },
+ },
+ defaultVariants: { variant: "default" },
+});
+
+export const tabsWrapper = recipe({
+ base: { display: "flex", position: "relative" },
+ variants: {
+ variant: {
+ default: { justifyContent: "flex-start" },
+ utila: { justifyContent: "flex-start" },
+ porto: { justifyContent: "flex-start" },
+ },
+ },
+ defaultVariants: { variant: "default" },
+});
diff --git a/packages/widget/src/app/routes/dashboard-tabs/tab.tsx b/packages/widget/src/app/routes/dashboard-tabs/tab.tsx
new file mode 100644
index 00000000..98ecb163
--- /dev/null
+++ b/packages/widget/src/app/routes/dashboard-tabs/tab.tsx
@@ -0,0 +1,63 @@
+import clsx from "clsx";
+import { useTranslation } from "react-i18next";
+import { combineRecipeWithVariant } from "../../../shared/styles/recipe-variant";
+import { Box } from "../../../shared/ui/primitives/box";
+import { pressAnimation } from "../../../shared/ui/primitives/button/styles.css";
+import { Text } from "../../../shared/ui/primitives/typography/text";
+import { useWidgetConfig } from "../../config";
+import { tab, tabBorder, tabContainer, tabText } from "./styles.css";
+
+type Props = {
+ readonly isSelected: boolean;
+ readonly onTabPress: () => void;
+ readonly variant:
+ | "earn"
+ | "stake"
+ | "defi"
+ | "rwa"
+ | "borrow"
+ | "manage"
+ | "activity";
+};
+
+export const DashboardTab = ({ isSelected, variant, onTabPress }: Props) => {
+ const { t } = useTranslation();
+ const appVariant = useWidgetConfig("variant");
+
+ return (
+
+
+
+ {t(`dashboard.details.tabs.${variant}`, variant)}
+
+
+
+ {isSelected && appVariant === "finery" ? (
+
+ ) : null}
+
+ );
+};
diff --git a/packages/widget/src/app/routes/guards/classic-transaction-workflow.tsx b/packages/widget/src/app/routes/guards/classic-transaction-workflow.tsx
new file mode 100644
index 00000000..4312aaac
--- /dev/null
+++ b/packages/widget/src/app/routes/guards/classic-transaction-workflow.tsx
@@ -0,0 +1,21 @@
+import { useAtomValue } from "@effect/atom-react";
+import type * as Atom from "effect/unstable/reactivity/Atom";
+import { Navigate, Outlet } from "react-router";
+import { ClassicTransactionWorkflowContext } from "../../../features/transaction-flow/react/classic-transaction-workflow-context";
+import type { ClassicTransactionWorkflowKey } from "../../../services/workflow/transaction-workflow-model";
+
+export const ClassicTransactionWorkflowGuard = ({
+ workflowKeyAtom,
+}: {
+ readonly workflowKeyAtom: Atom.Atom;
+}) => {
+ const workflowKey = useAtomValue(workflowKeyAtom);
+
+ if (!workflowKey) return ;
+
+ return (
+
+
+
+ );
+};
diff --git a/packages/widget/src/app/routes/guards/connected-wallet.tsx b/packages/widget/src/app/routes/guards/connected-wallet.tsx
new file mode 100644
index 00000000..717ebb36
--- /dev/null
+++ b/packages/widget/src/app/routes/guards/connected-wallet.tsx
@@ -0,0 +1,8 @@
+import { Navigate, Outlet } from "react-router";
+import { useSKWallet } from "../../../features/wallet";
+
+export const ConnectedCheck = () => {
+ const { isConnected } = useSKWallet();
+
+ return isConnected ? : ;
+};
diff --git a/packages/widget/src/app/routes/hooks/use-handle-deep-links.ts b/packages/widget/src/app/routes/hooks/use-handle-deep-links.ts
new file mode 100644
index 00000000..d7e7248c
--- /dev/null
+++ b/packages/widget/src/app/routes/hooks/use-handle-deep-links.ts
@@ -0,0 +1,81 @@
+import { useAtomValue } from "@effect/atom-react";
+import { useEffect } from "react";
+import { useNavigate } from "react-router";
+import { getProvidersDetails } from "../../../features/earn";
+import { usePendingActionDeepLink } from "../../../features/earn/support";
+import { initParamsAtom } from "../../../features/init-params";
+import { useMountAnimation } from "../../../features/mount-animation";
+import { useSetPendingActionRequest } from "../../../features/transaction-flow";
+import { useSKWallet } from "../../../features/wallet";
+import { useSavedRef } from "../../../shared/react/use-saved-ref";
+
+export const useHandleDeepLinks = () => {
+ const pendingActionDeepLinkCheck = usePendingActionDeepLink();
+ const navigateRef = useSavedRef(useNavigate());
+ const setPendingActionRequest = useSetPendingActionRequest();
+ const initQueryParams = useAtomValue(initParamsAtom);
+
+ const { mountAnimationFinished } = useMountAnimation();
+ const { isConnected } = useSKWallet();
+
+ const appReady = mountAnimationFinished && isConnected;
+
+ // Position details page
+ useEffect(() => {
+ if (
+ initQueryParams?.yieldId &&
+ initQueryParams.balanceId &&
+ !initQueryParams.pendingaction &&
+ appReady
+ ) {
+ navigateRef.current(
+ `positions/${initQueryParams.yieldId}/${initQueryParams.balanceId}`
+ );
+ }
+ }, [initQueryParams, navigateRef, appReady]);
+
+ // Select validators on position details page
+ useEffect(() => {
+ const data = pendingActionDeepLinkCheck.data;
+
+ if (appReady && data?.type === "positionDetails") {
+ navigateRef.current(
+ `positions/${data.yieldOp.id}/${data.balanceId}/select-validator/${data.pendingAction.type}`
+ );
+ }
+ }, [navigateRef, pendingActionDeepLinkCheck.data, appReady]);
+
+ // Review pending action
+ useEffect(() => {
+ const data = pendingActionDeepLinkCheck.data;
+ if (appReady && data?.type === "review") {
+ setPendingActionRequest({
+ actionDto: null,
+ requestDto: data.pendingActionDto.requestDto,
+ addresses: {
+ address: data.pendingActionDto.address,
+ additionalAddresses: data.pendingActionDto.additionalAddresses,
+ },
+ gasFeeToken: data.pendingActionDto.gasFeeToken,
+ integrationData: data.pendingActionDto.integrationData,
+ interactedToken: data.balance.token,
+ pendingActionType: data.pendingActionDto.requestDto.action,
+ providersDetails:
+ getProvidersDetails({
+ integrationData: data.pendingActionDto.integrationData,
+ validators: [],
+ yields: null,
+ selectedProviderYieldId: null,
+ }) ?? [],
+ });
+ navigateRef.current(
+ `positions/${data.yieldOp.id}/${data.balanceId}/pending-action/review`
+ );
+ }
+ }, [
+ setPendingActionRequest,
+ pendingActionDeepLinkCheck.data,
+ appReady,
+ navigateRef,
+ ]);
+};
diff --git a/packages/widget/src/app/routes/index.ts b/packages/widget/src/app/routes/index.ts
new file mode 100644
index 00000000..e811b590
--- /dev/null
+++ b/packages/widget/src/app/routes/index.ts
@@ -0,0 +1,5 @@
+export { ClassicRoutes } from "./classic-routes";
+export {
+ DashboardRoutes,
+ shouldRegisterDashboardEarnFooterButton,
+} from "./dashboard-routes";
diff --git a/packages/widget/src/app/runtime/app-runtime.ts b/packages/widget/src/app/runtime/app-runtime.ts
new file mode 100644
index 00000000..43a907c0
--- /dev/null
+++ b/packages/widget/src/app/runtime/app-runtime.ts
@@ -0,0 +1,59 @@
+import { Layer } from "effect";
+import * as Atom from "effect/unstable/reactivity/Atom";
+import {
+ BorrowApiService,
+ LegacyApiService,
+ YieldApiService,
+} from "../../services/api";
+import { ApiTransportService } from "../../services/api/transport";
+import {
+ WidgetBootstrapConfig,
+ type WidgetBootstrapConfigValue,
+} from "../../services/config/widget-config";
+import { RichErrorService } from "../../services/errors/rich-error-service";
+import { WidgetPersistence } from "../../services/persistence/widget-persistence";
+import { TrackingService } from "../../services/tracking/tracking-service";
+import { WalletService } from "../../services/wallet/wallet-service";
+import { TransactionWorkflowOperationsService } from "../../services/workflow/transaction-workflow-operations-service";
+import { TransactionWorkflowService } from "../../services/workflow/transaction-workflow-service";
+import { widgetBootstrapConfigAtom } from "../config";
+
+const makeAppLayer = (config: WidgetBootstrapConfigValue) => {
+ const configurationLayer = WidgetBootstrapConfig.layer(config);
+ const richErrorLayer = RichErrorService.layer;
+ const apiTransportLayer = ApiTransportService.layer.pipe(
+ Layer.provide(richErrorLayer),
+ Layer.provide(configurationLayer)
+ );
+ const apiLayer = Layer.mergeAll(
+ BorrowApiService.layer,
+ LegacyApiService.layer,
+ YieldApiService.layer
+ ).pipe(Layer.provide(apiTransportLayer));
+ const persistenceLayer = WidgetPersistence.layer;
+ const trackingLayer = TrackingService.layer.pipe(
+ Layer.provide(configurationLayer)
+ );
+ const walletLayer = WalletService.layer.pipe(Layer.provide(persistenceLayer));
+ const transactionWorkflowLayer = TransactionWorkflowService.layer.pipe(
+ Layer.provide(
+ TransactionWorkflowOperationsService.layer.pipe(
+ Layer.provide(Layer.mergeAll(apiLayer, trackingLayer, walletLayer))
+ )
+ )
+ );
+
+ return Layer.mergeAll(
+ configurationLayer,
+ richErrorLayer,
+ apiLayer,
+ persistenceLayer,
+ trackingLayer,
+ walletLayer,
+ transactionWorkflowLayer
+ ).pipe(Layer.provide(configurationLayer), Layer.fresh);
+};
+
+export const appRuntime = Atom.runtime((get) =>
+ makeAppLayer(get(widgetBootstrapConfigAtom))
+);
diff --git a/packages/widget/src/app/runtime/index.ts b/packages/widget/src/app/runtime/index.ts
new file mode 100644
index 00000000..dc6dbe0d
--- /dev/null
+++ b/packages/widget/src/app/runtime/index.ts
@@ -0,0 +1,3 @@
+export { widgetBootstrapConfigAtom } from "../config";
+export { appRuntime } from "./app-runtime";
+export * from "./root-inputs";
diff --git a/packages/widget/src/app/runtime/root-inputs.ts b/packages/widget/src/app/runtime/root-inputs.ts
new file mode 100644
index 00000000..0266a788
--- /dev/null
+++ b/packages/widget/src/app/runtime/root-inputs.ts
@@ -0,0 +1,92 @@
+import type { Wallet as SolanaWallet } from "@solana/wallet-adapter-react";
+import type { Connection } from "@solana/web3.js";
+import { Equal } from "effect";
+import * as Atom from "effect/unstable/reactivity/Atom";
+import type { SupportedSKChainIds } from "../../domain/types/chains";
+import type { SKExternalProviders } from "../../public-api/types";
+
+export type DynamicExternalProviderInput = {
+ readonly currentAddress: string;
+ readonly currentChain: SupportedSKChainIds | null;
+ readonly provider: SKExternalProviders["provider"];
+ readonly supportedChainIds: ReadonlyArray | null;
+ readonly type: "generic";
+} | null;
+
+export type SolanaWalletInput = {
+ readonly connection: Connection | null;
+ readonly wallets: ReadonlyArray;
+};
+
+type InputEquality = (first: A, second: A) => boolean;
+
+const makeRootInputAtom = (
+ initialValue: A,
+ label: string,
+ equals: InputEquality = Equal.equals
+) => {
+ const state = Atom.make(initialValue);
+ const equalWritable = Atom.writable(
+ (get) => get(state),
+ (context, next) => {
+ if (!equals(context.get(state), next)) context.set(state, next);
+ }
+ );
+
+ return Object.assign(equalWritable.pipe(Atom.withLabel(label)), {
+ initialValueTarget: state,
+ });
+};
+
+const solanaWalletInputEquals: InputEquality = (
+ first,
+ second
+) =>
+ first.connection === second.connection &&
+ first.wallets.length === second.wallets.length &&
+ first.wallets.every((wallet, index) => wallet === second.wallets[index]);
+
+export const defaultDynamicExternalProviderInput: DynamicExternalProviderInput =
+ null;
+
+export const defaultSolanaWalletInput: SolanaWalletInput = {
+ connection: null,
+ wallets: [],
+};
+
+export const dynamicExternalProviderInputAtom =
+ makeRootInputAtom(
+ defaultDynamicExternalProviderInput,
+ "dynamicExternalProviderInputAtom"
+ );
+
+export const solanaWalletInputAtom = makeRootInputAtom(
+ defaultSolanaWalletInput,
+ "solanaWalletInputAtom",
+ solanaWalletInputEquals
+);
+
+export const normalizeDynamicExternalProviderInput = (
+ externalProviders: SKExternalProviders | undefined
+): DynamicExternalProviderInput =>
+ externalProviders
+ ? {
+ currentAddress: externalProviders.currentAddress,
+ currentChain: externalProviders.currentChain ?? null,
+ provider: externalProviders.provider,
+ supportedChainIds: externalProviders.supportedChainIds
+ ? [...new Set(externalProviders.supportedChainIds)].sort(
+ (first, second) => first - second
+ )
+ : null,
+ type: externalProviders.type,
+ }
+ : null;
+
+export const normalizeSolanaWalletInput = ({
+ connection,
+ wallets,
+}: SolanaWalletInput): SolanaWalletInput => ({
+ connection,
+ wallets: [...wallets],
+});
diff --git a/packages/widget/src/assets/images/index.ts b/packages/widget/src/assets/images/index.ts
deleted file mode 100644
index 2334a5ac..00000000
--- a/packages/widget/src/assets/images/index.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { config } from "../../config";
-import { MaybeDocument } from "../../utils/maybe-document";
-
-const getImage = (name: string) => `${config.assetsUrl}/widget/${name}`;
-
-export const images = {
- bitget: getImage("bitget.png"),
- fees: getImage("fees.png"),
- ledgerLogo: getImage("ledger-logo.svg"),
- poweredBy: getImage("powered-by.png"),
- wcLogo: getImage("wc-logo.svg"),
- whatIsLiquidStaking: getImage("what-is-liquid-staking.png"),
-};
-
-export const preloadImages = () =>
- MaybeDocument.map(() =>
- Object.values(images).forEach((src) => {
- const img = new Image();
- img.src = src;
- })
- );
diff --git a/packages/widget/src/common/check-gas-amount.ts b/packages/widget/src/common/check-gas-amount.ts
deleted file mode 100644
index c620a372..00000000
--- a/packages/widget/src/common/check-gas-amount.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import BigNumber from "bignumber.js";
-import { EitherAsync, Left, List, Maybe, Right } from "purify-ts";
-import { equalTokens } from "../domain";
-import type { ActionDtoWithGasEstimate } from "../domain/types/action";
-import type { AddressWithTokenDto } from "../domain/types/addresses";
-import type { TokenDto } from "../domain/types/tokens";
-import type { ApiClient } from "../providers/api/api-client";
-
-type CheckGasAmountIfStake =
- | { isStake: true; stakeToken: TokenDto; stakeAmount: BigNumber }
- | { isStake: false };
-
-export const checkGasAmount = ({
- addressWithTokenDto,
- apiClient,
- gasEstimate,
- ...rest
-}: {
- addressWithTokenDto: AddressWithTokenDto;
- apiClient: ApiClient;
- gasEstimate: ActionDtoWithGasEstimate["gasEstimate"];
-} & CheckGasAmountIfStake) =>
- EitherAsync.liftEither(
- Maybe.fromNullable(gasEstimate).toEither(new GasTokenMissingError())
- ).chain((gasEstimate) =>
- EitherAsync(() =>
- apiClient.legacy.TokenControllerGetTokenBalances({
- payload: { addresses: [addressWithTokenDto] },
- })
- )
- .mapLeft(() => new GetGasTokenError())
- .chain((res) =>
- EitherAsync.liftEither(
- List.head(res)
- .map((val) => ({
- ...val,
- amount: new BigNumber(val.amount ?? 0),
- }))
- .toEither(new GasTokenMissingError())
- ).chain(async (gasTokenBalance) => {
- const amount =
- rest.isStake && equalTokens(gasTokenBalance.token, rest.stakeToken)
- ? gasTokenBalance.amount.minus(rest.stakeAmount)
- : gasTokenBalance.amount;
-
- if (gasEstimate.amount.isGreaterThan(amount)) {
- return Left(new NotEnoughGasTokenError());
- }
-
- return Right(null);
- })
- )
- .chainLeft(async (e) => Right(e))
- );
-
-export class NotEnoughGasTokenError extends Error {
- constructor() {
- super("Not enough gas token");
- }
-}
-
-export class GasTokenMissingError extends Error {
- constructor() {
- super("Gas token missing from response");
- }
-}
-
-class GetGasTokenError extends Error {
- constructor() {
- super("Get gas token failed");
- }
-}
diff --git a/packages/widget/src/common/delay-api-requests.ts b/packages/widget/src/common/delay-api-requests.ts
deleted file mode 100644
index 0b570222..00000000
--- a/packages/widget/src/common/delay-api-requests.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { config } from "../config";
-
-const delayMap = new Map, Record>();
-const subs = new Map<() => void, () => void>();
-
-const subscribe = (fn: () => void) => {
- subs.set(fn, fn);
-
- return () => {
- subs.delete(fn);
- };
-};
-
-const notify = () => {
- subs.forEach((fn) => fn());
-};
-
-const checkDelay = () => {
- if (delayMap.size === 0) return Promise.resolve();
-
- let unsub: () => void;
-
- return new Promise((res) => {
- unsub = subscribe(() => res(null));
- }).then(() => unsub());
-};
-
-export const waitForDelayedApiRequests = () => checkDelay();
-
-/**
- *
- * Delay API requests; E.g. until the animation is finished
- */
-export const delayAPIRequests = () => {
- if (config.env.isTestMode) return () => {};
-
- const key = {};
- delayMap.set(key, key);
-
- return () => {
- delayMap.delete(key);
-
- if (delayMap.size === 0) {
- notify();
- }
- };
-};
diff --git a/packages/widget/src/common/get-enabled-networks.ts b/packages/widget/src/common/get-enabled-networks.ts
deleted file mode 100644
index 088ed424..00000000
--- a/packages/widget/src/common/get-enabled-networks.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import type { QueryClient } from "@tanstack/react-query";
-import { EitherAsync } from "purify-ts";
-import { config } from "../config";
-import type { Networks } from "../domain/types/chains/networks";
-import type { ApiClient } from "../providers/api/api-client";
-
-export const getEnabledNetworks = ({
- apiClient,
- queryClient,
-}: {
- apiClient: ApiClient;
- queryClient: QueryClient;
-}) =>
- EitherAsync(() =>
- queryClient.fetchQuery({
- staleTime: Number.POSITIVE_INFINITY,
- queryKey: [config.appPrefix, "enabled-networks"],
- queryFn: async () =>
- new Set(
- (await apiClient.legacy.YieldControllerGetMyNetworks(undefined)).map(
- (network) => network as Networks
- )
- ),
- })
- ).mapLeft(() => new Error("Could not get enabled networks"));
diff --git a/packages/widget/src/common/get-token-balances.ts b/packages/widget/src/common/get-token-balances.ts
deleted file mode 100644
index f526b639..00000000
--- a/packages/widget/src/common/get-token-balances.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import type { QueryClient } from "@tanstack/react-query";
-import { EitherAsync, Right } from "purify-ts";
-import type { SKWallet } from "../domain/types/wallet";
-import type { DashboardYieldCategory } from "../domain/types/yields";
-import {
- getDefaultTokens,
- getYieldTypesForDashboardCategory,
-} from "../hooks/api/use-default-tokens";
-import { getTokenBalancesScan } from "../hooks/api/use-token-balances-scan";
-import type { ApiClient } from "../providers/api/api-client";
-import type { SettingsProps } from "../providers/settings/types";
-
-export const getTokenBalances = ({
- additionalAddresses,
- address,
- apiClient,
- network,
- selectedDashboardYieldCategory,
- queryClient,
- tokensForEnabledYieldsOnly,
-}: {
- additionalAddresses: SKWallet["additionalAddresses"];
- address: SKWallet["address"];
- apiClient: ApiClient;
- queryClient: QueryClient;
- network: SKWallet["network"];
- selectedDashboardYieldCategory?: DashboardYieldCategory | null;
- tokensForEnabledYieldsOnly: SettingsProps["tokensForEnabledYieldsOnly"];
-}) =>
- EitherAsync.fromPromise(() =>
- Promise.all([
- getDefaultTokens({
- apiClient,
- queryClient,
- network: network ?? undefined,
- enabledYieldsOnly: tokensForEnabledYieldsOnly,
- yieldTypes: getYieldTypesForDashboardCategory(
- selectedDashboardYieldCategory
- ),
- }),
- EitherAsync.liftEither(
- Right({ additionalAddresses, address, network })
- ).chain(async (params) => {
- if (!params.address || !params.network) {
- return Right([]);
- }
-
- return getTokenBalancesScan({
- apiClient,
- queryClient,
- tokenBalanceScanDto: {
- addresses: {
- address: params.address,
- additionalAddresses: params.additionalAddresses ?? undefined,
- },
- network: params.network,
- },
- }).mapLeft(() => new Error("could not get token balances scan"));
- }),
- ]).then(([defaultTokens, tokenBalances]) =>
- defaultTokens.chain((d) =>
- tokenBalances.map((b) => ({
- defaultTokens: d,
- tokenBalancesScan: b,
- }))
- )
- )
- ).mapLeft(() => new Error("could not get tokens"));
diff --git a/packages/widget/src/components/atoms/collapsible/index.tsx b/packages/widget/src/components/atoms/collapsible/index.tsx
deleted file mode 100644
index 7bbd5926..00000000
--- a/packages/widget/src/components/atoms/collapsible/index.tsx
+++ /dev/null
@@ -1,149 +0,0 @@
-import clsx from "clsx";
-import type { Variants } from "motion/react";
-import { AnimatePresence, motion, useAnimate, usePresence } from "motion/react";
-import type { PropsWithChildren } from "react";
-import { createContext, useContext, useEffect, useMemo, useState } from "react";
-import { useFirstMountState } from "../../../hooks/use-first-mount-state";
-import { useDisableTransitionDuration } from "../../../navigation/containers/animation-layout";
-import type { BoxProps } from "../box";
-import { Box } from "../box";
-import { CaretDownIcon } from "../icons/caret-down";
-import { caretContainer, rotate180deg, triggerContainer } from "./styles.css";
-
-type State = { collapsed: boolean; onClick: () => void; initial?: never };
-
-type Props = PropsWithChildren<
- State | { initial?: boolean; collapsed?: never; onClick?: never }
->;
-
-const CollapsibleContext = createContext(undefined);
-
-const useCollapsible = () => {
- const value = useContext(CollapsibleContext);
-
- if (!value) {
- throw new Error("useCollapsible must be used within a CollapsibleRoot");
- }
-
- return value;
-};
-
-export const CollapsibleRoot = ({ children, ...controlledProps }: Props) => {
- const internalState = useState(controlledProps.initial ?? true);
-
- const value = useMemo(
- () =>
- controlledProps.onClick
- ? {
- collapsed: controlledProps.collapsed,
- onClick: controlledProps.onClick,
- }
- : {
- collapsed: internalState[0],
- onClick: () => internalState[1]((prev) => !prev),
- },
- [controlledProps.onClick, controlledProps.collapsed, internalState]
- );
-
- return (
-
- {children}
-
- );
-};
-
-export const CollapsibleTrigger = ({
- children,
- ...rest
-}: PropsWithChildren) => {
- const { onClick } = useCollapsible();
-
- return (
-
- {children}
-
- );
-};
-
-export const CollapsibleArrow = () => {
- const { collapsed } = useCollapsible();
-
- return (
-
-
-
- );
-};
-
-export const CollapsibleContent = ({ children }: PropsWithChildren) => {
- const { collapsed } = useCollapsible();
-
- const isFirstMount = useFirstMountState();
-
- return (
-
- {!collapsed && (
- {children}
- )}
-
- );
-};
-
-const variants = {
- open: { opacity: 1, height: "auto" },
- closed: { opacity: 0, height: 0 },
-} satisfies Variants;
-
-const AnimateContent = ({
- isFirstMount,
- children,
-}: PropsWithChildren<{ isFirstMount: boolean }>) => {
- const [isPresent, safeToRemove] = usePresence();
- const [scope, animate] = useAnimate();
-
- const [, setDisableTransitionDuration] = useDisableTransitionDuration();
-
- useEffect(() => {
- if (isPresent) {
- const enterAnimation = async () => {
- setDisableTransitionDuration(true);
- await animate(scope.current, variants.open);
- setDisableTransitionDuration(false);
- };
-
- enterAnimation();
- } else {
- const exitAnimation = async () => {
- setDisableTransitionDuration(true);
- await animate(scope.current, variants.closed);
- setDisableTransitionDuration(false);
- safeToRemove();
- };
-
- exitAnimation();
- }
- }, [animate, isPresent, safeToRemove, scope, setDisableTransitionDuration]);
-
- return (
-
- {children}
-
- );
-};
diff --git a/packages/widget/src/components/atoms/content-loader/index.tsx b/packages/widget/src/components/atoms/content-loader/index.tsx
deleted file mode 100644
index 2933eeb1..00000000
--- a/packages/widget/src/components/atoms/content-loader/index.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import Skeleton from "react-loading-skeleton";
-import "react-loading-skeleton/dist/skeleton.css";
-import type { ComponentProps } from "react";
-import { vars } from "../../../styles/theme/contract.css";
-
-export const ContentLoaderSquare = ({
- heightPx,
- variant,
- containerClassName,
-}: {
- heightPx: number;
- variant?: { size?: "regular" | "medium" };
- containerClassName?: ComponentProps["containerClassName"];
-}) => {
- return (
-
- );
-};
diff --git a/packages/widget/src/components/atoms/copy-text/index.tsx b/packages/widget/src/components/atoms/copy-text/index.tsx
deleted file mode 100644
index 0704a0b4..00000000
--- a/packages/widget/src/components/atoms/copy-text/index.tsx
+++ /dev/null
@@ -1,147 +0,0 @@
-import { AnimatePresence, motion } from "motion/react";
-import type { ReactNode } from "react";
-import {
- createContext,
- useCallback,
- useContext,
- useEffect,
- useMemo,
- useState,
-} from "react";
-import { MaybeWindow } from "../../../utils/maybe-window";
-import { Box } from "../box";
-import { Check } from "../icons/check";
-import { Copy } from "../icons/copy";
-import { container } from "./styles.css";
-
-type CopyTextContextType = {
- showCopySuccess: boolean;
- onClick: (e: React.MouseEvent) => void;
-};
-
-const CopyTextContext = createContext(
- undefined
-);
-
-const useCopyText = () => {
- const context = useContext(CopyTextContext);
-
- if (!context) {
- throw new Error("useCopyText must be used within a CopyTextProvider");
- }
-
- return context;
-};
-
-export const Provider = ({
- text,
- children,
-}: {
- text: string;
- children: ReactNode;
-}) => {
- const [showCopySuccess, setShowCopySuccess] = useState(false);
-
- const onClick = useCallback(
- (e: React.MouseEvent) => {
- e.stopPropagation();
- MaybeWindow.ifJust((w) => w.navigator.clipboard.writeText(text));
- setShowCopySuccess(true);
- },
- [text]
- );
-
- useEffect(() => {
- if (!showCopySuccess) return;
-
- const timeout = setTimeout(() => setShowCopySuccess(false), 1000);
-
- return () => {
- clearTimeout(timeout);
- };
- }, [showCopySuccess]);
-
- const value = useMemo(
- () => ({ showCopySuccess, onClick }),
- [onClick, showCopySuccess]
- );
-
- return (
-
- {children}
-
- );
-};
-
-export const Root = ({
- children,
-}: {
- children:
- | ReactNode
- | ((args: Pick) => ReactNode);
-}) => {
- const { onClick, showCopySuccess } = useCopyText();
-
- return (
-
- {typeof children === "function"
- ? children({ showCopySuccess })
- : children}
-
- );
-};
-
-export const AnimatedContent = ({
- children,
- className,
-}: {
- children: ReactNode;
- className?: string;
-}) => {
- const { showCopySuccess, onClick } = useCopyText();
-
- return (
-
-
- {/* Workaround to animate with previous state */}
-
- {children}
-
-
-
- );
-};
-
-export const Icons = ({
- hw,
- className,
-}: {
- hw?: number;
- className?: string;
-}) => {
- const { showCopySuccess } = useCopyText();
-
- return showCopySuccess ? (
-
- ) : (
-
- );
-};
diff --git a/packages/widget/src/components/atoms/divider/index.tsx b/packages/widget/src/components/atoms/divider/index.tsx
deleted file mode 100644
index 9edd2807..00000000
--- a/packages/widget/src/components/atoms/divider/index.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { useSettings } from "../../../providers/settings";
-import { combineRecipeWithVariant } from "../../../utils/styles";
-import type { BoxProps } from "../box";
-import { Box } from "../box";
-import { divider } from "./styles.css";
-
-type Props = BoxProps;
-
-export const Divider = (props: Props) => {
- const { variant } = useSettings();
-
- return (
-
- );
-};
diff --git a/packages/widget/src/components/atoms/divider/styles.css.ts b/packages/widget/src/components/atoms/divider/styles.css.ts
deleted file mode 100644
index 1da03a88..00000000
--- a/packages/widget/src/components/atoms/divider/styles.css.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { recipe } from "@vanilla-extract/recipes";
-import { atoms } from "../../../styles/theme/atoms.css";
-
-export const divider = recipe({
- base: atoms({
- height: "px",
- width: "full",
- }),
- variants: {
- variant: {
- default: atoms({ background: "tabBorder" }),
- utila: atoms({ background: "tabBorder" }),
- },
- },
- defaultVariants: {
- variant: "default",
- },
-});
diff --git a/packages/widget/src/components/atoms/dropdown/index.tsx b/packages/widget/src/components/atoms/dropdown/index.tsx
deleted file mode 100644
index 0e7b185d..00000000
--- a/packages/widget/src/components/atoms/dropdown/index.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
-import { Fragment } from "react";
-import { useSettings } from "../../../providers/settings";
-import { id } from "../../../styles/theme/ids";
-import { Box } from "../box";
-import { CaretDownIcon } from "../icons/caret-down";
-import { Text } from "../typography/text";
-import {
- dropdownContent,
- dropdownGroup,
- dropdownItem,
- separator,
- trigger,
-} from "./styles.css";
-
-interface DropdownProps {
- options: T[];
- onSelect: (option: T["value"]) => void;
- selectedOption: T | undefined;
- placeholder: string;
- isError?: boolean;
-}
-
-export function Dropdown({
- options,
- onSelect,
- selectedOption,
- placeholder,
- isError,
-}: DropdownProps) {
- const { portalContainer } = useSettings();
-
- return (
-
-
-
- {selectedOption?.label ?? placeholder}
-
-
-
-
-
-
-
-
-
- {options.map((option, i) => (
-
-
- {option.label}
-
-
- {i < options.length - 1 && (
-
- )}
-
- ))}
-
-
-
-
-
- );
-}
diff --git a/packages/widget/src/components/atoms/dropdown/styles.css.ts b/packages/widget/src/components/atoms/dropdown/styles.css.ts
deleted file mode 100644
index 514f97df..00000000
--- a/packages/widget/src/components/atoms/dropdown/styles.css.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { style } from "@vanilla-extract/css";
-import { atoms } from "../../../styles/theme/atoms.css";
-import { vars } from "../../../styles/theme/contract.css";
-
-export const trigger = style([
- atoms({
- background: "dropdownBackground",
- px: "3",
- py: "2",
- }),
- { borderRadius: "6px" },
-]);
-
-export const dropdownContent = style([
- { borderRadius: "6px", overflow: "hidden" },
- atoms({
- background: "dropdownBackground",
- }),
-]);
-
-export const dropdownGroup = style([
- {
- display: "flex",
- flexDirection: "column",
- },
-]);
-
-export const dropdownItem = style([
- {
- outline: "none",
- userSelect: "none",
- cursor: "pointer",
- overflow: "hidden",
- },
- atoms({
- px: "5",
- py: "2",
- display: "flex",
- alignItems: "center",
- }),
- {
- selectors: {
- '&[data-state="checked"]': {
- background: vars.color.tokenSelectHoverBackground,
- },
- },
- ":hover": {
- background: vars.color.tokenSelectHoverBackground,
- },
- },
-]);
-
-export const separator = style([
- { height: "1px" },
- atoms({ background: "background" }),
-]);
diff --git a/packages/widget/src/components/atoms/max-button/index.tsx b/packages/widget/src/components/atoms/max-button/index.tsx
deleted file mode 100644
index a65cf552..00000000
--- a/packages/widget/src/components/atoms/max-button/index.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import clsx from "clsx";
-import type { PropsWithChildren } from "react";
-import { useTranslation } from "react-i18next";
-import { useSettings } from "../../../providers/settings";
-import { combineRecipeWithVariant } from "../../../utils/styles";
-import { Box, type BoxProps } from "../box";
-import { pressAnimation } from "../button/styles.css";
-import { Text } from "../typography/text";
-import { container, text } from "./styles.css";
-
-type MaxButtonProps = PropsWithChildren<{
- onMaxClick: () => void;
-}> &
- BoxProps;
-
-export const MaxButton = ({
- onMaxClick,
- className,
- ...rest
-}: MaxButtonProps) => {
- const { t } = useTranslation();
-
- const { variant } = useSettings();
-
- return (
-
-
- {t("shared.max")}
-
-
- );
-};
diff --git a/packages/widget/src/components/atoms/max-button/styles.css.ts b/packages/widget/src/components/atoms/max-button/styles.css.ts
deleted file mode 100644
index 30eb950c..00000000
--- a/packages/widget/src/components/atoms/max-button/styles.css.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { recipe } from "@vanilla-extract/recipes";
-import { atoms } from "../../../styles/theme/atoms.css";
-
-export const container = recipe({
- base: [
- atoms({
- px: "2",
- py: "1",
- marginLeft: "2",
- }),
- ],
- variants: {
- variant: {
- default: atoms({
- background: "smallLightButtonBackground",
- borderRadius: "base",
- }),
- utila: [
- { borderRadius: "4px" },
- atoms({ background: "smallLightButtonBackground" }),
- ],
- },
- },
- defaultVariants: {
- variant: "default",
- },
-});
-
-export const text = recipe({
- variants: {
- variant: {
- default: atoms({
- color: "smallLightButtonColor",
- fontWeight: "normal",
- }),
- utila: [
- atoms({ color: "smallLightButtonColor" }),
- atoms({
- fontWeight: "normal",
- }),
- ],
- },
- },
- defaultVariants: {
- variant: "default",
- },
-});
diff --git a/packages/widget/src/components/atoms/number-input/index.tsx b/packages/widget/src/components/atoms/number-input/index.tsx
deleted file mode 100644
index 6a020462..00000000
--- a/packages/widget/src/components/atoms/number-input/index.tsx
+++ /dev/null
@@ -1,137 +0,0 @@
-import BigNumber from "bignumber.js";
-import { motion, useAnimation } from "motion/react";
-import type { ChangeEvent } from "react";
-import { memo, useEffect, useRef, useState } from "react";
-import { createPortal } from "react-dom";
-import { useRootElement } from "../../../providers/root-element";
-import { formatNumber } from "../../../utils";
-import { Box } from "../box";
-import { container, numberInput, spanStyle } from "./styles.css";
-import { useAutoResizeText } from "./use-auto-resize-text";
-
-export type NumberInputProps = {
- onChange: (value: BigNumber) => void;
- onBlur?: () => void;
- value: BigNumber;
- disabled?: boolean;
- isInvalid?: boolean;
- shakeOnInvalid?: boolean;
-};
-
-export const NumberInput = memo(
- ({
- onChange,
- value,
- disabled,
- onBlur,
- isInvalid,
- shakeOnInvalid,
- }: NumberInputProps) => {
- const [localState, setLocalState] = useState("0");
- const [isFocused, setIsFocused] = useState(false);
-
- useEffect(() => {
- if (isFocused) return;
-
- setLocalState((prevState) => {
- if (value.isEqualTo(stringToBigNumber(prevState))) return prevState;
-
- return formatNumber(value);
- });
- }, [value, isFocused]);
-
- const _onChange = (e: ChangeEvent) => {
- const val = e.target.value;
-
- if (!e.target.validity.valid) return;
-
- setLocalState(val);
-
- if (!val) return onChange(new BigNumber(0));
-
- const value = stringToBigNumber(val);
-
- if (value.isNaN()) return;
-
- onChange(value);
- };
-
- const isZero = localState === "0" || localState === "";
-
- const inputRef = useRef(null);
- const spanRef = useRef(null);
-
- useAutoResizeText({
- inputRef,
- spanRef,
- inputVal: localState,
- });
-
- const rootElement = useRootElement();
-
- const animate = useAnimation();
-
- useEffect(() => {
- if (!shakeOnInvalid || !isInvalid) return;
-
- animate.start("shake");
- }, [animate, isInvalid, shakeOnInvalid]);
-
- return (
-
- {
- setIsFocused(false);
- if (isZero) setLocalState("0");
- onBlur?.();
- }}
- onFocus={() => {
- setIsFocused(true);
- if (isZero) setLocalState("");
- }}
- />
- {rootElement &&
- createPortal(
-
- {localState}
- ,
- rootElement
- )}
-
- );
- }
-);
-
-const stringToBigNumber = (str: string) => {
- const normalizedValue = /^\d{1,3}(,\d{3})+(\.\d+)?$/.test(str)
- ? str.replace(/,/g, "")
- : str.replace(/,/g, ".");
-
- return new BigNumber(normalizedValue);
-};
diff --git a/packages/widget/src/components/atoms/number-input/styles.css.ts b/packages/widget/src/components/atoms/number-input/styles.css.ts
deleted file mode 100644
index 17e9f72f..00000000
--- a/packages/widget/src/components/atoms/number-input/styles.css.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { style } from "@vanilla-extract/css";
-import { atoms } from "../../../styles/theme/atoms.css";
-import { vars } from "../../../styles/theme/contract.css";
-
-export const initialFontSizeVar = vars.fontSize["3xl"];
-
-const fontStyles = style([
- { fontSize: initialFontSizeVar },
- atoms({ fontWeight: "normal" }),
-]);
-
-export const numberInput = style([
- fontStyles,
- atoms({
- flex: 1,
- minWidth: "0",
- color: "text",
- }),
- {
- background: "none",
- border: "none",
- outline: "none",
- paddingLeft: 0,
- },
-]);
-
-export const spanStyle = style([
- fontStyles,
- {
- position: "absolute",
- visibility: "hidden",
- right: "100%",
- },
-]);
-
-export const container = style({
- display: "flex",
- minWidth: 0,
- flex: 1,
-});
diff --git a/packages/widget/src/components/atoms/number-input/use-auto-resize-text.ts b/packages/widget/src/components/atoms/number-input/use-auto-resize-text.ts
deleted file mode 100644
index e0cc8f0f..00000000
--- a/packages/widget/src/components/atoms/number-input/use-auto-resize-text.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-import type { RefObject } from "react";
-import { useEffect } from "react";
-import { usePrevious } from "../../../hooks/use-previous";
-import { useSettings } from "../../../providers/settings";
-import { vars } from "../../../styles/theme/contract.css";
-import { MaybeDocument } from "../../../utils/maybe-document";
-import { MaybeWindow } from "../../../utils/maybe-window";
-import { initialFontSizeVar } from "./styles.css";
-
-export const useAutoResizeText = ({
- inputVal,
- inputRef,
- spanRef,
-}: {
- inputVal: string;
- inputRef: RefObject;
- spanRef: RefObject;
-}) => {
- const prevVal = usePrevious(inputVal);
-
- const { disableResizingInputFontSize } = useSettings();
-
- useEffect(() => {
- if (
- !inputRef.current ||
- !spanRef.current ||
- !prevVal ||
- inputVal.length === prevVal.length ||
- disableResizingInputFontSize
- ) {
- return;
- }
-
- const newFontSize = scale({
- inputEl: inputRef.current,
- spanEl: spanRef.current,
- });
-
- inputRef.current.style.fontSize = `${newFontSize}px`;
- spanRef.current.style.fontSize = `${newFontSize}px`;
- }, [inputRef, inputVal, prevVal, spanRef, disableResizingInputFontSize]);
-};
-
-const scale = ({
- inputEl,
- spanEl,
-}: {
- inputEl: HTMLInputElement;
- spanEl: HTMLSpanElement;
-}) =>
- MaybeWindow.map((w) => {
- const currentInputWidth = inputEl.offsetWidth;
-
- const descendingFontSizes = getDescendingFontSizes(inputEl);
-
- let currentFontSize = Number.parseFloat(
- w.getComputedStyle(spanEl).fontSize
- );
-
- for (const fs of descendingFontSizes) {
- spanEl.style.fontSize = `${fs}px`;
- if (spanEl.offsetWidth < currentInputWidth) {
- currentFontSize = fs;
- break;
- }
- }
-
- return currentFontSize;
- }).extractNullable();
-
-const convertRemToPixels = (rem: number) =>
- rem *
- Number.parseFloat(
- MaybeDocument.map(
- (doc) => getComputedStyle(doc.documentElement).fontSize
- ).orDefault("0")
- );
-
-const getDescendingFontSizes = (el: HTMLElement) =>
- MaybeWindow.map((w) =>
- [
- initialFontSizeVar,
- vars.fontSize["2xl"],
- vars.fontSize.xl,
- vars.fontSize.lgx,
- vars.fontSize.lg,
- ].map((fs) =>
- convertRemToPixels(
- Number.parseFloat(
- w
- .getComputedStyle(el)
- .getPropertyValue(fs.replace(/(var\()|(\))/g, ""))
- )
- )
- )
- ).orDefault([]);
diff --git a/packages/widget/src/components/atoms/select-modal/index.tsx b/packages/widget/src/components/atoms/select-modal/index.tsx
deleted file mode 100644
index 8487f9db..00000000
--- a/packages/widget/src/components/atoms/select-modal/index.tsx
+++ /dev/null
@@ -1,268 +0,0 @@
-import { Content, Overlay, Portal, Root, Title } from "@radix-ui/react-dialog";
-import { Root as VisuallyHiddenRoot } from "@radix-ui/react-visually-hidden";
-import clsx from "clsx";
-import type { ChangeEvent, PropsWithChildren, ReactNode } from "react";
-import { createContext, useContext, useEffect, useMemo, useState } from "react";
-import { useSavedRef } from "../../../hooks/use-saved-ref";
-import { SettingsContext, useSettings } from "../../../providers/settings";
-import { id } from "../../../styles/theme/ids";
-import { combineRecipeWithVariant } from "../../../utils/styles";
-import { Box } from "../box";
-import { SearchIcon } from "../icons/search";
-import { XIcon } from "../icons/x-icon";
-import { ListItem } from "../list/list-item";
-import type { ItemContainerVariants } from "../list/styles.css";
-import { Spinner } from "../spinner";
-import { Text } from "../typography/text";
-import {
- container,
- content,
- noOutline,
- overlay,
- selectedListItem,
- selectModalItemContainer,
-} from "./styles.css";
-
-type SelectModalWithoutStateProps = PropsWithChildren<
- {
- title?: string;
- inputPlaceholder?: string;
- trigger?: ReactNode;
- onClose?: () => void;
- onOpen?: () => void;
- searchValue?: string;
- isLoading?: boolean;
- errorMessage?: string;
- disableClose?: boolean;
- hideTopBar?: boolean;
- } & (
- | {
- onSearch: (value: string) => void;
- searchValue: string;
- }
- | {
- onSearch?: never;
- searchValue?: never;
- }
- )
->;
-
-type SelectModalContextType = {
- isOpen: boolean;
- setOpen: (val: boolean) => void;
-};
-
-export type SelectModalProps = SelectModalWithoutStateProps & {
- state?: SelectModalContextType;
- portalContainer?: HTMLElement;
-};
-
-const SelectModalContext = createContext(
- undefined
-);
-
-const useSelectModalContext = () => {
- const value = useContext(SelectModalContext);
-
- if (!value) {
- throw new Error("SelectModalContext is not provided");
- }
-
- return value;
-};
-
-const SelectModalWithoutState = ({
- children,
- trigger,
- title,
- onSearch,
- searchValue,
- inputPlaceholder,
- onClose,
- onOpen,
- isLoading,
- errorMessage,
- disableClose,
- hideTopBar,
- portalContainer,
-}: SelectModalProps) => {
- const { isOpen, setOpen } = useSelectModalContext();
-
- const onCloseRef = useSavedRef(onClose);
- const onOpenRef = useSavedRef(onOpen);
-
- useEffect(() => {
- if (!isOpen) {
- onCloseRef.current?.();
- } else {
- onOpenRef.current?.();
- }
- }, [isOpen, onCloseRef, onOpenRef]);
-
- const showTopBar = !!title || !hideTopBar || onSearch;
-
- return (
-
- {trigger}
-
-
-
- setOpen(false)} className={overlay} />
-
-
-
- {showTopBar && (
-
-
- {title ? (
-
-
- {title}
-
-
- ) : (
-
- Selection Modal
-
- )}
-
- {isLoading && }
-
- {!disableClose && (
- setOpen(false)}>
-
-
- )}
-
- )}
-
- {onSearch && (
-
-
-
-
- ) =>
- onSearch(e.target.value)
- }
- />
-
- )}
-
- {!!errorMessage && (
-
- {errorMessage}
-
- )}
-
- {children}
-
-
-
-
-
- );
-};
-
-export const SelectModal = ({ state, ...props }: SelectModalProps) => {
- const [isOpen, setOpen] = useState(false);
-
- const value = useMemo(
- () =>
- state ?? {
- isOpen,
- setOpen: (val) => setOpen(val),
- },
- [isOpen, state]
- );
-
- return (
-
-
-
- );
-};
-
-export const SelectModalItemContainer = ({ children }: PropsWithChildren) => (
-
- {children}
-
-);
-
-export const SelectModalItem = ({
- children,
- onItemClick,
- testId,
- variant,
- className,
- selected,
-}: PropsWithChildren<{
- onItemClick?: (args: { closeModal: () => void }) => void;
- testId?: string;
- variant?: ItemContainerVariants;
- className?: string;
- selected?: boolean;
-}>) => {
- const { setOpen } = useSelectModalContext();
- const { variant: themeVariant } = useSettings();
-
- const onClick = () => onItemClick?.({ closeModal: () => setOpen(false) });
-
- return (
-
- {children}
-
- );
-};
diff --git a/packages/widget/src/components/atoms/select-modal/styles.css.ts b/packages/widget/src/components/atoms/select-modal/styles.css.ts
deleted file mode 100644
index 9d26c0fb..00000000
--- a/packages/widget/src/components/atoms/select-modal/styles.css.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import { keyframes, style } from "@vanilla-extract/css";
-import { recipe } from "@vanilla-extract/recipes";
-import { atoms } from "../../../styles/theme/atoms.css";
-import { vars } from "../../../styles/theme/contract.css";
-import { breakpoints, minMediaQuery } from "../../../styles/tokens/breakpoints";
-
-const slideUp = keyframes({
- "0%": { transform: "translateY(20%)" },
- "100%": { transform: "translateY(0)" },
-});
-
-const fadeIn = keyframes({
- "0%": { opacity: 0 },
- "100%": { opacity: 1 },
-});
-
-export const container = style({
- zIndex: 20,
- display: "flex",
- justifyContent: "center",
- alignItems: "center",
- position: "fixed",
- top: 0,
- bottom: 0,
- left: 0,
- right: 0,
-});
-
-export const overlay = style([
- {
- animation: `${fadeIn} 150ms ease`,
- willChange: "opacity",
- position: "absolute",
- inset: 0,
- background: vars.color.modalOverlayBackground,
- },
-]);
-
-export const content = style([
- {
- "@media": {
- [minMediaQuery("tablet")]: {
- width: "420px",
- },
- },
- animation: `${slideUp} 350ms cubic-bezier(.15,1.15,0.6,1.00), ${fadeIn} 150ms ease`,
- willChange: "transform, opacity",
- },
- atoms({
- width: "full",
- py: "4",
- background: "modalBodyBackground",
- }),
- {
- borderTopLeftRadius: vars.borderRadius.baseContract["2xl"],
- borderTopRightRadius: vars.borderRadius.baseContract["2xl"],
-
- scrollbarWidth: "none",
- "::-webkit-scrollbar": {
- display: "none",
- },
-
- maxWidth: `${breakpoints.tablet}px`,
- position: "absolute",
- overflow: "scroll",
- bottom: 0,
- "@media": {
- [minMediaQuery("tablet")]: {
- bottom: "auto",
- borderBottomLeftRadius: vars.borderRadius.baseContract["2xl"],
- borderBottomRightRadius: vars.borderRadius.baseContract["2xl"],
- },
- },
- },
-]);
-
-export const noOutline = style({ outline: "none" });
-
-export const selectModalItemContainer = style({
- padding: "2.5px 0",
-});
-
-export const selectModalGroupLabel = style({
- textTransform: "uppercase",
- letterSpacing: "0.06em",
-});
-
-export const selectedListItem = recipe({
- base: [
- {
- background: `color-mix(in srgb, ${vars.color.accent} 8%, transparent)`,
- border: `1px solid ${vars.color.accent}`,
- selectors: {
- "&:hover": {
- background: `color-mix(in srgb, ${vars.color.accent} 8%, transparent)`,
- },
- },
- },
- ],
- variants: {
- variant: {
- default: {},
- utila: {},
- },
- },
- defaultVariants: {
- variant: "default",
- },
-});
diff --git a/packages/widget/src/components/atoms/token-icon/index.tsx b/packages/widget/src/components/atoms/token-icon/index.tsx
deleted file mode 100644
index acc183a5..00000000
--- a/packages/widget/src/components/atoms/token-icon/index.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import type { TokenDto, YieldTokenDto } from "../../../domain/types/tokens";
-import type { YieldMetadata } from "../../../domain/types/yields";
-import { useSettings } from "../../../providers/settings";
-import type { Atoms } from "../../../styles/theme/atoms.css";
-import { NetworkLogoImage } from "./network-icon-image";
-import { TokenIconContainer } from "./token-icon-container";
-import { TokenIconImage } from "./token-icon-image";
-
-export const TokenIcon = ({
- token,
- metadata,
- tokenLogoHw,
- tokenNetworkLogoHw,
- hideNetwork,
-}: {
- token: TokenDto | YieldTokenDto;
- metadata?: Pick;
- tokenLogoHw?: Atoms["hw"];
- tokenNetworkLogoHw?: Atoms["hw"];
- hideNetwork?: boolean;
-}) => {
- const { hideNetworkLogo } = useSettings();
-
- return (
-
- {({ fallbackUrl, mainUrl, name, networkLogoUri }) => (
- <>
-
- {!hideNetwork && !hideNetworkLogo && (
-
- )}
- >
- )}
-
- );
-};
diff --git a/packages/widget/src/components/atoms/token-icon/network-icon-image/index.tsx b/packages/widget/src/components/atoms/token-icon/network-icon-image/index.tsx
deleted file mode 100644
index 3f4946bf..00000000
--- a/packages/widget/src/components/atoms/token-icon/network-icon-image/index.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import type { Atoms } from "../../../../styles/theme/atoms.css";
-import { Box } from "../../box";
-import { Image } from "../../image";
-import { logoContainer, logoImage } from "./style.css";
-
-type NetworkLogoImageProps = {
- networkLogoUri: string;
- networkName?: string;
- tokenNetworkLogoHw?: Atoms["hw"];
-};
-
-export const NetworkLogoImage = ({
- networkLogoUri,
- networkName,
- tokenNetworkLogoHw = "3",
-}: NetworkLogoImageProps) => (
-
-
-
-);
diff --git a/packages/widget/src/components/atoms/token-icon/provider-icon/index.tsx b/packages/widget/src/components/atoms/token-icon/provider-icon/index.tsx
deleted file mode 100644
index 81996499..00000000
--- a/packages/widget/src/components/atoms/token-icon/provider-icon/index.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import type { TokenDto } from "../../../../domain/types/tokens";
-import type { YieldMetadata } from "../../../../domain/types/yields";
-import type { Atoms } from "../../../../styles/theme/atoms.css";
-import { NetworkLogoImage } from "../network-icon-image";
-import { TokenIconContainer } from "../token-icon-container";
-import { TokenIconImage } from "../token-icon-image";
-
-export const ProviderIcon = ({
- token,
- metadata,
- tokenLogoHw,
- tokenNetworkLogoHw,
- hideNetwork,
-}: {
- token: TokenDto;
- metadata?: Pick;
- tokenLogoHw?: Atoms["hw"];
- tokenNetworkLogoHw?: Atoms["hw"];
- hideNetwork?: boolean;
-}) => {
- return (
-
- {({ fallbackUrl, mainUrl, name, providerIcon }) => (
- <>
-
- {!hideNetwork && providerIcon && (
-
- )}
- >
- )}
-
- );
-};
diff --git a/packages/widget/src/components/atoms/token-icon/token-icon-container/hooks/use-variant-network-urls.ts b/packages/widget/src/components/atoms/token-icon/token-icon-container/hooks/use-variant-network-urls.ts
deleted file mode 100644
index af8359ce..00000000
--- a/packages/widget/src/components/atoms/token-icon/token-icon-container/hooks/use-variant-network-urls.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { Maybe } from "purify-ts";
-import { useMemo } from "react";
-import type { SupportedSKChains } from "../../../../../domain/types/chains";
-import type { Networks } from "../../../../../domain/types/chains/networks";
-import { useSettings } from "../../../../../providers/settings";
-import type { SettingsProps } from "../../../../../providers/settings/types";
-import { getNetworkLogo } from "../../../../../utils";
-
-export const getVariantNetworkUrl = ({
- chainIconMapping,
- network,
-}: {
- network: Networks;
- chainIconMapping: SettingsProps["chainIconMapping"];
-}) => {
- const chainMappingResult = Maybe.fromNullable(chainIconMapping)
- .chainNullable((mapping) => {
- if (typeof mapping === "function") {
- return mapping(network as SupportedSKChains);
- }
-
- return mapping[network as SupportedSKChains];
- })
- .extractNullable();
-
- if (chainMappingResult) {
- return chainMappingResult;
- }
-
- return getNetworkLogo(network);
-};
-
-export const useVariantNetworkUrls = (network: Networks) => {
- const { chainIconMapping } = useSettings();
-
- return useMemo(
- () => getVariantNetworkUrl({ chainIconMapping, network }),
- [chainIconMapping, network]
- );
-};
diff --git a/packages/widget/src/components/atoms/token-icon/token-icon-container/hooks/use-variant-token-urls.ts b/packages/widget/src/components/atoms/token-icon/token-icon-container/hooks/use-variant-token-urls.ts
deleted file mode 100644
index a577a47a..00000000
--- a/packages/widget/src/components/atoms/token-icon/token-icon-container/hooks/use-variant-token-urls.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-import { Maybe } from "purify-ts";
-import { useMemo } from "react";
-import { config } from "../../../../../config";
-import type {
- TokenDto,
- YieldTokenDto,
-} from "../../../../../domain/types/tokens";
-import type { YieldMetadata } from "../../../../../domain/types/yields";
-import { useSettings } from "../../../../../providers/settings";
-
-export const useVariantTokenUrls = (
- token: TokenDto | YieldTokenDto,
- metadata?: Pick
-): {
- mainUrl: string | undefined;
- fallbackUrl: string | undefined;
- name: string;
- providerIcon: string | undefined;
-} => {
- const { variant, tokenIconMapping } = useSettings();
-
- return useMemo(() => {
- if (metadata) {
- const mainUrl = Maybe.fromFalsy(variant === "zerion")
- .filter(() =>
- skETHIconUrlsSuffix.some((v) => metadata.logoURI.endsWith(v))
- )
- .map(() => zerionETHIcon)
- .orDefault(metadata.logoURI);
-
- return {
- mainUrl,
- fallbackUrl: metadata.logoURI ?? token.logoURI,
- name: metadata.name,
- providerIcon: metadata.provider?.logoURI,
- };
- }
-
- const tokenMappingResult = Maybe.fromNullable(tokenIconMapping)
- .chainNullable((mapping) => {
- if (typeof mapping === "function") {
- return mapping(token as Parameters[0]);
- }
-
- return mapping[token.symbol];
- })
- .map((url) => ({
- mainUrl: url,
- fallbackUrl: url,
- name: token.name,
- providerIcon: undefined,
- }))
- .extractNullable();
-
- if (tokenMappingResult) {
- return tokenMappingResult;
- }
-
- const mainUrl = Maybe.fromFalsy(variant === "zerion")
- .map(() => {
- /**
- * Use Zerion's token icons
- */
- if (token.address && token.symbol === "MATIC") {
- return zerionMATICIcon(token.address);
- }
-
- if (token.symbol === "ETH") {
- return zerionETHIcon;
- }
- return token.logoURI;
- })
- .orDefault(token.logoURI);
-
- return {
- mainUrl,
- fallbackUrl: token.logoURI,
- name: token.name,
- providerIcon: undefined,
- };
- }, [token, metadata, variant, tokenIconMapping]);
-};
-
-const skETHIconUrlsSuffix = ["/tokens/eth.svg", "/tokens/steth2.svg"];
-const zerionMATICIcon = (address: string) =>
- `${config.zerion.iconsByAddress}${address}.png`;
-const zerionETHIcon = `${config.zerion.iconsByAddress}eth.png`;
diff --git a/packages/widget/src/components/atoms/token-icon/token-icon-container/index.tsx b/packages/widget/src/components/atoms/token-icon/token-icon-container/index.tsx
deleted file mode 100644
index d8182f9a..00000000
--- a/packages/widget/src/components/atoms/token-icon/token-icon-container/index.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import type { ReactElement } from "react";
-import type { Networks } from "../../../../domain/types/chains/networks";
-import type { TokenDto, YieldTokenDto } from "../../../../domain/types/tokens";
-import type { YieldMetadata } from "../../../../domain/types/yields";
-import { Box } from "../../box";
-import { useVariantNetworkUrls } from "./hooks/use-variant-network-urls";
-import { useVariantTokenUrls } from "./hooks/use-variant-token-urls";
-
-type TokenIconContainerProps = {
- token: TokenDto | YieldTokenDto;
- metadata?: Pick;
- hideNetwork?: boolean;
- children: (props: TokenIconContainerReturnType) => ReactElement;
-};
-
-type TokenIconContainerReturnType = ReturnType & {
- networkLogoUri: string;
-};
-
-export const TokenIconContainer = ({
- token,
- metadata,
- hideNetwork,
- children,
-}: TokenIconContainerProps) => {
- const { mainUrl, fallbackUrl, name, providerIcon } = useVariantTokenUrls(
- token,
- metadata
- );
-
- const networkLogoUri = useVariantNetworkUrls(token.network as Networks);
-
- return (
-
- {children({ mainUrl, fallbackUrl, name, networkLogoUri, providerIcon })}
-
- );
-};
diff --git a/packages/widget/src/components/atoms/token-icon/token-icon-image/index.tsx b/packages/widget/src/components/atoms/token-icon/token-icon-image/index.tsx
deleted file mode 100644
index 68b5101c..00000000
--- a/packages/widget/src/components/atoms/token-icon/token-icon-image/index.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import type { Atoms } from "../../../../styles/theme/atoms.css";
-import { Image } from "../../image";
-
-type TokenIconProps = {
- mainUrl?: string;
- fallbackUrl?: string;
- name: string;
- tokenLogoHw?: Atoms["hw"];
-};
-
-export const TokenIconImage = ({
- mainUrl,
- fallbackUrl,
- name,
- tokenLogoHw = "9",
-}: TokenIconProps) => (
-
-);
diff --git a/packages/widget/src/components/atoms/tooltip/index.tsx b/packages/widget/src/components/atoms/tooltip/index.tsx
deleted file mode 100644
index f912e653..00000000
--- a/packages/widget/src/components/atoms/tooltip/index.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import * as Tooltip from "@radix-ui/react-tooltip";
-import type { PropsWithChildren, ReactNode } from "react";
-import { useSettings } from "../../../providers/settings";
-import { id } from "../../../styles/theme/ids";
-import { Text } from "../typography/text";
-import { tooltipArrow, tooltipContent, triggerWrapper } from "./style.css";
-
-export const ToolTip = ({
- children,
- label,
- maxWidth = 200,
- textAlign = "center",
- asChild,
-}: PropsWithChildren<{
- label: string | ReactNode;
- maxWidth?: number;
- textAlign?: "center" | "left" | "right" | "end";
- asChild?: boolean;
-}>) => {
- const { portalContainer } = useSettings();
-
- return (
-
-
-
- {children}
-
-
-
- {typeof label === "string" ? (
-
- {label}
-
- ) : (
- label
- )}
-
-
-
-
-
- );
-};
diff --git a/packages/widget/src/components/atoms/tooltip/style.css.ts b/packages/widget/src/components/atoms/tooltip/style.css.ts
deleted file mode 100644
index 2208ab09..00000000
--- a/packages/widget/src/components/atoms/tooltip/style.css.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { style } from "@vanilla-extract/css";
-import { atoms } from "../../../styles/theme/atoms.css";
-import { vars } from "../../../styles/theme/contract.css";
-
-export const tooltipContent = atoms({
- borderRadius: "md",
- px: "2",
- py: "2",
- background: "tooltipBackground",
- zIndex: "modal",
-});
-
-export const triggerWrapper = atoms({
- display: "flex",
-});
-
-export const tooltipArrow = style([
- {
- fill: vars.color.tooltipBackground,
- },
-]);
diff --git a/packages/widget/src/components/atoms/virtual-list/index.tsx b/packages/widget/src/components/atoms/virtual-list/index.tsx
deleted file mode 100644
index 3919da7a..00000000
--- a/packages/widget/src/components/atoms/virtual-list/index.tsx
+++ /dev/null
@@ -1,264 +0,0 @@
-import {
- useVirtualizer,
- type VirtualizerOptions,
-} from "@tanstack/react-virtual";
-import clsx from "clsx";
-import { List, Maybe } from "purify-ts";
-import { useEffect, useMemo, useRef, useState } from "react";
-import { useSavedRef } from "../../../hooks/use-saved-ref";
-import { breakpoints } from "../../../styles/tokens/breakpoints";
-import { MaybeWindow } from "../../../utils/maybe-window";
-import { Box, type BoxDataAttributes, type BoxProps } from "../box";
-import { Spinner } from "../spinner";
-import {
- absoluteWrapper,
- container,
- relativeWrapper,
-} from "../virtual-list/style.css";
-
-type InfiniteScrollProps =
- | {
- hasNextPage: boolean;
- isFetchingNextPage: boolean;
- fetchNextPage: () => void;
- }
- | {
- hasNextPage?: never;
- isFetchingNextPage?: never;
- fetchNextPage?: never;
- };
-
-type VirtualListProps = {
- data: T[];
- itemContent: (index: number, item: T) => React.ReactNode;
- estimateSize: VirtualizerOptions["estimateSize"];
- className?: BoxProps["className"];
- maxHeight?: number;
-} & InfiniteScrollProps;
-
-type VirtualGroupListProps = {
- itemContent: (index: number, groupIndex: number) => React.ReactNode;
- groupContent: (index: number) => React.ReactNode;
- increaseViewportBy?: { bottom: number; top: number };
- groupCounts: number[];
- className?: BoxProps["className"];
- maxHeight?: number;
- estimateSize: VirtualizerOptions["estimateSize"];
-} & InfiniteScrollProps;
-
-export const VirtualList = ({
- data,
- itemContent,
- className,
- estimateSize,
- maxHeight = 600,
- hasNextPage,
- isFetchingNextPage,
- fetchNextPage,
-}: VirtualListProps) => {
- const innerRef = useRef(null);
-
- const isTabletOrBigger = useIsTabletOrBigger();
-
- const rowVirtualizer = useVirtualizer({
- count: data.length,
- getScrollElement: () => innerRef.current,
- estimateSize,
- overscan: 10,
- });
-
- const virtualItems = rowVirtualizer.getVirtualItems();
-
- const isEndReached = useMemo(
- () =>
- List.head([...virtualItems].reverse())
- .filter((item) => item.index >= data.length - 1)
- .isJust(),
- [virtualItems, data.length]
- );
-
- const fetchNextPageRef = useSavedRef(fetchNextPage);
-
- useEffect(() => {
- Maybe.fromFalsy(isEndReached)
- .filter(() => !!hasNextPage && !isFetchingNextPage)
- .ifJust(() => fetchNextPageRef.current?.());
- }, [isEndReached, hasNextPage, isFetchingNextPage, fetchNextPageRef]);
-
- const _maxHeight = isTabletOrBigger ? maxHeight : "max(65vh, 400px)";
-
- return (
-
-
-
- {virtualItems.map((virtualItem) => (
-
- {itemContent(virtualItem.index, data[virtualItem.index])}
-
- ))}
- {isFetchingNextPage && (
-
-
-
- )}
-
-
-
- );
-};
-
-export const GroupedVirtualList = ({
- itemContent,
- groupContent,
- increaseViewportBy,
- groupCounts,
- className,
- maxHeight = 600,
- estimateSize,
- hasNextPage,
- isFetchingNextPage,
- fetchNextPage,
- ...rest
-}: VirtualGroupListProps & BoxDataAttributes) => {
- const innerRef = useRef(null);
-
- const isTabletOrBigger = useIsTabletOrBigger();
-
- const rowVirtualizer = useVirtualizer({
- count: groupCounts.reduce(
- (acc, numChildren) => acc + numChildren,
- groupCounts.length
- ),
- getScrollElement: () => innerRef.current,
- estimateSize,
- overscan: 10,
- paddingStart: increaseViewportBy?.top,
- paddingEnd: increaseViewportBy?.bottom,
- });
-
- const virtualItems = rowVirtualizer.getVirtualItems();
- const fetchNextPageRef = useSavedRef(fetchNextPage);
-
- type ParentResult = {
- type: "parent";
- index: number;
- };
-
- type ChildResult = {
- type: "child";
- index: number;
- parentIndex: number;
- };
-
- type ResultsArray = ParentResult | ChildResult;
-
- const { resultArray } = useMemo(
- () =>
- groupCounts.reduce(
- (acc, numChildren, parentIndex) => {
- acc.resultArray.push({ type: "parent", index: parentIndex });
-
- acc.resultArray.push(
- ...Array.from(
- { length: numChildren },
- (_, i) =>
- ({
- type: "child",
- index: acc.childIndex + i,
- parentIndex: parentIndex,
- }) satisfies ChildResult
- )
- );
-
- acc.childIndex += numChildren;
-
- return acc;
- },
- { resultArray: [] as ResultsArray[], childIndex: 0 }
- ),
- [groupCounts]
- );
-
- const isEndReached = useMemo(
- () =>
- List.head([...virtualItems].reverse())
- .filter((item) => item.index >= resultArray.length - 1)
- .isJust(),
- [virtualItems, resultArray.length]
- );
-
- useEffect(() => {
- Maybe.fromFalsy(isEndReached)
- .filter(() => !!hasNextPage && !isFetchingNextPage)
- .ifJust(() => fetchNextPageRef.current?.());
- }, [isEndReached, hasNextPage, isFetchingNextPage, fetchNextPageRef]);
-
- const _maxHeight = isTabletOrBigger ? maxHeight : "max(65vh, 500px)";
-
- return (
-
-
-
- {rowVirtualizer.getVirtualItems().map((virtualItem) => {
- const item = resultArray[virtualItem.index];
- const type = item.type;
-
- return (
-
- {type === "child"
- ? itemContent(item.index, item.parentIndex)
- : groupContent(item.index)}
-
- );
- })}
- {isFetchingNextPage && (
-
-
-
- )}
-
-
-
- );
-};
-
-const useIsTabletOrBigger = () => {
- const [windowWidth] = useState(() =>
- MaybeWindow.map((w) => w.innerWidth).orDefault(0)
- );
-
- return windowWidth >= breakpoints.tablet;
-};
diff --git a/packages/widget/src/components/molecules/account-modal/index.tsx b/packages/widget/src/components/molecules/account-modal/index.tsx
deleted file mode 100644
index babf5912..00000000
--- a/packages/widget/src/components/molecules/account-modal/index.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-import { AvatarContext, ConnectButton } from "@stakekit/rainbowkit";
-import { Maybe } from "purify-ts";
-import { useContext } from "react";
-import type { Address } from "viem";
-import { useTrackEvent } from "../../../hooks/tracking/use-track-event";
-import { useSettings } from "../../../providers/settings";
-import { useSKWallet } from "../../../providers/sk-wallet";
-import { combineRecipeWithVariant } from "../../../utils/styles";
-import { Box } from "../../atoms/box";
-import { CaretDownIcon } from "../../atoms/icons/caret-down";
-import { Text } from "../../atoms/typography/text";
-import { avatarContainer, container, titleStyle } from "./styles.css";
-
-export const AccountModal = () => {
- const trackEvent = useTrackEvent();
-
- const { address } = useSKWallet();
-
- const { variant } = useSettings();
-
- const AvatarComponent = useContext(AvatarContext);
-
- return (
-
- {({ account, openAccountModal }) =>
- Maybe.fromRecord({
- account: Maybe.fromNullable(account),
- address: Maybe.fromNullable(address),
- })
- .map((a) => (
- {
- trackEvent("accountModalOpened");
- openAccountModal();
- }}
- >
-
- {a.account.ensAvatar ? (
-
- ) : (
- <>
-
- {AvatarComponent && (
-
- )}
-
-
-
- {a.account.ensName ?? a.account.displayName}
-
- >
- )}
-
-
-
-
-
-
- ))
- .extractNullable()
- }
-
- );
-};
diff --git a/packages/widget/src/components/molecules/account-modal/styles.css.ts b/packages/widget/src/components/molecules/account-modal/styles.css.ts
deleted file mode 100644
index 229fe181..00000000
--- a/packages/widget/src/components/molecules/account-modal/styles.css.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { style } from "@vanilla-extract/css";
-import { recipe } from "@vanilla-extract/recipes";
-import { atoms } from "../../../styles/theme/atoms.css";
-import { vars } from "../../../styles/theme/contract.css";
-
-export const container = recipe({
- base: {
- cursor: "pointer",
- transition: "0.125s ease",
- ":hover": {
- transform: "scale(1.025)",
- },
- ":active": {
- transform: "scale(0.95)",
- },
- },
- variants: {
- variant: {
- default: {},
- finery: {
- background: vars.color.summaryItemBackground,
- boxShadow: "0px 15px 30px 0px #0000000D",
- },
- porto: {
- background: vars.color.summaryItemBackground,
- },
- utila: {},
- },
- },
-});
-
-export const titleStyle = style([atoms({ fontWeight: "modalHeading" })]);
-
-export const avatarContainer = style({ overflow: "hidden" });
diff --git a/packages/widget/src/components/molecules/amount-toggle/index.tsx b/packages/widget/src/components/molecules/amount-toggle/index.tsx
deleted file mode 100644
index b2a47475..00000000
--- a/packages/widget/src/components/molecules/amount-toggle/index.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import {
- createContext,
- type Dispatch,
- type PropsWithChildren,
- type ReactNode,
- type SetStateAction,
- useContext,
- useState,
-} from "react";
-import { Box } from "../../atoms/box";
-
-type State = "full" | "short";
-
-type RootProps = {
- init?: State;
-};
-
-const Context = createContext<
- [State, Dispatch>] | undefined
->(undefined);
-
-const Root = ({ children, init }: PropsWithChildren) => (
-
- {children}
-
-);
-
-type AmountProps =
- | {
- fullAmount: string;
- shortAmount: string;
- children?: never;
- }
- | {
- fullAmount?: never;
- shortAmount?: never;
- children: (props: { state: State }) => ReactNode;
- };
-
-const useAmountContext = () => {
- const ctx = useContext(Context);
-
- if (!ctx) {
- throw new Error("useAmountContext must be used within a Root");
- }
-
- return ctx;
-};
-
-const Amount = ({ fullAmount, shortAmount, children }: AmountProps) => {
- const [state, setState] = useAmountContext();
-
- const toggle = () => setState(state === "full" ? "short" : "full");
-
- return (
-
- {children?.({ state }) ?? (state === "full" ? fullAmount : shortAmount)}
-
- );
-};
-
-export { Amount, Root };
diff --git a/packages/widget/src/components/molecules/chain-modal/index.tsx b/packages/widget/src/components/molecules/chain-modal/index.tsx
deleted file mode 100644
index fa277f83..00000000
--- a/packages/widget/src/components/molecules/chain-modal/index.tsx
+++ /dev/null
@@ -1,91 +0,0 @@
-import { ConnectButton } from "@stakekit/rainbowkit";
-import { Maybe } from "purify-ts";
-import { useTranslation } from "react-i18next";
-import { useTrackEvent } from "../../../hooks/tracking/use-track-event";
-import { useSettings } from "../../../providers/settings";
-import { useSKWallet } from "../../../providers/sk-wallet";
-import { combineRecipeWithVariant } from "../../../utils/styles";
-import { Box } from "../../atoms/box";
-import { CaretDownIcon } from "../../atoms/icons/caret-down";
-import { Text } from "../../atoms/typography/text";
-import { container, titleStyle } from "./styles.css";
-
-export const ChainModal = () => {
- const trackEvent = useTrackEvent();
-
- const { t } = useTranslation();
-
- const { chain: skChain } = useSKWallet();
-
- const { variant } = useSettings();
-
- return (
-
- {({ chain, openChainModal }) => {
- return Maybe.fromRecord({
- chain: Maybe.fromNullable(chain),
- skChain: Maybe.fromNullable(skChain),
- })
- .map((c) => (
- {
- trackEvent("chainModalOpened");
- openChainModal();
- }}
- >
- {Maybe.fromFalsy(c.chain.unsupported && variant !== "zerion")
- .map(() => (
-
-
- {t("shared.unsupported_network")}
-
-
- ))
- .orDefaultLazy(() => (
-
- {c.chain.iconUrl && (
-
- )}
-
- {c.skChain.name && (
- <>
-
- {c.skChain.name}
-
-
-
-
-
- >
- )}
-
- ))}
-
- ))
- .extractNullable();
- }}
-
- );
-};
diff --git a/packages/widget/src/components/molecules/chain-modal/styles.css.ts b/packages/widget/src/components/molecules/chain-modal/styles.css.ts
deleted file mode 100644
index 98bae9ac..00000000
--- a/packages/widget/src/components/molecules/chain-modal/styles.css.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { style } from "@vanilla-extract/css";
-import { recipe } from "@vanilla-extract/recipes";
-import { atoms } from "../../../styles/theme/atoms.css";
-import { vars } from "../../../styles/theme/contract.css";
-
-export const container = recipe({
- base: {
- cursor: "pointer",
- transition: "0.125s ease",
- ":hover": {
- transform: "scale(1.025)",
- },
- ":active": {
- transform: "scale(0.95)",
- },
- },
- variants: {
- variant: {
- default: {},
- finery: {
- background: vars.color.summaryItemBackground,
- boxShadow: "0px 15px 30px 0px #0000000D",
- },
- porto: {
- background: vars.color.summaryItemBackground,
- },
- },
- },
-});
-
-export const titleStyle = style([atoms({ fontWeight: "modalHeading" })]);
diff --git a/packages/widget/src/components/molecules/connect-button/index.tsx b/packages/widget/src/components/molecules/connect-button/index.tsx
deleted file mode 100644
index 1ad0af2c..00000000
--- a/packages/widget/src/components/molecules/connect-button/index.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import { useConnectModal } from "@stakekit/rainbowkit";
-import type { ComponentProps } from "react";
-import { useTranslation } from "react-i18next";
-import { useTrackEvent } from "../../../hooks/tracking/use-track-event";
-import { useAddLedgerAccount } from "../../../hooks/use-add-ledger-account";
-import { useSKWallet } from "../../../providers/sk-wallet";
-import { Button } from "../../atoms/button";
-
-export const ConnectButton = (props: ComponentProps) => {
- const { t } = useTranslation();
-
- const { isLedgerLiveAccountPlaceholder, chain } = useSKWallet();
-
- const addLedgerAccount = useAddLedgerAccount();
-
- const { openConnectModal } = useConnectModal();
-
- const trackEvent = useTrackEvent();
-
- const onClick = () => {
- if (isLedgerLiveAccountPlaceholder && chain) {
- trackEvent("addLedgerAccountClicked");
- return addLedgerAccount.mutate(chain);
- }
-
- trackEvent("connectWalletClicked");
- openConnectModal?.();
- };
-
- return (
-
- );
-};
diff --git a/packages/widget/src/components/molecules/estimated-reward-amounts/index.tsx b/packages/widget/src/components/molecules/estimated-reward-amounts/index.tsx
deleted file mode 100644
index 1ea31e72..00000000
--- a/packages/widget/src/components/molecules/estimated-reward-amounts/index.tsx
+++ /dev/null
@@ -1,135 +0,0 @@
-import clsx from "clsx";
-import { useTranslation } from "react-i18next";
-import { selectYieldRewardsText } from "../../../pages/details/earn-page/components/select-yield-section/styles.css";
-import { VerticalDivider } from "../../../pages-dashboard/common/components/divider";
-import { useSettings } from "../../../providers/settings";
-import { combineRecipeWithVariant } from "../../../utils/styles";
-import { Box } from "../../atoms/box";
-import { Text } from "../../atoms/typography/text";
-
-type EstimatedRewardAmountsProps = {
- earnYearly: string;
- earnMonthly: string;
-};
-
-export const EstimatedRewardAmounts = ({
- earnYearly,
- earnMonthly,
-}: EstimatedRewardAmountsProps) => {
- const { dashboardVariant, variant } = useSettings();
-
- if (dashboardVariant || variant === "utila" || variant === "porto") {
- return (
-
- );
- }
-
- return (
-
- );
-};
-
-const DefaultEarnYearlyOrMonthly = ({
- earnMonthly,
- earnYearly,
-}: EstimatedRewardAmountsProps) => {
- const { t } = useTranslation();
- const { variant } = useSettings();
-
- return (
- <>
-
-
- {t(variant === "zerion" ? "details.rewards.yearly" : "shared.yearly")}
-
-
- {earnYearly}
-
-
-
-
-
- {t("shared.monthly")}
-
-
- {earnMonthly}
-
-
- >
- );
-};
-
-const CompactEarnYearlyOrMonthly = ({
- earnMonthly,
- earnYearly,
-}: EstimatedRewardAmountsProps) => {
- const { t } = useTranslation();
-
- return (
-
-
- {t("shared.yearly")}
- {earnYearly}
-
-
-
-
-
- {t("shared.monthly")}
- {earnMonthly}
-
-
- );
-};
diff --git a/packages/widget/src/components/molecules/global-modals/index.tsx b/packages/widget/src/components/molecules/global-modals/index.tsx
deleted file mode 100644
index b737ecc7..00000000
--- a/packages/widget/src/components/molecules/global-modals/index.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import { useState } from "react";
-import { useGeoBlock } from "../../../hooks/use-geo-block";
-import { useRegionCodeName } from "../../../hooks/use-region-code-names";
-import { useShowTOS } from "../../../hooks/use-show-tos";
-import { HelpModal } from "../help-modal";
-import { RichErrorModal } from "../rich-error-modal";
-import { TosModal } from "../tos-modal";
-
-export const GlobalModals = () => {
- const geoBlock = useGeoBlock();
- const regionCodeName = useRegionCodeName(
- geoBlock ? geoBlock.regionCode : undefined
- );
-
- const [hideGeoBlock, setHideGeoBlock] = useState(false);
-
- const { enabled, onAccept, tosAccepted } = useShowTOS();
-
- if (geoBlock && !hideGeoBlock) {
- return (
- setHideGeoBlock(true),
- }}
- />
- );
- }
-
- if (enabled && !tosAccepted) {
- return ;
- }
-
- return ;
-};
diff --git a/packages/widget/src/components/molecules/header/index.tsx b/packages/widget/src/components/molecules/header/index.tsx
deleted file mode 100644
index 1ad9a1ce..00000000
--- a/packages/widget/src/components/molecules/header/index.tsx
+++ /dev/null
@@ -1,101 +0,0 @@
-import { ConnectButton } from "@stakekit/rainbowkit";
-import classNames from "clsx";
-import { motion } from "motion/react";
-import { Maybe } from "purify-ts";
-import { Box } from "../../atoms/box";
-import { CaretLeftIcon } from "../../atoms/icons/caret-left";
-import { XIcon } from "../../atoms/icons/x-icon";
-import { AccountModal } from "../account-modal";
-import { ChainModal } from "../chain-modal";
-import { animationContainer, parentButton } from "./styles.css";
-import { useHeader } from "./use-header";
-
-export const Header = () => {
- const {
- containerRef,
- wagmiConfig,
- variant,
- hideChainSelector,
- isConnected,
- showBack,
- onLeftIconPress,
- showDisconnect,
- onXPress,
- isConnecting,
- } = useHeader();
-
- return (
-
-
-
-
- {showBack ? (
-
-
-
- ) : (
-
- )}
-
-
- {showDisconnect && (
-
-
-
- )}
-
-
- {Maybe.fromFalsy(
- !wagmiConfig.isLoading && wagmiConfig.data && variant !== "zerion"
- )
- .map(() => (
-
- {({ account, chain, mounted }) => {
- return (
-
- {Maybe.fromFalsy(
- (isConnected || isConnecting) && chain && account
- )
- .map(() => (
-
- {!hideChainSelector && }
-
-
-
- ))
- .extractNullable()}
-
- );
- }}
-
- ))
- .extractNullable()}
-
-
- );
-};
diff --git a/packages/widget/src/components/molecules/header/styles.css.ts b/packages/widget/src/components/molecules/header/styles.css.ts
deleted file mode 100644
index 9dfd421c..00000000
--- a/packages/widget/src/components/molecules/header/styles.css.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { style } from "@vanilla-extract/css";
-import { atoms } from "../../../styles/theme/atoms.css";
-
-export const parentButton = style({
- opacity: 0,
- pointerEvents: "none",
- userSelect: "none",
-});
-
-export const animationContainer = style([
- atoms({ gap: "2" }),
- {
- display: "flex",
- justifyContent: "center",
- },
-]);
diff --git a/packages/widget/src/components/molecules/header/use-header.ts b/packages/widget/src/components/molecules/header/use-header.ts
deleted file mode 100644
index 8e78239b..00000000
--- a/packages/widget/src/components/molecules/header/use-header.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import { Maybe } from "purify-ts";
-import { useMemo } from "react";
-import { useNavigate } from "react-router";
-import { shouldShowDisconnect } from "../../../domain/types/connectors";
-import { useDetailsMatch } from "../../../hooks/navigation/use-details-match";
-import { useTrackEvent } from "../../../hooks/tracking/use-track-event";
-import { useLogout } from "../../../hooks/use-logout";
-import { useSettings } from "../../../providers/settings";
-import { useSKWallet } from "../../../providers/sk-wallet";
-import { useWagmiConfig } from "../../../providers/wagmi";
-import { useSyncHeaderHeight } from "./use-sync-header-height";
-
-export const useHeader = () => {
- const navigate = useNavigate();
-
- const { containerRef } = useSyncHeaderHeight();
-
- const { variant, hideChainSelector, hideAccountAndChainSelector } =
- useSettings();
-
- const { isConnected, isConnecting, connector } = useSKWallet();
-
- const showDisconnect = useMemo(
- () =>
- Maybe.fromNullable(connector).map(shouldShowDisconnect).orDefault(false),
- [connector]
- );
-
- const wagmiConfig = useWagmiConfig();
-
- const showBack = !useDetailsMatch();
-
- const trackEvent = useTrackEvent();
-
- const onLeftIconPress = () => {
- if (!showBack) return;
-
- trackEvent("backClicked");
- navigate(-1);
- };
-
- const { mutate: logout } = useLogout();
-
- const onXPress = () => {
- trackEvent("widgetDisconnectClicked");
- logout();
- };
-
- return {
- onLeftIconPress,
- onXPress,
- containerRef,
- wagmiConfig,
- variant,
- hideChainSelector,
- isConnected,
- isConnecting,
- showDisconnect,
- showBack,
- hideAccountAndChainSelector,
- };
-};
diff --git a/packages/widget/src/components/molecules/header/use-sync-header-height.ts b/packages/widget/src/components/molecules/header/use-sync-header-height.ts
deleted file mode 100644
index b7296af9..00000000
--- a/packages/widget/src/components/molecules/header/use-sync-header-height.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { useSyncElementHeight } from "../../../hooks/use-sync-element-height";
-import createStateContext from "../../../utils/create-state-context";
-
-export const [useHeaderHeight, HeaderHeightProvider] = createStateContext(0);
-
-export const useSyncHeaderHeight = () => {
- return useSyncElementHeight(useHeaderHeight()[1]);
-};
diff --git a/packages/widget/src/components/molecules/help-modal/index.tsx b/packages/widget/src/components/molecules/help-modal/index.tsx
deleted file mode 100644
index 678db7c1..00000000
--- a/packages/widget/src/components/molecules/help-modal/index.tsx
+++ /dev/null
@@ -1,264 +0,0 @@
-import { Trigger } from "@radix-ui/react-dialog";
-import type { ReactNode } from "react";
-import { useContext, useMemo } from "react";
-import { Trans, useTranslation } from "react-i18next";
-import { images } from "../../../assets/images";
-import type { useGeoBlock } from "../../../hooks/use-geo-block";
-import { TrackingContext } from "../../../providers/tracking";
-import { formatCountryCode } from "../../../utils/formatters";
-import { MaybeWindow } from "../../../utils/maybe-window";
-import { SKAnchor } from "../../atoms/anchor";
-import { Box } from "../../atoms/box";
-import { Button } from "../../atoms/button";
-import { HelpIcon } from "../../atoms/icons/help";
-import type { SelectModalProps } from "../../atoms/select-modal";
-import { SelectModal } from "../../atoms/select-modal";
-import { Heading } from "../../atoms/typography/heading";
-import { Text } from "../../atoms/typography/text";
-import { container, imageStyle } from "./style.css";
-
-type ModalType =
- | ({ type: "geoBlock"; onClose: () => void } & Exclude<
- ReturnType,
- false
- > & {
- regionCodeName: string | undefined;
- })
- | { type: "getInTouch" }
- | { type: "whatIsStakeKit" };
-
-type HelpModalProps = {
- modal: ModalType;
- customTrigger?: ReactNode;
-};
-
-export const HelpModal = ({ modal, customTrigger }: HelpModalProps) => {
- const { t, i18n } = useTranslation();
-
- const getContent = (
- modal: ModalType
- ): {
- title: string;
- description: string | ReactNode;
- image: string;
- link?: string;
- button?: { title: string; onClick: () => void };
- } => {
- switch (modal.type) {
- case "geoBlock": {
- const title = t("help_modals.geo_block.title");
- const countryName = formatCountryCode({
- language: i18n.language,
- countryCode: modal.countryCode,
- });
-
- if (modal.tags.has("OFAC") && modal.tags.has("OFSI")) {
- return {
- title,
- description: (
- }}
- />
- ),
- image: images.fees,
- };
- }
- if (modal.tags.has("OFSI") && modal.tags.has("Crypto Ban")) {
- return {
- title,
- description: (
- }}
- />
- ),
- image: images.fees,
- };
- }
- if (modal.tags.has("Crypto Ban")) {
- return {
- title,
- description: (
- }}
- />
- ),
- image: images.fees,
- };
- }
- if (modal.tags.has("OFAC")) {
- return {
- title,
- description: (
- }}
- />
- ),
- image: images.fees,
- };
- }
- if (modal.tags.has("OFSI")) {
- return {
- title,
- description: (
- }}
- />
- ),
- image: images.fees,
- };
- }
- if (modal.tags.has("Pending Litigation")) {
- return {
- title,
- description: (
- }}
- />
- ),
- image: images.fees,
- };
- }
- if (modal.tags.has("Staking Ban")) {
- return {
- title,
- description: (
- }}
- />
- ),
- image: images.fees,
- };
- }
-
- return {
- title,
- description: (
- }}
- />
- ),
- image: images.fees,
- };
- }
-
- case "getInTouch": {
- return {
- title: t("help_modals.get_in_touch.title"),
- button: {
- title: t("help_modals.get_in_touch.button"),
- onClick: () =>
- MaybeWindow.ifJust((w) =>
- w.open("https://twitter.com/yield_xyz", "_blank")
- ),
- },
- description: "",
- image: images.whatIsLiquidStaking,
- };
- }
-
- case "whatIsStakeKit": {
- return {
- title: t("help_modals.what_is_stakekit.title"),
- description: (
- ,
- link0: ,
- link1: ,
- }}
- />
- ),
- image: images.poweredBy,
- };
- }
- }
- };
-
- // HelpModal can be used out of default Widget context
- const trackEvent = useContext(TrackingContext)?.trackEvent;
-
- const { description, image, title, link, button } = getContent(modal);
-
- const selectModalProps = useMemo(() => {
- const base: SelectModalProps = {
- onOpen: () => trackEvent?.("helpModalOpened", { modal: title }),
- };
-
- return modal.type === "geoBlock"
- ? {
- ...base,
- state: {
- isOpen: true,
- setOpen: modal.onClose,
- },
- }
- : {
- ...base,
- trigger: (
-
- {customTrigger ?? (
-
-
-
- )}
-
- ),
- };
- }, [customTrigger, modal, title, trackEvent]);
-
- return (
-
-
-
-
- {title}
-
-
-
- {description}
-
-
-
- {!!link && {link}}
-
- {button && (
-
-
-
- )}
-
-
- );
-};
diff --git a/packages/widget/src/components/molecules/kyc-gate-card/index.tsx b/packages/widget/src/components/molecules/kyc-gate-card/index.tsx
deleted file mode 100644
index b51a7168..00000000
--- a/packages/widget/src/components/molecules/kyc-gate-card/index.tsx
+++ /dev/null
@@ -1,202 +0,0 @@
-import { useRef, useState } from "react";
-import { useTranslation } from "react-i18next";
-import type { KycGate } from "../../../domain/types/kyc";
-import { vars } from "../../../styles/theme/contract.css";
-import { MaybeWindow } from "../../../utils/maybe-window";
-import { Box } from "../../atoms/box";
-import { Button } from "../../atoms/button";
-import { Arrow } from "../../atoms/icons/arrow";
-import { WarningIcon } from "../../atoms/icons/warning";
-import { Spinner } from "../../atoms/spinner";
-import { Text } from "../../atoms/typography/text";
-import {
- cardStyle,
- iconContainerStyle,
- spinnerContainerStyle,
-} from "./styles.css";
-import { KycVerificationModal } from "./verification-modal";
-
-type KycGateCardProps = {
- readonly gate: KycGate;
- readonly isChecking?: boolean;
- readonly onCheckStatus?: () => void;
- readonly providerName: string | null;
-};
-
-type CardState = Exclude | "checking";
-
-const translationKeys = {
- checking: {
- title: "details.kyc_gate.checking.title",
- body: "details.kyc_gate.checking.body",
- bodyFallback: "details.kyc_gate.checking.body_fallback",
- },
- pending: {
- title: "details.kyc_gate.pending.title",
- body: "details.kyc_gate.pending.body",
- bodyFallback: "details.kyc_gate.pending.body_fallback",
- },
- rejected: {
- title: "details.kyc_gate.rejected.title",
- body: "details.kyc_gate.rejected.body",
- bodyFallback: "details.kyc_gate.rejected.body_fallback",
- cta: "details.kyc_gate.rejected.cta",
- },
- start_kyc: {
- title: "details.kyc_gate.start_kyc.title",
- body: "details.kyc_gate.start_kyc.body",
- bodyFallback: "details.kyc_gate.start_kyc.body_fallback",
- cta: "details.kyc_gate.start_kyc.cta",
- },
- unknown: {
- title: "details.kyc_gate.unknown.title",
- body: "details.kyc_gate.unknown.body",
- bodyFallback: "details.kyc_gate.unknown.body_fallback",
- },
-} as const satisfies Record<
- CardState,
- {
- readonly title: string;
- readonly body: string;
- readonly bodyFallback: string;
- readonly cta?: string;
- }
->;
-
-const getCardState = ({
- gate,
- isChecking,
-}: Pick): CardState | null => {
- if (isChecking) return "checking";
- if (gate.state === "pass") return null;
-
- return gate.state;
-};
-
-const getKycUrl = (gate: KycGate) =>
- gate.state === "pass" ? undefined : gate.kycUrl;
-
-export const KycGateCard = ({
- gate,
- isChecking = false,
- onCheckStatus,
- providerName,
-}: KycGateCardProps) => {
- const { t } = useTranslation();
- const [verificationModalOpen, setVerificationModalOpen] = useState(false);
- const refreshOnVerificationClose = useRef(false);
- const cardState = getCardState({ gate, isChecking });
-
- if (!cardState) return null;
-
- const kycUrl = getKycUrl(gate);
- const copy = translationKeys[cardState];
- const values = { providerName: providerName ?? "" };
- const hasProviderName = !!providerName;
- const bodyKey = hasProviderName ? copy.body : copy.bodyFallback;
- const ctaKey =
- cardState === "start_kyc" || cardState === "rejected"
- ? translationKeys[cardState].cta
- : null;
- const showVerifyButton = !!ctaKey && !!kycUrl;
- const showCheckStatusButton =
- cardState === "pending" || cardState === "unknown";
- const showEmbeddedVerification =
- showVerifyButton && gate.state !== "pass" && gate.iframeAllowed === true;
-
- const onVerifyClick = () => {
- if (!kycUrl) return;
-
- if (showEmbeddedVerification) {
- refreshOnVerificationClose.current = true;
- setVerificationModalOpen(true);
- return;
- }
-
- MaybeWindow.ifJust((w) => {
- w.open(kycUrl, "_blank", "noopener,noreferrer");
- });
- };
-
- const onVerificationModalOpenChange = (isOpen: boolean) => {
- if (isOpen) {
- refreshOnVerificationClose.current = true;
- setVerificationModalOpen(true);
- return;
- }
-
- if (refreshOnVerificationClose.current) {
- refreshOnVerificationClose.current = false;
- onCheckStatus?.();
- }
-
- setVerificationModalOpen(false);
- };
-
- return (
- <>
-
-
-
-
- {t(copy.title)}
-
- {cardState === "checking" ? (
-
-
-
- ) : (
-
-
-
- )}
-
-
- {t(bodyKey, values)}
-
-
- {showVerifyButton && (
-
- )}
-
- {showCheckStatusButton && (
-
- )}
-
-
-
-
-
- {showEmbeddedVerification && (
-
- )}
- >
- );
-};
diff --git a/packages/widget/src/components/molecules/kyc-gate-card/styles.css.ts b/packages/widget/src/components/molecules/kyc-gate-card/styles.css.ts
deleted file mode 100644
index c727ccd6..00000000
--- a/packages/widget/src/components/molecules/kyc-gate-card/styles.css.ts
+++ /dev/null
@@ -1,170 +0,0 @@
-import { keyframes, style } from "@vanilla-extract/css";
-import { recipe } from "@vanilla-extract/recipes";
-import { atoms } from "../../../styles/theme/atoms.css";
-import { vars } from "../../../styles/theme/contract.css";
-
-const modalFadeIn = keyframes({
- "0%": { opacity: 0 },
- "100%": { opacity: 1 },
-});
-
-const modalScaleIn = keyframes({
- "0%": { opacity: 0, transform: "translateY(12px) scale(0.98)" },
- "100%": { opacity: 1, transform: "translateY(0) scale(1)" },
-});
-
-const stateStyles = {
- checking: {
- background: vars.color.backgroundMuted,
- borderColor: "transparent",
- },
- start_kyc: {
- background: "color-mix(in oklab, #5147f3 8%, transparent)",
- borderColor: "#5147f3",
- },
- pending: {
- background: "color-mix(in oklab, #ffc21b 16%, transparent)",
- borderColor: "#ffc21b",
- },
- rejected: {
- background: "color-mix(in oklab, #ff3b1f 10%, transparent)",
- borderColor: "#ff3b1f",
- },
- unknown: {
- background: vars.color.backgroundMuted,
- borderColor: vars.color.textMuted,
- },
-} as const;
-
-export const cardStyle = recipe({
- base: [
- atoms({
- borderRadius: "xl",
- borderStyle: "solid",
- borderWidth: 1,
- px: "4",
- py: "4",
- width: "full",
- }),
- ],
- variants: {
- state: stateStyles,
- },
-});
-
-export const iconContainerStyle = recipe({
- base: [
- atoms({
- alignItems: "center",
- borderRadius: "full",
- display: "flex",
- justifyContent: "center",
- }),
- {
- height: "24px",
- width: "24px",
- flexShrink: 0,
- },
- ],
- variants: {
- state: {
- start_kyc: { background: "transparent" },
- pending: { background: "transparent" },
- rejected: { background: "transparent" },
- unknown: { background: "transparent" },
- },
- },
-});
-
-export const spinnerContainerStyle = style([
- atoms({
- alignItems: "center",
- borderRadius: "full",
- display: "flex",
- justifyContent: "center",
- }),
- {
- height: "24px",
- width: "24px",
- flexShrink: 0,
- },
-]);
-
-export const verificationModalContainerStyle = style({
- alignItems: "center",
- bottom: 0,
- display: "flex",
- justifyContent: "center",
- left: 0,
- padding: "16px",
- position: "fixed",
- right: 0,
- top: 0,
- zIndex: 30,
-});
-
-export const verificationModalOverlayStyle = style({
- animation: `${modalFadeIn} 150ms ease`,
- background: vars.color.modalOverlayBackground,
- inset: 0,
- position: "absolute",
-});
-
-export const verificationModalContentStyle = style([
- atoms({
- background: "modalBodyBackground",
- }),
- {
- animation: `${modalScaleIn} 180ms ease`,
- borderRadius: vars.borderRadius.baseContract["2xl"],
- display: "flex",
- flexDirection: "column",
- height: "min(86vh, 720px)",
- maxHeight: "calc(100vh - 32px)",
- maxWidth: "760px",
- overflow: "hidden",
- position: "relative",
- width: "100%",
- zIndex: 1,
- "@media": {
- "screen and (max-width: 767px)": {
- height: "calc(100vh - 24px)",
- maxHeight: "calc(100vh - 24px)",
- },
- },
- },
-]);
-
-export const verificationModalHeaderStyle = style([
- atoms({
- alignItems: "center",
- display: "flex",
- justifyContent: "space-between",
- px: "4",
- py: "3",
- }),
- {
- borderBottom: `1px solid ${vars.color.backgroundMuted}`,
- flexShrink: 0,
- },
-]);
-
-export const verificationModalCloseStyle = style([
- atoms({
- alignItems: "center",
- display: "flex",
- justifyContent: "center",
- }),
- {
- height: "32px",
- width: "32px",
- },
-]);
-
-export const verificationModalFrameStyle = style({
- background: "#ffffff",
- border: 0,
- flex: 1,
- minHeight: 0,
- width: "100%",
-});
diff --git a/packages/widget/src/components/molecules/reward-rate-breakdown/index.tsx b/packages/widget/src/components/molecules/reward-rate-breakdown/index.tsx
deleted file mode 100644
index 17f84857..00000000
--- a/packages/widget/src/components/molecules/reward-rate-breakdown/index.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-import { useTranslation } from "react-i18next";
-import {
- getRewardRateBreakdown,
- type RewardRateBreakdownItem,
- type YieldRewardRateDto,
-} from "../../../domain/types/reward-rate";
-import { getRewardRateFormatted } from "../../../utils/formatters";
-import { Box } from "../../atoms/box";
-import { Text } from "../../atoms/typography/text";
-
-const getLabelKey = (key: RewardRateBreakdownItem["key"]) => {
- switch (key) {
- case "native":
- return "details.apy_composition.native";
- case "protocol_incentive":
- return "details.apy_composition.protocol_incentive";
- case "campaign":
- return "details.apy_composition.campaign";
- }
-};
-
-export const RewardRateBreakdown = ({
- rewardRate,
- showUpToCampaign = false,
- title,
- testId,
-}: {
- rewardRate: YieldRewardRateDto | null | undefined;
- showUpToCampaign?: boolean;
- title?: string;
- testId?: string;
-}) => {
- const { t } = useTranslation();
-
- const items = getRewardRateBreakdown(rewardRate, {
- showUpToCampaign,
- });
-
- if (items.length < 2) {
- return null;
- }
-
- return (
-
- {title ? (
- {title}
- ) : null}
-
- {items.map((item) => {
- const value = getRewardRateFormatted({
- rewardRate: item.rate,
- });
-
- return (
-
-
- {t(getLabelKey(item.key))}
-
-
-
- {item.isUpTo
- ? t("details.apy_composition.up_to", { value })
- : value}
-
-
- );
- })}
-
- );
-};
diff --git a/packages/widget/src/components/molecules/reward-token-details/index.tsx b/packages/widget/src/components/molecules/reward-token-details/index.tsx
deleted file mode 100644
index e4e1b333..00000000
--- a/packages/widget/src/components/molecules/reward-token-details/index.tsx
+++ /dev/null
@@ -1,100 +0,0 @@
-import { Maybe } from "purify-ts";
-import type { ComponentProps } from "react";
-import { Trans } from "react-i18next";
-import type { YieldPendingActionType } from "../../../domain/types/pending-action";
-import type { useRewardTokenDetails } from "../../../hooks/use-reward-token-details";
-import { humanizePendingActionType } from "../../../utils/formatters";
-import { Box } from "../../atoms/box";
-import { MorphoStarsIcon } from "../../atoms/icons/morpho-stars";
-import { Image } from "../../atoms/image";
-import { Text } from "../../atoms/typography/text";
-import { inlineText } from "./style.css";
-
-export const RewardTokenDetails = ({
- rewardToken,
- ...rest
-}: {
- rewardToken: ReturnType;
-} & (
- | { type: "stake" | "unstake"; pendingAction?: never }
- | {
- type: "pendingAction";
- pendingAction: YieldPendingActionType;
- }
-)) => {
- const i18nKey: ComponentProps["i18nKey"] = (() => {
- if (rest.type === "stake") {
- return "details.reward_token";
- }
-
- if (rest.type === "pendingAction") {
- return `pending_action_review.pending_action_type.${
- rest.pendingAction.toLowerCase() as Lowercase
- }` as const;
- }
-
- return "unstake_review.unstake_from";
- })();
-
- const i18nDefaults =
- rest.type === "pendingAction"
- ? humanizePendingActionType(rest.pendingAction)
- : undefined;
-
- return rewardToken
- .map((rt) => {
- return (
-
- {Maybe.fromNullable(rt.logoUri)
- .filter(() => isMorphoProvider(rt.providerName))
- .map((logoUri) => (
-
-
-
-
-
-
-
- ))
- .extractNullable()}
-
-
-
- {rt.symbols}
-
- ),
- highlight2: (
-
- ),
- }}
- />
-
-
- );
- })
- .extractNullable();
-};
-
-export const isMorphoProvider = (providerName: string) =>
- /morpho/i.test(providerName);
diff --git a/packages/widget/src/components/molecules/reward-token-details/style.css.ts b/packages/widget/src/components/molecules/reward-token-details/style.css.ts
deleted file mode 100644
index 52a24937..00000000
--- a/packages/widget/src/components/molecules/reward-token-details/style.css.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { style } from "@vanilla-extract/css";
-import { atoms } from "../../../styles/theme/atoms.css";
-
-export const inlineText = style([
- atoms({
- fontSize: "xs",
- color: "textMuted",
- }),
- { display: "inline" },
-]);
diff --git a/packages/widget/src/components/molecules/rich-error-modal/index.tsx b/packages/widget/src/components/molecules/rich-error-modal/index.tsx
deleted file mode 100644
index 6d8240df..00000000
--- a/packages/widget/src/components/molecules/rich-error-modal/index.tsx
+++ /dev/null
@@ -1,94 +0,0 @@
-import { useEffect } from "react";
-import { useTranslation } from "react-i18next";
-import { images } from "../../../assets/images";
-import { useRichErrors } from "../../../hooks/use-rich-errors";
-import { Box } from "../../atoms/box";
-import { SelectModal } from "../../atoms/select-modal";
-import { Heading } from "../../atoms/typography/heading";
-import { Text } from "../../atoms/typography/text";
-import { imageStyle } from "./style.css";
-
-export const RichErrorModal = () => {
- const { i18n, t } = useTranslation();
- const { error, resetError } = useRichErrors();
- const { message, details } = error ?? {};
- const hasKnownMessage = message ? i18n.exists(`errors.${message}`) : false;
-
- useEffect(() => resetError, [resetError]);
-
- return (
- !isOpen && resetError() }}
- onClose={resetError}
- >
-
-
- {!message && (
-
-
- {t("shared.something_went_wrong")}
-
-
- )}
-
- {message && hasKnownMessage && (
- <>
-
-
- {t(`errors.${message}.title`)}
-
-
- {t(`errors.${message}.details`, details)}
-
-
-
-
-
- {t("error_modal.solution")}
-
-
-
-
- {t(`errors.${message}.solution`)}
-
-
-
- >
- )}
-
- {message && !hasKnownMessage && (
-
-
- {t("shared.something_went_wrong")}
-
-
- {message}
-
-
- )}
-
-
- );
-};
diff --git a/packages/widget/src/components/molecules/select-opportunity-list-item/index.tsx b/packages/widget/src/components/molecules/select-opportunity-list-item/index.tsx
deleted file mode 100644
index 2623435e..00000000
--- a/packages/widget/src/components/molecules/select-opportunity-list-item/index.tsx
+++ /dev/null
@@ -1,111 +0,0 @@
-import type { ComponentProps } from "react";
-import { useTranslation } from "react-i18next";
-import type { YieldBase } from "../../../domain/types/yields";
-import {
- capitalizeFirstLetters,
- getRewardRateFormatted,
- getRewardTypeFormatted,
-} from "../../../utils/formatters";
-import { Box } from "../../atoms/box";
-import { SelectModalItem } from "../../atoms/select-modal";
-import { ProviderIcon } from "../../atoms/token-icon/provider-icon";
-import { Text } from "../../atoms/typography/text";
-import {
- itemSubtitle,
- rewardRateLabel,
- rewardRateText,
- selectItemText,
-} from "./styles.css";
-
-type SelectOpportunityListItemProps = {
- item: T;
- onYieldSelect: (item: T) => void;
- testId?: string;
- selected?: boolean;
-};
-
-export const SelectOpportunityListItem = ({
- item,
- onYieldSelect,
- testId,
- selected,
-}: SelectOpportunityListItemProps) => {
- const { t } = useTranslation();
-
- const onItemClick: ComponentProps["onItemClick"] = ({
- closeModal,
- }) => {
- onYieldSelect(item);
- closeModal();
- };
-
- const provider = item.provider;
-
- const rateFormatted = getRewardRateFormatted({
- rewardRate: item.rewardRate.total,
- });
- const rateTypeLabel = getRewardTypeFormatted(item.rewardRate.rateType);
-
- const subtitle = provider?.name
- ? t("details.opportunity_item_subtitle_no_network", {
- provider: provider.name,
- })
- : capitalizeFirstLetters(item.token.network);
-
- return (
-
- [0]["token"]}
- />
-
-
-
- {item.metadata.name}
-
-
-
- {subtitle}
-
-
-
-
- {rateFormatted}
-
- {rateTypeLabel && (
-
- {rateTypeLabel}
-
- )}
-
-
- );
-};
diff --git a/packages/widget/src/components/molecules/select-opportunity-list-item/styles.css.ts b/packages/widget/src/components/molecules/select-opportunity-list-item/styles.css.ts
deleted file mode 100644
index 5172a194..00000000
--- a/packages/widget/src/components/molecules/select-opportunity-list-item/styles.css.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { style } from "@vanilla-extract/css";
-import { atoms } from "../../../styles/theme/atoms.css";
-
-export const selectItemText = style([
- atoms({
- color: "tokenSelect",
- fontWeight: "tokenSelect",
- }),
-]);
-
-export const rewardRateText = style([
- atoms({ color: "positionsRewardRate" }),
- { whiteSpace: "nowrap" },
-]);
-
-export const rewardRateLabel = style({
- textTransform: "uppercase",
- whiteSpace: "nowrap",
-});
-
-export const itemSubtitle = style([
- atoms({ color: "textMuted" }),
- {
- overflow: "hidden",
- textOverflow: "ellipsis",
- whiteSpace: "nowrap",
- },
-]);
diff --git a/packages/widget/src/components/molecules/select-validator/index.tsx b/packages/widget/src/components/molecules/select-validator/index.tsx
deleted file mode 100644
index 9b057f0d..00000000
--- a/packages/widget/src/components/molecules/select-validator/index.tsx
+++ /dev/null
@@ -1,214 +0,0 @@
-import type { PropsWithChildren } from "react";
-import { useMemo, useState } from "react";
-import { useTranslation } from "react-i18next";
-import type { Yield } from "../../../domain/types/yields";
-import type { ValidatorDto } from "../../../generated/api/yield";
-import { Box } from "../../atoms/box";
-import type { SelectModalProps } from "../../atoms/select-modal";
-import { SelectModal } from "../../atoms/select-modal";
-import { Text } from "../../atoms/typography/text";
-import type { GroupedItem } from "./select-validator-list";
-import { SelectValidatorList } from "./select-validator-list";
-import { emptyState } from "./styles.css";
-
-type SelectValidatorProps = PropsWithChildren<
- Pick<
- SelectModalProps,
- "isLoading" | "onClose" | "onOpen" | "state" | "trigger"
- > & {
- selectedValidators: Set;
- onItemClick: (item: ValidatorDto) => void;
- onViewMoreClick?: () => void;
- onLoadMore?: () => void;
- hasMore?: boolean;
- isLoadingMore?: boolean;
- validators: ValidatorDto[];
- selectedStake: Yield;
- multiSelect: boolean;
- } & (
- | { onSearch: (value: string) => void; searchValue: string }
- | {
- onSearch?: never;
- searchValue?: never;
- }
- )
->;
-
-export const SelectValidator = ({
- state,
- isLoading,
- onClose,
- onOpen,
- trigger,
- selectedValidators,
- onItemClick,
- onViewMoreClick,
- onLoadMore,
- hasMore,
- isLoadingMore,
- validators,
- multiSelect,
- selectedStake,
- children,
- ...rest
-}: SelectValidatorProps) => {
- const { t } = useTranslation();
-
- const [_viewMore, setViewMore] = useState(false);
-
- const _onViewMoreClick = () => {
- onViewMoreClick?.();
- onLoadMore?.();
- setViewMore(true);
- };
-
- const _onClose = () => {
- setViewMore(false);
- onClose?.();
- };
-
- const hasRequestedMoreValidators = _viewMore || !!rest.searchValue;
- const showExpandedValidators = multiSelect || hasRequestedMoreValidators;
-
- const data = useMemo<{
- tableData: ValidatorDto[];
- groupedItems: GroupedItem[];
- groupCounts: number[];
- }>(() => {
- if (!validators.length && hasMore && !hasRequestedMoreValidators) {
- return {
- tableData: [],
- groupedItems: [{ items: [], label: "view_more" }],
- groupCounts: [1],
- };
- }
-
- if (!validators.length) {
- return {
- tableData: [],
- groupedItems: [],
- groupCounts: [],
- };
- }
-
- const groupedItems = validators.reduce<{
- preferred: GroupedItem;
- other: GroupedItem;
- }>(
- (acc, val) => {
- if (val.preferred) {
- acc.preferred.items.push(val);
- } else if (showExpandedValidators) {
- acc.other.items.push(val);
- }
-
- return acc;
- },
- {
- preferred: {
- items: [] as ValidatorDto[],
- label: t("details.validators_preferred"),
- },
- other: {
- items: [] as ValidatorDto[],
- label: t("details.validators_other"),
- },
- }
- );
-
- // If we do not have preferred validators, show all other
- if (!groupedItems.preferred.items.length && validators.length) {
- const canViewMore = !hasRequestedMoreValidators && !!hasMore;
-
- return {
- tableData: validators,
- groupedItems: [
- {
- items: validators,
- label: t("details.validators_other"),
- },
- ...(canViewMore ? [{ items: [], label: "view_more" }] : []),
- ],
- groupCounts: [validators.length, ...(canViewMore ? [1] : [])],
- };
- }
-
- const canViewMore =
- !hasRequestedMoreValidators &&
- (!!hasMore ||
- (!showExpandedValidators &&
- groupedItems.preferred.items.length !== validators.length));
-
- const groupedItemsValues = Object.values(groupedItems);
-
- return {
- tableData: groupedItemsValues.flatMap((val) => val.items),
- groupedItems: [
- ...groupedItemsValues.filter((val) => !!val.items.length),
- ...(canViewMore ? [{ items: [], label: "view_more" }] : []),
- ],
- groupCounts: [
- ...groupedItemsValues
- .filter((val) => !!val.items.length)
- .map((val) => val.items.length),
- ...(canViewMore ? [1] : []),
- ],
- };
- }, [
- hasMore,
- validators,
- t,
- hasRequestedMoreValidators,
- showExpandedValidators,
- ]);
-
- const searchProps = rest.onSearch
- ? {
- onSearch: rest.onSearch,
- searchValue: rest.searchValue,
- }
- : {};
- const infiniteScrollProps =
- hasRequestedMoreValidators && onLoadMore
- ? {
- hasNextPage: !!hasMore,
- isFetchingNextPage: !!isLoadingMore,
- fetchNextPage: onLoadMore,
- }
- : {};
- const emptyMessage = rest.searchValue?.trim().length
- ? t("details.validators_no_results")
- : t("details.validators_empty");
-
- return (
-
- {data.groupCounts.length ? (
-
- ) : isLoading ? null : (
-
- {emptyMessage}
-
- )}
-
- {children}
-
- );
-};
diff --git a/packages/widget/src/components/molecules/select-validator/styles.css.ts b/packages/widget/src/components/molecules/select-validator/styles.css.ts
deleted file mode 100644
index d0c46723..00000000
--- a/packages/widget/src/components/molecules/select-validator/styles.css.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import { style } from "@vanilla-extract/css";
-import { atoms } from "../../../styles/theme/atoms.css";
-import { vars } from "../../../styles/theme/contract.css";
-
-export const validatorVirtuosoContainer = style([atoms({ marginTop: "2" })]);
-
-export const emptyState = style([
- atoms({
- display: "flex",
- justifyContent: "center",
- alignItems: "center",
- px: "4",
- }),
- {
- minHeight: "180px",
- },
-]);
-
-const breakWord = style({ wordBreak: "break-all" });
-
-export const modalItemNameContainer = style([
- atoms({ marginRight: "2" }),
- breakWord,
- {
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- },
-]);
-
-export const inactiveContainer = style([
- atoms({
- borderRadius: "base",
- background: "positionsActionRequiredBackground",
- }),
- { padding: "2px 6px" },
-]);
-
-export const noWrap = style({ whiteSpace: "nowrap" });
-
-export const rewardRateText = style([
- atoms({ color: "positionsRewardRate", fontWeight: "bold" }),
- { whiteSpace: "nowrap" },
-]);
-
-export const rewardRateLabel = style({
- textTransform: "uppercase",
- whiteSpace: "nowrap",
-});
-
-export const groupLabel = style({
- textTransform: "uppercase",
- letterSpacing: "0.06em",
-});
-
-export const addressParent = style({});
-
-export const addressHover = style({
- selectors: {
- [`${addressParent}:hover &`]: { color: vars.color.text },
- },
-});
diff --git a/packages/widget/src/components/molecules/select-yield/index.tsx b/packages/widget/src/components/molecules/select-yield/index.tsx
deleted file mode 100644
index 6e55d98c..00000000
--- a/packages/widget/src/components/molecules/select-yield/index.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-import type { PropsWithChildren } from "react";
-import { useMemo } from "react";
-import { useTranslation } from "react-i18next";
-import type { Yield } from "../../../domain/types/yields";
-import { useMultiYields } from "../../../hooks/api/use-multi-yields";
-import { Box } from "../../atoms/box";
-import type { SelectModalProps } from "../../atoms/select-modal";
-import {
- SelectModal,
- SelectModalItemContainer,
-} from "../../atoms/select-modal";
-import { VirtualList } from "../../atoms/virtual-list";
-import { SelectOpportunityListItem } from "../select-opportunity-list-item";
-
-type SelectYieldProps = PropsWithChildren<
- Pick & {
- onItemClick: (yieldDto: Yield) => void;
- providerYieldIds: Yield["id"][];
- selectedYieldId?: Yield["id"];
- }
->;
-
-export const SelectYield = ({
- state,
- onClose,
- onOpen,
- trigger,
- providerYieldIds,
- onItemClick,
- selectedYieldId,
- children,
-}: SelectYieldProps) => {
- const { t } = useTranslation();
-
- const multiYields = useMultiYields(providerYieldIds);
-
- const data = useMemo(() => multiYields.data ?? [], [multiYields.data]);
-
- return (
-
-
- (
-
-
-
- )}
- estimateSize={() => 40}
- />
-
-
- {children}
-
- );
-};
diff --git a/packages/widget/src/components/molecules/summary-item/index.tsx b/packages/widget/src/components/molecules/summary-item/index.tsx
deleted file mode 100644
index 32a0397a..00000000
--- a/packages/widget/src/components/molecules/summary-item/index.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-import type BigNumber from "bignumber.js";
-import { useSettings } from "../../../providers/settings";
-import { formatNumber } from "../../../utils";
-import { combineRecipeWithVariant } from "../../../utils/styles";
-import { Box } from "../../atoms/box";
-import { Spinner } from "../../atoms/spinner";
-import { Text } from "../../atoms/typography/text";
-import {
- type SummaryLabelContainerVariants,
- summaryItem,
- summaryLabel,
- summaryLabelContainer,
- summaryNumber,
-} from "./index.css";
-
-export const SummaryItem = ({
- label,
- value,
- isLoading,
- type,
-}: {
- label: string;
- value: BigNumber | undefined;
- isLoading: boolean;
- type: NonNullable["type"];
-}) => {
- const { variant } = useSettings();
-
- const isApyType = type === "apy";
-
- return (
-
- {isLoading ? (
-
- ) : (
-
- {value?.gt(0)
- ? isApyType
- ? `${formatNumber(value, 2)}%`
- : `$${formatNumber(value, 3)}`
- : "-"}
-
- )}
-
-
-
-
- {label}
-
-
-
-
- );
-};
diff --git a/packages/widget/src/components/molecules/tos-modal/index.tsx b/packages/widget/src/components/molecules/tos-modal/index.tsx
deleted file mode 100644
index f34b526a..00000000
--- a/packages/widget/src/components/molecules/tos-modal/index.tsx
+++ /dev/null
@@ -1,371 +0,0 @@
-import {
- Close,
- Content,
- Overlay,
- Portal,
- Root,
- Title,
- Trigger,
-} from "@radix-ui/react-dialog";
-import type { PropsWithChildren, ReactNode } from "react";
-import { useContext, useEffect } from "react";
-import { Trans, useTranslation } from "react-i18next";
-import { useSettings } from "../../../providers/settings";
-import { TrackingContext } from "../../../providers/tracking";
-import { id } from "../../../styles/theme/ids";
-import { SKAnchor } from "../../atoms/anchor";
-import { Box } from "../../atoms/box";
-import { Button } from "../../atoms/button";
-import { XIcon } from "../../atoms/icons/x-icon";
-import { Heading } from "../../atoms/typography/heading";
-import { Text } from "../../atoms/typography/text";
-import {
- closeButton,
- container,
- content,
- contentContainer,
- overlay,
- sectionContainer,
- termsText,
-} from "./style.css";
-
-type TosModalProps = {
- customTrigger?: ReactNode;
- onAccept: () => void;
- onDecline: () => void;
- isOpen: boolean;
-};
-
-export const TosModal = ({
- customTrigger,
- onAccept,
- onDecline,
- isOpen,
-}: TosModalProps) => {
- const { t } = useTranslation();
- const { portalContainer } = useSettings();
- const trackEvent = useContext(TrackingContext)?.trackEvent;
-
- useEffect(() => {
- if (isOpen) {
- trackEvent?.("termsModalOpened");
- }
- }, [isOpen, trackEvent]);
-
- const handleAccept = () => {
- trackEvent?.("termsModalAccepted");
- onAccept();
- };
-
- const handleDecline = () => {
- trackEvent?.("termsModalDeclined");
- onDecline();
- };
-
- return (
-
- {customTrigger && {customTrigger}}
-
-
-
-
-
-
-
-
-
- {t("tos_modal.title")}
-
-
-
-
-
-
-
-
-
-
-
- {t("tos_modal.introduction")}
-
-
-
- {t("tos_modal.additional_terms")}
-
-
-
- {t("tos_modal.risk_warning")}
-
-
-
- {t("tos_modal.agreement_binding")}
-
-
-
-
- {t("tos_modal.sections.stakekit_services.title")}
-
-
- {t("tos_modal.sections.stakekit_services.content")}
-
-
-
-
-
- {t("tos_modal.sections.agreement_to_terms.title")}
-
-
- {t("tos_modal.sections.agreement_to_terms.content")}
-
-
-
-
-
- {t("tos_modal.sections.intellectual_property.title")}
-
-
- {t("tos_modal.sections.intellectual_property.content_1")}
-
-
- {t("tos_modal.sections.intellectual_property.content_2")}
-
-
-
-
-
- {t("tos_modal.sections.user_content.title")}
-
-
- {t("tos_modal.sections.user_content.content_1")}
-
-
- {t("tos_modal.sections.user_content.content_2")}
-
-
- {t("tos_modal.sections.user_content.content_3")}
-
-
-
-
-
- {t("tos_modal.sections.acceptable_use.title")}
-
-
- {t("tos_modal.sections.acceptable_use.intro")}
-
-
- {t("tos_modal.sections.acceptable_use.violation_laws")}
-
-
- {t("tos_modal.sections.acceptable_use.harm_minors")}
-
-
- {t("tos_modal.sections.acceptable_use.impersonation")}
-
-
- {t("tos_modal.sections.acceptable_use.restrict_use")}
-
-
- {t("tos_modal.sections.acceptable_use.interfere")}
-
-
- {t("tos_modal.sections.acceptable_use.additional")}
-
-
-
-
-
- {t("tos_modal.sections.termination.title")}
-
-
- {t("tos_modal.sections.termination.content")}
-
-
-
-
-
- {t("tos_modal.sections.fees.title")}
-
- {t("tos_modal.sections.fees.content")}
-
-
-
-
- {t("tos_modal.sections.taxes.title")}
-
- {t("tos_modal.sections.taxes.content")}
-
-
-
-
- {t("tos_modal.sections.confidentiality.title")}
-
-
- {t("tos_modal.sections.confidentiality.content_1")}
-
-
- {t("tos_modal.sections.confidentiality.content_2")}
-
-
-
-
-
- {t("tos_modal.sections.non_disclosure.title")}
-
-
- {t("tos_modal.sections.non_disclosure.content_1")}
-
-
- {t("tos_modal.sections.non_disclosure.content_2")}
-
-
-
-
-
- {t("tos_modal.sections.required_disclosures.title")}
-
-
- {t("tos_modal.sections.required_disclosures.content")}
-
-
-
-
-
- {t("tos_modal.sections.return_confidential.title")}
-
-
- {t("tos_modal.sections.return_confidential.content")}
-
-
-
-
-
- {t("tos_modal.sections.limitation_liability.title")}
-
-
- {t("tos_modal.sections.limitation_liability.content_1")}
-
-
- {t("tos_modal.sections.limitation_liability.content_2")}
-
-
-
-
-
- {t("tos_modal.sections.dispute_resolution.title")}
-
-
- {t("tos_modal.sections.dispute_resolution.content_1")}
-
-
- {t("tos_modal.sections.dispute_resolution.content_2")}
-
-
- {t("tos_modal.sections.dispute_resolution.content_3")}
-
-
-
-
-
- {t("tos_modal.sections.governing_law.title")}
-
-
- {t("tos_modal.sections.governing_law.content")}
-
-
-
-
-
- {t("tos_modal.sections.class_action.title")}
-
-
- {t("tos_modal.sections.class_action.content")}
-
-
-
-
-
- {t("tos_modal.sections.time_limits.title")}
-
-
- {t("tos_modal.sections.time_limits.content")}
-
-
-
-
-
- {t("tos_modal.sections.changes_terms.title")}
-
-
- {t("tos_modal.sections.changes_terms.content")}
-
-
-
-
-
- {t("tos_modal.sections.privacy_policy.title")}
-
-
- {t("tos_modal.sections.privacy_policy.content_1")}
-
-
- {t("tos_modal.sections.privacy_policy.content_2")}
-
-
-
-
-
- {t("tos_modal.sections.contact.title")}
-
-
- {t("tos_modal.sections.contact.content")}
-
-
-
-
-
- }}
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-};
-
-const TermsText = ({ children }: PropsWithChildren) => {
- return {children};
-};
diff --git a/packages/widget/src/components/molecules/tos-modal/style.css.ts b/packages/widget/src/components/molecules/tos-modal/style.css.ts
deleted file mode 100644
index 75076ebd..00000000
--- a/packages/widget/src/components/molecules/tos-modal/style.css.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import { keyframes, style } from "@vanilla-extract/css";
-import { atoms } from "../../../styles/theme/atoms.css";
-
-const fadeIn = keyframes({
- "0%": { opacity: 0 },
- "100%": { opacity: 1 },
-});
-
-export const overlay = style([
- atoms({
- background: "modalOverlayBackground",
- zIndex: "overlay",
- }),
- {
- position: "fixed",
- inset: 0,
- animation: `${fadeIn} 150ms ease`,
- willChange: "opacity",
- },
-]);
-
-export const content = style([
- atoms({ background: "modalBodyBackground", zIndex: "modal" }),
- {
- borderRadius: "8px",
- boxShadow: "0px 15px 30px 0px #0000000D",
- position: "fixed",
- top: "50%",
- left: "50%",
- transform: "translate(-50%, -50%)",
- width: "650px",
- maxHeight: "85vh",
- overflow: "hidden",
- },
-]);
-
-export const container = style({
- padding: "24px",
- maxHeight: "80vh",
- overflow: "auto",
-});
-
-export const contentContainer = style({
- display: "flex",
- flexDirection: "column",
- gap: "16px",
-});
-
-export const sectionContainer = style({
- display: "flex",
- flexDirection: "column",
- gap: "8px",
- marginBottom: "8px",
-});
-
-export const closeButton = style({
- background: "none",
- border: "none",
- cursor: "pointer",
- padding: "4px",
- borderRadius: "4px",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- ":hover": {
- backgroundColor: "rgba(100, 23, 23, 0.1)",
- },
-});
-
-export const termsText = style([
- atoms({ color: "textMuted" }),
- {
- fontSize: "12px",
- lineHeight: "145%",
- },
-]);
diff --git a/packages/widget/src/components/molecules/yield-risk/index.tsx b/packages/widget/src/components/molecules/yield-risk/index.tsx
deleted file mode 100644
index 7360f98a..00000000
--- a/packages/widget/src/components/molecules/yield-risk/index.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-import { useTranslation } from "react-i18next";
-import {
- getYieldRiskDisplay,
- getYieldRiskSourceLabel,
- type Yield,
- type YieldRiskDisplay,
-} from "../../../domain/types/yields";
-import { Box } from "../../atoms/box";
-import { InfoIcon } from "../../atoms/icons/info";
-import { ToolTip } from "../../atoms/tooltip";
-import { Text } from "../../atoms/typography/text";
-import {
- riskInfoButton,
- riskRatingBadge,
- riskRatingBadgeText,
- riskSummaryActions,
- riskSummaryContainer,
-} from "./styles.css";
-
-export { riskSummaryActions } from "./styles.css";
-
-type RiskRatingBadgeProps = {
- risk: YieldRiskDisplay;
- size?: "compact" | "default";
- testId?: string;
-};
-
-export const YieldRiskInfoTooltip = () => {
- const { t } = useTranslation();
-
- return (
-
-
-
-
-
- );
-};
-
-export const RiskRatingBadge = ({
- risk,
- size = "compact",
- testId,
-}: RiskRatingBadgeProps) => {
- const { t } = useTranslation();
-
- return (
-
-
- {risk.rating}
-
-
- );
-};
-
-export const YieldRiskRatingSummary = ({ yieldDto }: { yieldDto: Yield }) => {
- const { t } = useTranslation();
- const risk = getYieldRiskDisplay(yieldDto);
-
- if (!risk) return null;
-
- const sourceLabel = getYieldRiskSourceLabel(risk.source, t);
-
- return (
-
-
- {t("details.risk.title")}
-
- {t("details.risk.rated_by", { source: sourceLabel })}
-
-
-
-
-
-
-
-
-
- );
-};
diff --git a/packages/widget/src/components/molecules/zerion-chain-modal/index.tsx b/packages/widget/src/components/molecules/zerion-chain-modal/index.tsx
deleted file mode 100644
index 8c337c0a..00000000
--- a/packages/widget/src/components/molecules/zerion-chain-modal/index.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import { Maybe } from "purify-ts";
-import { useCallback, useMemo } from "react";
-import { useSettings } from "../../../providers/settings";
-import type { VariantProps } from "../../../providers/settings/types";
-import { useSKWallet } from "../../../providers/sk-wallet";
-import { Box } from "../../atoms/box";
-
-export const ZerionChainModal = () => {
- const settings = useSettings();
-
- const { connectorChains, connector, chain } = useSKWallet();
-
- const chainIds = useMemo(
- () => connectorChains.map((c) => c.id),
- [connectorChains]
- );
-
- const switchChain = connector?.switchChain;
-
- const onSwitchChain = useCallback<
- Parameters<
- Extract["chainModal"]
- >[0]["onSwitchChain"]
- >((chainId) => switchChain?.({ chainId }), [switchChain]);
-
- if (settings.variant !== "zerion" || !switchChain || !connector) return null;
-
- return Maybe.fromNullable(
- settings.chainModal({
- chainIds,
- selectedChainId: chain.id,
- onSwitchChain,
- })
- )
- .map((elem) => (
-
- {elem}
-
- ))
- .extractNullable();
-};
diff --git a/packages/widget/src/config/index.ts b/packages/widget/src/config/index.ts
deleted file mode 100644
index 0a04004d..00000000
--- a/packages/widget/src/config/index.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-const assetsUrl = "https://assets.stakek.it";
-
-export const config = {
- appName: "Yield.xyz",
- appIcon: `${assetsUrl}/stakekit/sk-icon_320x320.png`,
- appUrl: "https://yield.xyz",
- currency: "USD",
- queryClient: {
- staleTime: 1000 * 5,
- cacheTime: 1000 * 60 * 5,
- },
- walletConnectV2: {
- projectId: "10a5257c04d1d3097ff768a139c95f50",
- },
- appPrefix: "sk-widget",
- env: {
- apiUrl: import.meta.env.VITE_API_URL ?? "https://api.stakek.it/",
- yieldsApiUrl:
- import.meta.env.VITE_YIELDS_API_URL ?? "https://api.yield.xyz",
- isTestMode: import.meta.env.MODE === "test",
- isDevMode: import.meta.env.MODE === "development",
- forceAddress: import.meta.env.VITE_FORCE_ADDRESS,
- },
- assetsUrl,
- zerion: {
- tracking: "2cbed59baa70c46f7cadef7b05eb1bc3",
- iconsByAddress: "https://token-icons.s3.amazonaws.com/",
- iconsByNetwork: "https://chain-icons.s3.amazonaws.com/",
- },
-} as const;
diff --git a/packages/widget/src/domain/borrow/action-definition.ts b/packages/widget/src/domain/borrow/action-definition.ts
new file mode 100644
index 00000000..562a117c
--- /dev/null
+++ b/packages/widget/src/domain/borrow/action-definition.ts
@@ -0,0 +1,6 @@
+import { Schema } from "effect";
+import * as BorrowApi from "../../generated/api/borrow";
+
+export class ActionDefinition extends Schema.Class(
+ "BorrowActionDefinition"
+)(BorrowApi.ActionDefinitionDto.fields) {}
diff --git a/packages/widget/src/domain/borrow/action-request.ts b/packages/widget/src/domain/borrow/action-request.ts
new file mode 100644
index 00000000..7345d49f
--- /dev/null
+++ b/packages/widget/src/domain/borrow/action-request.ts
@@ -0,0 +1,244 @@
+import BigNumber from "bignumber.js";
+import { Schema } from "effect";
+import * as BorrowApi from "../../generated/api/borrow";
+import type { CollateralToken } from "./collateral-token";
+import { IntegrationId, MarketId, TokenAddress, WalletAddress } from "./ids";
+import type { Market } from "./market";
+
+type AmountValue = BigNumber | number | string;
+
+type BorrowFormInput = {
+ readonly borrowAmount: AmountValue;
+ readonly collateralAmount: AmountValue;
+ readonly selectedCollateralToken: CollateralToken | null;
+ readonly selectedMarket: Market | null;
+};
+
+type BorrowFormBase = {
+ readonly selectedMarket: Market;
+};
+
+export type BorrowPlusCollateralForm = BorrowFormBase & {
+ readonly _tag: "BorrowPlusCollateral";
+ readonly borrowAmount: BigNumber;
+ readonly collateralAmount: BigNumber;
+ readonly selectedCollateralToken: CollateralToken;
+};
+
+export type BorrowOnlyForm = BorrowFormBase & {
+ readonly _tag: "BorrowOnly";
+ readonly borrowAmount: BigNumber;
+};
+
+export type CollateralOnlyForm = BorrowFormBase & {
+ readonly _tag: "CollateralOnly";
+ readonly collateralAmount: BigNumber;
+ readonly selectedCollateralToken: CollateralToken;
+};
+
+export type DecodedBorrowForm =
+ | BorrowPlusCollateralForm
+ | BorrowOnlyForm
+ | CollateralOnlyForm;
+
+export const ActionRequest = Schema.Struct({
+ ...BorrowApi.ActionRequestDto.fields,
+ integrationId: IntegrationId,
+ address: WalletAddress,
+ args: Schema.Struct({
+ ...BorrowApi.ActionRequestDto.fields.args.fields,
+ marketId: MarketId,
+ tokenAddress: Schema.optionalKey(TokenAddress),
+ collateralTokenAddress: Schema.optionalKey(TokenAddress),
+ }),
+});
+export type ActionRequest = typeof ActionRequest.Type;
+
+type BaseActionRequestInput = {
+ readonly address: WalletAddress;
+ readonly integrationId: IntegrationId;
+ readonly marketId: MarketId;
+};
+
+type RepayActionRequestInput = BaseActionRequestInput &
+ (
+ | {
+ readonly amount: AmountValue;
+ readonly repayAll?: false;
+ }
+ | {
+ readonly amount?: never;
+ readonly repayAll: true;
+ }
+ ) & {
+ readonly tokenAddress?: TokenAddress;
+ };
+
+type WithdrawActionRequestInput = BaseActionRequestInput & {
+ readonly amount: AmountValue;
+ readonly tokenAddress?: TokenAddress;
+};
+
+type CollateralToggleActionRequestInput = BaseActionRequestInput & {
+ readonly action: "enableCollateral" | "disableCollateral";
+ readonly tokenAddress?: TokenAddress;
+};
+
+const toAmount = (amount: AmountValue) => new BigNumber(amount);
+
+const toActionAmount = (amount: AmountValue) => toAmount(amount).toString(10);
+
+const tokenAddressArg = (tokenAddress: TokenAddress | undefined) =>
+ tokenAddress ? { tokenAddress } : {};
+
+const collateralTokenAddressArg = (tokenAddress: TokenAddress | undefined) =>
+ tokenAddress ? { collateralTokenAddress: tokenAddress } : {};
+
+export const decodeBorrowForm = (
+ input: BorrowFormInput
+): DecodedBorrowForm | null => {
+ if (!input.selectedMarket) {
+ return null;
+ }
+
+ const borrowAmount = toAmount(input.borrowAmount);
+ const collateralAmount = toAmount(input.collateralAmount);
+ const hasBorrowAmount = borrowAmount.gt(0);
+ const hasCollateralAmount = collateralAmount.gt(0);
+
+ if (hasBorrowAmount && hasCollateralAmount && input.selectedCollateralToken) {
+ return {
+ _tag: "BorrowPlusCollateral",
+ borrowAmount,
+ collateralAmount,
+ selectedCollateralToken: input.selectedCollateralToken,
+ selectedMarket: input.selectedMarket,
+ };
+ }
+
+ if (hasBorrowAmount) {
+ return {
+ _tag: "BorrowOnly",
+ borrowAmount,
+ selectedMarket: input.selectedMarket,
+ };
+ }
+
+ if (hasCollateralAmount && input.selectedCollateralToken) {
+ return {
+ _tag: "CollateralOnly",
+ collateralAmount,
+ selectedCollateralToken: input.selectedCollateralToken,
+ selectedMarket: input.selectedMarket,
+ };
+ }
+
+ return null;
+};
+
+export const isDecodedBorrow = (
+ form: DecodedBorrowForm
+): form is BorrowOnlyForm | BorrowPlusCollateralForm =>
+ form._tag === "BorrowOnly" || form._tag === "BorrowPlusCollateral";
+
+export const isDecodedCollateral = (
+ form: DecodedBorrowForm
+): form is CollateralOnlyForm | BorrowPlusCollateralForm =>
+ form._tag === "CollateralOnly" || form._tag === "BorrowPlusCollateral";
+
+export const buildBorrowActionRequest = ({
+ address,
+ form,
+ integrationId = form.selectedMarket.integrationId,
+}: {
+ readonly address: WalletAddress;
+ readonly form: DecodedBorrowForm;
+ readonly integrationId?: IntegrationId;
+}): ActionRequest => {
+ if (form._tag === "CollateralOnly") {
+ return {
+ action: "supply",
+ address,
+ args: {
+ amount: toActionAmount(form.collateralAmount),
+ marketId: form.selectedMarket.id,
+ ...tokenAddressArg(form.selectedCollateralToken.token.address),
+ },
+ integrationId,
+ };
+ }
+
+ return {
+ action: "borrow",
+ address,
+ args: {
+ amount: toActionAmount(form.borrowAmount),
+ marketId: form.selectedMarket.id,
+ ...tokenAddressArg(form.selectedMarket.loanToken.address),
+ ...(form._tag === "BorrowPlusCollateral"
+ ? {
+ collateralAmount: toActionAmount(form.collateralAmount),
+ ...collateralTokenAddressArg(
+ form.selectedCollateralToken.token.address
+ ),
+ }
+ : {}),
+ },
+ integrationId,
+ };
+};
+
+export const buildRepayActionRequest = ({
+ address,
+ amount,
+ integrationId,
+ marketId,
+ repayAll,
+ tokenAddress,
+}: RepayActionRequestInput): ActionRequest => ({
+ action: "repay",
+ address,
+ args: {
+ marketId,
+ ...(repayAll
+ ? { repayAll: true }
+ : {
+ amount: toActionAmount(amount),
+ }),
+ ...tokenAddressArg(tokenAddress),
+ },
+ integrationId,
+});
+
+export const buildWithdrawActionRequest = ({
+ address,
+ amount,
+ integrationId,
+ marketId,
+ tokenAddress,
+}: WithdrawActionRequestInput): ActionRequest => ({
+ action: "withdraw",
+ address,
+ args: {
+ amount: toActionAmount(amount),
+ marketId,
+ ...tokenAddressArg(tokenAddress),
+ },
+ integrationId,
+});
+
+export const buildCollateralToggleActionRequest = ({
+ action,
+ address,
+ integrationId,
+ marketId,
+ tokenAddress,
+}: CollateralToggleActionRequestInput): ActionRequest => ({
+ action,
+ address,
+ args: {
+ marketId,
+ ...tokenAddressArg(tokenAddress),
+ },
+ integrationId,
+});
diff --git a/packages/widget/src/domain/borrow/action.ts b/packages/widget/src/domain/borrow/action.ts
new file mode 100644
index 00000000..4f27c797
--- /dev/null
+++ b/packages/widget/src/domain/borrow/action.ts
@@ -0,0 +1,45 @@
+import { Schema } from "effect";
+import * as BorrowApi from "../../generated/api/borrow";
+import {
+ ActionId,
+ IntegrationId,
+ MarketId,
+ TokenAddress,
+ WalletAddress,
+} from "./ids";
+import { Transaction } from "./transaction";
+
+class ActionRawArguments extends Schema.Class(
+ "BorrowActionRawArguments"
+)({
+ ...BorrowApi.ActionDto.fields.rawArguments.schema.fields,
+ marketId: MarketId,
+ tokenAddress: Schema.optionalKey(TokenAddress),
+ collateralTokenAddress: Schema.optionalKey(TokenAddress),
+ amount: Schema.optionalKey(Schema.FiniteFromString),
+ amountRaw: Schema.optionalKey(Schema.BigIntFromString),
+ collateralAmount: Schema.optionalKey(Schema.FiniteFromString),
+ collateralAmountRaw: Schema.optionalKey(Schema.BigIntFromString),
+}) {}
+
+class ActionMetadata extends Schema.Class(
+ "BorrowActionMetadata"
+)({
+ currentHealthFactor: Schema.NullOr(Schema.FiniteFromString),
+ predictedHealthFactor: Schema.NullOr(Schema.FiniteFromString),
+ currentLtv: Schema.FiniteFromString,
+ predictedLtv: Schema.FiniteFromString,
+ liquidationThreshold: Schema.FiniteFromString,
+ predictedTotalSupplyUsd: Schema.FiniteFromString,
+ predictedTotalDebtUsd: Schema.FiniteFromString,
+}) {}
+
+export class Action extends Schema.Class("BorrowAction")({
+ ...BorrowApi.ActionDto.fields,
+ id: ActionId,
+ integrationId: IntegrationId,
+ address: WalletAddress,
+ transactions: Schema.Array(Transaction),
+ rawArguments: Schema.optionalKey(ActionRawArguments),
+ metadata: Schema.optionalKey(ActionMetadata),
+}) {}
diff --git a/packages/widget/src/domain/borrow/collateral-token.ts b/packages/widget/src/domain/borrow/collateral-token.ts
new file mode 100644
index 00000000..5230afc9
--- /dev/null
+++ b/packages/widget/src/domain/borrow/collateral-token.ts
@@ -0,0 +1,15 @@
+import { Schema } from "effect";
+import * as BorrowApi from "../../generated/api/borrow";
+import { BorrowToken } from "./token";
+
+export class CollateralToken extends Schema.Class(
+ "BorrowCollateralToken"
+)({
+ ...BorrowApi.CollateralTokenDto.fields,
+ token: BorrowToken,
+ priceUsd: Schema.FiniteFromString,
+ maxLtv: Schema.FiniteFromString,
+ liquidationThreshold: Schema.FiniteFromString,
+ liquidationPenalty: Schema.FiniteFromString,
+ supplyRate: Schema.FiniteFromString,
+}) {}
diff --git a/packages/widget/src/domain/borrow/ids.ts b/packages/widget/src/domain/borrow/ids.ts
new file mode 100644
index 00000000..65ed286b
--- /dev/null
+++ b/packages/widget/src/domain/borrow/ids.ts
@@ -0,0 +1,62 @@
+import { identity, Schema, SchemaTransformation } from "effect";
+
+import {
+ TokenAddress as CanonicalTokenAddress,
+ WalletAddress as CanonicalWalletAddress,
+} from "../schema/identifiers";
+
+export const ChainId = Schema.String.pipe(Schema.brand("BorrowChainId"));
+export type ChainId = typeof ChainId.Type;
+
+export const decodeChainId = Schema.decodeSync(
+ Schema.Union([Schema.String, Schema.Number]).pipe(
+ Schema.decodeTo(
+ ChainId,
+ SchemaTransformation.transform({
+ decode: (value) => value.toString(),
+ encode: identity,
+ })
+ )
+ )
+);
+
+export const WalletAddress = Schema.String.check(Schema.isPattern(/^0x/)).pipe(
+ Schema.decodeTo(CanonicalWalletAddress)
+);
+export type WalletAddress = typeof CanonicalWalletAddress.Type;
+
+export const ActionId = Schema.String.pipe(Schema.brand("BorrowActionId"));
+export type ActionId = typeof ActionId.Type;
+
+export const IntegrationId = Schema.String.pipe(
+ Schema.brand("BorrowIntegrationId")
+);
+export type IntegrationId = typeof IntegrationId.Type;
+
+export const MarketId = Schema.String.pipe(Schema.brand("BorrowMarketId"));
+export type MarketId = typeof MarketId.Type;
+
+export const TokenAddress = Schema.String.pipe(
+ Schema.decode(SchemaTransformation.toLowerCase()),
+ Schema.decodeTo(CanonicalTokenAddress)
+);
+export type TokenAddress = typeof CanonicalTokenAddress.Type;
+
+export const TransactionId = Schema.String.pipe(
+ Schema.brand("BorrowTransactionId")
+);
+export type TransactionId = typeof TransactionId.Type;
+
+const TokenId = Schema.TemplateLiteral([
+ Schema.String,
+ Schema.Literal("::"),
+ TokenAddress,
+]).pipe(Schema.brand("BorrowTokenId"));
+
+export const decodeTokenId = ({
+ symbol,
+ address,
+}: {
+ readonly symbol: string;
+ readonly address?: TokenAddress;
+}) => Schema.decodeSync(TokenId)(`${symbol}::${address ?? ""}`);
diff --git a/packages/widget/src/domain/borrow/index.ts b/packages/widget/src/domain/borrow/index.ts
new file mode 100644
index 00000000..b65d0795
--- /dev/null
+++ b/packages/widget/src/domain/borrow/index.ts
@@ -0,0 +1,16 @@
+export * from "./action";
+export * from "./action-definition";
+export * from "./action-request";
+export * from "./collateral-token";
+export * from "./ids";
+export * from "./integration";
+export * from "./market";
+export * from "./network";
+export * from "./pending-action";
+export * from "./position";
+export * from "./position-items";
+export * from "./position-projection";
+export * from "./responses";
+export * from "./token";
+export * from "./transaction";
+export * from "./wallet";
diff --git a/packages/widget/src/domain/borrow/integration.ts b/packages/widget/src/domain/borrow/integration.ts
new file mode 100644
index 00000000..66dcd7a3
--- /dev/null
+++ b/packages/widget/src/domain/borrow/integration.ts
@@ -0,0 +1,12 @@
+import { Schema } from "effect";
+import * as BorrowApi from "../../generated/api/borrow";
+import { ActionDefinition } from "./action-definition";
+import { IntegrationId } from "./ids";
+
+export class Integration extends Schema.Class("BorrowIntegration")(
+ {
+ ...BorrowApi.IntegrationDto.fields,
+ id: IntegrationId,
+ actions: Schema.Array(ActionDefinition),
+ }
+) {}
diff --git a/packages/widget/src/domain/borrow/market.ts b/packages/widget/src/domain/borrow/market.ts
new file mode 100644
index 00000000..b13fd97c
--- /dev/null
+++ b/packages/widget/src/domain/borrow/market.ts
@@ -0,0 +1,52 @@
+import { Schema } from "effect";
+import * as BorrowApi from "../../generated/api/borrow";
+import { CollateralToken } from "./collateral-token";
+import { IntegrationId, MarketId } from "./ids";
+import { BorrowNetwork } from "./network";
+import { BorrowToken } from "./token";
+
+export class Market extends Schema.Class("BorrowMarket")({
+ ...BorrowApi.MarketDto.fields,
+ id: MarketId,
+ integrationId: IntegrationId,
+ network: BorrowNetwork,
+ loanToken: BorrowToken,
+ collateralTokens: Schema.Array(CollateralToken),
+ borrowRate: Schema.FiniteFromString,
+ totalSupply: Schema.FiniteFromString,
+ totalSupplyRaw: Schema.BigIntFromString,
+ totalBorrow: Schema.FiniteFromString,
+ totalBorrowRaw: Schema.BigIntFromString,
+ availableLiquidity: Schema.FiniteFromString,
+ availableLiquidityRaw: Schema.BigIntFromString,
+ utilizationRate: Schema.FiniteFromString,
+ loanTokenPriceUsd: Schema.FiniteFromString,
+}) {
+ getMaxLtv() {
+ if (this.collateralTokens.length === 0) {
+ return null;
+ }
+
+ return Math.min(...this.collateralTokens.map((token) => token.maxLtv));
+ }
+
+ getLiquidationPenalty() {
+ if (this.collateralTokens.length === 0) {
+ return null;
+ }
+
+ return Math.max(
+ ...this.collateralTokens.map((token) => token.liquidationPenalty)
+ );
+ }
+
+ getLiquidationThreshold() {
+ if (this.collateralTokens.length === 0) {
+ return null;
+ }
+
+ return Math.min(
+ ...this.collateralTokens.map((token) => token.liquidationThreshold)
+ );
+ }
+}
diff --git a/packages/widget/src/domain/borrow/network.ts b/packages/widget/src/domain/borrow/network.ts
new file mode 100644
index 00000000..0be5e8b4
--- /dev/null
+++ b/packages/widget/src/domain/borrow/network.ts
@@ -0,0 +1,49 @@
+import { Schema } from "effect";
+import type { Chain } from "viem";
+import { arbitrum, base, mainnet, optimism } from "viem/chains";
+import { type ChainId, decodeChainId } from "./ids";
+
+export const BorrowNetwork = Schema.Literals([
+ "ethereum",
+ "base",
+ "arbitrum",
+ "optimism",
+]);
+export type BorrowNetwork = typeof BorrowNetwork.Type;
+
+export const supportedBorrowNetworks = [
+ "ethereum",
+ "base",
+ "arbitrum",
+ "optimism",
+] as const satisfies ReadonlyArray;
+
+export const borrowChainsByNetwork: Record = {
+ ethereum: mainnet,
+ base,
+ arbitrum,
+ optimism,
+};
+
+export const borrowChainEntries = Object.entries(borrowChainsByNetwork) as [
+ BorrowNetwork,
+ Chain,
+][];
+
+export const borrowChainIdsToNetworks = Object.fromEntries(
+ borrowChainEntries.map(([network, chain]) => [
+ decodeChainId(chain.id),
+ network,
+ ])
+) as Record;
+
+export const borrowViemChains = Object.values(
+ borrowChainsByNetwork
+) as ReadonlyArray;
+
+export const isBorrowNetwork = Schema.is(BorrowNetwork);
+
+export const getBorrowNetworkForChainId = (
+ chainId: number | string
+): BorrowNetwork | null =>
+ borrowChainIdsToNetworks[decodeChainId(chainId)] ?? null;
diff --git a/packages/widget/src/domain/borrow/pending-action.ts b/packages/widget/src/domain/borrow/pending-action.ts
new file mode 100644
index 00000000..8269d87e
--- /dev/null
+++ b/packages/widget/src/domain/borrow/pending-action.ts
@@ -0,0 +1,57 @@
+import { Schema } from "effect";
+import { MarketId, TokenAddress } from "./ids";
+
+export class WithdrawPendingAction extends Schema.Class(
+ "BorrowWithdrawPendingAction"
+)({
+ type: Schema.Literal("withdraw"),
+ label: Schema.String,
+ args: Schema.Struct({
+ amountRaw: Schema.BigIntFromString,
+ tokenAddress: TokenAddress,
+ marketId: MarketId,
+ }),
+}) {}
+
+export class RepayPendingAction extends Schema.Class(
+ "BorrowRepayPendingAction"
+)({
+ type: Schema.Literal("repay"),
+ label: Schema.String,
+ args: Schema.Struct({
+ tokenAddress: TokenAddress,
+ marketId: MarketId,
+ }),
+}) {}
+
+const CollateralToggleArgs = Schema.Struct({
+ tokenAddress: TokenAddress,
+ marketId: MarketId,
+});
+
+export class EnableCollateralPendingAction extends Schema.Class(
+ "BorrowEnableCollateralPendingAction"
+)({
+ type: Schema.Literal("enableCollateral"),
+ label: Schema.String,
+ args: CollateralToggleArgs,
+}) {}
+
+export class DisableCollateralPendingAction extends Schema.Class(
+ "BorrowDisableCollateralPendingAction"
+)({
+ type: Schema.Literal("disableCollateral"),
+ label: Schema.String,
+ args: CollateralToggleArgs,
+}) {}
+
+export const PendingAction = Schema.Union([
+ WithdrawPendingAction,
+ RepayPendingAction,
+ EnableCollateralPendingAction,
+ DisableCollateralPendingAction,
+]);
+export type PendingAction = typeof PendingAction.Type;
+
+export const PendingActions = Schema.Array(PendingAction);
+export type PendingActions = typeof PendingActions.Type;
diff --git a/packages/widget/src/domain/borrow/position-items.ts b/packages/widget/src/domain/borrow/position-items.ts
new file mode 100644
index 00000000..9abfae13
--- /dev/null
+++ b/packages/widget/src/domain/borrow/position-items.ts
@@ -0,0 +1,124 @@
+import type { MarketId } from "./ids";
+import type { Integration } from "./integration";
+import type { Market } from "./market";
+import type { PendingAction } from "./pending-action";
+import type { BorrowAccountPosition } from "./position";
+import { Position, type SupplyBalance } from "./position";
+
+type IntegrationPosition = {
+ readonly integration: Integration;
+ readonly position: BorrowAccountPosition;
+};
+
+export const deriveBorrowPositionItems = ({
+ integrationPositions,
+ markets,
+}: {
+ readonly integrationPositions: ReadonlyArray;
+ readonly markets: ReadonlyArray;
+}): Position[] => {
+ const marketsById = new Map(markets.map((market) => [market.id, market]));
+ const positionsByMarketId = new Map();
+
+ for (const integrationPosition of integrationPositions) {
+ const debtBalances = integrationPosition.position.debtBalances;
+ const supplyBalances = integrationPosition.position.supplyBalances;
+ const supplyBalancesByTokenAddress = new Map(
+ supplyBalances.map((supplyBalance) => [
+ supplyBalance.tokenAddress,
+ supplyBalance,
+ ])
+ );
+ const supplyBalancesAddedToDebtPositions = new Set();
+
+ for (const debtBalance of debtBalances) {
+ const market = marketsById.get(debtBalance.marketId);
+
+ if (!market) {
+ continue;
+ }
+
+ const positionSupplyBalances: SupplyBalance[] = [];
+ const supplyPendingActions: PendingAction[] = [];
+
+ for (const collateralToken of market.collateralTokens) {
+ if (!collateralToken.token.address) {
+ continue;
+ }
+
+ const supplyBalance = supplyBalancesByTokenAddress.get(
+ collateralToken.token.address
+ );
+
+ if (!supplyBalance) {
+ continue;
+ }
+
+ positionSupplyBalances.push(supplyBalance);
+ supplyPendingActions.push(...supplyBalance.pendingActions);
+ supplyBalancesAddedToDebtPositions.add(supplyBalance.tokenAddress);
+ }
+
+ positionsByMarketId.set(
+ debtBalance.marketId,
+ new Position({
+ debtBalance,
+ debtPendingActions: debtBalance.pendingActions,
+ id: debtBalance.marketId,
+ integration: integrationPosition.integration,
+ market,
+ supplyBalances: positionSupplyBalances,
+ supplyPendingActions,
+ })
+ );
+ }
+
+ for (const supplyBalance of supplyBalances) {
+ if (supplyBalancesAddedToDebtPositions.has(supplyBalance.tokenAddress)) {
+ continue;
+ }
+
+ const market = marketsById.get(supplyBalance.marketId);
+
+ if (!market) {
+ continue;
+ }
+
+ const existingPosition = positionsByMarketId.get(supplyBalance.marketId);
+
+ if (existingPosition) {
+ positionsByMarketId.set(
+ supplyBalance.marketId,
+ new Position({
+ debtBalance: existingPosition.debtBalance,
+ debtPendingActions: existingPosition.debtPendingActions,
+ id: existingPosition.id,
+ integration: existingPosition.integration,
+ market: existingPosition.market,
+ supplyBalances: [...existingPosition.supplyBalances, supplyBalance],
+ supplyPendingActions: [
+ ...existingPosition.supplyPendingActions,
+ ...supplyBalance.pendingActions,
+ ],
+ })
+ );
+ continue;
+ }
+
+ positionsByMarketId.set(
+ supplyBalance.marketId,
+ new Position({
+ debtBalance: null,
+ debtPendingActions: [],
+ id: supplyBalance.marketId,
+ integration: integrationPosition.integration,
+ market,
+ supplyBalances: [supplyBalance],
+ supplyPendingActions: supplyBalance.pendingActions,
+ })
+ );
+ }
+ }
+
+ return [...positionsByMarketId.values()];
+};
diff --git a/packages/widget/src/domain/borrow/position-projection.ts b/packages/widget/src/domain/borrow/position-projection.ts
new file mode 100644
index 00000000..c32fde05
--- /dev/null
+++ b/packages/widget/src/domain/borrow/position-projection.ts
@@ -0,0 +1,27 @@
+import { Match } from "effect";
+
+export const projectLtvRatio = ({
+ collateralUsd,
+ debtUsd,
+}: {
+ readonly collateralUsd: number;
+ readonly debtUsd: number;
+}) =>
+ Match.value({
+ collateralUsd,
+ debtUsd,
+ }).pipe(
+ Match.when(
+ ({ collateralUsd }) => collateralUsd > 0,
+ () => {
+ const projectedLtvRatio = debtUsd / collateralUsd;
+
+ return projectedLtvRatio > 1 ? 100 : projectedLtvRatio;
+ }
+ ),
+ Match.when(
+ ({ debtUsd }) => debtUsd > 0,
+ () => 100
+ ),
+ Match.orElse(() => 0)
+ );
diff --git a/packages/widget/src/domain/borrow/position.ts b/packages/widget/src/domain/borrow/position.ts
new file mode 100644
index 00000000..2c5ab5f2
--- /dev/null
+++ b/packages/widget/src/domain/borrow/position.ts
@@ -0,0 +1,219 @@
+import { Array as EArray, Option, pipe, Record, Result, Schema } from "effect";
+import { sumAll } from "effect/Number";
+import * as BorrowApi from "../../generated/api/borrow";
+import {
+ decodeTokenId,
+ IntegrationId,
+ MarketId,
+ TokenAddress,
+ WalletAddress,
+} from "./ids";
+import { Integration } from "./integration";
+import { Market } from "./market";
+import { BorrowNetwork } from "./network";
+import { PendingActions } from "./pending-action";
+
+const PositionState = Schema.Struct({
+ ...BorrowApi.SupplyBalanceDto.fields.positionState.schema.fields,
+ currentLtv: Schema.FiniteFromString,
+ liquidationThreshold: Schema.FiniteFromString,
+ healthFactor: Schema.NullOr(Schema.FiniteFromString),
+ availableToBorrowUsd: Schema.FiniteFromString,
+});
+
+export const SupplyBalance = Schema.Struct({
+ ...BorrowApi.SupplyBalanceDto.fields,
+ marketId: MarketId,
+ tokenAddress: TokenAddress,
+ balance: Schema.FiniteFromString,
+ balanceRaw: Schema.BigIntFromString,
+ balanceUsd: Schema.FiniteFromString,
+ apy: Schema.FiniteFromString,
+ positionState: Schema.optionalKey(PositionState),
+ pendingActions: PendingActions,
+});
+export type SupplyBalance = typeof SupplyBalance.Type;
+
+export const DebtBalance = Schema.Struct({
+ ...BorrowApi.DebtBalanceDto.fields,
+ marketId: MarketId,
+ tokenAddress: TokenAddress,
+ balance: Schema.FiniteFromString,
+ balanceRaw: Schema.BigIntFromString,
+ balanceUsd: Schema.FiniteFromString,
+ apy: Schema.FiniteFromString,
+ pendingActions: PendingActions,
+});
+export type DebtBalance = typeof DebtBalance.Type;
+
+export const BorrowAccountPosition = Schema.Struct({
+ ...BorrowApi.PositionDto.fields,
+ address: WalletAddress,
+ integrationId: IntegrationId,
+ network: BorrowNetwork,
+ totalSuppliedUsd: Schema.FiniteFromString,
+ totalCollateralUsd: Schema.FiniteFromString,
+ totalBorrowedUsd: Schema.FiniteFromString,
+ netWorthUsd: Schema.FiniteFromString,
+ healthFactor: Schema.NullOr(Schema.FiniteFromString),
+ currentLtv: Schema.FiniteFromString,
+ availableToBorrowUsd: Schema.NullOr(Schema.FiniteFromString),
+ netApy: Schema.FiniteFromString,
+ supplyBalances: Schema.Array(SupplyBalance),
+ debtBalances: Schema.Array(DebtBalance),
+});
+export type BorrowAccountPosition = typeof BorrowAccountPosition.Type;
+
+export class Position extends Schema.Class("BorrowPosition")({
+ id: MarketId,
+ market: Market,
+ integration: Integration,
+ debtBalance: Schema.NullOr(DebtBalance),
+ supplyBalances: Schema.Array(SupplyBalance),
+ debtPendingActions: PendingActions,
+ supplyPendingActions: PendingActions,
+}) {
+ getCurrentLtv() {
+ if (this.debtBalance == null) {
+ return null;
+ }
+
+ const totalCollateralUsd = this.getTotalCollateralUsd();
+
+ if (totalCollateralUsd <= 0) {
+ return null;
+ }
+
+ return this.debtBalance.balanceUsd / totalCollateralUsd;
+ }
+
+ getTotalCollateralUsd() {
+ return pipe(
+ EArray.filterMap(this.supplyBalances, (supplyBalance) =>
+ supplyBalance.isCollateral
+ ? Result.succeed(supplyBalance.balanceUsd)
+ : Result.failVoid
+ ),
+ sumAll
+ );
+ }
+
+ getCollateralTokenDetails() {
+ const collateralTokensRecord = Record.fromIterableWith(
+ this.market.collateralTokens,
+ (collateralToken) => [
+ decodeTokenId({
+ symbol: collateralToken.token.symbol,
+ address: collateralToken.token.address,
+ }),
+ collateralToken,
+ ]
+ );
+
+ return EArray.reduce(
+ this.supplyBalances,
+ {
+ maxLtv: Number.POSITIVE_INFINITY,
+ liquidationThreshold: Number.POSITIVE_INFINITY,
+ liquidationPenalty: Number.POSITIVE_INFINITY,
+ },
+ (details, supplyBalance) => {
+ const tokenId = decodeTokenId({
+ symbol: supplyBalance.tokenSymbol,
+ address: supplyBalance.tokenAddress,
+ });
+ const collateralToken = Record.get(collateralTokensRecord, tokenId);
+
+ if (Option.isNone(collateralToken)) {
+ return details;
+ }
+
+ return {
+ maxLtv: Math.min(details.maxLtv, collateralToken.value.maxLtv),
+ liquidationThreshold: Math.min(
+ details.liquidationThreshold,
+ collateralToken.value.liquidationThreshold
+ ),
+ liquidationPenalty: Math.min(
+ details.liquidationPenalty,
+ collateralToken.value.liquidationPenalty
+ ),
+ };
+ }
+ );
+ }
+
+ getMeta() {
+ const collateralTokens = this.supplyBalances.filter(
+ (supplyBalance) => supplyBalance.isCollateral
+ );
+ const collateralToken = collateralTokens[0];
+
+ if (!this.debtBalance || collateralTokens.length > 1 || !collateralToken) {
+ return {
+ name: this.market.loanToken.symbol,
+ symbol: this.market.loanToken.symbol,
+ };
+ }
+
+ return {
+ name: `${collateralToken.tokenSymbol}/${this.debtBalance.tokenSymbol}`,
+ symbol: collateralToken.tokenSymbol,
+ };
+ }
+
+ getTotalSuppliedUsd() {
+ return pipe(
+ this.supplyBalances.map((supplyBalance) => supplyBalance.balanceUsd),
+ sumAll
+ );
+ }
+
+ getTotalBorrowedUsd() {
+ return this.debtBalance?.balanceUsd ?? 0;
+ }
+
+ getNetWorthUsd() {
+ return this.getTotalSuppliedUsd() - this.getTotalBorrowedUsd();
+ }
+
+ getHealthFactor() {
+ if (this.debtBalance == null) {
+ return null;
+ }
+
+ const currentLtv = this.getCurrentLtv();
+
+ if (currentLtv == null || currentLtv <= 0) {
+ return 0;
+ }
+
+ const { liquidationThreshold } = this.getCollateralTokenDetails();
+
+ if (!Number.isFinite(liquidationThreshold)) {
+ return 0;
+ }
+
+ return liquidationThreshold / currentLtv;
+ }
+
+ getNetApy() {
+ const netWorthUsd = this.getNetWorthUsd();
+
+ if (netWorthUsd === 0) {
+ return 0;
+ }
+
+ const totalSupplyEarnings = pipe(
+ this.supplyBalances.map(
+ (supplyBalance) => supplyBalance.balanceUsd * supplyBalance.apy
+ ),
+ sumAll
+ );
+
+ const totalBorrowCosts =
+ (this.debtBalance?.balanceUsd ?? 0) * (this.debtBalance?.apy ?? 0);
+
+ return (totalSupplyEarnings - totalBorrowCosts) / netWorthUsd;
+ }
+}
diff --git a/packages/widget/src/domain/borrow/responses.ts b/packages/widget/src/domain/borrow/responses.ts
new file mode 100644
index 00000000..3d76e55a
--- /dev/null
+++ b/packages/widget/src/domain/borrow/responses.ts
@@ -0,0 +1,38 @@
+import { Schema, SchemaGetter } from "effect";
+import * as BorrowApi from "../../generated/api/borrow";
+import { TolerantTopLevelArray } from "../schema/response";
+import { Integration } from "./integration";
+import { Market } from "./market";
+import { BorrowAccountPosition } from "./position";
+
+const ItemIdentifier = Schema.Struct({ id: Schema.String }).pipe(
+ Schema.decodeTo(Schema.String, {
+ decode: SchemaGetter.transform((value) => value.id),
+ encode: SchemaGetter.forbidden(() => "Item identifier is decode-only"),
+ })
+);
+
+export const BorrowIntegrationsResponse = TolerantTopLevelArray(Integration, {
+ operation: "borrow-integrations",
+ identifier: ItemIdentifier,
+});
+
+const BorrowMarketItems = TolerantTopLevelArray(Market, {
+ operation: "borrow-markets",
+ identifier: ItemIdentifier,
+});
+
+export const BorrowMarketsResponse = Schema.Struct({
+ ...BorrowApi.MarketsControllerGetMarketsV1200.fields,
+ items: Schema.optionalKey(BorrowMarketItems),
+});
+
+const IntegrationPositionResponse = Schema.Struct({
+ integration: Schema.toType(Integration),
+ position: BorrowAccountPosition,
+});
+
+export const BorrowIntegrationPositionsResponse = TolerantTopLevelArray(
+ IntegrationPositionResponse,
+ { operation: "borrow-positions" }
+);
diff --git a/packages/widget/src/domain/borrow/token.ts b/packages/widget/src/domain/borrow/token.ts
new file mode 100644
index 00000000..432ca4cd
--- /dev/null
+++ b/packages/widget/src/domain/borrow/token.ts
@@ -0,0 +1,8 @@
+import { Schema } from "effect";
+import * as BorrowApi from "../../generated/api/borrow";
+import { TokenAddress } from "./ids";
+
+export class BorrowToken extends Schema.Class("BorrowToken")({
+ ...BorrowApi.CollateralTokenDto.fields.token.fields,
+ address: Schema.optionalKey(TokenAddress),
+}) {}
diff --git a/packages/widget/src/domain/borrow/transaction.ts b/packages/widget/src/domain/borrow/transaction.ts
new file mode 100644
index 00000000..b9982dd6
--- /dev/null
+++ b/packages/widget/src/domain/borrow/transaction.ts
@@ -0,0 +1,74 @@
+import { Effect, Schema } from "effect";
+import * as BorrowApi from "../../generated/api/borrow";
+import { TransactionId, WalletAddress } from "./ids";
+import { BorrowNetwork } from "./network";
+
+export class Transaction extends Schema.Class("BorrowTransaction")(
+ {
+ ...BorrowApi.TransactionDto.fields,
+ id: TransactionId,
+ network: BorrowNetwork,
+ chainId: Schema.FiniteFromString,
+ address: WalletAddress,
+ }
+) {}
+
+const HexString = Schema.TemplateLiteral([Schema.Literal("0x"), Schema.String]);
+const Numberish = Schema.Union([Schema.String, Schema.Number, Schema.BigInt]);
+
+export const BorrowWalletEvmSignablePayload = Schema.Struct({
+ chainId: Schema.optionalKey(Numberish),
+ data: HexString,
+ from: HexString,
+ gasLimit: Numberish,
+ nonce: Schema.optionalKey(Numberish),
+ to: HexString,
+ type: Schema.optionalKey(Numberish),
+ value: Schema.optionalKey(Numberish),
+});
+
+export type BorrowWalletEvmSignablePayload =
+ typeof BorrowWalletEvmSignablePayload.Type;
+
+const BorrowEvmSignablePayloadInput = Schema.Union([
+ BorrowWalletEvmSignablePayload,
+ Schema.fromJsonString(BorrowWalletEvmSignablePayload),
+]);
+
+const normalizeNumberish = (
+ value: bigint | number | string | undefined,
+ fallback = "0"
+) => (value == null ? fallback : value.toString());
+
+const numberishToNumber = (
+ value: bigint | number | string | undefined,
+ fallback: bigint | number | string = 0
+) => Number(value == null ? fallback : value);
+
+export const decodeBorrowTransactionForWallet = (transaction: Transaction) =>
+ Schema.decodeUnknownEffect(BorrowEvmSignablePayloadInput)(
+ transaction.signablePayload
+ ).pipe(
+ Effect.map((payload) =>
+ JSON.stringify({
+ chainId: numberishToNumber(payload.chainId, transaction.chainId),
+ data: payload.data,
+ from: payload.from,
+ gasLimit: normalizeNumberish(payload.gasLimit),
+ nonce: numberishToNumber(payload.nonce),
+ to: payload.to,
+ type: numberishToNumber(payload.type),
+ value: normalizeNumberish(payload.value),
+ })
+ )
+ );
+
+export const SubmitTransactionCommand = Schema.Struct(
+ BorrowApi.SubmitTransactionDto.fields
+);
+export type SubmitTransactionCommand = typeof SubmitTransactionCommand.Type;
+
+export const SubmitTransactionResult = Schema.Struct(
+ BorrowApi.SubmitTransactionResponseDto.fields
+);
+export type SubmitTransactionResult = typeof SubmitTransactionResult.Type;
diff --git a/packages/widget/src/domain/borrow/wallet.ts b/packages/widget/src/domain/borrow/wallet.ts
new file mode 100644
index 00000000..f55bb0cf
--- /dev/null
+++ b/packages/widget/src/domain/borrow/wallet.ts
@@ -0,0 +1,31 @@
+import { Schema } from "effect";
+import { ChainId, WalletAddress } from "./ids";
+import { BorrowNetwork } from "./network";
+
+export const WalletAccount = Schema.Struct({
+ address: WalletAddress,
+});
+export type WalletAccount = typeof WalletAccount.Type;
+
+export const WalletChain = Schema.Struct({
+ chainId: ChainId,
+ name: Schema.String,
+ network: BorrowNetwork,
+ iconUrl: Schema.optionalKey(Schema.String),
+});
+export type WalletChain = typeof WalletChain.Type;
+
+export const DisconnectedWalletState = Schema.Struct({
+ status: Schema.Literal("disconnected"),
+});
+export type DisconnectedWalletState = typeof DisconnectedWalletState.Type;
+
+export const ConnectedWalletState = Schema.Struct({
+ status: Schema.Literal("connected"),
+ currentAccount: WalletAccount,
+ accounts: Schema.NonEmptyArray(WalletAccount),
+ currentChain: WalletChain,
+ chains: Schema.NonEmptyArray(WalletChain),
+ network: BorrowNetwork,
+});
+export type ConnectedWalletState = typeof ConnectedWalletState.Type;
diff --git a/packages/widget/src/domain/index.ts b/packages/widget/src/domain/index.ts
index 21939f0b..f113a4b7 100644
--- a/packages/widget/src/domain/index.ts
+++ b/packages/widget/src/domain/index.ts
@@ -1,19 +1,17 @@
import BigNumber from "bignumber.js";
-import { Left, type Maybe, Right } from "purify-ts";
-import type { Override } from "../types/utils";
-import type {
- ActionDto,
- TransactionDto,
- TransactionStatus,
-} from "./types/action";
+import { Result } from "effect";
+import type { Override } from "../shared/types/utils";
+import type { ActionTransaction, YieldAction } from "./schema/action-models";
+import type { EarnYieldWithProvider } from "./schema/earn-models";
+import type { AppToken } from "./schema/legacy-models";
+import type { TransactionStatus } from "./types/action";
import type { AnyPendingActionDto } from "./types/pending-action";
import {
isPendingActionValidatorAddressesRequired,
isPendingActionValidatorAddressRequired,
} from "./types/pending-action";
-import type { TokenDto } from "./types/tokens";
+
import { equalTokens } from "./types/tokens";
-import type { Yield } from "./types/yields";
export { getTokenPriceInUSD } from "./types/price";
export { equalTokens, tokenString } from "./types/tokens";
@@ -22,8 +20,8 @@ export const stakeTokenSameAsGasToken = ({
stakeToken,
yieldDto,
}: {
- stakeToken: TokenDto;
- yieldDto: Yield;
+ stakeToken: AppToken;
+ yieldDto: EarnYieldWithProvider;
}) => equalTokens(stakeToken, yieldDto.mechanics.gasFeeToken);
export const getMaxAmount = ({
@@ -33,11 +31,11 @@ export const getMaxAmount = ({
}: {
availableAmount: BigNumber;
gasEstimateTotal: BigNumber;
- integrationMaxLimit: Maybe;
+ integrationMaxLimit: BigNumber | null;
}) => {
return BigNumber.max(
BigNumber.min(
- integrationMaxLimit.orDefault(BigNumber(Number.POSITIVE_INFINITY)),
+ integrationMaxLimit ?? BigNumber(Number.POSITIVE_INFINITY),
availableAmount.minus(gasEstimateTotal)
),
new BigNumber(0)
@@ -49,18 +47,18 @@ export const getMaxAmount = ({
* @summary Get stake transactions available for signing or tx status check.
* If any of the transactions are in a failed state, return an error
*/
-export const getValidStakeSessionTx = (stakeDto: ActionDto) => {
- const val: ActionDto = {
+export const getValidStakeSessionTx = (stakeDto: YieldAction) => {
+ const val: YieldAction = {
...stakeDto,
transactions: stakeDto.transactions.filter(
(
tx
): tx is Override<
- TransactionDto,
+ ActionTransaction,
{
status: Override<
- TransactionDto["status"],
- Exclude
+ ActionTransaction["status"],
+ Exclude
>;
}
> => tx.status !== "SKIPPED"
@@ -68,8 +66,8 @@ export const getValidStakeSessionTx = (stakeDto: ActionDto) => {
};
return val.transactions.some((tx) => isTxError(tx.status))
- ? Left(new Error("Transaction failed"))
- : Right(val);
+ ? Result.fail(new Error("Transaction failed"))
+ : Result.succeed(val);
};
export const isTxError = (txStatus: TransactionStatus) =>
diff --git a/packages/widget/src/domain/schema/action-models.ts b/packages/widget/src/domain/schema/action-models.ts
new file mode 100644
index 00000000..968855bd
--- /dev/null
+++ b/packages/widget/src/domain/schema/action-models.ts
@@ -0,0 +1,95 @@
+import { Schema } from "effect";
+import * as YieldApi from "../../generated/api/yield-schema";
+import {
+ ActionId,
+ TokenAddress,
+ TransactionId,
+ ValidatorAddress,
+ WalletAddress,
+ YieldId,
+} from "./identifiers";
+import { PrecisionDecimalFromString } from "./scalars";
+
+const ActionToken = Schema.Struct({
+ ...YieldApi.TokenDto.fields,
+ address: Schema.optionalKey(TokenAddress),
+ decimals: Schema.Number.check(Schema.isInt()),
+});
+
+const ActionArguments = Schema.Struct({
+ ...YieldApi.CreateActionDto.fields.arguments.schema.fields,
+ providerId: Schema.optionalKey(YieldId),
+ validatorAddress: Schema.optionalKey(ValidatorAddress),
+ validatorAddresses: Schema.optionalKey(Schema.Array(ValidatorAddress)),
+});
+
+const ManageActionArguments = Schema.Struct({
+ ...YieldApi.CreateManageActionDto.fields.arguments.schema.fields,
+ providerId: Schema.optionalKey(YieldId),
+ validatorAddress: Schema.optionalKey(ValidatorAddress),
+ validatorAddresses: Schema.optionalKey(Schema.Array(ValidatorAddress)),
+});
+
+const TransactionGasEstimate = Schema.Struct({
+ amount: PrecisionDecimalFromString,
+ gasLimit: Schema.optionalKey(Schema.String),
+ token: ActionToken,
+});
+
+export const TransactionGasEstimateJson = Schema.fromJsonString(
+ TransactionGasEstimate
+);
+
+export const ActionTransaction = Schema.Struct({
+ ...YieldApi.TransactionDto.fields,
+ id: TransactionId,
+});
+export type ActionTransaction = typeof ActionTransaction.Type;
+
+export const YieldAction = Schema.Struct({
+ ...YieldApi.ActionDto.fields,
+ address: WalletAddress,
+ id: ActionId,
+ rawArguments: Schema.NullOr(ActionArguments),
+ transactions: Schema.Array(ActionTransaction),
+ yieldId: YieldId,
+});
+export type YieldAction = typeof YieldAction.Type;
+
+export const ActionCommand = Schema.Struct({
+ ...YieldApi.CreateActionDto.fields,
+ address: WalletAddress,
+ arguments: Schema.optionalKey(ActionArguments),
+ yieldId: YieldId,
+});
+export type ActionCommand = typeof ActionCommand.Type;
+
+export const ManageActionCommand = Schema.Struct({
+ ...YieldApi.CreateManageActionDto.fields,
+ address: WalletAddress,
+ arguments: Schema.optionalKey(ManageActionArguments),
+ yieldId: YieldId,
+});
+export type ManageActionCommand = typeof ManageActionCommand.Type;
+
+export const PendingAction = YieldApi.PendingActionDto;
+export type PendingAction = typeof PendingAction.Type;
+
+export const SubmitTransactionHashCommand = Schema.Struct({
+ payload: YieldApi.SubmitHashDto,
+ transactionId: TransactionId,
+});
+export type SubmitTransactionHashCommand =
+ typeof SubmitTransactionHashCommand.Type;
+
+export const SubmitSignedTransactionCommand = Schema.Struct({
+ payload: YieldApi.SubmitTransactionDto,
+ transactionId: TransactionId,
+});
+export type SubmitSignedTransactionCommand =
+ typeof SubmitSignedTransactionCommand.Type;
+
+export const TransactionStatusCommand = Schema.Struct({
+ transactionId: TransactionId,
+});
+export type TransactionStatusCommand = typeof TransactionStatusCommand.Type;
diff --git a/packages/widget/src/domain/schema/activity-models.ts b/packages/widget/src/domain/schema/activity-models.ts
new file mode 100644
index 00000000..1cdb9147
--- /dev/null
+++ b/packages/widget/src/domain/schema/activity-models.ts
@@ -0,0 +1,14 @@
+import { Schema } from "effect";
+import * as YieldApi from "../../generated/api/yield-schema";
+import { YieldAction } from "./action-models";
+import { TolerantTopLevelArray } from "./response";
+
+export const ActivityActionsPage = Schema.Struct({
+ ...YieldApi.ActionsControllerGetActions200.fields,
+ items: Schema.optionalKey(
+ TolerantTopLevelArray(YieldAction, {
+ operation: "activity-actions",
+ })
+ ),
+});
+export type ActivityActionsPage = typeof ActivityActionsPage.Type;
diff --git a/packages/widget/src/domain/schema/address-models.ts b/packages/widget/src/domain/schema/address-models.ts
new file mode 100644
index 00000000..468f61ba
--- /dev/null
+++ b/packages/widget/src/domain/schema/address-models.ts
@@ -0,0 +1,19 @@
+import { Schema } from "effect";
+import * as LegacyApi from "../../generated/api/legacy-schema";
+import { WalletAddress } from "./identifiers";
+
+export const AdditionalAddresses = Schema.Union([
+ LegacyApi.CosmosAdditionalAddressesDto,
+ LegacyApi.BinanceAdditionalAddressesDto,
+ LegacyApi.SolanaAdditionalAddressesDto,
+ LegacyApi.TezosAdditionalAddressesDto,
+ LegacyApi.AvalancheCAdditionalAddressesDto,
+]);
+export type AdditionalAddresses = typeof AdditionalAddresses.Type;
+
+export const WalletAddresses = Schema.Struct({
+ ...LegacyApi.AddressesDto.fields,
+ address: WalletAddress,
+ additionalAddresses: Schema.optionalKey(AdditionalAddresses),
+});
+export type WalletAddresses = typeof WalletAddresses.Type;
diff --git a/packages/widget/src/domain/schema/api-errors.ts b/packages/widget/src/domain/schema/api-errors.ts
new file mode 100644
index 00000000..106dbb29
--- /dev/null
+++ b/packages/widget/src/domain/schema/api-errors.ts
@@ -0,0 +1,33 @@
+import { Data, Schema } from "effect";
+
+export class MissingBorrowApiConfig extends Data.TaggedError(
+ "MissingBorrowApiConfig"
+)<{
+ readonly message: string;
+}> {}
+
+export class ApiRequestError extends Schema.TaggedErrorClass()(
+ "ApiRequestError",
+ {
+ operation: Schema.String,
+ cause: Schema.Defect(),
+ }
+) {}
+
+export class ResponseDecodeError extends Schema.TaggedErrorClass()(
+ "ResponseDecodeError",
+ {
+ operation: Schema.String,
+ issue: Schema.String,
+ cause: Schema.Defect(),
+ }
+) {}
+
+export class InputValidationError extends Schema.TaggedErrorClass()(
+ "InputValidationError",
+ {
+ operation: Schema.String,
+ issue: Schema.String,
+ cause: Schema.Defect(),
+ }
+) {}
diff --git a/packages/widget/src/domain/schema/dashboard-models.ts b/packages/widget/src/domain/schema/dashboard-models.ts
new file mode 100644
index 00000000..8a2b49cd
--- /dev/null
+++ b/packages/widget/src/domain/schema/dashboard-models.ts
@@ -0,0 +1,119 @@
+import { Schema, SchemaTransformation } from "effect";
+import * as LegacyApi from "../../generated/api/legacy-schema";
+import * as YieldApi from "../../generated/api/yield-schema";
+import { AdditionalAddresses } from "./address-models";
+import { TokenAddress, WalletAddress, YieldId } from "./identifiers";
+import { Network } from "./network-model";
+import { TolerantTopLevelArray, TolerantTopLevelRecord } from "./response";
+import { ValidDateFromString } from "./scalars";
+
+export const KycStatus = YieldApi.KycStatusResponseDto;
+export type KycStatus = typeof KycStatus.Type;
+
+export const HistoryPeriod = Schema.Literals(["30d", "90d", "1y", "all"]);
+export type HistoryPeriod = typeof HistoryPeriod.Type;
+
+export const HistoryPoint = Schema.Struct({
+ date: Schema.DateValid,
+ timestamp: Schema.String,
+ value: Schema.Number.check(Schema.isFinite()),
+});
+export type HistoryPoint = typeof HistoryPoint.Type;
+
+const RewardRatePoint = YieldApi.RewardRateSnapshotDto.pipe(
+ Schema.decodeTo(
+ HistoryPoint,
+ SchemaTransformation.transform({
+ decode: (item): HistoryPoint => ({
+ date: new Date(item.timestamp),
+ timestamp: item.timestamp,
+ value: Number(item.rewardRate) * 100,
+ }),
+ encode: (point: HistoryPoint) => ({
+ rewardRate: String(point.value / 100),
+ timestamp: point.timestamp,
+ }),
+ })
+ )
+);
+
+const TvlPointWire = Schema.Struct({
+ timestamp: Schema.String,
+ tvl: Schema.optionalKey(Schema.NullOr(Schema.String)),
+ tvlUsd: Schema.optionalKey(Schema.NullOr(Schema.FiniteFromString)),
+});
+
+const TvlPoint = TvlPointWire.pipe(
+ Schema.decodeTo(
+ HistoryPoint,
+ SchemaTransformation.transform({
+ decode: (item): HistoryPoint => ({
+ date: new Date(item.timestamp),
+ timestamp: item.timestamp,
+ value: item.tvlUsd ?? Number.NaN,
+ }),
+ encode: (
+ point: HistoryPoint
+ ): {
+ readonly timestamp: string;
+ readonly tvl?: string | null;
+ readonly tvlUsd?: number | null;
+ } => ({ timestamp: point.timestamp, tvlUsd: point.value }),
+ })
+ )
+);
+
+const ValidHistoryPoint = HistoryPoint.check(
+ Schema.makeFilter(
+ (point) =>
+ !Number.isNaN(point.date.getTime()) && Number.isFinite(point.value),
+ { expected: "a finite history point with a valid timestamp" }
+ )
+);
+
+export const RewardRateHistoryResponse = Schema.Struct({
+ ...YieldApi.RewardRateHistoryResponseDto.fields,
+ yieldId: YieldId,
+ from: ValidDateFromString,
+ to: ValidDateFromString,
+ items: TolerantTopLevelArray(
+ RewardRatePoint.pipe(Schema.decodeTo(ValidHistoryPoint)),
+ { operation: "yield-reward-rate-history" }
+ ),
+});
+
+export const TvlHistoryResponse = Schema.Struct({
+ ...YieldApi.TvlHistoryResponseDto.fields,
+ yieldId: YieldId,
+ from: ValidDateFromString,
+ to: ValidDateFromString,
+ items: TolerantTopLevelArray(
+ TvlPoint.pipe(Schema.decodeTo(ValidHistoryPoint)),
+ { operation: "yield-tvl-history" }
+ ),
+});
+
+export const RewardsToken = Schema.Struct({
+ ...LegacyApi.TokenDto.fields,
+ network: Network,
+ address: Schema.optionalKey(TokenAddress),
+ decimals: Schema.Number.check(Schema.isInt()),
+});
+
+export const RewardsSummary = Schema.Struct({
+ ...LegacyApi.YieldRewardsSummaryResponseDto.fields,
+ token: RewardsToken,
+});
+export type RewardsSummary = typeof RewardsSummary.Type;
+
+export const RewardsSummaryRecord = TolerantTopLevelRecord(
+ YieldId,
+ RewardsSummary,
+ { operation: "yield-rewards-summary" }
+);
+
+export const RewardsAddresses = Schema.Struct({
+ address: WalletAddress,
+ additionalAddresses: Schema.optionalKey(AdditionalAddresses),
+});
+export type RewardsAddresses = typeof RewardsAddresses.Type;
diff --git a/packages/widget/src/domain/schema/decode-diagnostics.ts b/packages/widget/src/domain/schema/decode-diagnostics.ts
new file mode 100644
index 00000000..d2e23fa0
--- /dev/null
+++ b/packages/widget/src/domain/schema/decode-diagnostics.ts
@@ -0,0 +1,19 @@
+import { Effect } from "effect";
+
+type DecodeRejectionDiagnostic = {
+ readonly operation: string;
+ readonly location: number | string;
+ readonly identifier?: string;
+ readonly issue: string;
+};
+
+export const logDecodeRejection = (diagnostic: DecodeRejectionDiagnostic) =>
+ Effect.logWarning("Rejected API response entry").pipe(
+ Effect.annotateLogs({
+ event: "api_decode_rejection",
+ operation: diagnostic.operation,
+ location: String(diagnostic.location),
+ issue: diagnostic.issue,
+ ...(diagnostic.identifier ? { identifier: diagnostic.identifier } : {}),
+ })
+ );
diff --git a/packages/widget/src/domain/schema/earn-models.ts b/packages/widget/src/domain/schema/earn-models.ts
new file mode 100644
index 00000000..0dd520f0
--- /dev/null
+++ b/packages/widget/src/domain/schema/earn-models.ts
@@ -0,0 +1,198 @@
+import { Option, Schema, SchemaGetter } from "effect";
+import * as YieldApi from "../../generated/api/yield-schema";
+import { PendingAction } from "./action-models";
+import {
+ ProviderId,
+ TokenAddress,
+ ValidatorAddress,
+ YieldId,
+} from "./identifiers";
+import { TolerantTopLevelArray } from "./response";
+import { BigIntFromString, PrecisionDecimalFromString } from "./scalars";
+
+export const EarnToken = Schema.Struct({
+ ...YieldApi.TokenDto.fields,
+ decimals: Schema.Number.check(Schema.isInt()),
+ address: Schema.optionalKey(TokenAddress),
+});
+export type EarnToken = typeof EarnToken.Type;
+
+const EarnReward = Schema.Struct({
+ ...YieldApi.RewardDto.fields,
+ token: EarnToken,
+});
+
+const EarnRewardRate = Schema.Struct({
+ ...YieldApi.YieldDto.fields.rewardRate.fields,
+ components: Schema.Array(EarnReward),
+});
+
+export const EarnProvider = Schema.Struct({
+ ...YieldApi.ProviderDto.fields,
+ id: ProviderId,
+});
+export type EarnProvider = typeof EarnProvider.Type;
+
+export const EarnValidatorKey = Schema.NonEmptyString.pipe(
+ Schema.brand("EarnValidatorKey")
+);
+export type EarnValidatorKey = typeof EarnValidatorKey.Type;
+
+const makeEarnValidatorKey = Schema.decodeSync(EarnValidatorKey);
+
+const EarnValidatorWire = Schema.Struct({
+ ...YieldApi.ValidatorDto.fields,
+ address: ValidatorAddress,
+ providerId: Schema.optionalKey(ProviderId),
+ rewardRate: Schema.optionalKey(EarnRewardRate),
+});
+
+export const EarnValidator = EarnValidatorWire.pipe(
+ Schema.extendTo(
+ { key: EarnValidatorKey },
+ {
+ key: (validator) =>
+ Option.some(
+ makeEarnValidatorKey(
+ validator.subnet?.id === undefined
+ ? validator.address
+ : `${validator.address}:${validator.subnet.id}`
+ )
+ ),
+ }
+ )
+);
+export type EarnValidator = typeof EarnValidator.Type;
+
+const EarnYieldMechanics = Schema.Struct({
+ ...YieldApi.YieldDto.fields.mechanics.fields,
+ gasFeeToken: EarnToken,
+});
+
+export const EarnYield = Schema.Struct({
+ ...YieldApi.YieldDto.fields,
+ id: YieldId,
+ providerId: ProviderId,
+ inputTokens: Schema.Array(EarnToken),
+ mechanics: EarnYieldMechanics,
+ outputToken: Schema.optionalKey(EarnToken),
+ rewardRate: EarnRewardRate,
+ token: EarnToken,
+ tokens: Schema.Array(EarnToken),
+});
+export type EarnYield = typeof EarnYield.Type;
+
+export const EarnYieldWithProvider = Schema.Struct({
+ ...EarnYield.fields,
+ provider: Schema.optionalKey(EarnProvider),
+});
+export type EarnYieldWithProvider = typeof EarnYieldWithProvider.Type;
+
+export const EarnBalance = Schema.Struct({
+ ...YieldApi.BalanceDto.fields,
+ amount: PrecisionDecimalFromString,
+ amountRaw: BigIntFromString,
+ amountUsd: Schema.optionalKey(Schema.NullOr(PrecisionDecimalFromString)),
+ pendingActions: Schema.Array(PendingAction),
+ token: EarnToken,
+ validator: Schema.optionalKey(EarnValidator),
+ validators: Schema.optionalKey(Schema.Array(EarnValidator)),
+});
+export type EarnBalance = typeof EarnBalance.Type;
+
+export const EarnPosition = Schema.Struct({
+ ...YieldApi.YieldBalancesDto.fields,
+ yieldId: YieldId,
+ balances: Schema.Array(EarnBalance),
+ outputTokenBalance: Schema.optionalKey(Schema.NullOr(EarnBalance)),
+ rewardRate: Schema.optionalKey(Schema.NullOr(EarnRewardRate)),
+});
+export type EarnPosition = typeof EarnPosition.Type;
+
+export const EarnYieldBalancesResponse = Schema.Struct({
+ ...YieldApi.YieldBalancesDto.fields,
+ yieldId: YieldId,
+ balances: TolerantTopLevelArray(EarnBalance, {
+ operation: "yield-balances",
+ }),
+ outputTokenBalance: Schema.optionalKey(Schema.NullOr(EarnBalance)),
+ rewardRate: Schema.optionalKey(Schema.NullOr(EarnRewardRate)),
+});
+export type EarnYieldBalancesResponse = typeof EarnYieldBalancesResponse.Type;
+
+const YieldIdentifier = Schema.Struct({ id: Schema.String }).pipe(
+ Schema.decodeTo(Schema.String, {
+ decode: SchemaGetter.transform((value) => value.id),
+ encode: SchemaGetter.forbidden(() => "Yield identifier is decode-only"),
+ })
+);
+
+const ValidatorIdentifier = Schema.Struct({ address: Schema.String }).pipe(
+ Schema.decodeTo(Schema.String, {
+ decode: SchemaGetter.transform((value) => value.address),
+ encode: SchemaGetter.forbidden(() => "Validator identifier is decode-only"),
+ })
+);
+
+const makeEarnYieldPage = (operation: string) =>
+ Schema.Struct({
+ ...YieldApi.YieldsControllerGetYields200.fields,
+ items: Schema.optionalKey(
+ TolerantTopLevelArray(EarnYield, {
+ operation,
+ identifier: YieldIdentifier,
+ })
+ ),
+ });
+
+export const EarnYieldPage = makeEarnYieldPage("earn-yield-catalog");
+
+export const AvailableYieldCategoriesPage = makeEarnYieldPage(
+ "available-yield-categories"
+);
+
+export const TokenYieldScopePage = makeEarnYieldPage("token-yield-scope");
+
+const EarnTokenWithAvailableYields = Schema.Struct({
+ token: EarnToken,
+ availableYields: Schema.Array(YieldId),
+});
+
+const EarnTokenWithAvailableYieldItems = TolerantTopLevelArray(
+ EarnTokenWithAvailableYields,
+ { operation: "default-token-options" }
+);
+
+export const EarnTokenPage = Schema.Struct({
+ ...YieldApi.TokensControllerGetTokens200.fields,
+ items: Schema.optionalKey(EarnTokenWithAvailableYieldItems),
+});
+
+export const EarnLegacyTokenOptionsResponse = EarnTokenWithAvailableYieldItems;
+
+const EarnTokenBalance = Schema.Struct({
+ ...EarnTokenWithAvailableYields.fields,
+ amount: PrecisionDecimalFromString,
+});
+
+export const EarnTokenBalancesResponse = TolerantTopLevelArray(
+ EarnTokenBalance,
+ { operation: "token-balances-scan" }
+);
+
+const EarnValidatorItems = TolerantTopLevelArray(EarnValidator, {
+ operation: "validators",
+ identifier: ValidatorIdentifier,
+});
+
+export const EarnValidatorPage = Schema.Struct({
+ ...YieldApi.YieldsControllerGetYieldValidators200.fields,
+ items: Schema.optionalKey(EarnValidatorItems),
+});
+
+export const EarnPositionsResponse = Schema.Struct({
+ ...YieldApi.BalancesResponseDto.fields,
+ items: TolerantTopLevelArray(EarnPosition, {
+ operation: "positions-data",
+ }),
+});
diff --git a/packages/widget/src/domain/schema/financial-models.ts b/packages/widget/src/domain/schema/financial-models.ts
new file mode 100644
index 00000000..47abbbcc
--- /dev/null
+++ b/packages/widget/src/domain/schema/financial-models.ts
@@ -0,0 +1,64 @@
+import { Schema } from "effect";
+import * as LegacyApi from "../../generated/api/legacy-schema";
+import * as YieldApi from "../../generated/api/yield-schema";
+import { AdditionalAddresses } from "./address-models";
+import { RewardsToken } from "./dashboard-models";
+import { TokenAddress, WalletAddress, YieldId } from "./identifiers";
+import { Network } from "./network-model";
+import { TolerantTopLevelArray } from "./response";
+import { PrecisionDecimalFromString } from "./scalars";
+
+export const TokenBalanceScanCommand = Schema.Struct({
+ ...LegacyApi.TokenBalanceScanDto.fields,
+ addresses: Schema.Struct({
+ address: WalletAddress,
+ additionalAddresses: Schema.optionalKey(AdditionalAddresses),
+ }),
+ network: Network,
+});
+export type TokenBalanceScanCommand = typeof TokenBalanceScanCommand.Type;
+
+export const TokenBalance = Schema.Struct({
+ ...LegacyApi.TokenBalanceScanResponseDto.fields,
+ amount: PrecisionDecimalFromString,
+ availableYields: Schema.Array(YieldId),
+ token: RewardsToken,
+});
+export type TokenBalance = typeof TokenBalance.Type;
+
+export const TokenBalancesResponse = TolerantTopLevelArray(TokenBalance, {
+ operation: "token-balances-scan",
+});
+
+const GasBalanceAddress = Schema.Struct({
+ ...LegacyApi.AddressWithTokenDto.fields,
+ address: WalletAddress,
+ additionalAddresses: Schema.optionalKey(AdditionalAddresses),
+ network: Network,
+ tokenAddress: Schema.optionalKey(TokenAddress),
+});
+
+export const GasBalancesCommand = Schema.Struct({
+ ...LegacyApi.BalancesRequestDto.fields,
+ addresses: Schema.Array(GasBalanceAddress).check(Schema.isMinLength(1)),
+});
+export type GasBalancesCommand = typeof GasBalancesCommand.Type;
+
+export const GasTokenBalancesResponse = TolerantTopLevelArray(TokenBalance, {
+ operation: "gas-balance-check",
+});
+
+const YieldBalancesQuery = Schema.Struct({
+ ...YieldApi.BalancesQueryDto.fields,
+ address: WalletAddress,
+ network: Network,
+ yieldId: Schema.optionalKey(YieldId),
+});
+
+export const YieldBalancesCommand = Schema.Struct({
+ ...YieldApi.BalancesRequestDto.fields,
+ queries: Schema.Array(YieldBalancesQuery)
+ .check(Schema.isMinLength(1))
+ .check(Schema.isMaxLength(25)),
+});
+export type YieldBalancesCommand = typeof YieldBalancesCommand.Type;
diff --git a/packages/widget/src/domain/schema/health-price-models.ts b/packages/widget/src/domain/schema/health-price-models.ts
new file mode 100644
index 00000000..20f09882
--- /dev/null
+++ b/packages/widget/src/domain/schema/health-price-models.ts
@@ -0,0 +1,94 @@
+import { Schema, SchemaTransformation } from "effect";
+import * as LegacyApi from "../../generated/api/legacy-schema";
+import * as YieldApi from "../../generated/api/yield-schema";
+import { tokenString } from "../types/tokens";
+import { TokenAddress } from "./identifiers";
+import { Network } from "./network-model";
+import { TolerantTopLevelRecord } from "./response";
+import { UtcDateTimeFromString } from "./scalars";
+
+export const HealthStatus = Schema.Struct({
+ ...YieldApi.HealthStatusDto.fields,
+ timestamp: UtcDateTimeFromString,
+});
+export type HealthStatus = typeof HealthStatus.Type;
+
+const PriceTokenModel = Schema.Struct({
+ ...LegacyApi.TokenDto.fields,
+ network: Network,
+ address: Schema.optionalKey(TokenAddress),
+ decimals: Schema.Number.check(Schema.isInt()),
+});
+const PriceToken = LegacyApi.TokenDto.pipe(Schema.decodeTo(PriceTokenModel));
+
+const PriceRequestModel = Schema.Struct({
+ currency: Schema.String,
+ tokenList: Schema.Array(PriceToken),
+});
+export const PriceRequest = LegacyApi.PriceRequestDto.pipe(
+ Schema.decodeTo(PriceRequestModel)
+);
+export type PriceRequest = typeof PriceRequest.Type;
+
+type Price = {
+ readonly price: number | undefined;
+ readonly price24H: number | undefined;
+};
+
+export class Prices {
+ constructor(public readonly value: Map) {}
+
+ getByToken(token: {
+ readonly symbol: string;
+ readonly network: string;
+ readonly address?: string;
+ }) {
+ return this.value.get(tokenString(token)) ?? null;
+ }
+}
+
+const PriceEntry = Schema.Struct({
+ price: Schema.optionalKey(Schema.Number.check(Schema.isFinite())),
+ price_24_h: Schema.optionalKey(Schema.Number.check(Schema.isFinite())),
+});
+
+const PriceEntries = TolerantTopLevelRecord(Schema.NonEmptyString, PriceEntry, {
+ operation: "token-prices",
+});
+
+export const PriceResponse = PriceEntries.pipe(
+ Schema.decodeTo(
+ Schema.instanceOf(Prices),
+ SchemaTransformation.transform({
+ decode: (entries): Prices =>
+ new Prices(
+ new Map(
+ Object.entries(entries).map(([key, value]) => [
+ key,
+ {
+ price: value.price,
+ price24H: value.price_24_h,
+ },
+ ])
+ )
+ ),
+ encode: (
+ prices: Prices
+ ): Readonly<
+ Record<
+ string,
+ { readonly price?: number; readonly price_24_h?: number }
+ >
+ > =>
+ Object.fromEntries(
+ Array.from(prices.value, ([key, value]) => [
+ key,
+ {
+ price: value.price,
+ price_24_h: value.price24H,
+ },
+ ])
+ ),
+ })
+ )
+);
diff --git a/packages/widget/src/domain/schema/identifiers.ts b/packages/widget/src/domain/schema/identifiers.ts
new file mode 100644
index 00000000..528d5e40
--- /dev/null
+++ b/packages/widget/src/domain/schema/identifiers.ts
@@ -0,0 +1,32 @@
+import { Schema } from "effect";
+
+export const YieldId = Schema.NonEmptyString.pipe(Schema.brand("YieldId"));
+export type YieldId = typeof YieldId.Type;
+
+export const ProviderId = Schema.NonEmptyString.pipe(
+ Schema.brand("ProviderId")
+);
+export type ProviderId = typeof ProviderId.Type;
+
+export const ActionId = Schema.NonEmptyString.pipe(Schema.brand("ActionId"));
+export type ActionId = typeof ActionId.Type;
+
+export const TransactionId = Schema.NonEmptyString.pipe(
+ Schema.brand("TransactionId")
+);
+export type TransactionId = typeof TransactionId.Type;
+
+export const WalletAddress = Schema.NonEmptyString.pipe(
+ Schema.brand("WalletAddress")
+);
+export type WalletAddress = typeof WalletAddress.Type;
+
+export const TokenAddress = Schema.NonEmptyString.pipe(
+ Schema.brand("TokenAddress")
+);
+export type TokenAddress = typeof TokenAddress.Type;
+
+export const ValidatorAddress = Schema.NonEmptyString.pipe(
+ Schema.brand("ValidatorAddress")
+);
+export type ValidatorAddress = typeof ValidatorAddress.Type;
diff --git a/packages/widget/src/domain/schema/init-params.ts b/packages/widget/src/domain/schema/init-params.ts
new file mode 100644
index 00000000..dce82d5f
--- /dev/null
+++ b/packages/widget/src/domain/schema/init-params.ts
@@ -0,0 +1,52 @@
+import { Effect, Option, Schema, SchemaGetter } from "effect";
+import { isSupportedChain, type SupportedSKChains } from "../types/chains";
+import { YieldId } from "./identifiers";
+import { Network } from "./network-model";
+
+const invalidAsNull = (schema: S) =>
+ Schema.NullOr(schema).pipe(
+ Schema.catchDecoding(() => Effect.succeed(Option.some(null)))
+ );
+
+const SafeQueryParam = Schema.String.check(
+ Schema.isPattern(/^(?!.*\.\.)[a-zA-Z0-9-_.+]*$/)
+);
+
+const PendingActionType = SafeQueryParam.check(Schema.isPattern(/^[A-Z_]+$/));
+
+const InitTab = Schema.Literals(["earn", "positions"]);
+
+const SafeYieldId = SafeQueryParam.check(
+ Schema.isPattern(/^[^-]+-[^-]+-.+$/)
+).pipe(Schema.decodeTo(YieldId));
+
+const SupportedNetwork = Network.pipe(
+ Schema.decodeTo(
+ Schema.declare(
+ (value): value is SupportedSKChains =>
+ typeof value === "string" && isSupportedChain(value),
+ {
+ expected: "a widget-supported network",
+ }
+ )
+ )
+);
+
+const AccountId = Schema.String.pipe(
+ Schema.decodeTo(Schema.String, {
+ decode: SchemaGetter.decodeUriComponent(),
+ encode: SchemaGetter.encodeUriComponent(),
+ })
+);
+
+export const InitParams = Schema.Struct({
+ accountId: invalidAsNull(AccountId),
+ balanceId: invalidAsNull(SafeQueryParam),
+ network: invalidAsNull(SupportedNetwork),
+ pendingaction: invalidAsNull(PendingActionType),
+ tab: invalidAsNull(InitTab),
+ token: invalidAsNull(Schema.String),
+ validator: invalidAsNull(Schema.String),
+ yieldId: invalidAsNull(SafeYieldId),
+});
+export type InitParams = typeof InitParams.Type;
diff --git a/packages/widget/src/domain/schema/legacy-models.ts b/packages/widget/src/domain/schema/legacy-models.ts
new file mode 100644
index 00000000..93120faf
--- /dev/null
+++ b/packages/widget/src/domain/schema/legacy-models.ts
@@ -0,0 +1,29 @@
+import { Schema } from "effect";
+import * as LegacyApi from "../../generated/api/legacy-schema";
+import * as YieldApi from "../../generated/api/yield-schema";
+import { TokenAddress } from "./identifiers";
+
+export const AppToken = Schema.Struct({
+ ...YieldApi.TokenDto.fields,
+ address: Schema.optionalKey(TokenAddress),
+ decimals: Schema.Number.check(Schema.isInt()),
+});
+export type AppToken = typeof AppToken.Type;
+
+export const GeolocationError = LegacyApi.GeolocationError;
+export type GeolocationError = typeof GeolocationError.Type;
+
+export const FeeConfiguration = LegacyApi.FeeConfigurationDto;
+export type FeeConfiguration = typeof FeeConfiguration.Type;
+
+export const LegacyPendingAction = LegacyApi.PendingActionDto;
+export type LegacyPendingAction = typeof LegacyPendingAction.Type;
+
+export const LegacyTransaction = LegacyApi.TransactionDto;
+export type LegacyTransaction = typeof LegacyTransaction.Type;
+
+export const TronResource = LegacyApi.TronResourceType;
+export type TronResource = typeof TronResource.Type;
+
+export const ActivityActionsQuery = YieldApi.ActionsControllerGetActionsParams;
+export type ActivityActionsQuery = typeof ActivityActionsQuery.Type;
diff --git a/packages/widget/src/domain/schema/network-model.ts b/packages/widget/src/domain/schema/network-model.ts
new file mode 100644
index 00000000..d693589c
--- /dev/null
+++ b/packages/widget/src/domain/schema/network-model.ts
@@ -0,0 +1,4 @@
+import * as LegacyApi from "../../generated/api/legacy-schema";
+
+export const Network = LegacyApi.Networks;
+export type Network = typeof Network.Type;
diff --git a/packages/widget/src/domain/schema/response.ts b/packages/widget/src/domain/schema/response.ts
new file mode 100644
index 00000000..b3449b7b
--- /dev/null
+++ b/packages/widget/src/domain/schema/response.ts
@@ -0,0 +1,110 @@
+import {
+ Array as EArray,
+ Effect,
+ Option,
+ Result,
+ Schema,
+ SchemaGetter,
+} from "effect";
+import { logDecodeRejection } from "./decode-diagnostics";
+
+type CollectionResponseSchemaOptions = {
+ readonly operation: string;
+ readonly identifier?: Schema.ConstraintDecoder;
+};
+
+const decodeIdentifier = (
+ schema: Schema.ConstraintDecoder | undefined,
+ input: unknown
+) => {
+ if (!schema) return undefined;
+
+ const result = Schema.decodeUnknownResult(schema)(input);
+ return Result.isSuccess(result) ? String(result.success) : undefined;
+};
+
+/**
+ * A response schema that rejects invalid top-level array entries independently.
+ * The complete item schema is applied once per entry, so nested failures reject
+ * their parent entry instead of producing partially decoded models.
+ */
+export const TolerantTopLevelArray = <
+ Item extends Schema.ConstraintDecoder,
+>(
+ item: Item,
+ options: CollectionResponseSchemaOptions
+) =>
+ Schema.Array(Schema.Unknown).pipe(
+ Schema.decodeTo(Schema.Array(Schema.toType(item)), {
+ decode: SchemaGetter.transformOrFail((inputs) =>
+ Effect.forEach(inputs, (input, index) => {
+ const result = Schema.decodeUnknownResult(item)(input);
+
+ return Result.match(result, {
+ onFailure: (failure) =>
+ logDecodeRejection({
+ operation: options.operation,
+ location: index,
+ identifier: decodeIdentifier(options.identifier, input),
+ issue: failure.message,
+ }).pipe(Effect.as(Option.none- ())),
+ onSuccess: (value) => Effect.succeed(Option.some(value)),
+ });
+ }).pipe(Effect.map(EArray.getSomes))
+ ),
+ encode: SchemaGetter.forbidden(
+ () => "Cannot encode a tolerant top-level array response"
+ ),
+ })
+ );
+
+/**
+ * A response schema that rejects invalid top-level key-value entries
+ * independently. Both the key and the complete value must decode successfully.
+ */
+export const TolerantTopLevelRecord = <
+ Key extends Schema.Record.Key & Schema.ConstraintDecoder,
+ Value extends Schema.ConstraintDecoder,
+>(
+ key: Key,
+ value: Value,
+ options: CollectionResponseSchemaOptions
+) => {
+ const target = Schema.Record(Schema.toType(key), Schema.toType(value));
+
+ return Schema.Record(Schema.String, Schema.Unknown).pipe(
+ Schema.decodeTo(target, {
+ decode: SchemaGetter.transformOrFail((input) =>
+ Effect.forEach(Object.entries(input), ([rawKey, rawValue]) => {
+ const decodedKey = Schema.decodeUnknownResult(key)(rawKey);
+ const decodedValue = Schema.decodeUnknownResult(value)(rawValue);
+
+ if (Result.isSuccess(decodedKey) && Result.isSuccess(decodedValue)) {
+ return Effect.succeed(
+ Option.some([decodedKey.success, decodedValue.success] as const)
+ );
+ }
+
+ const issue = Result.isFailure(decodedKey)
+ ? `key: ${decodedKey.failure.message}`
+ : Result.isFailure(decodedValue)
+ ? `value: ${decodedValue.failure.message}`
+ : "Unknown key-value decode failure";
+
+ return logDecodeRejection({
+ operation: options.operation,
+ location: rawKey,
+ identifier:
+ decodeIdentifier(options.identifier, rawValue) ?? rawKey,
+ issue,
+ }).pipe(
+ Effect.as(Option.none())
+ );
+ }).pipe(Effect.map(EArray.getSomes), Effect.map(Object.fromEntries))
+ ),
+ encode: SchemaGetter.forbidden(
+ () => "Cannot encode a tolerant top-level record response"
+ ),
+ })
+ );
+};
diff --git a/packages/widget/src/domain/schema/scalars.ts b/packages/widget/src/domain/schema/scalars.ts
new file mode 100644
index 00000000..a76ca090
--- /dev/null
+++ b/packages/widget/src/domain/schema/scalars.ts
@@ -0,0 +1,25 @@
+import BigNumber from "bignumber.js";
+import { Schema, SchemaTransformation } from "effect";
+
+export const BigIntFromString = Schema.BigIntFromString;
+export type BigIntFromString = typeof BigIntFromString.Type;
+
+export const PrecisionDecimalFromString = Schema.String.check(
+ Schema.isStringFinite()
+).pipe(
+ Schema.decodeTo(
+ Schema.instanceOf(BigNumber),
+ SchemaTransformation.transform({
+ decode: (value) => new BigNumber(value),
+ encode: (value) => value.toFixed(),
+ })
+ )
+);
+
+export const ValidDateFromString = Schema.DateFromString.pipe(
+ Schema.decodeTo(Schema.DateValid)
+);
+export type ValidDateFromString = typeof ValidDateFromString.Type;
+
+export const UtcDateTimeFromString = Schema.DateTimeUtcFromString;
+export type UtcDateTimeFromString = typeof UtcDateTimeFromString.Type;
diff --git a/packages/widget/src/domain/schema/wallet-models.ts b/packages/widget/src/domain/schema/wallet-models.ts
new file mode 100644
index 00000000..f8e1ecec
--- /dev/null
+++ b/packages/widget/src/domain/schema/wallet-models.ts
@@ -0,0 +1,19 @@
+import { Schema, SchemaTransformation } from "effect";
+import * as LegacyApi from "../../generated/api/legacy-schema";
+import { Network } from "./network-model";
+
+const EnabledNetworkArray = LegacyApi.YieldControllerGetMyNetworksdefault.pipe(
+ Schema.decodeTo(Schema.Array(Network))
+);
+
+export const EnabledNetworksResponse = EnabledNetworkArray.pipe(
+ Schema.decodeTo(
+ Schema.ReadonlySet(Network),
+ SchemaTransformation.transform({
+ decode: (networks): ReadonlySet => new Set(networks),
+ encode: (networks: ReadonlySet): ReadonlyArray =>
+ Array.from(networks),
+ })
+ )
+);
+export type EnabledNetworks = typeof EnabledNetworksResponse.Type;
diff --git a/packages/widget/src/domain/types/action.ts b/packages/widget/src/domain/types/action.ts
index 380597ae..54a55065 100644
--- a/packages/widget/src/domain/types/action.ts
+++ b/packages/widget/src/domain/types/action.ts
@@ -1,28 +1,27 @@
import type BigNumber from "bignumber.js";
-import type {
- ActionDto as YieldActionDtoGenerated,
- CreateActionDto as YieldCreateActionDtoGenerated,
- CreateManageActionDto as YieldCreateManageActionDtoGenerated,
- TransactionDto as YieldTransactionDtoGenerated,
-} from "../../generated/api/yield";
-import type { TokenDto } from "./tokens";
-import type { Yield } from "./yields";
-
-export type ActionDto = YieldActionDtoGenerated;
-export type TransactionDto = YieldTransactionDtoGenerated;
-export type TransactionType = TransactionDto["type"];
-export type ActionType = ActionDto["type"];
-export type ActionStatus = ActionDto["status"];
-export type TransactionStatus = TransactionDto["status"];
-
-export type YieldCreateActionDto = YieldCreateActionDtoGenerated;
-export type YieldCreateManageActionDto = YieldCreateManageActionDtoGenerated;
-export type YieldActionDto = ActionDto;
-export type YieldTransactionDto = TransactionDto;
+import { Option, Schema } from "effect";
+import {
+ type ActionCommand,
+ type ActionTransaction,
+ TransactionGasEstimateJson,
+ type YieldAction,
+} from "../schema/action-models";
+import type { EarnYieldWithProvider } from "../schema/earn-models";
+import type { ValidatorAddress } from "../schema/identifiers";
+import type { AppToken } from "../schema/legacy-models";
+
+export type TransactionType = ActionTransaction["type"];
+export type ActionType = YieldAction["type"];
+export type ActionStatus = YieldAction["status"];
+export type TransactionStatus = ActionTransaction["status"];
+
+export const getActionProviderYieldId = (
+ command: Pick
+) => command.arguments?.providerId ?? null;
type TransactionGasEstimate = {
- amount: string;
+ amount: BigNumber;
gasLimit?: string;
- token: TokenDto;
+ token: AppToken;
} | null;
export const ActionTypes = {
@@ -77,21 +76,15 @@ const NATIVE_TOKEN_PLACEHOLDER = "0x";
const toLower = (value: string) => value.toLowerCase();
-type EncodedGasEstimate = {
- amount?: string | null;
- gasLimit?: string | null;
- token?: TokenDto | null;
-};
-
export const getActionInputToken = ({
actionDto,
inputToken,
yieldDto,
}: {
- actionDto: ActionDto;
- inputToken?: TokenDto;
- yieldDto?: Yield | null;
-}): TokenDto | undefined => {
+ actionDto: YieldAction;
+ inputToken?: AppToken;
+ yieldDto?: EarnYieldWithProvider | null;
+}): AppToken | undefined => {
if (inputToken) {
return inputToken;
}
@@ -124,49 +117,18 @@ export const getActionInputToken = ({
};
export const getActionValidatorAddresses = (
- actionDto: ActionDto
-): ReadonlyArray | null =>
+ actionDto: YieldAction
+): ReadonlyArray | null =>
actionDto.rawArguments?.validatorAddresses ??
(actionDto.rawArguments?.validatorAddress
? [actionDto.rawArguments.validatorAddress]
: null);
export const getTransactionGasEstimate = (
- transactionDto: TransactionDto
-): TransactionGasEstimate => {
- const gasEstimate = transactionDto.gasEstimate;
-
- if (!gasEstimate) {
- return null;
- }
-
- try {
- const parsed = JSON.parse(gasEstimate) as EncodedGasEstimate | null;
-
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
- return null;
- }
-
- const amount = parsed.amount ?? null;
- const token = parsed.token;
-
- if (!amount || !token) {
- return null;
- }
-
- return {
- amount,
- token,
- };
- } catch {
- return null;
- }
-};
-
-export type ActionDtoWithGasEstimate = {
- gasEstimate: {
- amount: BigNumber;
- token: TokenDto;
- gasLimit?: string;
- } | null;
-};
+ transactionDto: ActionTransaction
+): TransactionGasEstimate =>
+ transactionDto.gasEstimate
+ ? Schema.decodeOption(TransactionGasEstimateJson)(
+ transactionDto.gasEstimate
+ ).pipe(Option.getOrNull)
+ : null;
diff --git a/packages/widget/src/domain/types/addresses.ts b/packages/widget/src/domain/types/addresses.ts
deleted file mode 100644
index a8304ff2..00000000
--- a/packages/widget/src/domain/types/addresses.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import type {
- AddressesDto,
- AddressWithTokenDto as LegacyAddressWithTokenDto,
-} from "../../generated/api/legacy";
-
-export type AddressWithTokenDto = LegacyAddressWithTokenDto;
-export type AddressWithTokenDtoAdditionalAddresses = NonNullable<
- LegacyAddressWithTokenDto["additionalAddresses"]
->;
-export type { AddressesDto };
diff --git a/packages/widget/src/domain/types/chains/cosmos.ts b/packages/widget/src/domain/types/chains/cosmos.ts
index f0a8bc4d..d6a0d5e3 100644
--- a/packages/widget/src/domain/types/chains/cosmos.ts
+++ b/packages/widget/src/domain/types/chains/cosmos.ts
@@ -1,7 +1,11 @@
import type { Chain } from "@stakekit/rainbowkit";
-import type { CosmosChainsAssets } from "../../../providers/cosmos/chains/types";
+import type { chains } from "chain-registry";
import { CosmosNetworks } from "./networks";
+export type CosmosChain = (typeof chains)[number];
+export type WithWagmiName = T & { readonly wagmiName: string };
+export type CosmosChainsAssets = WithWagmiName;
+
export const supportedCosmosChains = [
CosmosNetworks.Akash,
CosmosNetworks.Cosmos,
diff --git a/packages/widget/src/domain/types/chains/evm.ts b/packages/widget/src/domain/types/chains/evm.ts
index df98f792..48243077 100644
--- a/packages/widget/src/domain/types/chains/evm.ts
+++ b/packages/widget/src/domain/types/chains/evm.ts
@@ -21,8 +21,9 @@ import {
unichain,
viction,
} from "viem/chains";
-import type { KebabToCamelCase } from "../../../types/utils";
-import { getNetworkLogo } from "../../../utils";
+import { EvmChainIds } from "../../../public-api/types";
+import { getNetworkLogo } from "../../../shared/lib";
+import type { KebabToCamelCase } from "../../../shared/types/utils";
import { EvmNetworks } from "./networks";
const supportedEVMChains = [
@@ -261,31 +262,7 @@ export const evmChainsMap: EvmChainsMap = {
},
};
-export enum EvmChainIds {
- Ethereum = 1,
- Polygon = 137,
- Optimism = 10,
- Arbitrum = 42_161,
- AvalancheC = 43_114,
- Celo = 42_220,
- Harmony = 1_666_600_000,
- Viction = 88,
- Binance = 56,
- Base = 8453,
- Linea = 59_144,
- Core = 1116,
- Sonic = 146,
- EthereumHoodi = 560048,
- EthereumGoerli = 5,
- EthereumSepolia = 11_155_111,
- Unichain = 130,
- Katana = 747474,
- Gnosis = 100,
- Hyperevm = 999,
- Plasma = 9745,
- Monad = 143,
- MonadTestnet = 10143,
-}
+export { EvmChainIds } from "../../../public-api/types";
EvmChainIds satisfies Record<
Capitalize>,
diff --git a/packages/widget/src/domain/types/chains/index.ts b/packages/widget/src/domain/types/chains/index.ts
index b21c75c1..b21c1ca3 100644
--- a/packages/widget/src/domain/types/chains/index.ts
+++ b/packages/widget/src/domain/types/chains/index.ts
@@ -1,5 +1,5 @@
import type { ChainGroup } from "@stakekit/rainbowkit";
-import { getNetworkLogo } from "../../../utils";
+import { getNetworkLogo } from "../../../shared/lib";
import { type SupportedCosmosChains, supportedCosmosChainsSet } from "./cosmos";
import {
type EvmChainIds,
diff --git a/packages/widget/src/domain/types/chains/misc.ts b/packages/widget/src/domain/types/chains/misc.ts
index fd477eab..b0db091e 100644
--- a/packages/widget/src/domain/types/chains/misc.ts
+++ b/packages/widget/src/domain/types/chains/misc.ts
@@ -1,6 +1,7 @@
import type { Chain } from "@stakekit/rainbowkit";
-import type { KebabToCamelCase } from "../../../types/utils";
-import { getTokenLogo } from "../../../utils";
+import { MiscChainIds } from "../../../public-api/types";
+import { getTokenLogo } from "../../../shared/lib";
+import type { KebabToCamelCase } from "../../../shared/types/utils";
import { MiscNetworks } from "./networks";
const supportedMiscChains = [
@@ -147,14 +148,7 @@ export const miscChainsMap: MiscChainsMap = {
},
};
-export enum MiscChainIds {
- Near = 397,
- Tezos = 1729,
- Solana = 501,
- Tron = 79,
- Ton = 3412,
- Cardano = 2000,
-}
+export { MiscChainIds } from "../../../public-api/types";
MiscChainIds satisfies Record<
Capitalize>,
diff --git a/packages/widget/src/domain/types/chains/networks.ts b/packages/widget/src/domain/types/chains/networks.ts
index a8470d3b..bb2efa4d 100644
--- a/packages/widget/src/domain/types/chains/networks.ts
+++ b/packages/widget/src/domain/types/chains/networks.ts
@@ -1,8 +1,6 @@
-import type { TokenDto } from "../../../generated/api/yield";
+import type { Network } from "../../schema/network-model";
-export type Networks = TokenDto["network"];
-
-type NetworkMap = Record;
+type NetworkMap = Record;
export const EvmNetworks = {
Ethereum: "ethereum",
diff --git a/packages/widget/src/domain/types/chains/substrate.ts b/packages/widget/src/domain/types/chains/substrate.ts
index f6181fc2..3146e0a9 100644
--- a/packages/widget/src/domain/types/chains/substrate.ts
+++ b/packages/widget/src/domain/types/chains/substrate.ts
@@ -1,6 +1,7 @@
import type { Chain } from "@stakekit/rainbowkit";
-import type { KebabToCamelCase } from "../../../types/utils";
-import { getNetworkLogo } from "../../../utils";
+import { SubstrateChainIds } from "../../../public-api/types";
+import { getNetworkLogo } from "../../../shared/lib";
+import type { KebabToCamelCase } from "../../../shared/types/utils";
import { SubstrateNetworks } from "./networks";
const supportedSubstrateChains = [
@@ -86,10 +87,7 @@ export const substrateChainsMap: SubstrateChainsMap = {
},
};
-export enum SubstrateChainIds {
- Polkadot = 9999,
- Bittensor = 558,
-}
+export { SubstrateChainIds } from "../../../public-api/types";
SubstrateChainIds satisfies Record<
Capitalize>,
diff --git a/packages/widget/src/domain/types/connectors.ts b/packages/widget/src/domain/types/connectors.ts
index 0637aa0a..a748cf7c 100644
--- a/packages/widget/src/domain/types/connectors.ts
+++ b/packages/widget/src/domain/types/connectors.ts
@@ -1,9 +1,9 @@
-import type { Observable } from "rxjs";
+import type { Stream } from "effect";
import type { Chain } from "viem";
import type { Connector } from "wagmi";
export type ConnectorWithFilteredChains = {
- $filteredChains: Observable;
+ $filteredChains: Stream.Stream;
};
export const isConnectorWithFilteredChains = (
diff --git a/packages/widget/src/domain/types/errors.ts b/packages/widget/src/domain/types/errors.ts
index 3b73b92b..897cd065 100644
--- a/packages/widget/src/domain/types/errors.ts
+++ b/packages/widget/src/domain/types/errors.ts
@@ -1,11 +1,8 @@
-import type {
- GeolocationError,
- StakeKitErrorDto,
-} from "../../generated/api/legacy";
+import type { GeolocationError } from "../schema/legacy-models";
const GeolocationErrorType = {
GEO_LOCATION: "GEO_LOCATION",
} as const;
-export type { GeolocationError, StakeKitErrorDto };
+export type { GeolocationError };
export { GeolocationErrorType };
diff --git a/packages/widget/src/domain/types/external-providers.ts b/packages/widget/src/domain/types/external-providers.ts
index c6491775..c5714114 100644
--- a/packages/widget/src/domain/types/external-providers.ts
+++ b/packages/widget/src/domain/types/external-providers.ts
@@ -1,73 +1,81 @@
-import { EitherAsync, Left, Maybe, Right } from "purify-ts";
-import type { RefObject } from "react";
+import { Data, Effect } from "effect";
+import type { SKTx, SKTxMeta } from "../../public-api/types";
import type { SKExternalProviders } from "./wallets";
-import type { SKTx, SKTxMeta } from "./wallets/generic-wallet";
-export class ExternalProviderError extends Error {
- _tag = "ExternalProviderError";
+export class ExternalProviderError extends Data.TaggedError(
+ "ExternalProviderError"
+)<{
+ readonly cause?: unknown;
+ readonly customMessage: string | null;
+ readonly message: string;
+}> {}
- constructor(
- readonly customMessage: string | null,
- cause?: unknown
- ) {
- super(customMessage ?? "External provider failed", { cause });
- }
-}
+export type CurrentRef = {
+ readonly current: A;
+};
export class ExternalProvider {
- constructor(private variantProvider: RefObject) {}
+ constructor(private variantProvider: CurrentRef) {}
sendTransaction(tx: SKTx, txMeta: SKTxMeta) {
- return EitherAsync.liftEither(
- Maybe.fromNullable(
- this.variantProvider.current.provider.sendTransaction
- ).toEither(new Error("Invalid provider type"))
- )
- .chain((_sendTransaction) =>
- EitherAsync(() => _sendTransaction(tx, txMeta)).mapLeft((error) =>
- toExternalProviderError(error)
- )
- )
- .chain((res) => {
+ const sendTransaction =
+ this.variantProvider.current.provider.sendTransaction;
+
+ if (!sendTransaction) {
+ return Effect.fail(new Error("Invalid provider type"));
+ }
+
+ return Effect.tryPromise({
+ try: () => sendTransaction(tx, txMeta),
+ catch: toExternalProviderError,
+ }).pipe(
+ Effect.flatMap((res) => {
if (typeof res === "string") {
- return EitherAsync.liftEither(Right(res));
+ return Effect.succeed(res);
}
if (res.type === "success") {
- return EitherAsync.liftEither(Right(res.txHash));
+ return Effect.succeed(res.txHash);
}
- return EitherAsync.liftEither(
- Left(new ExternalProviderError(res.error))
+ return Effect.fail(
+ new ExternalProviderError({
+ customMessage: res.error,
+ message: res.error ?? "External provider failed",
+ })
);
- });
+ })
+ );
}
switchChain({ chainId }: { chainId: number }) {
- return EitherAsync(() =>
- this.variantProvider.current.provider.switchChain(chainId)
- ).mapLeft((e) => {
- console.error(e);
- return new Error("Failed to switch chain");
+ return Effect.tryPromise({
+ try: () => this.variantProvider.current.provider.switchChain(chainId),
+ catch: (error) => new Error("Failed to switch chain", { cause: error }),
});
}
signMessage(messageHash: string) {
- return EitherAsync(() =>
- this.variantProvider.current.provider.signMessage(messageHash)
- ).mapLeft((e) => {
- console.error(e);
- return toExternalProviderError(e);
+ return Effect.tryPromise({
+ try: () => this.variantProvider.current.provider.signMessage(messageHash),
+ catch: toExternalProviderError,
});
}
}
const toExternalProviderError = (error: unknown) =>
- new ExternalProviderError(
- error instanceof Error && error.message
- ? error.message
- : typeof error === "string" && error
- ? error
- : null,
- error
- );
+ new ExternalProviderError({
+ cause: error,
+ customMessage:
+ error instanceof Error && error.message
+ ? error.message
+ : typeof error === "string" && error
+ ? error
+ : null,
+ message:
+ error instanceof Error && error.message
+ ? error.message
+ : typeof error === "string" && error
+ ? error
+ : "External provider failed",
+ });
diff --git a/packages/widget/src/domain/types/fees.ts b/packages/widget/src/domain/types/fees.ts
deleted file mode 100644
index 562e2bc2..00000000
--- a/packages/widget/src/domain/types/fees.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import type { FeeConfigurationDto } from "../../generated/api/legacy";
-
-export type { FeeConfigurationDto };
diff --git a/packages/widget/src/domain/types/gas.ts b/packages/widget/src/domain/types/gas.ts
new file mode 100644
index 00000000..ff4cb213
--- /dev/null
+++ b/packages/widget/src/domain/types/gas.ts
@@ -0,0 +1,31 @@
+import type BigNumber from "bignumber.js";
+import { equalTokens } from "..";
+import type { TokenBalance } from "../schema/financial-models";
+
+type ComparableToken = {
+ readonly address?: string;
+ readonly network: string;
+ readonly symbol: string;
+};
+
+type CheckGasAmountIfStake =
+ | { isStake: true; stakeToken: ComparableToken; stakeAmount: BigNumber }
+ | { isStake: false };
+
+export const checkGasAmount = ({
+ gasTokenBalance,
+ gasEstimate,
+ ...rest
+}: {
+ gasTokenBalance: TokenBalance | undefined;
+ gasEstimate: BigNumber;
+} & CheckGasAmountIfStake) => {
+ if (!gasTokenBalance) return true;
+
+ const amount =
+ rest.isStake && equalTokens(gasTokenBalance.token, rest.stakeToken)
+ ? gasTokenBalance.amount.minus(rest.stakeAmount)
+ : gasTokenBalance.amount;
+
+ return gasEstimate.isGreaterThan(amount);
+};
diff --git a/packages/widget/src/domain/types/init-params.ts b/packages/widget/src/domain/types/init-params.ts
index 266b1d70..694fe918 100644
--- a/packages/widget/src/domain/types/init-params.ts
+++ b/packages/widget/src/domain/types/init-params.ts
@@ -1,14 +1 @@
-import type { SupportedSKChains } from "./chains";
-import type { TokenString } from "./tokens";
-import type { Yield } from "./yields";
-
-export type InitParams = {
- network: SupportedSKChains | null;
- token: TokenString | (string & {}) | null;
- yieldId: string | null;
- validator: string | null;
- pendingaction: string | null;
- yieldData: Yield | null;
- accountId: string | null;
- tab: "earn" | "positions" | null;
-};
+export type { InitParams } from "../schema/init-params";
diff --git a/packages/widget/src/domain/types/kyc.ts b/packages/widget/src/domain/types/kyc.ts
index 63345323..b3852afb 100644
--- a/packages/widget/src/domain/types/kyc.ts
+++ b/packages/widget/src/domain/types/kyc.ts
@@ -1,5 +1,5 @@
-import type { KycStatusResponseDto } from "../../generated/api/yield";
-import type { Yield } from "./yields";
+import type { KycStatus } from "../schema/dashboard-models";
+import type { EarnYieldWithProvider } from "../schema/earn-models";
export type KycGate =
| { state: "pass" }
@@ -14,12 +14,13 @@ export type KycGate =
};
type KycUrlSource = {
- readonly status?: Pick | null;
- readonly yieldDto?: Yield | null;
+ readonly status?: Pick | null;
+ readonly yieldDto?: EarnYieldWithProvider | null;
};
-export const getKycProviderName = (yieldDto: Yield | null | undefined) =>
- yieldDto?.provider?.name ?? null;
+export const getKycProviderName = (
+ yieldDto: EarnYieldWithProvider | null | undefined
+) => yieldDto?.provider?.name ?? null;
export const getKycUrl = ({ status, yieldDto }: KycUrlSource) =>
status?.authorizeUrl ??
@@ -31,7 +32,7 @@ const getKycGateUrlFields = ({
yieldDto,
}: {
readonly kycUrl?: string;
- readonly yieldDto?: Yield | null;
+ readonly yieldDto?: EarnYieldWithProvider | null;
}) => ({
...(kycUrl ? { kycUrl } : {}),
...(kycUrl && yieldDto?.mechanics.requirements?.kyc?.iframeAllowed === true
@@ -43,8 +44,8 @@ export const mapKycStatusToGate = ({
status,
yieldDto,
}: {
- readonly status?: KycStatusResponseDto | null;
- readonly yieldDto?: Yield | null;
+ readonly status?: KycStatus | null;
+ readonly yieldDto?: EarnYieldWithProvider | null;
}): KycGate => {
const kycUrl = getKycUrl({ status, yieldDto });
diff --git a/packages/widget/src/domain/types/pending-action.ts b/packages/widget/src/domain/types/pending-action.ts
index 5c29b877..e00741a0 100644
--- a/packages/widget/src/domain/types/pending-action.ts
+++ b/packages/widget/src/domain/types/pending-action.ts
@@ -1,22 +1,19 @@
-import type { PendingActionDto as LegacyPendingActionDto } from "../../generated/api/legacy";
import type {
- CreateManageActionDto,
- PendingActionDto as YieldPendingActionDtoGenerated,
-} from "../../generated/api/yield";
+ ManageActionCommand,
+ PendingAction,
+} from "../schema/action-models";
+import type { LegacyPendingAction } from "../schema/legacy-models";
-export type YieldPendingActionDto = YieldPendingActionDtoGenerated;
export type YieldPendingActionType =
- | YieldPendingActionDto["type"]
- | NonNullable;
+ | PendingAction["type"]
+ | NonNullable;
type PendingActionArgName =
| "amount"
| "validatorAddress"
| "validatorAddresses";
-export type AnyPendingActionDto =
- | LegacyPendingActionDto
- | YieldPendingActionDto;
+export type AnyPendingActionDto = LegacyPendingAction | PendingAction;
type PendingActionAmountConfig = {
required: boolean;
@@ -61,12 +58,9 @@ const getPendingActionArgument = (
pendingAction: AnyPendingActionDto,
name: PendingActionArgName
) => {
- const v2Field = (
- pendingAction as YieldPendingActionDto
- ).arguments?.fields?.find(
- (
- field: NonNullable["fields"][number]
- ) => field.name === name
+ const v2Field = (pendingAction as PendingAction).arguments?.fields?.find(
+ (field: NonNullable["fields"][number]) =>
+ field.name === name
);
if (v2Field) {
@@ -77,7 +71,7 @@ const getPendingActionArgument = (
};
}
- const legacyField = (pendingAction as LegacyPendingActionDto).args?.args?.[
+ const legacyField = (pendingAction as LegacyPendingAction).args?.args?.[
name
] as
| {
diff --git a/packages/widget/src/domain/types/positions.ts b/packages/widget/src/domain/types/positions.ts
index 00a49368..c7b2217c 100644
--- a/packages/widget/src/domain/types/positions.ts
+++ b/packages/widget/src/domain/types/positions.ts
@@ -1,67 +1,161 @@
import BigNumber from "bignumber.js";
-import type {
- BalanceDto,
- BalancesRequestDto,
- ValidatorDto,
- YieldBalancesDto,
- BalanceType as YieldBalanceTypeGenerated,
-} from "../../generated/api/yield";
import { equalTokens } from "..";
+import type {
+ EarnBalance,
+ EarnPosition,
+ EarnValidator,
+} from "../schema/earn-models";
+import type { YieldId } from "../schema/identifiers";
import type { YieldRewardRateDto } from "./reward-rate";
-import type { TokenDto } from "./tokens";
+import type { ValidatorKey } from "./validators";
-export type YieldBalanceDto = BalanceDto;
-export type YieldBalancesByYieldDto = YieldBalancesDto;
-export type YieldBalancesRequestDto = BalancesRequestDto;
-export type YieldBalanceType = YieldBalanceTypeGenerated;
+export type YieldBalanceType = EarnBalance["type"];
export type PositionBalancesByType = Map<
YieldBalanceType,
- (YieldBalanceDto & {
+ (EarnBalance & {
tokenPriceInUsd: BigNumber;
})[]
>;
+export type PositionValidators = ReadonlyArray;
export type PositionDetailsLabelType = "hasFrozenV1";
type BalanceType = "validators" | "default";
-export type BalanceDataKey =
- | BalanceType
- | `validator::${ValidatorDto["address"]}`;
+export type BalanceDataKey = BalanceType | `validator::${ValidatorKey}`;
export type PositionsData = Map<
- YieldBalancesByYieldDto["yieldId"],
+ YieldId,
{
- yieldId: YieldBalancesByYieldDto["yieldId"];
+ yieldId: YieldId;
rewardRate?: YieldRewardRateDto | null;
balanceData: Map<
BalanceDataKey,
- { balances: YieldBalanceDto[] } & (
- | { type: "validators"; validators: ReadonlyArray }
+ { balances: EarnBalance[] } & (
+ | { type: "validators"; validators: PositionValidators }
| { type: "default" }
)
>;
}
>;
+export type PositionData =
+ PositionsData extends Map ? Value : never;
+
+export type PositionBalances =
+ PositionData["balanceData"] extends Map
+ ? Value & { rewardRate: PositionData["rewardRate"] }
+ : never;
+
export const getPositionBalanceDataKey = (
- balance: YieldBalanceDto
+ balance: EarnBalance
): BalanceDataKey => {
if (Array.isArray(balance.validators) && balance.validators.length > 1) {
return "validators";
}
- if (balance.validator?.address) {
- return `validator::${balance.validator.address}` as BalanceDataKey;
+ const validator = balance.validator ?? balance.validators?.[0];
+
+ if (validator) {
+ return `validator::${validator.key}` as BalanceDataKey;
}
return "default";
};
+const getBalanceValidators = (balance: EarnBalance) =>
+ balance.validators ?? (balance.validator ? [balance.validator] : []);
+
+export const toPositionsData = (
+ balancesData: ReadonlyArray
+): PositionsData =>
+ balancesData.reduce((positions, position) => {
+ positions.set(position.yieldId, {
+ yieldId: position.yieldId,
+ rewardRate: position.rewardRate,
+ balanceData: [...position.balances]
+ .sort((a, b) =>
+ getPositionBalanceDataKey(a).localeCompare(
+ getPositionBalanceDataKey(b)
+ )
+ )
+ .reduce(
+ (balances, balance) => {
+ const key = getPositionBalanceDataKey(balance);
+ const previous = balances.get(key);
+
+ if (previous) {
+ previous.balances.push(balance);
+ } else if (key === "default") {
+ balances.set(key, {
+ balances: [balance],
+ type: "default",
+ });
+ } else {
+ balances.set(key, {
+ balances: [balance],
+ type: "validators",
+ validators: getBalanceValidators(balance),
+ });
+ }
+
+ return balances;
+ },
+ new Map() as PositionData["balanceData"]
+ ),
+ });
+
+ return positions;
+ }, new Map() as PositionsData);
+
+export const getPositionData = (
+ positions: PositionsData,
+ yieldId: YieldId | null
+): PositionData | null => (yieldId ? (positions.get(yieldId) ?? null) : null);
+
+export const getPositionBalances = (
+ position: PositionData | null,
+ balanceId: string | null
+): PositionBalances | null => {
+ if (!position || !balanceId) return null;
+
+ const balanceData =
+ position.balanceData.get(balanceId as BalanceDataKey) ??
+ position.balanceData.values().next().value;
+
+ return balanceData
+ ? { ...balanceData, rewardRate: position.rewardRate }
+ : null;
+};
+
+export const toPositionBalancesByType = (
+ balances: ReadonlyArray
+): PositionBalancesByType =>
+ balances.reduce((byType, balance) => {
+ const amount = new BigNumber(balance.amount);
+ if (amount.isZero() || amount.isNaN()) return byType;
+
+ const tokenPriceInUsd = new BigNumber(
+ String(balance.amountUsd ?? 0).replace(/,/g, "")
+ );
+ const previous = byType.get(balance.type);
+
+ byType.set(balance.type, [
+ ...(previous ?? []),
+ { ...balance, tokenPriceInUsd },
+ ]);
+
+ return byType;
+ }, new Map() as PositionBalancesByType);
+
export const getPositionTotalAmount = (
- balances: YieldBalanceDto[],
- baseToken: TokenDto
+ balances: EarnBalance[],
+ baseToken: {
+ readonly address?: string;
+ readonly symbol: string;
+ readonly network: string;
+ }
) => {
const baseTokenBalance = balances.find((b) =>
equalTokens(b.token, baseToken)
@@ -70,10 +164,10 @@ export const getPositionTotalAmount = (
const baseTokenPriceInUsd = (() => {
if (!baseTokenBalance?.amountUsd) return null;
- const amount = BigNumber(baseTokenBalance.amount);
+ const amount = baseTokenBalance.amount;
if (amount.lte(0)) return null;
- return BigNumber(baseTokenBalance.amountUsd).dividedBy(amount);
+ return baseTokenBalance.amountUsd.dividedBy(amount);
})();
return balances.reduce(
@@ -87,7 +181,7 @@ export const getPositionTotalAmount = (
};
}
- const balanceAmountUsd = BigNumber(b.amountUsd ?? 0);
+ const balanceAmountUsd = b.amountUsd ?? BigNumber(0);
if (baseTokenPriceInUsd && !baseTokenPriceInUsd.isZero()) {
return {
diff --git a/packages/widget/src/domain/types/price.ts b/packages/widget/src/domain/types/price.ts
index 47bc7d90..765fb804 100644
--- a/packages/widget/src/domain/types/price.ts
+++ b/packages/widget/src/domain/types/price.ts
@@ -1,36 +1,12 @@
import BigNumber from "bignumber.js";
-import { Maybe } from "purify-ts";
-import type { PriceRequestDto as LegacyPriceRequestDto } from "../../generated/api/legacy";
-import { type TokenString, tokenString } from "./tokens";
+import type { Prices } from "../schema/health-price-models";
-export type PriceRequestDto = LegacyPriceRequestDto;
-export type PriceResponseDto = Record<
- string,
- {
- price: number | undefined;
- price_24_h: number | undefined;
- }
->;
-
-type PriceToken = {
- symbol: string;
- network: string;
- address?: string;
-};
-
-export type Price = {
- price: number | undefined;
- price24H: number | undefined;
+type PriceLookupToken = {
+ readonly symbol: string;
+ readonly network: string;
+ readonly address?: string;
};
-export class Prices {
- constructor(public value: Map) {}
-
- getByToken(token: PriceToken) {
- return Maybe.fromNullable(this.value.get(tokenString(token)));
- }
-}
-
export const getTokenPriceInUSD = ({
token,
baseToken,
@@ -38,8 +14,8 @@ export const getTokenPriceInUSD = ({
prices,
pricePerShare,
}: {
- token: PriceToken;
- baseToken: PriceToken | null;
+ token: PriceLookupToken;
+ baseToken: PriceLookupToken | null;
amount: string | BigNumber;
pricePerShare: string | null;
prices: Prices;
@@ -48,22 +24,14 @@ export const getTokenPriceInUSD = ({
if (pricePerShare && baseToken) {
const baseTokenPrice = new BigNumber(
- prices
- .getByToken(baseToken)
- .chainNullable((v) => v.price)
- .orDefault(0)
+ prices.getByToken(baseToken)?.price ?? 0
);
const pricePerShareBN = BigNumber(pricePerShare);
return amountBN.times(baseTokenPrice).times(pricePerShareBN);
}
- const tokenPrice = new BigNumber(
- prices
- .getByToken(token)
- .chainNullable((v) => v.price)
- .orDefault(0)
- );
+ const tokenPrice = new BigNumber(prices.getByToken(token)?.price ?? 0);
return amountBN.times(tokenPrice);
};
diff --git a/packages/widget/src/domain/types/reward-rate.ts b/packages/widget/src/domain/types/reward-rate.ts
index d2bbe3af..cd1b974f 100644
--- a/packages/widget/src/domain/types/reward-rate.ts
+++ b/packages/widget/src/domain/types/reward-rate.ts
@@ -1,16 +1,19 @@
+import { Array as EArray } from "effect";
import type {
- RewardDto,
- ValidatorDto,
- YieldDto,
-} from "../../generated/api/yield";
-
-type YieldRewardDto = RewardDto;
-export type YieldRewardRateDto = NonNullable;
-type YieldWithRewardRate = Pick;
-type ValidatorRewardRateDto = NonNullable;
+ EarnValidator,
+ EarnYieldWithProvider,
+} from "../schema/earn-models";
+import type { ValidatorKey } from "./validators";
+
+export type YieldRewardRateDto = NonNullable<
+ EarnYieldWithProvider["rewardRate"]
+>;
+type YieldRewardDto = YieldRewardRateDto["components"][number];
+type YieldWithRewardRate = Pick;
+type ValidatorRewardRateDto = NonNullable;
export type SelectedValidators =
- | ReadonlyArray
- | ReadonlyMap;
+ | ReadonlyArray
+ | ReadonlyMap;
type RewardRateBreakdownKey = "native" | "protocol_incentive" | "campaign";
@@ -62,15 +65,14 @@ const getSelectedValidatorsRewardRate = (
validator.rewardRate ? [validator.rewardRate] : []
);
- if (rewardRates.length < 2) {
- return rewardRates[0];
- }
+ if (!EArray.isArrayNonEmpty(rewardRates)) return undefined;
+ if (rewardRates.length === 1) return EArray.headNonEmpty(rewardRates);
return averageRewardRates(rewardRates);
};
const averageRewardRates = (
- rewardRates: ValidatorRewardRateDto[]
+ rewardRates: EArray.NonEmptyArray
): ValidatorRewardRateDto => {
const componentsByKey = rewardRates.reduce((acc, rewardRate) => {
rewardRate.components.forEach((component) => {
@@ -84,13 +86,13 @@ const averageRewardRates = (
});
return acc;
- }, new Map());
+ }, new Map());
return {
total:
rewardRates.reduce((acc, rewardRate) => acc + rewardRate.total, 0) /
rewardRates.length,
- rateType: rewardRates[0].rateType,
+ rateType: EArray.headNonEmpty(rewardRates).rateType,
components: [...componentsByKey.values()].map(({ component, rate }) => ({
...component,
rate: rate / rewardRates.length,
diff --git a/packages/widget/src/domain/types/rewards.ts b/packages/widget/src/domain/types/rewards.ts
index 7d637248..1f125e82 100644
--- a/packages/widget/src/domain/types/rewards.ts
+++ b/packages/widget/src/domain/types/rewards.ts
@@ -1,46 +1,50 @@
+import { Schema } from "effect";
import type { Resources } from "i18next";
+import type { EarnYieldWithProvider } from "../schema/earn-models";
+import { YieldId } from "../schema/identifiers";
import {
CosmosNetworks,
EvmNetworks,
MiscNetworks,
SubstrateNetworks,
} from "./chains/networks";
-import type { Yield } from "./yields";
+
+const decodeYieldId = Schema.decodeSync(YieldId);
const enabledRewardsSummaryYieldIds = {
[SubstrateNetworks.Polkadot]: [
{
- id: "polkadot-dot-validator-staking",
+ id: decodeYieldId("polkadot-dot-validator-staking"),
name: "dashboard.enabled_rewards_summary_yield_names.polkadot_dot_validator_staking",
},
],
[EvmNetworks.AvalancheC]: [
{
- id: "avalanche-avax-liquid-staking",
+ id: decodeYieldId("avalanche-avax-liquid-staking"),
name: "dashboard.enabled_rewards_summary_yield_names.avalanche_avax_liquid_staking",
},
],
[CosmosNetworks.Cronos]: [
{
- id: "cronos-cro-native-staking",
+ id: decodeYieldId("cronos-cro-native-staking"),
name: "dashboard.enabled_rewards_summary_yield_names.cronos_cro_native_staking",
},
],
[EvmNetworks.Ethereum]: [
{
- id: "ethereum-matic-native-staking",
+ id: decodeYieldId("ethereum-matic-native-staking"),
name: "dashboard.enabled_rewards_summary_yield_names.ethereum_matic_native_staking",
},
],
[MiscNetworks.BinanceBeacon]: [
{
- id: "bsc-bnb-native-staking",
+ id: decodeYieldId("bsc-bnb-native-staking"),
name: "dashboard.enabled_rewards_summary_yield_names.bsc_bnb_native_staking",
},
],
[MiscNetworks.Tron]: [
{
- id: "tron-trx-native-staking",
+ id: decodeYieldId("tron-trx-native-staking"),
name: "dashboard.enabled_rewards_summary_yield_names.tron_trx_native_staking",
},
],
@@ -52,7 +56,7 @@ const enabledRewardsSummaryYieldIds = {
| typeof MiscNetworks.BinanceBeacon
| typeof MiscNetworks.Tron,
{
- id: Yield["id"];
+ id: EarnYieldWithProvider["id"];
name: `dashboard.enabled_rewards_summary_yield_names.${keyof Resources["translation"]["dashboard"]["enabled_rewards_summary_yield_names"]}`;
}[]
>;
@@ -63,7 +67,7 @@ const enabledRewardsSummaryYieldIdsSet = new Set(
)
);
-export type EnabledRewardsSummaryYieldId =
+type EnabledRewardsSummaryYieldId =
(typeof enabledRewardsSummaryYieldIds)[keyof typeof enabledRewardsSummaryYieldIds][number]["id"];
export const isValidYieldIdForRewardsSummary = (
diff --git a/packages/widget/src/domain/types/stake.ts b/packages/widget/src/domain/types/stake.ts
index 748cbd08..dd218c3e 100644
--- a/packages/widget/src/domain/types/stake.ts
+++ b/packages/widget/src/domain/types/stake.ts
@@ -1,105 +1,26 @@
import BigNumber from "bignumber.js";
-import { List, Maybe } from "purify-ts";
-import type { ValidatorDto } from "../../generated/api/yield";
-import { tokenString } from "..";
-import type { SupportedSKChains } from "./chains";
+import { Array as EArray, Option } from "effect";
+import type {
+ EarnValidator,
+ EarnYieldWithProvider,
+} from "../schema/earn-models";
+import type { InitParams } from "../schema/init-params";
+import type { Network } from "../schema/network-model";
import { Networks } from "./chains/networks";
-import type { InitParams } from "./init-params";
+
import type { PositionsData } from "./positions";
-import type { TokenBalanceScanResponseDto } from "./token-balance";
-import type { TokenString } from "./tokens";
-import {
- getYieldActionArg,
- isBittensorStaking,
- type Yield,
- type YieldBase,
-} from "./yields";
-
-const amountGreaterThanZero = (val: TokenBalanceScanResponseDto) =>
- new BigNumber(val.amount).isGreaterThan(0);
-
-const hasYields = (val: TokenBalanceScanResponseDto) =>
- !!val.availableYields.length;
-
-const hasYieldsAndAmount = (val: TokenBalanceScanResponseDto) =>
- hasYields(val) && amountGreaterThanZero(val);
-
-export type PreferredTokenYieldsPerNetwork = {
- [Key in SupportedSKChains]?: Record;
-};
+import type { ValidatorKey } from "./validators";
+import { getYieldActionArg, isBittensorStaking } from "./yields";
-export const getInitialToken = (args: {
- initQueryParams: Maybe;
- tokenBalances: ReadonlyArray;
- defaultTokens: ReadonlyArray;
- network: SupportedSKChains | null;
- preferredTokenYieldsPerNetwork: PreferredTokenYieldsPerNetwork | null;
-}) =>
- /**
- * TB based on query params
- */
- args.initQueryParams
- .filter((val) => !!val.token)
- .chain((val) =>
- List.find(
- (t) => {
- const tokenSymbolCompare =
- val.token?.toLowerCase() === t.token.symbol.toLowerCase();
-
- const tokenNetworkCompare =
- val.network?.toLowerCase() === t.token.network.toLowerCase();
-
- const tokenStringCompare = tokenString(t.token) === val.token;
-
- return (
- (tokenSymbolCompare && tokenNetworkCompare) || tokenStringCompare
- );
- },
- [...args.tokenBalances, ...args.defaultTokens]
- )
- )
- /**
- * TB based on preferred token
- */
- .altLazy(() =>
- Maybe.fromNullable(args.network)
- .chain((n) =>
- Maybe.fromNullable(args.preferredTokenYieldsPerNetwork?.[n])
- )
- .altLazy(() =>
- Maybe.fromNullable(args.preferredTokenYieldsPerNetwork).chainNullable(
- (v) => Object.values(v)[0]
- )
- )
- .chain((preferredTokens) =>
- List.find(
- (val) => !!preferredTokens[tokenString(val.token)],
- [...args.tokenBalances, ...args.defaultTokens]
- )
- )
- )
- /**
- * TB based on first token with available yields and amount > 0
- */
- .altLazy(() => List.find(hasYieldsAndAmount, [...args.tokenBalances]))
- /**
- * TB based on first token with available yields
- */
- .altLazy(() => List.find(hasYields, [...args.tokenBalances]))
- .altLazy(() => List.find(hasYields, [...args.defaultTokens]))
- .altLazy(() => List.head([...args.defaultTokens]))
- .map((val) => val.token);
+export type { PreferredTokenYieldsPerNetwork } from "../../public-api/types";
export const canBeInitialYield = (args: {
- initQueryParams: Maybe;
- yieldDto: YieldBase;
+ initQueryParams: InitParams | null;
+ yieldDto: EarnYieldWithProvider;
tokenBalanceAmount: BigNumber;
positionsData: PositionsData;
}) => {
- const initYieldId = args.initQueryParams
- .chainNullable((queryParams) => queryParams.yieldId)
- .map((yieldId) => yieldId.toLowerCase())
- .extractNullable();
+ const initYieldId = args.initQueryParams?.yieldId?.toLowerCase() ?? null;
if (initYieldId) {
return initYieldId === args.yieldDto.id.toLowerCase();
@@ -118,7 +39,7 @@ const balanceValidForYield = ({
positionsData,
}: {
tokenBalanceAmount: BigNumber;
- yieldDto: YieldBase;
+ yieldDto: EarnYieldWithProvider;
positionsData: PositionsData;
}) =>
tokenBalanceAmount.isGreaterThanOrEqualTo(
@@ -126,45 +47,40 @@ const balanceValidForYield = ({
);
export const getInitSelectedValidators = (args: {
- initQueryParams: Maybe;
- validators: ValidatorDto[];
-}) =>
- args.initQueryParams
- .chainNullable((params) => params.validator)
- .chain((initV) =>
- List.find(
- (val) =>
- val.name?.toLowerCase() === initV.toLowerCase() ||
- val.address === initV,
- args.validators
- )
- )
- .altLazy(() => List.head(args.validators))
- .map((v) => new Map([[v.address, v]]))
- .orDefault(new Map());
+ initQueryParams: InitParams | null;
+ validators: ReadonlyArray;
+}) => {
+ const initValidator = args.initQueryParams?.validator;
+ const selected =
+ (initValidator
+ ? EArray.findFirst(
+ args.validators,
+ (validator) =>
+ validator.name?.toLowerCase() === initValidator.toLowerCase() ||
+ validator.address === initValidator
+ ).pipe(Option.getOrUndefined)
+ : undefined) ?? EArray.head(args.validators).pipe(Option.getOrUndefined);
+
+ return selected
+ ? new Map([[selected.key, selected]])
+ : new Map();
+};
export const isForceMaxAmount = (
args: { minimum?: number | null; maximum?: number | null } | null | undefined
) => args?.minimum === -1 && args?.maximum === -1;
-const yieldsWithEnterMinBasedOnPosition = new Map>([
+const yieldsWithEnterMinBasedOnPosition = new Map>([
[Networks.Polkadot, new Set(["polkadot-dot-validator-staking"])],
]);
-export const isNetworkWithEnterMinBasedOnPosition = (network: Networks) =>
- yieldsWithEnterMinBasedOnPosition.has(network);
-
-const isYieldWithEnterMinBasedOnPosition = (yieldDto: YieldBase) =>
- Maybe.fromNullable(
- yieldsWithEnterMinBasedOnPosition.get(
- yieldDto.mechanics.gasFeeToken.network as Networks
- )
- )
- .filter((set) => set.has(yieldDto.id))
- .isJust();
+const isYieldWithEnterMinBasedOnPosition = (yieldDto: EarnYieldWithProvider) =>
+ yieldsWithEnterMinBasedOnPosition
+ .get(yieldDto.mechanics.gasFeeToken.network as Network)
+ ?.has(yieldDto.id) ?? false;
export const getMinStakeAmount = (
- yieldDto: YieldBase,
+ yieldDto: EarnYieldWithProvider,
positionsData: PositionsData
) => {
const integrationMin = new BigNumber(
@@ -172,12 +88,11 @@ export const getMinStakeAmount = (
);
if (isYieldWithEnterMinBasedOnPosition(yieldDto)) {
- const hasStaked = Maybe.fromNullable(positionsData.get(yieldDto.id))
- .map((val) => [...val.balanceData.values()])
- .map((val) =>
- val.some((v) => v.balances.some((b) => b.type === "active"))
- )
- .orDefault(false);
+ const hasStaked = EArray.some(
+ Array.from(positionsData.get(yieldDto.id)?.balanceData.values() ?? []),
+ (position) =>
+ EArray.some(position.balances, (balance) => balance.type === "active")
+ );
if (hasStaked) {
return new BigNumber(0);
@@ -190,7 +105,7 @@ export const getMinStakeAmount = (
};
export const getMinUnstakeAmount = (
- yieldDto: Yield,
+ yieldDto: EarnYieldWithProvider,
pricePerShare: string | null
) => {
const integrationMin = new BigNumber(
diff --git a/packages/widget/src/domain/types/token-balance.ts b/packages/widget/src/domain/types/token-balance.ts
index 72afea64..23de4bb1 100644
--- a/packages/widget/src/domain/types/token-balance.ts
+++ b/packages/widget/src/domain/types/token-balance.ts
@@ -1,15 +1,4 @@
-import type {
- TokenBalanceScanDto as LegacyTokenBalanceScanDto,
- TokenBalanceScanResponseDto as LegacyTokenBalanceScanResponseDto,
- YieldBalanceLabelDto as LegacyYieldBalanceLabelDto,
-} from "../../generated/api/legacy";
-import type { TokenDto } from "./tokens";
-
-export type TokenBalanceScanDto = LegacyTokenBalanceScanDto;
-export type TokenBalanceScanResponseDto = Omit<
- LegacyTokenBalanceScanResponseDto,
- "token"
-> & {
- readonly token: TokenDto;
+export type YieldBalanceLabelDto = {
+ readonly params: Readonly>;
+ readonly type: string;
};
-export type YieldBalanceLabelDto = LegacyYieldBalanceLabelDto;
diff --git a/packages/widget/src/domain/types/tokens.ts b/packages/widget/src/domain/types/tokens.ts
index e102bc28..6e19e488 100644
--- a/packages/widget/src/domain/types/tokens.ts
+++ b/packages/widget/src/domain/types/tokens.ts
@@ -1,16 +1,8 @@
-import type {
- TokenDto as LegacyTokenDto,
- TokenControllerGetTokensParams as TokenGetTokensParams,
-} from "../../generated/api/legacy";
-import type { TokenDto as YieldTokenDtoGenerated } from "../../generated/api/yield";
+import type { AppToken } from "../schema/legacy-models";
-export type YieldTokenDto = YieldTokenDtoGenerated;
-export type TokenDto = LegacyTokenDto | YieldTokenDto;
-export type { TokenGetTokensParams };
+export type TokenString = `${AppToken["network"]}-${string}`;
-export type TokenString = `${TokenDto["network"]}-${TokenDto["address"]}`;
-
-type TokenLike = Pick & {
+type TokenLike = Pick & {
network: string;
address?: string;
};
diff --git a/packages/widget/src/domain/types/transaction.ts b/packages/widget/src/domain/types/transaction.ts
index 9f25d561..0de7f0dd 100644
--- a/packages/widget/src/domain/types/transaction.ts
+++ b/packages/widget/src/domain/types/transaction.ts
@@ -3,128 +3,120 @@ import {
type CommonMessageInfoRelaxedInternal,
loadMessageRelaxed,
} from "@ton/core";
-import type { GetType } from "purify-ts";
-import {
- array,
- boolean,
- Codec,
- Left,
- number,
- oneOf,
- optional,
- Right,
- record,
- string,
- unknown,
-} from "purify-ts";
+import { Result, Schema, SchemaTransformation } from "effect";
import { type Address, type Hex, numberToHex } from "viem";
-import type { TransactionVerificationMessageDto } from "../../generated/api/legacy";
-import type { GetEitherRight } from "../../types/utils";
-
-export type { TransactionVerificationMessageDto };
-
-const bigintCodec = Codec.custom({
- decode: (input) => {
- if (typeof input !== "string" && typeof input !== "number") {
- return Left("Invalid value type");
- }
-
- const val = BigInt(input);
- return Right(val);
- },
- encode: (input) => input.toString(),
-});
+const BigIntFromNumber = Schema.Number.pipe(
+ Schema.decodeTo(
+ Schema.BigInt,
+ SchemaTransformation.transform({
+ decode: (value) => BigInt(value),
+ encode: (value) => Number(value),
+ })
+ )
+);
+
+const BigIntFromString = Schema.String.pipe(
+ Schema.decodeTo(
+ Schema.BigInt,
+ SchemaTransformation.transform({
+ decode: (value) => BigInt(value),
+ encode: (value) => value.toString(),
+ })
+ )
+);
-const hexStringCodec = Codec.custom({
- decode: (input) =>
- typeof input === "string" && input.startsWith("0x")
- ? Right(input as Hex)
- : Left("Invalid hex string"),
- encode: (input) => input,
-});
+const BigIntFromStringOrNumber = Schema.Union([
+ BigIntFromString,
+ BigIntFromNumber,
+]);
-const addressCodec = Codec.custom({
- decode: (input) =>
- typeof input === "string" && input.startsWith("0x")
- ? Right(input as Address)
- : Left("Invalid address"),
- encode: (input) => input,
+const HexString = Schema.declare(
+ (input): input is Hex => typeof input === "string" && input.startsWith("0x"),
+ { expected: "a 0x-prefixed hex string" }
+);
+
+const EvmAddress = Schema.declare(
+ (input): input is Address =>
+ typeof input === "string" && input.startsWith("0x"),
+ { expected: "a 0x-prefixed address" }
+);
+
+const UnsignedEvmTransaction = Schema.Struct({
+ data: HexString,
+ to: EvmAddress,
+ gasLimit: BigIntFromStringOrNumber,
+ from: EvmAddress,
+ value: Schema.optionalKey(BigIntFromStringOrNumber),
+ nonce: Schema.Number,
+ type: Schema.Number,
+ gasPrice: Schema.optionalKey(BigIntFromStringOrNumber),
+ maxFeePerGas: Schema.optionalKey(BigIntFromStringOrNumber),
+ maxPriorityFeePerGas: Schema.optionalKey(BigIntFromStringOrNumber),
+ chainId: Schema.Number,
});
-export const unsignedEVMTransactionCodec = Codec.interface({
- data: hexStringCodec,
- to: addressCodec,
- gasLimit: bigintCodec,
- from: addressCodec,
- value: optional(bigintCodec),
- nonce: number,
- type: number,
- gasPrice: optional(bigintCodec),
- maxFeePerGas: optional(bigintCodec),
- maxPriorityFeePerGas: optional(bigintCodec),
- chainId: number,
+export const unsignedEVMTransactionCodec = UnsignedEvmTransaction;
+
+const prepareDecodedEvmTransaction = (
+ decodedTx: typeof UnsignedEvmTransaction.Type,
+ address: Address
+) => ({
+ to: decodedTx.to,
+ from: address,
+ data: decodedTx.data,
+ value: decodedTx.value ? numberToHex(decodedTx.value) : undefined,
+ nonce: numberToHex(decodedTx.nonce),
+ gas: numberToHex(decodedTx.gasLimit),
+ chainId: numberToHex(decodedTx.chainId),
+ ...(decodedTx.maxFeePerGas
+ ? {
+ type: "0x2" as const,
+ maxFeePerGas: numberToHex(decodedTx.maxFeePerGas),
+ maxPriorityFeePerGas: decodedTx.maxPriorityFeePerGas
+ ? numberToHex(decodedTx.maxPriorityFeePerGas)
+ : undefined,
+ }
+ : {
+ type: "0x1" as const,
+ gasPrice: decodedTx.gasPrice
+ ? numberToHex(decodedTx.gasPrice)
+ : undefined,
+ }),
});
export const decodeAndPrepareEvmTransaction = ({
address,
- input,
+ tx,
}: {
address: Address;
- input: unknown;
+ tx: string;
}) =>
- unsignedEVMTransactionCodec.decode(input).map((decodedTx) => ({
- to: decodedTx.to,
- from: address,
- data: decodedTx.data,
- value: decodedTx.value ? numberToHex(decodedTx.value) : undefined,
- nonce: numberToHex(decodedTx.nonce),
- gas: numberToHex(decodedTx.gasLimit),
- chainId: numberToHex(decodedTx.chainId),
- ...(decodedTx.maxFeePerGas
- ? {
- type: "0x2" as const,
- maxFeePerGas: numberToHex(decodedTx.maxFeePerGas),
- maxPriorityFeePerGas: decodedTx.maxPriorityFeePerGas
- ? numberToHex(decodedTx.maxPriorityFeePerGas)
- : undefined,
- }
- : {
- type: "0x1" as const,
- gasPrice: decodedTx.gasPrice
- ? numberToHex(decodedTx.gasPrice)
- : undefined,
- }),
- }));
-
-export type DecodedEVMTransaction = GetEitherRight<
- ReturnType
->;
+ Schema.decodeResult(Schema.fromJsonString(UnsignedEvmTransaction))(tx).pipe(
+ Result.map((decodedTx) => prepareDecodedEvmTransaction(decodedTx, address))
+ );
-export const unsignedTronTransactionCodec = Codec.interface({
- raw_data: Codec.interface({
- contract: array(record(string, unknown)),
- ref_block_bytes: string,
- ref_block_hash: string,
- expiration: number,
- timestamp: number,
- data: optional(unknown),
- fee_limit: optional(unknown),
+const UnsignedTronTransaction = Schema.Struct({
+ raw_data: Schema.Struct({
+ contract: Schema.Array(Schema.Record(Schema.String, Schema.Unknown)),
+ ref_block_bytes: Schema.String,
+ ref_block_hash: Schema.String,
+ expiration: Schema.Number,
+ timestamp: Schema.Number,
+ data: Schema.optionalKey(Schema.Unknown),
+ fee_limit: Schema.optionalKey(Schema.Unknown),
}),
- raw_data_hex: string,
- txID: string,
- visible: boolean,
+ raw_data_hex: Schema.String,
+ txID: Schema.String,
+ visible: Schema.Boolean,
});
-export type DecodedTronTransaction = GetType<
- typeof unsignedTronTransactionCodec
->;
+export const unsignedTronTransactionCodec = UnsignedTronTransaction;
-export const unsignedSolanaTransactionCodec = string;
+const UnsignedSolanaTransaction = Schema.String;
+export const unsignedSolanaTransactionCodec = UnsignedSolanaTransaction;
-export type DecodedSolanaTransaction = GetType<
- typeof unsignedSolanaTransactionCodec
->;
+export type DecodedSolanaTransaction = typeof UnsignedSolanaTransaction.Type;
type SolanaTransactionEncoding = "base64" | "hex";
@@ -172,23 +164,27 @@ export const normalizeSolanaTransactionToHex = (
): DecodedSolanaTransaction =>
decodeSolanaTransactionToBuffer(tx).buffer.toString("hex");
-export const unsignedTonTransactionTonConnectCodec = Codec.interface({
- seqno: bigintCodec,
- message: string,
+const UnsignedTonTransactionTonConnect = Schema.Struct({
+ seqno: BigIntFromStringOrNumber,
+ message: Schema.String,
});
-export const unsignedTonTransactionCodec = oneOf([
- unsignedTonTransactionTonConnectCodec,
- array(
- Codec.interface({
- address: string,
- amount: string,
- payload: string,
+const UnsignedTonTransaction = Schema.Union([
+ UnsignedTonTransactionTonConnect,
+ Schema.Array(
+ Schema.Struct({
+ address: Schema.String,
+ amount: Schema.String,
+ payload: Schema.String,
})
),
]);
-export type DecodedTonTransaction = GetType;
+export const unsignedTonTransactionTonConnectCodec =
+ UnsignedTonTransactionTonConnect;
+export const unsignedTonTransactionCodec = UnsignedTonTransaction;
+
+type DecodedTonTransaction = typeof UnsignedTonTransaction.Type;
type DecodedTonRawTransaction = Extract<
DecodedTonTransaction,
@@ -198,8 +194,8 @@ type DecodedTonRawTransaction = Extract<
export const normalizeTonTransactionToRaw = (
tx: DecodedTonTransaction
): DecodedTonRawTransaction => {
- if (Array.isArray(tx)) {
- return tx;
+ if (!("message" in tx)) {
+ return tx as DecodedTonRawTransaction;
}
const parsedTx = loadMessageRelaxed(Cell.fromBase64(tx.message).beginParse());
@@ -214,28 +210,28 @@ export const normalizeTonTransactionToRaw = (
];
};
-export const substratePayloadCodec = Codec.interface({
- tx: Codec.interface({
- address: string,
- assetId: optional(hexStringCodec),
- blockHash: hexStringCodec,
- blockNumber: hexStringCodec,
- era: hexStringCodec,
- genesisHash: hexStringCodec,
- metadataHash: optional(hexStringCodec),
- method: string,
- mode: optional(number),
- nonce: hexStringCodec,
- specVersion: hexStringCodec,
- tip: hexStringCodec,
- transactionVersion: hexStringCodec,
- signedExtensions: array(string),
- version: number,
- metadataRpc: hexStringCodec,
+const SubstratePayload = Schema.Struct({
+ tx: Schema.Struct({
+ address: Schema.String,
+ assetId: Schema.optionalKey(HexString),
+ blockHash: HexString,
+ blockNumber: HexString,
+ era: HexString,
+ genesisHash: HexString,
+ metadataHash: Schema.optionalKey(HexString),
+ method: Schema.String,
+ mode: Schema.optionalKey(Schema.Number),
+ nonce: HexString,
+ specVersion: HexString,
+ tip: HexString,
+ transactionVersion: HexString,
+ signedExtensions: Schema.Array(Schema.String),
+ version: Schema.Number,
+ metadataRpc: HexString,
}),
- specName: string,
- specVersion: number,
- metadataRpc: hexStringCodec,
+ specName: Schema.String,
+ specVersion: Schema.Number,
+ metadataRpc: HexString,
});
-export type DecodedSubstrateTransaction = GetType;
+export const substratePayloadCodec = SubstratePayload;
diff --git a/packages/widget/src/domain/types/tron.ts b/packages/widget/src/domain/types/tron.ts
deleted file mode 100644
index 58366792..00000000
--- a/packages/widget/src/domain/types/tron.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import type { TronResourceType } from "../../generated/api/legacy";
-
-export type { TronResourceType };
diff --git a/packages/widget/src/domain/types/validators.ts b/packages/widget/src/domain/types/validators.ts
new file mode 100644
index 00000000..34fd9396
--- /dev/null
+++ b/packages/widget/src/domain/types/validators.ts
@@ -0,0 +1,4 @@
+import type { EarnValidator } from "../schema/earn-models";
+
+export type ValidatorKey = string;
+export type ValidatorInput = Omit;
diff --git a/packages/widget/src/domain/types/wallet.ts b/packages/widget/src/domain/types/wallet.ts
index 6a193252..96efca31 100644
--- a/packages/widget/src/domain/types/wallet.ts
+++ b/packages/widget/src/domain/types/wallet.ts
@@ -1,45 +1,24 @@
import type { Account } from "@ledgerhq/wallet-api-client";
-import type { EitherAsync } from "purify-ts";
import type { Chain } from "viem";
import type { Connector } from "wagmi";
-import type { TransactionDto } from "../../generated/api/legacy";
-import type {
- SendTransactionError,
- TransactionDecodeError,
-} from "../../providers/sk-wallet/errors";
-import type { Nullable } from "../../types/utils";
-import type { AddressWithTokenDtoAdditionalAddresses } from "./addresses";
-import type { SupportedSKChains } from "./chains";
-import type { Networks } from "./chains/networks";
-import type { SKTxMeta } from "./wallets/generic-wallet";
+import type { AdditionalAddresses } from "../schema/address-models";
+import type { WalletAddress } from "../schema/identifiers";
-type SignedTxOrMessage = string;
+import type { SupportedSKChains } from "./chains";
export type SKWallet = {
- disconnect: () => Promise;
- signTransaction: (args: {
- tx: NonNullable;
- txMeta: SKTxMeta;
- ledgerHwAppId: Nullable;
- network: Networks;
- }) => EitherAsync<
- TransactionDecodeError | SendTransactionError,
- { signedTx: SignedTxOrMessage; broadcasted: boolean }
- >;
- signMessage: (message: string) => EitherAsync;
- additionalAddresses: AddressWithTokenDtoAdditionalAddresses | null;
+ additionalAddresses: AdditionalAddresses | null;
isLedgerLive: boolean;
isLedgerLiveAccountPlaceholder: boolean;
connectorChains: Chain[];
} & (
| {
network: SupportedSKChains;
- address: string;
+ address: WalletAddress;
chain: Chain;
isConnected: true;
isConnecting: false;
ledgerAccounts: Account[];
- onLedgerAccountChange: (account: Account) => void;
connector: Connector;
}
| {
@@ -49,7 +28,6 @@ export type SKWallet = {
isConnected: false;
isConnecting: boolean;
ledgerAccounts: null;
- onLedgerAccountChange: null;
connector: null;
}
);
diff --git a/packages/widget/src/domain/types/wallets/generic-wallet.ts b/packages/widget/src/domain/types/wallets/generic-wallet.ts
index 6194deb3..79993af7 100644
--- a/packages/widget/src/domain/types/wallets/generic-wallet.ts
+++ b/packages/widget/src/domain/types/wallets/generic-wallet.ts
@@ -1,77 +1,8 @@
-import type {
- DecodedEVMTransaction,
- DecodedSolanaTransaction,
- DecodedSubstrateTransaction,
- DecodedTonTransaction,
- DecodedTronTransaction,
-} from "../../types/transaction";
-import type { ActionDto, TransactionDto } from "../action";
-import type { TokenDto, YieldTokenDto } from "../tokens";
-
-type EVMTx = {
- type: "evm";
- tx: DecodedEVMTransaction;
-};
-
-type SolanaTx = {
- type: "solana";
- tx: DecodedSolanaTransaction;
-};
-
-type TonTx = {
- type: "ton";
- tx: DecodedTonTransaction;
-};
-
-export type TronTx = {
- type: "tron";
- tx: DecodedTronTransaction;
-};
-
-export type BittensorTx = {
- type: "bittensor";
- tx: DecodedSubstrateTransaction;
-};
-
-export type SKTx = EVMTx | SolanaTx | TonTx | TronTx | BittensorTx;
-
-export type ActionMeta = {
- actionId: ActionDto["id"];
- actionType: ActionDto["type"];
- address?: ActionDto["address"];
- amount: ActionDto["amount"];
- amountRaw?: ActionDto["amountRaw"];
- rawArguments?: ActionDto["rawArguments"];
- yieldId?: ActionDto["yieldId"];
- inputToken: TokenDto | YieldTokenDto | undefined;
- providersDetails: {
- name: string;
- address: string | undefined;
- rewardRate: number | undefined;
- rewardType: string | undefined;
- website: string | undefined;
- logo: string | undefined;
- }[];
-};
-
-export type SKTxMeta = ActionMeta & {
- txId: TransactionDto["id"];
- txType: TransactionDto["type"];
-} & Pick<
- TransactionDto,
- "structuredTransaction" | "annotatedTransaction" | "gasEstimate"
- >;
-
-export type SKWallet = {
- signMessage: (message: string) => Promise;
- switchChain: (chainId: number) => Promise;
- getTransactionReceipt?(txHash: string): Promise<{ transactionHash?: string }>;
- sendTransaction(
- tx: SKTx,
- txMeta: SKTxMeta
- ): Promise<
- | string
- | { type: "success"; txHash: string }
- | { type: "error"; error: string }
- >;
-};
+export type {
+ ActionMeta,
+ BittensorTx,
+ SKTx,
+ SKTxMeta,
+ SKWallet,
+ TronTx,
+} from "../../../public-api/types";
diff --git a/packages/widget/src/domain/types/wallets/index.ts b/packages/widget/src/domain/types/wallets/index.ts
index 0ba10a3b..991f0292 100644
--- a/packages/widget/src/domain/types/wallets/index.ts
+++ b/packages/widget/src/domain/types/wallets/index.ts
@@ -1,12 +1 @@
-import type { SupportedSKChainIds } from "../chains";
-import type { TokenString } from "../tokens";
-import type { SKWallet } from "./generic-wallet";
-
-export type SKExternalProviders = {
- currentChain?: SupportedSKChainIds;
- currentAddress: string;
- initToken?: TokenString;
- supportedChainIds?: SupportedSKChainIds[];
- type: "generic";
- provider: SKWallet;
-};
+export type { SKExternalProviders } from "../../../public-api/types";
diff --git a/packages/widget/src/domain/types/yields.ts b/packages/widget/src/domain/types/yields.ts
index b4dcb18a..bb86ebd7 100644
--- a/packages/widget/src/domain/types/yields.ts
+++ b/packages/widget/src/domain/types/yields.ts
@@ -1,29 +1,24 @@
import BigNumber from "bignumber.js";
-import { Array as EArray, pipe } from "effect";
+import { Array as EArray, pipe, Schema } from "effect";
import type { TFunction } from "i18next";
-import { Maybe } from "purify-ts";
+import {
+ DashboardYieldCategory,
+ type DashboardYieldCategory as DashboardYieldCategoryType,
+} from "../../public-api/types";
import type {
- YieldType as ApiYieldType,
- ArgumentFieldDto,
- ProviderDto,
- ValidatorDto,
- YieldDto as YieldApiYieldDto,
- YieldRiskEntryDto,
-} from "../../generated/api/yield";
+ EarnProvider,
+ EarnYieldWithProvider,
+} from "../schema/earn-models";
+import { YieldId } from "../schema/identifiers";
import type { SupportedSKChains } from "./chains";
import { EvmNetworks } from "./chains/networks";
import { equalTokens, tokenString } from "./tokens";
-export type YieldProviderDetails = ProviderDto;
-
-export type Yield = YieldApiYieldDto & {
- provider?: YieldProviderDetails;
-};
-
-export type YieldBase = Yield;
-
type YieldRiskRatingTone = "positive" | "warning" | "danger" | "neutral";
-type KnownYieldRiskRatingSource = YieldRiskEntryDto["source"];
+type YieldRiskEntry = NonNullable<
+ EarnYieldWithProvider["risk"]
+>["ratings"][number];
+type KnownYieldRiskRatingSource = YieldRiskEntry["source"];
type YieldRiskRatingSource = KnownYieldRiskRatingSource | (string & {});
export type YieldRiskDisplay = {
rating: string;
@@ -31,10 +26,10 @@ export type YieldRiskDisplay = {
tone: YieldRiskRatingTone;
};
export type YieldMetadata = Pick<
- YieldApiYieldDto["metadata"],
+ EarnYieldWithProvider["metadata"],
"logoURI" | "name"
> & {
- provider?: YieldProviderDetails;
+ provider?: EarnProvider;
};
const knownApiYieldTypes = [
@@ -47,14 +42,26 @@ const knownApiYieldTypes = [
"concentrated_liquidity_pool",
"liquidity_pool",
"liquid_staking",
-] as const satisfies ReadonlyArray;
+] as const satisfies ReadonlyArray;
type KnownApiYieldType = (typeof knownApiYieldTypes)[number];
type LocallyDerivedYieldType = "native_staking" | "pooled_staking";
type KnownExtendedYieldType = KnownApiYieldType | LocallyDerivedYieldType;
export type ExtendedYieldType = KnownExtendedYieldType | "unknown";
type YieldActionType = "enter" | "exit";
-type YieldArgumentName = ArgumentFieldDto["name"];
+type YieldArguments = NonNullable<
+ EarnYieldWithProvider["mechanics"]["arguments"]
+>;
+type YieldArgumentField = NonNullable<
+ YieldArguments["enter"]
+>["fields"][number];
+type YieldArgumentName = YieldArgumentField["name"];
+
+type ValidatorDto = {
+ readonly address: string;
+ readonly name?: string | null;
+ readonly preferred?: boolean;
+};
type YieldArgumentConfig = {
required?: boolean;
@@ -83,35 +90,28 @@ export type ValidatorsConfig = Map<
}
>;
-export const DashboardYieldCategory = {
- RWA: "rwa",
- DeFi: "defi",
- Stake: "stake",
-} as const;
-
-export type DashboardYieldCategory =
- (typeof DashboardYieldCategory)[keyof typeof DashboardYieldCategory];
+export { DashboardYieldCategory };
export const dashboardYieldCategories = [
DashboardYieldCategory.RWA,
DashboardYieldCategory.DeFi,
DashboardYieldCategory.Stake,
-] as const satisfies ReadonlyArray;
+] as const satisfies ReadonlyArray;
const dashboardYieldCategoryValues = new Set(dashboardYieldCategories);
export const normalizeDashboardYieldCategoryOrder = (
order?: ReadonlyArray | null
-): DashboardYieldCategory[] => {
- const normalized: DashboardYieldCategory[] = [];
+): DashboardYieldCategoryType[] => {
+ const normalized: DashboardYieldCategoryType[] = [];
for (const category of order ?? []) {
if (
typeof category === "string" &&
dashboardYieldCategoryValues.has(category) &&
- !normalized.includes(category as DashboardYieldCategory)
+ !normalized.includes(category as DashboardYieldCategoryType)
) {
- normalized.push(category as DashboardYieldCategory);
+ normalized.push(category as DashboardYieldCategoryType);
}
}
@@ -139,23 +139,23 @@ const apiYieldTypeToDashboardCategory = {
concentrated_liquidity_pool: "defi",
liquidity_pool: "defi",
real_world_asset: "rwa",
-} as const satisfies Record;
+} as const satisfies Record;
export const getApiYieldTypesForDashboardCategory = (
- category: DashboardYieldCategory
+ category: DashboardYieldCategoryType
): KnownApiYieldType[] =>
(
Object.entries(apiYieldTypeToDashboardCategory) as [
KnownApiYieldType,
- DashboardYieldCategory,
+ DashboardYieldCategoryType,
][]
)
.filter(([, mapped]) => mapped === category)
.map(([yieldType]) => yieldType);
export const getDashboardYieldCategory = (
- yieldDto: YieldBase
-): DashboardYieldCategory | null => {
+ yieldDto: EarnYieldWithProvider
+): DashboardYieldCategoryType | null => {
const yieldType = getExtendedYieldType(yieldDto);
if (yieldType === "real_world_asset") return "rwa";
@@ -169,25 +169,24 @@ export const getDashboardYieldCategory = (
return null;
};
-export const filterValidators = ({
+export const filterValidators = ({
validatorsConfig,
validators,
network,
yieldId,
}: {
validatorsConfig: ValidatorsConfig;
- validators: ValidatorDto[];
- network: Yield["token"]["network"];
- yieldId?: Yield["id"];
-}) => {
- const valConfig = Maybe.fromNullable(
- validatorsConfig.get(network as SupportedSKChains)
- )
- .altLazy(() => Maybe.fromNullable(validatorsConfig.get("*")))
- .extractNullable();
+ validators: ReadonlyArray;
+ network: EarnYieldWithProvider["token"]["network"];
+ yieldId?: EarnYieldWithProvider["id"];
+}): T[] => {
+ const valConfig =
+ validatorsConfig.get(network as SupportedSKChains) ??
+ validatorsConfig.get("*") ??
+ null;
const filtered = !valConfig
- ? validators
+ ? [...validators]
: (() => {
const {
allowed,
@@ -206,10 +205,10 @@ export const filterValidators = ({
!!(mergePreferredWithDefault && v.preferred);
if (preferredOnly) {
- return isPreferred ? [{ ...v, preferred: true }] : [];
+ return isPreferred ? [{ ...v, preferred: true } as T] : [];
}
- return [{ ...v, preferred: isPreferred }];
+ return [{ ...v, preferred: isPreferred } as T];
});
})();
@@ -236,7 +235,7 @@ const secondsToDays = (seconds: number | undefined) => {
};
export const getYieldActionArg = (
- yieldDto: YieldBase,
+ yieldDto: EarnYieldWithProvider,
type: YieldActionType,
name: YieldArgumentName
): YieldArgumentConfig | null => {
@@ -257,12 +256,12 @@ export const getYieldActionArg = (
};
export const isYieldActionArgRequired = (
- yieldDto: YieldBase,
+ yieldDto: EarnYieldWithProvider,
type: YieldActionType,
name: YieldArgumentName
) => !!getYieldActionArg(yieldDto, type, name)?.required;
-export const getYieldRewardTokens = (yieldDto: YieldBase) =>
+export const getYieldRewardTokens = (yieldDto: EarnYieldWithProvider) =>
pipe(
[
...(yieldDto.outputToken ? [yieldDto.outputToken] : []),
@@ -289,7 +288,7 @@ const getRiskTone = (rating: string): YieldRiskRatingTone => {
};
export const getYieldRiskDisplay = (
- yieldDto: Pick
+ yieldDto: Pick
): YieldRiskDisplay | null => {
const firstRating = yieldDto.risk?.ratings[0];
const rating = firstRating?.rating.trim();
@@ -317,16 +316,17 @@ export const getYieldRiskSourceLabel = (
}
};
-export const hasYieldFeeConfigurationEnabled = (yieldDto: Yield) =>
- Object.values(yieldDto.mechanics.fee ?? {}).some(Boolean);
+export const hasYieldFeeConfigurationEnabled = (
+ yieldDto: EarnYieldWithProvider
+) => Object.values(yieldDto.mechanics.fee ?? {}).some(Boolean);
-export const getYieldCooldownPeriod = (yieldDto: Yield) =>
+export const getYieldCooldownPeriod = (yieldDto: EarnYieldWithProvider) =>
secondsToDays(yieldDto.mechanics.cooldownPeriod?.seconds);
-export const getYieldWarmupPeriod = (yieldDto: Yield) =>
+export const getYieldWarmupPeriod = (yieldDto: EarnYieldWithProvider) =>
secondsToDays(yieldDto.mechanics.warmupPeriod?.seconds);
-export const getYieldTvlUsd = (yieldDto: Yield) => {
+export const getYieldTvlUsd = (yieldDto: EarnYieldWithProvider) => {
const tvlUsd = yieldDto.statistics?.tvlUsd;
if (tvlUsd == null || tvlUsd === "") return null;
@@ -334,7 +334,9 @@ export const getYieldTvlUsd = (yieldDto: Yield) => {
return tvlUsd;
};
-export const getYieldFeePercent = (yieldDto: Yield): number | null => {
+export const getYieldFeePercent = (
+ yieldDto: EarnYieldWithProvider
+): number | null => {
const fee = yieldDto.mechanics.fee;
if (!fee) return null;
@@ -350,7 +352,7 @@ export const getYieldFeePercent = (yieldDto: Yield): number | null => {
return total / 100;
};
-export const getYieldLockupPeriod = (yieldDto: Yield) =>
+export const getYieldLockupPeriod = (yieldDto: EarnYieldWithProvider) =>
secondsToDays(yieldDto.mechanics.lockupPeriod?.seconds);
const knownApiYieldTypeValues = new Set(knownApiYieldTypes);
@@ -359,7 +361,7 @@ const isKnownApiYieldType = (type: string): type is KnownApiYieldType =>
knownApiYieldTypeValues.has(type);
export const getExtendedYieldType = (
- yieldDto: YieldBase
+ yieldDto: EarnYieldWithProvider
): ExtendedYieldType => {
if (isNativeStaking(yieldDto)) {
return "native_staking";
@@ -374,12 +376,12 @@ export const getExtendedYieldType = (
return isKnownApiYieldType(type) ? type : "unknown";
};
-export const getYieldOutputToken = (yieldDto: YieldBase) =>
- Maybe.fromNullable(yieldDto.outputToken).filter(
- (outputToken) => !equalTokens(outputToken, yieldDto.token)
- );
+export const getYieldOutputToken = (yieldDto: EarnYieldWithProvider) =>
+ yieldDto.outputToken && !equalTokens(yieldDto.outputToken, yieldDto.token)
+ ? yieldDto.outputToken
+ : null;
-const hasPositivePricePerShare = (yieldDto: YieldBase) => {
+const hasPositivePricePerShare = (yieldDto: EarnYieldWithProvider) => {
const price = yieldDto.state?.pricePerShareState?.price;
if (price === null || price === undefined) return false;
@@ -389,8 +391,8 @@ const hasPositivePricePerShare = (yieldDto: YieldBase) => {
return amount.isFinite() && amount.isGreaterThan(0);
};
-export const hasYieldBearingOutputToken = (yieldDto: YieldBase) =>
- getYieldOutputToken(yieldDto).isJust() && hasPositivePricePerShare(yieldDto);
+export const hasYieldBearingOutputToken = (yieldDto: EarnYieldWithProvider) =>
+ getYieldOutputToken(yieldDto) !== null && hasPositivePricePerShare(yieldDto);
const isStakingYieldType = (yieldType: ExtendedYieldType) =>
yieldType === "staking" ||
@@ -410,7 +412,7 @@ export const isDepositYieldType = (yieldType: ExtendedYieldType) =>
yieldType === "liquidity_pool";
export const getYieldTypeLabels = (
- yieldDto: YieldBase,
+ yieldDto: EarnYieldWithProvider,
t: TFunction
): YieldTypeLabelsMap[keyof YieldTypeLabelsMap] => {
const map = {
@@ -506,39 +508,43 @@ const yieldTypesSortRank: { [Key in ExtendedYieldType]: number } = {
unknown: 12,
};
-export const getYieldTypesSortRank = (yieldDto: YieldBase) =>
+export const getYieldTypesSortRank = (yieldDto: EarnYieldWithProvider) =>
yieldTypesSortRank[getExtendedYieldType(yieldDto)];
-const isEthereumStaking = (yieldDto: YieldBase) =>
+const isEthereumStaking = (yieldDto: EarnYieldWithProvider) =>
yieldDto.mechanics.type === "staking" &&
yieldDto.token.network === EvmNetworks.Ethereum &&
yieldDto.token.symbol === "ETH";
-const isNativeStaking = (yieldDto: YieldBase) =>
- Maybe.fromFalsy(isEthereumStaking(yieldDto))
- .chain(() =>
- Maybe.fromFalsy(
- isYieldActionArgRequired(yieldDto, "enter", "amount")
- ).chain(() =>
- Maybe.fromNullable(
- getYieldActionArg(yieldDto, "enter", "amount")?.minimum
- )
- )
- )
- .map(BigNumber)
- .filter((v) => v.isEqualTo(32))
- .isJust();
-
-const isPooledStaking = (yieldDto: YieldBase) =>
+const isNativeStaking = (yieldDto: EarnYieldWithProvider) => {
+ if (
+ !isEthereumStaking(yieldDto) ||
+ !isYieldActionArgRequired(yieldDto, "enter", "amount")
+ ) {
+ return false;
+ }
+
+ const minimum = getYieldActionArg(yieldDto, "enter", "amount")?.minimum;
+
+ return minimum !== null && minimum !== undefined
+ ? BigNumber(minimum).isEqualTo(32)
+ : false;
+};
+
+const isPooledStaking = (yieldDto: EarnYieldWithProvider) =>
isEthereumStaking(yieldDto) && !isNativeStaking(yieldDto);
-export const isYieldWithProviderOptions = (yieldDto: YieldBase) =>
+export const isYieldWithProviderOptions = (yieldDto: EarnYieldWithProvider) =>
!!getYieldActionArg(yieldDto, "enter", "providerId")?.required;
-export const getYieldProviderYieldIds = (yieldDto: YieldBase) =>
- getYieldActionArg(yieldDto, "enter", "providerId")?.options ?? [];
+export const getYieldProviderYieldIds = (yieldDto: EarnYieldWithProvider) =>
+ Schema.decodeSync(Schema.Array(YieldId))(
+ getYieldActionArg(yieldDto, "enter", "providerId")?.options ?? []
+ );
-export const isYieldValidatorSelectionRequired = (yieldDto: Yield) =>
+export const isYieldValidatorSelectionRequired = (
+ yieldDto: EarnYieldWithProvider
+) =>
!!(
yieldDto.mechanics.requiresValidatorSelection ||
isYieldActionArgRequired(yieldDto, "enter", "validatorAddress") ||
@@ -556,10 +562,10 @@ const zeroRewardRateYieldIdWhitelist = new Set([
]);
export const isNonZeroRewardRateYield = (
- yieldDto: Pick
+ yieldDto: Pick
) =>
(yieldDto.rewardRate?.total ?? 0) > 0 ||
zeroRewardRateYieldIdWhitelist.has(yieldDto.id);
-export const isERC4626 = (yieldDto: Yield) =>
+export const isERC4626 = (yieldDto: EarnYieldWithProvider) =>
yieldDto.metadata.supportedStandards?.includes("ERC4626") ?? false;
diff --git a/packages/widget/src/features/activity/index.ts b/packages/widget/src/features/activity/index.ts
new file mode 100644
index 00000000..f9c7afa7
--- /dev/null
+++ b/packages/widget/src/features/activity/index.ts
@@ -0,0 +1,11 @@
+export type { ActivityFilterOption } from "./model/filters";
+export { useActivityFilter } from "./react/use-activity-filter";
+export {
+ useActivitySelectedAction,
+ useActivitySelectedValidators,
+ useActivitySelectedYield,
+} from "./react/use-activity-selection";
+export {
+ ActivityActionsKey,
+ getActivityActionsRequestParams,
+} from "./resources/activity-requests";
diff --git a/packages/widget/src/features/activity/model/filters.ts b/packages/widget/src/features/activity/model/filters.ts
new file mode 100644
index 00000000..719891e1
--- /dev/null
+++ b/packages/widget/src/features/activity/model/filters.ts
@@ -0,0 +1,23 @@
+import type { ActivityActionsQuery } from "../../../domain/schema/legacy-models";
+import {
+ type DashboardYieldCategory,
+ getApiYieldTypesForDashboardCategory,
+} from "../../../domain/types/yields";
+
+export type ActivityFilter = "all" | DashboardYieldCategory;
+
+export type ActivityFilterOption = {
+ readonly count: number;
+ readonly filter: ActivityFilter;
+};
+
+export const activityFilterCategories = [
+ "stake",
+ "defi",
+ "rwa",
+] as const satisfies ReadonlyArray;
+
+export const getActivityFilterYieldTypes = (
+ filter: ActivityFilter
+): ActivityActionsQuery["yieldTypes"] =>
+ filter === "all" ? undefined : getApiYieldTypesForDashboardCategory(filter);
diff --git a/packages/widget/src/features/activity/react/use-activity-actions.ts b/packages/widget/src/features/activity/react/use-activity-actions.ts
new file mode 100644
index 00000000..391a3d7b
--- /dev/null
+++ b/packages/widget/src/features/activity/react/use-activity-actions.ts
@@ -0,0 +1,301 @@
+import { useAtom, useAtomMount, useAtomValue } from "@effect/atom-react";
+import { Data, Duration, Effect, Option, Stream } from "effect";
+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
+import * as Atom from "effect/unstable/reactivity/Atom";
+import { appRuntime } from "../../../app/runtime";
+import type { WalletAddress } from "../../../domain/schema/identifiers";
+import type { Network } from "../../../domain/schema/network-model";
+import { getActionValidatorAddresses } from "../../../domain/types/action";
+import { YieldApiService } from "../../../services/api/yield-api-service";
+import { withApiResourcePolicy } from "../../../shared/effect/api-resource";
+import {
+ getPullResultItems,
+ paginatedApiStream,
+} from "../../../shared/effect/pagination";
+import {
+ getYieldValidatorsByAddressesEffect,
+ YieldOpportunityKey,
+ yieldOpportunityAtom,
+} from "../../earn";
+import { currentWalletStateAtom } from "../../wallet";
+import {
+ type ActivityFilter,
+ type ActivityFilterOption,
+ activityFilterCategories,
+} from "../model/filters";
+import {
+ ActivityActionsKey,
+ getActivityActionsRequestParams,
+} from "../resources/activity-requests";
+
+const PAGE_SIZE = 50;
+const COUNT_PAGE_SIZE = 1;
+
+class ActivityActionsError extends Data.TaggedError("ActivityActionsError")<{
+ readonly cause: unknown;
+}> {}
+
+const activityTotalAtom = Atom.family((_key: ActivityActionsKey) =>
+ Atom.make(null)
+);
+const activityHasNextPageAtom = Atom.family((_key: ActivityActionsKey) =>
+ Atom.make(false)
+);
+
+const makeActivityActionsPullAtom = (key: ActivityActionsKey) =>
+ appRuntime.pull(
+ (context) => {
+ if (!key.address || !key.network) return Stream.empty;
+ const address = key.address;
+ const network = key.network;
+
+ return paginatedApiStream({
+ fetchPage: (offset) =>
+ Effect.gen(function* () {
+ const api = yield* YieldApiService;
+ const page = yield* api.getActivityActions(
+ getActivityActionsRequestParams({
+ address,
+ filter: key.filter,
+ limit: PAGE_SIZE,
+ network,
+ offset,
+ })
+ );
+ context.set(activityTotalAtom(key), page.total);
+ context.set(
+ activityHasNextPageAtom(key),
+ page.offset + page.limit < page.total
+ );
+ const data = yield* Effect.forEach(
+ page.items ?? [],
+ (action) =>
+ Effect.gen(function* () {
+ const yieldResult = yield* context
+ .result(
+ yieldOpportunityAtom(
+ new YieldOpportunityKey({ yieldId: action.yieldId })
+ )
+ )
+ .pipe(Effect.option);
+ const yieldData = Option.getOrNull(yieldResult);
+ const validatorsData =
+ yield* getYieldValidatorsByAddressesEffect({
+ addresses: getActionValidatorAddresses(action) ?? [],
+ yieldId: action.yieldId,
+ }).pipe(Effect.orElseSucceed(() => []));
+
+ return { actionData: action, validatorsData, yieldData };
+ }),
+ { concurrency: 5 }
+ );
+
+ return {
+ items: data,
+ limit: page.limit,
+ offset: page.offset,
+ total: page.total,
+ };
+ }).pipe(
+ Effect.mapError((cause) => new ActivityActionsError({ cause }))
+ ),
+ });
+ },
+ { initialValue: [] }
+ );
+
+const activityActionsPullAtom = Atom.family(makeActivityActionsPullAtom);
+
+const currentActivityActionsKeyAtom = Atom.family((filter: ActivityFilter) =>
+ Atom.make((get) => {
+ const wallet = get(currentWalletStateAtom);
+
+ return new ActivityActionsKey({
+ address: wallet.status === "connected" ? wallet.address : null,
+ filter,
+ network: wallet.status === "connected" ? wallet.network : null,
+ });
+ })
+);
+
+const currentActivityActionsPullAtom = Atom.family((filter: ActivityFilter) =>
+ Atom.writable>, void>(
+ (context) =>
+ context.get(
+ activityActionsPullAtom(
+ context.get(currentActivityActionsKeyAtom(filter))
+ )
+ ),
+ (context, _value) =>
+ context.set(
+ activityActionsPullAtom(
+ context.get(currentActivityActionsKeyAtom(filter))
+ ),
+ undefined
+ )
+ )
+);
+
+const currentPrefetchActivityActionsKeyAtom = Atom.family(
+ (filter: ActivityFilter) =>
+ Atom.make((get) => {
+ const wallet = get(currentWalletStateAtom);
+ const filterOptions = get(currentActivityFilterOptionsAtom).pipe(
+ AsyncResult.value,
+ Option.getOrElse(() => [])
+ );
+ const enabled = filterOptions.length > 0;
+
+ return new ActivityActionsKey({
+ address:
+ enabled && wallet.status === "connected" ? wallet.address : null,
+ filter,
+ network:
+ enabled && wallet.status === "connected" ? wallet.network : null,
+ });
+ })
+);
+
+const currentPrefetchActivityActionsPullAtom = Atom.family(
+ (filter: ActivityFilter) =>
+ Atom.writable>, void>(
+ (context) =>
+ context.get(
+ activityActionsPullAtom(
+ context.get(currentPrefetchActivityActionsKeyAtom(filter))
+ )
+ ),
+ (context, _value) =>
+ context.set(
+ activityActionsPullAtom(
+ context.get(currentPrefetchActivityActionsKeyAtom(filter))
+ ),
+ undefined
+ )
+ )
+);
+
+const currentActivityTotalAtom = Atom.family((filter: ActivityFilter) =>
+ Atom.make((get) =>
+ get(activityTotalAtom(get(currentActivityActionsKeyAtom(filter))))
+ )
+);
+
+const currentActivityHasNextPageAtom = Atom.family((filter: ActivityFilter) =>
+ Atom.make((get) =>
+ get(activityHasNextPageAtom(get(currentActivityActionsKeyAtom(filter))))
+ )
+);
+
+const currentActivityActionsRefreshAtom = Atom.family(
+ (filter: ActivityFilter) =>
+ Atom.make(
+ (get) => () =>
+ get.refresh(
+ activityActionsPullAtom(get(currentActivityActionsKeyAtom(filter)))
+ )
+ )
+);
+
+class ActivityFilterOptionsKey extends Data.Class<{
+ readonly address: typeof WalletAddress.Type | null;
+ readonly network: Network | null;
+}> {}
+
+const activityFilterOptionsAtom = Atom.family((key: ActivityFilterOptionsKey) =>
+ appRuntime
+ .atom(() =>
+ Effect.gen(function* () {
+ if (!key.address || !key.network) return [];
+
+ const api = yield* YieldApiService;
+ const count = (filter: ActivityFilter) =>
+ api
+ .getActivityActions(
+ getActivityActionsRequestParams({
+ address: key.address!,
+ filter,
+ limit: COUNT_PAGE_SIZE,
+ network: key.network!,
+ offset: 0,
+ })
+ )
+ .pipe(Effect.map((page) => page.total));
+ const allCount = yield* count("all");
+
+ if (allCount <= 0) return [];
+
+ const categoryCounts = yield* Effect.forEach(
+ activityFilterCategories,
+ (filter) =>
+ count(filter).pipe(Effect.map((count) => ({ filter, count }))),
+ { concurrency: 3 }
+ );
+ const visible = categoryCounts.filter((item) => item.count > 0);
+
+ return visible.length > 0
+ ? [{ filter: "all" as const, count: allCount }, ...visible]
+ : [];
+ }).pipe(Effect.mapError((cause) => new ActivityActionsError({ cause })))
+ )
+ .pipe(
+ withApiResourcePolicy({
+ idleTTL: Duration.minutes(5),
+ staleTime: Duration.minutes(1),
+ revalidateOnMount: true,
+ })
+ )
+);
+
+const currentActivityFilterOptionsAtom = Atom.make((get) => {
+ const wallet = get(currentWalletStateAtom);
+
+ return get(
+ activityFilterOptionsAtom(
+ new ActivityFilterOptionsKey({
+ address: wallet.status === "connected" ? wallet.address : null,
+ network: wallet.status === "connected" ? wallet.network : null,
+ })
+ )
+ );
+}).pipe(Atom.withLabel("currentActivityFilterOptionsAtom"));
+
+export const useActivityFilterOptions = (): ActivityFilterOption[] => {
+ const result = useAtomValue(currentActivityFilterOptionsAtom);
+
+ return result.pipe(
+ AsyncResult.value,
+ Option.getOrElse(() => [])
+ );
+};
+
+export const usePrefetchActivityActionFilters = () => {
+ const make = (filter: ActivityFilter) =>
+ currentPrefetchActivityActionsPullAtom(filter);
+
+ useAtomMount(make("all"));
+ useAtomMount(make("stake"));
+ useAtomMount(make("defi"));
+ useAtomMount(make("rwa"));
+};
+
+export const useActivityActions = (filter: ActivityFilter = "all") => {
+ const resource = currentActivityActionsPullAtom(filter);
+ const [result, pull] = useAtom(resource);
+ const refresh = useAtomValue(currentActivityActionsRefreshAtom(filter));
+ const total = useAtomValue(currentActivityTotalAtom(filter));
+ const hasNextPage = useAtomValue(currentActivityHasNextPageAtom(filter));
+ const allItems = [...getPullResultItems(result)];
+
+ return {
+ allItems,
+ data: total === null ? undefined : { pages: [{ total }] },
+ error: result.pipe(AsyncResult.error, Option.getOrUndefined),
+ fetchNextPage: () => pull(),
+ hasNextPage,
+ isFetchingNextPage: result.waiting && allItems.length > 0,
+ isLoading: result.waiting && allItems.length === 0,
+ isPending: result.waiting && allItems.length === 0,
+ refetch: refresh,
+ } as const;
+};
diff --git a/packages/widget/src/features/activity/react/use-activity-filter.ts b/packages/widget/src/features/activity/react/use-activity-filter.ts
new file mode 100644
index 00000000..7bf01322
--- /dev/null
+++ b/packages/widget/src/features/activity/react/use-activity-filter.ts
@@ -0,0 +1,20 @@
+import { useAtom } from "@effect/atom-react";
+import type { ActivityFilter, ActivityFilterOption } from "../model/filters";
+import { activityFilterAtom } from "../state/filter";
+
+export const useActivityFilter = (
+ options: ReadonlyArray
+) => {
+ const [selectedFilter, setSelectedFilter] = useAtom(activityFilterAtom);
+ const isSelectedAvailable =
+ selectedFilter === "all" ||
+ options.some((option) => option.filter === selectedFilter);
+
+ return {
+ selectedFilter: isSelectedAvailable ? selectedFilter : "all",
+ setSelectedFilter,
+ } satisfies {
+ readonly selectedFilter: ActivityFilter;
+ readonly setSelectedFilter: (filter: ActivityFilter) => void;
+ };
+};
diff --git a/packages/widget/src/features/activity/react/use-activity-selection.ts b/packages/widget/src/features/activity/react/use-activity-selection.ts
new file mode 100644
index 00000000..8408da1f
--- /dev/null
+++ b/packages/widget/src/features/activity/react/use-activity-selection.ts
@@ -0,0 +1,18 @@
+import { useAtomSet, useAtomValue } from "@effect/atom-react";
+import {
+ activitySelectedActionAtom,
+ activitySelectedValidatorsAtom,
+ activitySelectedYieldAtom,
+ activitySelectionAtom,
+} from "../state/selection";
+
+export const useActivitySelectedAction = () =>
+ useAtomValue(activitySelectedActionAtom);
+
+export const useActivitySelectedYield = () =>
+ useAtomValue(activitySelectedYieldAtom);
+
+export const useActivitySelectedValidators = () =>
+ useAtomValue(activitySelectedValidatorsAtom);
+
+export const useSetActivitySelection = () => useAtomSet(activitySelectionAtom);
diff --git a/packages/widget/src/features/activity/resources/activity-requests.ts b/packages/widget/src/features/activity/resources/activity-requests.ts
new file mode 100644
index 00000000..4b5e6878
--- /dev/null
+++ b/packages/widget/src/features/activity/resources/activity-requests.ts
@@ -0,0 +1,40 @@
+import { Data } from "effect";
+import type { WalletAddress } from "../../../domain/schema/identifiers";
+import type { Network } from "../../../domain/schema/network-model";
+import {
+ type ActivityFilter,
+ getActivityFilterYieldTypes,
+} from "../model/filters";
+
+const ACTIVITY_ACTION_STATUSES = ["SUCCESS", "FAILED"] as const;
+
+export const getActivityActionsRequestParams = ({
+ address,
+ filter,
+ limit,
+ network,
+ offset,
+}: {
+ readonly address: string;
+ readonly filter: ActivityFilter;
+ readonly limit: number;
+ readonly network: Network;
+ readonly offset: number;
+}) => {
+ const yieldTypes = getActivityFilterYieldTypes(filter);
+
+ return {
+ address,
+ limit,
+ offset,
+ network,
+ statuses: ACTIVITY_ACTION_STATUSES,
+ ...(yieldTypes?.length ? { yieldTypes } : {}),
+ };
+};
+
+export class ActivityActionsKey extends Data.Class<{
+ readonly address: WalletAddress | null;
+ readonly filter: ActivityFilter;
+ readonly network: Network | null;
+}> {}
diff --git a/packages/widget/src/features/activity/state/filter.ts b/packages/widget/src/features/activity/state/filter.ts
new file mode 100644
index 00000000..b716d5fc
--- /dev/null
+++ b/packages/widget/src/features/activity/state/filter.ts
@@ -0,0 +1,7 @@
+import * as Atom from "effect/unstable/reactivity/Atom";
+import type { ActivityFilter } from "../model/filters";
+
+export const activityFilterAtom = Atom.make("all").pipe(
+ Atom.keepAlive,
+ Atom.withLabel("activityFilterAtom")
+);
diff --git a/packages/widget/src/features/activity/state/selection.ts b/packages/widget/src/features/activity/state/selection.ts
new file mode 100644
index 00000000..bbc6ae44
--- /dev/null
+++ b/packages/widget/src/features/activity/state/selection.ts
@@ -0,0 +1,56 @@
+import * as Atom from "effect/unstable/reactivity/Atom";
+import type { YieldAction } from "../../../domain/schema/action-models";
+import type {
+ EarnValidator,
+ EarnYieldWithProvider,
+} from "../../../domain/schema/earn-models";
+import { getActionInputToken } from "../../../domain/types/action";
+import {
+ type ClassicTransactionWorkflowKey,
+ type ClassicTransactionWorkflowProviderDetail,
+ makeClassicTransactionWorkflowKey,
+} from "../../../services/workflow/transaction-workflow-model";
+import { selectAtom } from "../../../shared/effect/select-atom";
+
+type ActivitySelection = {
+ readonly providersDetails: ReadonlyArray;
+ readonly selectedAction: YieldAction;
+ readonly selectedValidators: ReadonlyArray;
+ readonly selectedYield: EarnYieldWithProvider;
+};
+
+type ActivitySelectionState = ActivitySelection | null;
+
+export const activitySelectionAtom = Atom.make(
+ null
+).pipe(Atom.keepAlive, Atom.withLabel("activitySelectionAtom"));
+
+export const activitySelectedActionAtom = selectAtom(
+ activitySelectionAtom,
+ (selection) => selection?.selectedAction ?? null
+);
+
+export const activitySelectedYieldAtom = selectAtom(
+ activitySelectionAtom,
+ (selection) => selection?.selectedYield ?? null
+);
+
+export const activitySelectedValidatorsAtom = selectAtom(
+ activitySelectionAtom,
+ (selection) => selection?.selectedValidators ?? null
+);
+
+export const activityTransactionWorkflowKeyAtom = selectAtom(
+ activitySelectionAtom,
+ (selection): ClassicTransactionWorkflowKey | null =>
+ selection
+ ? makeClassicTransactionWorkflowKey({
+ action: selection.selectedAction,
+ inputToken: getActionInputToken({
+ actionDto: selection.selectedAction,
+ yieldDto: selection.selectedYield,
+ }),
+ providersDetails: selection.providersDetails,
+ })
+ : null
+);
diff --git a/packages/widget/src/features/activity/ui.ts b/packages/widget/src/features/activity/ui.ts
new file mode 100644
index 00000000..ef942874
--- /dev/null
+++ b/packages/widget/src/features/activity/ui.ts
@@ -0,0 +1,2 @@
+export { AnimatedActivityPage } from "./ui/classic/activity-page/activity.page";
+export { ActivityTabPage } from "./ui/dashboard/activity";
diff --git a/packages/widget/src/features/activity/ui/classic/activity-page/activity.page.tsx b/packages/widget/src/features/activity/ui/classic/activity-page/activity.page.tsx
new file mode 100644
index 00000000..79e7cd82
--- /dev/null
+++ b/packages/widget/src/features/activity/ui/classic/activity-page/activity.page.tsx
@@ -0,0 +1,106 @@
+import { motion } from "motion/react";
+import { useTranslation } from "react-i18next";
+import { VirtualList } from "../../../../../shared/ui/components/virtual-list";
+import { Box } from "../../../../../shared/ui/primitives/box";
+import { Text } from "../../../../../shared/ui/primitives/typography/text";
+import { useMountAnimation } from "../../../../mount-animation";
+import { FallbackContent, PageContainer } from "../../../../widget-shell";
+import { ActionListItem } from "./components/action-list-item";
+import { ActivityFilters } from "./components/activity-filters";
+import { useActivityPage } from "./hooks/use-activity-page";
+import { container } from "./style.css";
+
+const ActivityPageComponent = () => {
+ const {
+ content,
+ allData,
+ filterOptions,
+ selectedFilter,
+ onFilterSelect,
+ showingCount,
+ total,
+ onActionSelect,
+ activityActions,
+ showActivityContent,
+ showActivityControls,
+ showActivityList,
+ } = useActivityPage();
+
+ const { t } = useTranslation();
+
+ return (
+
+ {content}
+
+
+ {showActivityContent && (
+ <>
+ {showActivityControls && (
+
+ )}
+
+ {showActivityList ? (
+ <>
+
+
+ {t("activity.showing_count", {
+ showing: showingCount,
+ total,
+ })}
+
+
+
+ 80}
+ itemContent={(_index, item) => (
+
+ )}
+ />
+ >
+ ) : (
+
+
+
+ )}
+ >
+ )}
+
+
+ );
+};
+
+export const AnimatedActivityPage = () => {
+ const { mountAnimationFinished } = useMountAnimation();
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/packages/widget/src/features/activity/ui/classic/activity-page/components/action-list-item/index.tsx b/packages/widget/src/features/activity/ui/classic/activity-page/components/action-list-item/index.tsx
new file mode 100644
index 00000000..3319278d
--- /dev/null
+++ b/packages/widget/src/features/activity/ui/classic/activity-page/components/action-list-item/index.tsx
@@ -0,0 +1,149 @@
+import { useTranslation } from "react-i18next";
+import type { ClassicTransactionWorkflowProviderDetail } from "../../../../../../../services/workflow/transaction-workflow-model";
+import { Box } from "../../../../../../../shared/ui/primitives/box";
+import { ListItem } from "../../../../../../../shared/ui/primitives/list/list-item";
+import { Text } from "../../../../../../../shared/ui/primitives/typography/text";
+import { useActionListItem } from "../../hooks/use-action-list-item";
+import type { ActionYieldDto } from "../../types";
+import { ActivityIcon } from "../activity-icon";
+import {
+ amountNeutral,
+ amountPositive,
+ failedBadge,
+ infoColumn,
+ listItem,
+ noWrap,
+ timeColumn,
+ titleText,
+ viaText,
+} from "../activity-item.css";
+
+export const ActionListItem = ({
+ action,
+ onActionSelect,
+}: {
+ action: ActionYieldDto;
+ onActionSelect: (
+ action: ActionYieldDto,
+ providersDetails: ReadonlyArray
+ ) => void;
+}) => {
+ const { t } = useTranslation();
+ const {
+ canOpenDetails,
+ providersDetails,
+ iconType,
+ title,
+ tokenSymbol,
+ amount,
+ amountSign,
+ isPositive,
+ timestampAbsolute,
+ timestampRelative,
+ showFailedBadge,
+ badgeLabel,
+ showUnavailableYieldDetails,
+ unavailableYieldLabel,
+ } = useActionListItem(action);
+
+ const firstProvider = providersDetails?.[0];
+ const providerLabel = firstProvider
+ ? t("positions.via", {
+ providerName: firstProvider.name ?? firstProvider.address,
+ count: Math.max((providersDetails?.length ?? 0) - 1, 1),
+ })
+ : null;
+
+ return (
+
+ onActionSelect(action, providersDetails ?? [])
+ : undefined
+ }
+ className={listItem}
+ variant={{ hover: canOpenDetails ? "enabled" : "disabled" }}
+ >
+
+
+
+
+
+
+ {title}
+
+ {showFailedBadge && (
+
+
+ {badgeLabel}
+
+
+ )}
+
+
+ {providerLabel ? (
+
+ {providerLabel}
+
+ ) : showUnavailableYieldDetails ? (
+
+ {unavailableYieldLabel}
+
+ ) : null}
+
+
+
+
+ {amount ? (
+
+ {amountSign}
+ {amount} {tokenSymbol}
+
+ ) : null}
+
+
+
+ {timestampAbsolute}
+
+
+ {timestampRelative}
+
+
+
+
+
+
+ );
+};
diff --git a/packages/widget/src/features/activity/ui/classic/activity-page/components/activity-filters/index.tsx b/packages/widget/src/features/activity/ui/classic/activity-page/components/activity-filters/index.tsx
new file mode 100644
index 00000000..c73c855a
--- /dev/null
+++ b/packages/widget/src/features/activity/ui/classic/activity-page/components/activity-filters/index.tsx
@@ -0,0 +1,74 @@
+import clsx from "clsx";
+import { useTranslation } from "react-i18next";
+import { Box } from "../../../../../../../shared/ui/primitives/box";
+import { pressAnimation } from "../../../../../../../shared/ui/primitives/button/styles.css";
+import { Text } from "../../../../../../../shared/ui/primitives/typography/text";
+import type {
+ ActivityFilter,
+ ActivityFilterOption,
+} from "../../../../../model/filters";
+import { filterCount, filterPill, filtersContainer } from "./styles.css";
+
+type Props = {
+ options: ActivityFilterOption[];
+ selectedFilter: ActivityFilter;
+ onSelect: (filter: ActivityFilter) => void;
+};
+
+export const ActivityFilters = ({
+ options,
+ selectedFilter,
+ onSelect,
+}: Props) => {
+ const { t } = useTranslation();
+
+ if (options.length === 0) return null;
+
+ return (
+
+ {options.map(({ filter, count }) => {
+ const isSelected = filter === selectedFilter;
+
+ return (
+ onSelect(filter)}
+ data-state={isSelected ? "selected" : "default"}
+ className={clsx([
+ pressAnimation,
+ filterPill({ state: isSelected ? "active" : "default" }),
+ ])}
+ >
+
+ {t(`activity.filters.${filter}`)}
+
+
+
+
+ {count}
+
+
+
+ );
+ })}
+
+ );
+};
diff --git a/packages/widget/src/features/activity/ui/classic/activity-page/components/activity-filters/styles.css.ts b/packages/widget/src/features/activity/ui/classic/activity-page/components/activity-filters/styles.css.ts
new file mode 100644
index 00000000..7cffcdd5
--- /dev/null
+++ b/packages/widget/src/features/activity/ui/classic/activity-page/components/activity-filters/styles.css.ts
@@ -0,0 +1,67 @@
+import { style } from "@vanilla-extract/css";
+import { recipe } from "@vanilla-extract/recipes";
+import { atoms } from "../../../../../../../shared/styles/theme/atoms.css";
+import { vars } from "../../../../../../../shared/styles/theme/contract.css";
+
+export const filtersContainer = style([
+ atoms({ display: "flex", alignItems: "center", paddingBottom: "3" }),
+ {
+ gap: "8px",
+ flexWrap: "wrap",
+ },
+]);
+
+export const filterPill = recipe({
+ base: [
+ atoms({
+ display: "flex",
+ alignItems: "center",
+ borderWidth: 1,
+ borderStyle: "solid",
+ }),
+ {
+ gap: "6px",
+ cursor: "pointer",
+ userSelect: "none",
+ borderRadius: "9999px",
+ padding: "6px 12px",
+ whiteSpace: "nowrap",
+ },
+ ],
+ variants: {
+ state: {
+ default: atoms({ background: "transparent", borderColor: "text" }),
+ active: atoms({
+ background: "text",
+ borderColor: "text",
+ color: "primary",
+ }),
+ },
+ },
+ defaultVariants: {
+ state: "default",
+ },
+});
+
+export const filterCount = recipe({
+ base: [
+ atoms({ display: "flex", alignItems: "center", justifyContent: "center" }),
+ {
+ borderRadius: "9999px",
+ minWidth: "20px",
+ height: "18px",
+ padding: "0 6px",
+ },
+ ],
+ variants: {
+ state: {
+ default: atoms({ background: "backgroundMuted" }),
+ active: {
+ background: `color-mix(in srgb, ${vars.color.primary} 18%, transparent)`,
+ },
+ },
+ },
+ defaultVariants: {
+ state: "default",
+ },
+});
diff --git a/packages/widget/src/features/activity/ui/classic/activity-page/components/activity-icon/index.tsx b/packages/widget/src/features/activity/ui/classic/activity-page/components/activity-icon/index.tsx
new file mode 100644
index 00000000..0bd5c16b
--- /dev/null
+++ b/packages/widget/src/features/activity/ui/classic/activity-page/components/activity-icon/index.tsx
@@ -0,0 +1,18 @@
+import { Box } from "../../../../../../../shared/ui/primitives/box";
+import { Arrow } from "../../../../../../../shared/ui/primitives/icons/arrow";
+import { GifIcon } from "../../../../../../../shared/ui/primitives/icons/gift";
+import { iconCircle } from "../activity-item.css";
+
+export type ActivityIconType = "in" | "out" | "rewards";
+
+export const ActivityIcon = ({ type }: { type: ActivityIconType }) => (
+
+ {type === "rewards" ? (
+
+ ) : type === "out" ? (
+
+ ) : (
+
+ )}
+
+);
diff --git a/packages/widget/src/pages/details/activity-page/components/activity-item.css.ts b/packages/widget/src/features/activity/ui/classic/activity-page/components/activity-item.css.ts
similarity index 84%
rename from packages/widget/src/pages/details/activity-page/components/activity-item.css.ts
rename to packages/widget/src/features/activity/ui/classic/activity-page/components/activity-item.css.ts
index ca0f54c2..453a6d4d 100644
--- a/packages/widget/src/pages/details/activity-page/components/activity-item.css.ts
+++ b/packages/widget/src/features/activity/ui/classic/activity-page/components/activity-item.css.ts
@@ -1,5 +1,13 @@
import { style } from "@vanilla-extract/css";
-import { atoms } from "../../../../styles/theme/atoms.css";
+import { atoms } from "../../../../../../shared/styles/theme/atoms.css";
+
+export const failedBadge = style([
+ atoms({
+ background: "positionsActionRequiredBackground",
+ borderRadius: "base",
+ }),
+ { padding: "2px 4px" },
+]);
export const listItem = style([
atoms({ gap: "1" }),
diff --git a/packages/widget/src/features/activity/ui/classic/activity-page/hooks/use-action-list-item.ts b/packages/widget/src/features/activity/ui/classic/activity-page/hooks/use-action-list-item.ts
new file mode 100644
index 00000000..a6a7f5dc
--- /dev/null
+++ b/packages/widget/src/features/activity/ui/classic/activity-page/hooks/use-action-list-item.ts
@@ -0,0 +1,230 @@
+import { useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ ActionStatus,
+ type ActionType,
+ getActionInputToken,
+} from "../../../../../../domain/types/action";
+import { defaultFormattedNumber } from "../../../../../../shared/lib";
+import {
+ getActivityDayKind,
+ getActivityRelativeTime,
+} from "../../../../../../shared/lib/date";
+import { capitalizeFirstLetters } from "../../../../../../shared/lib/formatters";
+import { useProvidersDetails } from "../../../../../earn";
+import type { ActivityIconType } from "../components/activity-icon";
+import type { ActionYieldDto } from "../types";
+
+type ActivityDirection = "deposit" | "withdraw" | "rewards" | "other";
+
+const DEPOSIT_ACTIONS = new Set([
+ "STAKE",
+ "STAKE_LOCKED",
+ "RESTAKE",
+ "REBOND",
+]);
+
+const WITHDRAW_ACTIONS = new Set([
+ "UNSTAKE",
+ "WITHDRAW",
+ "WITHDRAW_ALL",
+ "CLAIM_UNSTAKED",
+ "UNLOCK_LOCKED",
+]);
+
+const REWARD_ACTIONS = new Set([
+ "CLAIM_REWARDS",
+ "RESTAKE_REWARDS",
+ "AUTO_SWEEP_UNSTAKE_REWARDS",
+ "AUTO_SWEEP_WITHDRAW_REWARDS",
+]);
+
+const getDirection = (type: ActionType): ActivityDirection => {
+ if (DEPOSIT_ACTIONS.has(type)) return "deposit";
+ if (WITHDRAW_ACTIONS.has(type)) return "withdraw";
+ if (REWARD_ACTIONS.has(type)) return "rewards";
+ return "other";
+};
+
+const ICON_TYPE_MAP: Record = {
+ deposit: "in",
+ rewards: "rewards",
+ withdraw: "out",
+ other: "in",
+};
+
+const ADDRESS_LIKE_TOKEN = /^0x[0-9a-fA-F]{40}$/;
+
+const getReadableRawTokenSymbol = (
+ value: string | null | undefined
+): string | null => {
+ if (!value || value === "0x" || ADDRESS_LIKE_TOKEN.test(value)) {
+ return null;
+ }
+
+ return value;
+};
+
+const getFallbackTokenSymbol = ({
+ direction,
+ inputToken,
+ outputToken,
+ unknownTokenLabel,
+}: {
+ direction: ActivityDirection;
+ inputToken: string | null | undefined;
+ outputToken: string | null | undefined;
+ unknownTokenLabel: string;
+}) => {
+ const preferredToken =
+ direction === "withdraw" || direction === "other"
+ ? (outputToken ?? inputToken)
+ : (inputToken ?? outputToken);
+
+ return getReadableRawTokenSymbol(preferredToken) ?? unknownTokenLabel;
+};
+
+export const useActionListItem = (action: ActionYieldDto) => {
+ const { t, i18n } = useTranslation();
+
+ const locale = i18n.language;
+
+ const integrationData = action.yieldData ?? null;
+
+ const providersDetails = useProvidersDetails({
+ integrationData,
+ validators: action.validatorsData,
+ selectedProviderYieldId: null,
+ });
+
+ const direction = useMemo(
+ () => getDirection(action.actionData.type),
+ [action.actionData.type]
+ );
+
+ const iconType = ICON_TYPE_MAP[direction];
+
+ /** Deposits/rewards show the underlying input token, withdrawals the vault token. */
+ const tokenSymbol = useMemo(() => {
+ const yieldData = action.yieldData;
+
+ if (!yieldData) {
+ return getFallbackTokenSymbol({
+ direction,
+ inputToken: action.actionData.rawArguments?.inputToken,
+ outputToken: action.actionData.rawArguments?.outputToken,
+ unknownTokenLabel: t("activity.item.unknown_token"),
+ });
+ }
+
+ const yieldToken = yieldData.token;
+
+ if (direction === "withdraw" || direction === "other") {
+ return yieldToken.symbol;
+ }
+
+ const inputToken = getActionInputToken({
+ actionDto: action.actionData,
+ yieldDto: yieldData,
+ });
+
+ return inputToken?.symbol ?? yieldToken.symbol;
+ }, [action.actionData, action.yieldData, direction, t]);
+
+ const amount = useMemo(
+ () =>
+ action.actionData.amount == null
+ ? null
+ : defaultFormattedNumber(action.actionData.amount),
+ [action.actionData.amount]
+ );
+
+ const isPositive = direction === "deposit" || direction === "rewards";
+
+ const amountSign = useMemo(() => {
+ if (!amount) return "";
+ if (direction === "withdraw") return "-";
+ if (direction === "deposit" || direction === "rewards") return "+";
+ return "";
+ }, [amount, direction]);
+
+ const title = useMemo(() => {
+ switch (direction) {
+ case "deposit":
+ return t("activity.item.deposited", { token: tokenSymbol });
+ case "withdraw":
+ return t("activity.item.withdrew", { token: tokenSymbol });
+ case "rewards":
+ return t("activity.item.rewards");
+ default:
+ return t("activity.item.generic", {
+ action: capitalizeFirstLetters(
+ action.actionData.type.replaceAll("_", " ")
+ ),
+ token: tokenSymbol,
+ });
+ }
+ }, [direction, t, tokenSymbol, action.actionData.type]);
+
+ const { timestampAbsolute, timestampRelative } = useMemo(() => {
+ const createdAt = action.actionData.createdAt;
+
+ if (!createdAt) {
+ return { timestampAbsolute: "", timestampRelative: "" };
+ }
+
+ const date = new Date(createdAt);
+ const dayKind = getActivityDayKind(date);
+
+ const time = date.toLocaleTimeString(locale, {
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ });
+
+ const absolute =
+ dayKind === "today"
+ ? `${t("activity.date_group_labels.today")} · ${time}`
+ : dayKind === "yesterday"
+ ? t("activity.date_group_labels.yesterday")
+ : date.toLocaleDateString(locale, {
+ day: "2-digit",
+ month: "short",
+ year: "numeric",
+ });
+
+ const relativeParts = getActivityRelativeTime(date);
+ const relative =
+ relativeParts.unit === "now"
+ ? t("activity.time.now")
+ : relativeParts.unit === "minutes"
+ ? t("activity.time.minutes_ago", { count: relativeParts.value })
+ : relativeParts.unit === "hours"
+ ? t("activity.time.hours_ago", { count: relativeParts.value })
+ : t("activity.time.days_ago", { count: relativeParts.value });
+
+ return { timestampAbsolute: absolute, timestampRelative: relative };
+ }, [action.actionData.createdAt, locale, t]);
+
+ const showFailedBadge = action.actionData.status === ActionStatus.FAILED;
+ const canOpenDetails = !!action.yieldData;
+ const showUnavailableYieldDetails = !action.yieldData;
+
+ return {
+ canOpenDetails,
+ integrationData,
+ providersDetails,
+ iconType,
+ title,
+ tokenSymbol,
+ amount,
+ amountSign,
+ isPositive,
+ timestampAbsolute,
+ timestampRelative,
+ showFailedBadge,
+ badgeLabel: t("activity.failed"),
+ showUnavailableYieldDetails,
+ unavailableYieldLabel: t("activity.item.yield_unavailable"),
+ };
+};
diff --git a/packages/widget/src/features/activity/ui/classic/activity-page/hooks/use-activity-page.tsx b/packages/widget/src/features/activity/ui/classic/activity-page/hooks/use-activity-page.tsx
new file mode 100644
index 00000000..cbd1d41b
--- /dev/null
+++ b/packages/widget/src/features/activity/ui/classic/activity-page/hooks/use-activity-page.tsx
@@ -0,0 +1,201 @@
+import { useConnectModal } from "@stakekit/rainbowkit";
+import { type ReactNode, useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router";
+import {
+ ActionStatus,
+ type TransactionType,
+} from "../../../../../../domain/types/action";
+import type { ClassicTransactionWorkflowProviderDetail } from "../../../../../../services/workflow/transaction-workflow-model";
+import { Box } from "../../../../../../shared/ui/primitives/box";
+import { ContentLoaderSquare } from "../../../../../../shared/ui/primitives/content-loader";
+import { Text } from "../../../../../../shared/ui/primitives/typography/text";
+import { useTrackPage } from "../../../../../tracking";
+import { useSKWallet } from "../../../../../wallet";
+import { FallbackContent } from "../../../../../widget-shell";
+import type {
+ ActivityFilter,
+ ActivityFilterOption,
+} from "../../../../model/filters";
+import {
+ useActivityActions,
+ useActivityFilterOptions,
+ usePrefetchActivityActionFilters,
+} from "../../../../react/use-activity-actions";
+import { useActivityFilter } from "../../../../react/use-activity-filter";
+import { useSetActivitySelection } from "../../../../react/use-activity-selection";
+import type { ActionYieldDto } from "../types";
+
+type UseActivityPageResult = {
+ content: ReactNode;
+ onActionSelect: (
+ val: ActionYieldDto,
+ providersDetails: ReadonlyArray
+ ) => void;
+ showingCount: number;
+ total: number;
+ allData: ReturnType["allItems"];
+ filterOptions: ActivityFilterOption[];
+ selectedFilter: ActivityFilter;
+ onFilterSelect: (filter: ActivityFilter) => void;
+ activityActions: ReturnType;
+ showActivityContent: boolean;
+ showActivityControls: boolean;
+ showActivityList: boolean;
+};
+
+export const useActivityPage = ({
+ selectionMode = "navigate",
+}: {
+ readonly selectionMode?: "navigate" | "select";
+} = {}): UseActivityPageResult => {
+ useTrackPage("activity");
+
+ const { isConnected, isConnecting } = useSKWallet();
+ const { openConnectModal } = useConnectModal();
+ const navigate = useNavigate();
+ const setActivitySelection = useSetActivitySelection();
+ const filterOptions = useActivityFilterOptions();
+ const { selectedFilter, setSelectedFilter } =
+ useActivityFilter(filterOptions);
+ const activityActions = useActivityActions(selectedFilter);
+ usePrefetchActivityActionFilters();
+
+ const onActionSelect = (
+ data: ActionYieldDto,
+ providersDetails: ReadonlyArray
+ ) => {
+ if (!isConnected) return openConnectModal?.();
+ if (!data.yieldData) return;
+
+ setActivitySelection({
+ providersDetails,
+ selectedAction: data.actionData,
+ selectedYield: data.yieldData,
+ selectedValidators: data.validatorsData,
+ });
+
+ if (selectionMode === "select") return;
+
+ if (
+ data.actionData.status === ActionStatus.SUCCESS ||
+ data.actionData.status === ActionStatus.PROCESSING
+ ) {
+ const urls = data.actionData.transactions
+ .map((transaction) => ({
+ type: transaction.type,
+ url: transaction.explorerUrl,
+ }))
+ .filter(
+ (
+ transaction
+ ): transaction is {
+ type: TransactionType;
+ url: string;
+ } => !!transaction.url
+ );
+ const path =
+ data.actionData.type === "UNSTAKE"
+ ? "unstake"
+ : data.actionData.type === "STAKE"
+ ? "stake"
+ : "pending";
+
+ return navigate(`/activity/${path}-review/complete`, {
+ state: { urls },
+ });
+ }
+
+ if (
+ data.actionData.status === ActionStatus.CREATED ||
+ data.actionData.status === ActionStatus.WAITING_FOR_NEXT ||
+ data.actionData.status === ActionStatus.FAILED
+ ) {
+ return navigate("/activity/review");
+ }
+ };
+
+ const allData = activityActions.allItems;
+
+ const showingCount = allData?.length ?? 0;
+
+ const apiTotal =
+ (activityActions.data as { pages: { total?: number }[] } | undefined)
+ ?.pages?.[0]?.total ??
+ allData?.length ??
+ 0;
+ const total = apiTotal;
+ const hasRenderableActivity = !!allData?.length;
+ const hasActivityFilters = filterOptions.length > 0;
+ const showActivityControls = !activityActions.isPending && hasActivityFilters;
+ const showActivityList = !activityActions.isPending && hasRenderableActivity;
+ const showActivityContent = showActivityControls || showActivityList;
+
+ const { t } = useTranslation();
+
+ const content = useMemo(() => {
+ if (!isConnected && !isConnecting) {
+ return (
+
+
+ {t("dashboard.details.activity_connect_wallet")}
+
+
+ );
+ }
+
+ if (isConnected && !showActivityContent && !activityActions.isPending) {
+ return (
+
+
+
+ );
+ }
+
+ if (
+ isConnected &&
+ activityActions.isPending &&
+ !activityActions.isFetchingNextPage
+ ) {
+ return (
+
+ {[...Array(5).keys()].map((item) => (
+
+ ))}
+
+ );
+ }
+
+ return null;
+ }, [
+ isConnected,
+ isConnecting,
+ showActivityContent,
+ activityActions.isPending,
+ activityActions.isFetchingNextPage,
+ t,
+ ]);
+
+ return {
+ content,
+ onActionSelect,
+ showingCount,
+ total,
+ allData,
+ filterOptions,
+ selectedFilter,
+ onFilterSelect: setSelectedFilter,
+ activityActions,
+ showActivityContent,
+ showActivityControls,
+ showActivityList,
+ };
+};
diff --git a/packages/widget/src/pages/details/activity-page/style.css.ts b/packages/widget/src/features/activity/ui/classic/activity-page/style.css.ts
similarity index 100%
rename from packages/widget/src/pages/details/activity-page/style.css.ts
rename to packages/widget/src/features/activity/ui/classic/activity-page/style.css.ts
diff --git a/packages/widget/src/features/activity/ui/classic/activity-page/types.ts b/packages/widget/src/features/activity/ui/classic/activity-page/types.ts
new file mode 100644
index 00000000..0733f6a6
--- /dev/null
+++ b/packages/widget/src/features/activity/ui/classic/activity-page/types.ts
@@ -0,0 +1,11 @@
+import type { YieldAction } from "../../../../../domain/schema/action-models";
+import type {
+ EarnValidator,
+ EarnYieldWithProvider,
+} from "../../../../../domain/schema/earn-models";
+
+export type ActionYieldDto = {
+ actionData: YieldAction;
+ yieldData: EarnYieldWithProvider | null;
+ validatorsData: EarnValidator[];
+};
diff --git a/packages/widget/src/features/activity/ui/dashboard/activity/activity.page.tsx b/packages/widget/src/features/activity/ui/dashboard/activity/activity.page.tsx
new file mode 100644
index 00000000..596beb41
--- /dev/null
+++ b/packages/widget/src/features/activity/ui/dashboard/activity/activity.page.tsx
@@ -0,0 +1,113 @@
+import { useEffect } from "react";
+import { useTranslation } from "react-i18next";
+import { VirtualList } from "../../../../../shared/ui/components/virtual-list";
+import { Box } from "../../../../../shared/ui/primitives/box";
+import { Text } from "../../../../../shared/ui/primitives/typography/text";
+import { useSKWallet } from "../../../../wallet";
+import { FallbackContent } from "../../../../widget-shell";
+import {
+ useActivitySelectedAction,
+ useSetActivitySelection,
+} from "../../../react/use-activity-selection";
+import { ActionListItem } from "../../classic/activity-page/components/action-list-item";
+import { ActivityFilters } from "../../classic/activity-page/components/activity-filters";
+import { useActivityPage } from "../../classic/activity-page/hooks/use-activity-page";
+import { container } from "./styles.css";
+
+const ActivityPageComponent = () => {
+ const {
+ content,
+ allData,
+ filterOptions,
+ selectedFilter,
+ onFilterSelect,
+ showingCount,
+ total,
+ onActionSelect,
+ activityActions,
+ showActivityContent,
+ showActivityControls,
+ showActivityList,
+ } = useActivityPage({ selectionMode: "select" });
+
+ const { t } = useTranslation();
+
+ return (
+
+ {content}
+
+
+ {showActivityContent && (
+ <>
+ {showActivityControls && (
+
+ )}
+
+ {showActivityList ? (
+ <>
+
+
+ {t("activity.showing_count", {
+ showing: showingCount,
+ total,
+ })}
+
+
+
+ 80}
+ itemContent={(_index, item) => (
+
+ )}
+ />
+ >
+ ) : (
+
+
+
+ )}
+ >
+ )}
+
+
+ );
+};
+
+const _ActivityPage = () => {
+ const setActivitySelection = useSetActivitySelection();
+ const { isConnected, network } = useSKWallet();
+
+ const selectedAction = useActivitySelectedAction();
+
+ // biome-ignore lint: false
+ useEffect(() => {
+ setActivitySelection(null);
+ }, [network, setActivitySelection]);
+
+ useEffect(() => {
+ if (!isConnected && selectedAction) {
+ setActivitySelection(null);
+ }
+ }, [isConnected, selectedAction, setActivitySelection]);
+
+ return ;
+};
+
+export const ActivityPage = () => <_ActivityPage />;
diff --git a/packages/widget/src/features/activity/ui/dashboard/activity/index.tsx b/packages/widget/src/features/activity/ui/dashboard/activity/index.tsx
new file mode 100644
index 00000000..c48831c5
--- /dev/null
+++ b/packages/widget/src/features/activity/ui/dashboard/activity/index.tsx
@@ -0,0 +1,59 @@
+import { Outlet, useNavigate } from "react-router";
+import { useWidgetConfig } from "../../../../../app/config";
+import { combineRecipeWithVariant } from "../../../../../shared/styles/recipe-variant";
+import { Box } from "../../../../../shared/ui/primitives/box";
+import { CaretLeftIcon } from "../../../../../shared/ui/primitives/icons/caret-left";
+import { AnimationPage } from "../../../../widget-shell";
+import {
+ useActivitySelectedAction,
+ useSetActivitySelection,
+} from "../../../react/use-activity-selection";
+import { ActivityPage } from "./activity.page";
+import { activityDetailsContainer } from "./styles.css";
+
+export const ActivityTabPage = () => {
+ const variant = useWidgetConfig("variant");
+ const navigate = useNavigate();
+ const selectedAction = useActivitySelectedAction();
+ const setActivitySelection = useSetActivitySelection();
+
+ const showDetails = selectedAction !== null;
+
+ const onBack = () => {
+ setActivitySelection(null);
+ navigate("/activity");
+ };
+
+ return (
+
+
+ {showDetails ? (
+ <>
+
+
+
+
+
+
+
+ >
+ ) : (
+
+
+
+ )}
+
+
+ );
+};
diff --git a/packages/widget/src/features/activity/ui/dashboard/activity/styles.css.ts b/packages/widget/src/features/activity/ui/dashboard/activity/styles.css.ts
new file mode 100644
index 00000000..d989e51e
--- /dev/null
+++ b/packages/widget/src/features/activity/ui/dashboard/activity/styles.css.ts
@@ -0,0 +1,31 @@
+import { style } from "@vanilla-extract/css";
+import { recipe } from "@vanilla-extract/recipes";
+import { atoms } from "../../../../../shared/styles/theme/atoms.css";
+
+export const container = style({
+ minHeight: "300px",
+});
+
+export const activityDetailsContainer = recipe({
+ base: [
+ atoms({ background: "dashboardDetailsSectionBackground" }),
+ {
+ borderRadius: "16px",
+ minHeight: "400px",
+ display: "flex",
+ flexDirection: "row",
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ ],
+ variants: {
+ variant: {
+ default: {},
+ utila: {},
+ porto: {
+ borderRadius: "8px",
+ },
+ finery: {},
+ },
+ },
+});
diff --git a/packages/widget/src/features/borrow/atoms/action-creation.ts b/packages/widget/src/features/borrow/atoms/action-creation.ts
new file mode 100644
index 00000000..dc3f7742
--- /dev/null
+++ b/packages/widget/src/features/borrow/atoms/action-creation.ts
@@ -0,0 +1,35 @@
+import { Data, Effect } from "effect";
+import { appRuntime } from "../../../app/runtime";
+import type { Action, ActionRequest } from "../../../domain/borrow";
+import { BorrowApiService } from "../../../services/api/borrow-api-service";
+
+export class BorrowActionCreationError extends Data.TaggedError(
+ "BorrowActionCreationError"
+)<{
+ readonly cause?: unknown;
+ readonly message: string;
+}> {}
+
+const terminalStatuses = new Set(["FAILED", "CANCELED", "STALE"]);
+
+const validateCreatedAction = (action: Action) =>
+ terminalStatuses.has(action.status)
+ ? Effect.fail(
+ new BorrowActionCreationError({
+ message: `Borrow action ended with ${action.status} status.`,
+ })
+ )
+ : Effect.succeed(action);
+
+export const borrowCreateActionAtom = appRuntime.fn((request: ActionRequest) =>
+ BorrowApiService.use((api) => api.executeAction(request)).pipe(
+ Effect.mapError(
+ (cause) =>
+ new BorrowActionCreationError({
+ cause,
+ message: "Borrow action could not be created.",
+ })
+ ),
+ Effect.flatMap(validateCreatedAction)
+ )
+);
diff --git a/packages/widget/src/features/borrow/atoms/action-form.ts b/packages/widget/src/features/borrow/atoms/action-form.ts
new file mode 100644
index 00000000..a87d1bf6
--- /dev/null
+++ b/packages/widget/src/features/borrow/atoms/action-form.ts
@@ -0,0 +1,141 @@
+import * as Atom from "effect/unstable/reactivity/Atom";
+import type {
+ Action,
+ ActionRequest,
+ CollateralToken,
+ DebtBalance,
+ DisableCollateralPendingAction,
+ EnableCollateralPendingAction,
+ Position,
+ RepayPendingAction,
+ SupplyBalance,
+ WithdrawPendingAction,
+} from "../../../domain/borrow";
+
+export type BorrowWithdrawTokenOption = {
+ readonly action: WithdrawPendingAction;
+ readonly collateralToken: CollateralToken;
+ readonly supplyBalance: SupplyBalance;
+};
+
+export type BorrowPositionPendingActionContext =
+ | {
+ readonly action: RepayPendingAction;
+ readonly debtBalance: DebtBalance;
+ readonly position: Position;
+ readonly type: "repay";
+ }
+ | {
+ readonly position: Position;
+ readonly tokens: ReadonlyArray;
+ readonly type: "withdraw";
+ }
+ | {
+ readonly action:
+ | DisableCollateralPendingAction
+ | EnableCollateralPendingAction;
+ readonly position: Position;
+ readonly supplyBalance: SupplyBalance;
+ readonly type: "disableCollateral" | "enableCollateral";
+ };
+
+export type BorrowActionFormReviewState = {
+ readonly request: ActionRequest;
+ readonly summary: {
+ readonly action:
+ | "borrow"
+ | "borrowAndSupply"
+ | "disableCollateral"
+ | "enableCollateral"
+ | "repay"
+ | "supply"
+ | "withdraw";
+ readonly borrowAmount?: string;
+ readonly collateralAmount?: string;
+ readonly collateralTokenSymbol?: string;
+ readonly existingCollateralUsd?: string;
+ readonly existingDebtUsd?: string;
+ readonly loanTokenSymbol?: string;
+ readonly marketLabel: string;
+ readonly network: string;
+ readonly projectedCollateralUsd?: string;
+ readonly projectedDebtUsd?: string;
+ readonly projectedHealthFactor?: string;
+ readonly projectedLtv?: string;
+ readonly providerName: string;
+ };
+};
+
+export type BorrowActionFormExecutionState = BorrowActionFormReviewState & {
+ readonly action: Action;
+};
+
+export type BorrowActionFormState =
+ | {
+ readonly type: "idle";
+ }
+ | {
+ readonly context: BorrowPositionPendingActionContext;
+ readonly type: "positionAction";
+ }
+ | {
+ readonly reviewState: BorrowActionFormReviewState;
+ readonly type: "review";
+ }
+ | {
+ readonly executionState: BorrowActionFormExecutionState;
+ readonly type: "execution";
+ };
+
+export type BorrowActionFormAction =
+ | {
+ readonly context: BorrowPositionPendingActionContext;
+ readonly type: "preparePositionAction";
+ }
+ | {
+ readonly reviewState: BorrowActionFormReviewState;
+ readonly type: "prepareReview";
+ }
+ | {
+ readonly executionState: BorrowActionFormExecutionState;
+ readonly type: "prepareExecution";
+ }
+ | {
+ readonly type: "reset";
+ };
+
+const defaultBorrowActionFormState: BorrowActionFormState = {
+ type: "idle",
+};
+
+export const borrowActionFormAtom = Atom.writable<
+ BorrowActionFormState,
+ BorrowActionFormAction
+>(
+ () => defaultBorrowActionFormState,
+ (context, action) => {
+ switch (action.type) {
+ case "preparePositionAction":
+ context.setSelf({
+ context: action.context,
+ type: "positionAction",
+ });
+ return;
+ case "prepareReview":
+ context.setSelf({
+ reviewState: action.reviewState,
+ type: "review",
+ });
+ return;
+ case "prepareExecution":
+ context.setSelf({
+ executionState: action.executionState,
+ type: "execution",
+ });
+ return;
+ case "reset":
+ context.setSelf(defaultBorrowActionFormState);
+ return;
+ }
+ }
+);
diff --git a/packages/widget/src/features/borrow/atoms/form.ts b/packages/widget/src/features/borrow/atoms/form.ts
new file mode 100644
index 00000000..14eb9109
--- /dev/null
+++ b/packages/widget/src/features/borrow/atoms/form.ts
@@ -0,0 +1,552 @@
+import BigNumber from "bignumber.js";
+import { Data } from "effect";
+import type { AsyncResult as AtomAsyncResult } from "effect/unstable/reactivity/AsyncResult";
+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
+import * as Atom from "effect/unstable/reactivity/Atom";
+import {
+ type BorrowNetwork,
+ buildBorrowActionRequest,
+ type CollateralToken,
+ decodeBorrowForm,
+ type Integration,
+ isBorrowNetwork,
+ type Market,
+ type Position,
+ projectLtvRatio,
+} from "../../../domain/borrow";
+import type { TokenBalance } from "../../../domain/schema/financial-models";
+import type { WalletAddress } from "../../../domain/schema/identifiers";
+import { tokenBalancesScanAtom } from "../../portfolio";
+import { currentWalletStateAtom } from "../../wallet";
+import {
+ type BorrowMarketWalletBalances,
+ deriveBorrowMarketWalletBalances,
+} from "../balances";
+import {
+ type BorrowAtomResultError,
+ BorrowMarketsKey,
+ BorrowPositionsKey,
+ borrowIntegrationsAtom,
+ borrowMarketsAtom,
+ borrowPositionsAtom,
+} from "./resources";
+
+export type BorrowFormIntent = {
+ readonly borrowAmount: string;
+ readonly collateralAmount: string;
+ readonly selectedCollateralTokenAddress: string | null;
+ readonly selectedMarketId: string | null;
+};
+
+export type BorrowFormAction =
+ | {
+ readonly type: "borrowAmount/set";
+ readonly amount: BigNumber | number | string;
+ }
+ | {
+ readonly type: "collateralAmount/set";
+ readonly amount: BigNumber | number | string;
+ }
+ | {
+ readonly type: "collateralToken/select";
+ readonly tokenAddress: string | null;
+ }
+ | {
+ readonly type: "market/select";
+ readonly marketId: string;
+ }
+ | {
+ readonly type: "reset";
+ };
+
+export type BorrowPreparedReviewState = {
+ readonly request: ReturnType;
+ readonly summary: {
+ readonly action: "borrow" | "borrowAndSupply" | "supply";
+ readonly borrowAmount?: string;
+ readonly collateralAmount?: string;
+ readonly collateralTokenSymbol?: string;
+ readonly existingCollateralUsd?: string;
+ readonly existingDebtUsd?: string;
+ readonly projectedCollateralUsd?: string;
+ readonly projectedDebtUsd?: string;
+ readonly projectedHealthFactor?: string;
+ readonly projectedLtv?: string;
+ readonly loanTokenSymbol?: string;
+ readonly marketLabel: string;
+ readonly network: string;
+ readonly providerName: string;
+ };
+};
+
+export type BorrowFormValidation = {
+ readonly borrowAmountGreaterThanAvailable: boolean;
+ readonly collateralAmountGreaterThanBalance: boolean;
+ readonly hasAmounts: boolean;
+ readonly hasValidationError: boolean;
+ readonly ltvGreaterThanMax: boolean;
+};
+
+export type BorrowFormProjection = {
+ readonly borrowMaxAmount: BigNumber;
+ readonly borrowUsd: BigNumber;
+ readonly collateralMaxAmount: BigNumber;
+ readonly collateralUsd: BigNumber;
+ readonly existingCollateralUsd: BigNumber;
+ readonly existingDebtUsd: BigNumber;
+ readonly maxLtv: number | null;
+ readonly projectedCollateralUsd: BigNumber;
+ readonly projectedDebtUsd: BigNumber;
+ readonly projectedHealthFactor: number | null;
+ readonly projectedLtv: number;
+};
+
+export type BorrowDashboardView = {
+ readonly borrowAmount: BigNumber;
+ readonly collateralAmount: BigNumber;
+ readonly integrationsResult: AtomAsyncResult<
+ ReadonlyArray,
+ BorrowAtomResultError
+ >;
+ readonly isActionReady: boolean;
+ readonly markets: ReadonlyArray;
+ readonly marketsResult: AtomAsyncResult<
+ ReadonlyArray,
+ BorrowAtomResultError
+ >;
+ readonly preparedReviewState: BorrowPreparedReviewState | null;
+ readonly projection: BorrowFormProjection;
+ readonly selectedCollateralBalance:
+ | BorrowMarketWalletBalances["selectedCollateralToken"]
+ | null;
+ readonly selectedCollateralToken: CollateralToken | null;
+ readonly selectedCollateralTokenAddress: string | null;
+ readonly selectedIntegration: Integration | null;
+ readonly selectedMarket: Market | null;
+ readonly selectedMarketPosition: Position | null;
+ readonly selectedMarketId: string | null;
+ readonly validation: BorrowFormValidation;
+ readonly walletBalances: BorrowMarketWalletBalances | null;
+};
+
+export class BorrowFormScopeKey extends Data.Class<{
+ readonly scopeId: string;
+}> {}
+
+export class BorrowDashboardKey extends Data.Class<{
+ readonly network: BorrowNetwork;
+ readonly walletAddress: WalletAddress;
+}> {}
+
+export const makeDefaultBorrowFormIntent = (): BorrowFormIntent => ({
+ borrowAmount: "0",
+ collateralAmount: "0",
+ selectedCollateralTokenAddress: null,
+ selectedMarketId: null,
+});
+
+const toAmountString = (amount: BigNumber | number | string) =>
+ new BigNumber(amount).toString(10);
+
+export const applyBorrowFormAction = ({
+ action,
+ intent,
+}: {
+ readonly action: BorrowFormAction;
+ readonly intent: BorrowFormIntent;
+}): BorrowFormIntent => {
+ switch (action.type) {
+ case "borrowAmount/set":
+ return {
+ ...intent,
+ borrowAmount: toAmountString(action.amount),
+ };
+ case "collateralAmount/set":
+ return {
+ ...intent,
+ collateralAmount: toAmountString(action.amount),
+ };
+ case "collateralToken/select":
+ return {
+ ...intent,
+ collateralAmount: "0",
+ selectedCollateralTokenAddress: action.tokenAddress,
+ };
+ case "market/select":
+ return {
+ ...intent,
+ borrowAmount: "0",
+ collateralAmount: "0",
+ selectedCollateralTokenAddress: null,
+ selectedMarketId: action.marketId,
+ };
+ case "reset":
+ return makeDefaultBorrowFormIntent();
+ }
+};
+
+const borrowFormIntentAtom = Atom.family((_scope: BorrowFormScopeKey) =>
+ Atom.make(makeDefaultBorrowFormIntent())
+);
+
+const getSelectedMarket = ({
+ intent,
+ markets,
+}: {
+ readonly intent: BorrowFormIntent;
+ readonly markets: ReadonlyArray;
+}) =>
+ markets.find((market) => market.id === intent.selectedMarketId) ??
+ markets[0] ??
+ null;
+
+const getSelectedCollateralToken = ({
+ intent,
+ selectedMarket,
+}: {
+ readonly intent: BorrowFormIntent;
+ readonly selectedMarket: Market | null;
+}) => {
+ if (!selectedMarket) {
+ return null;
+ }
+
+ return (
+ selectedMarket.collateralTokens.find(
+ (collateralToken) =>
+ collateralToken.token.address === intent.selectedCollateralTokenAddress
+ ) ??
+ selectedMarket.collateralTokens[0] ??
+ null
+ );
+};
+
+const getPreparedReviewState = ({
+ borrowAmount,
+ collateralAmount,
+ isActionReady,
+ projection,
+ selectedCollateralToken,
+ selectedIntegration,
+ selectedMarket,
+ walletAddress,
+}: {
+ readonly borrowAmount: BigNumber;
+ readonly collateralAmount: BigNumber;
+ readonly isActionReady: boolean;
+ readonly projection: BorrowFormProjection;
+ readonly selectedCollateralToken: CollateralToken | null;
+ readonly selectedIntegration: Integration | null;
+ readonly selectedMarket: Market | null;
+ readonly walletAddress: WalletAddress;
+}): BorrowPreparedReviewState | null => {
+ if (!isActionReady || !selectedMarket || !selectedCollateralToken) {
+ return null;
+ }
+
+ const form = decodeBorrowForm({
+ borrowAmount,
+ collateralAmount,
+ selectedCollateralToken,
+ selectedMarket,
+ });
+
+ if (!form) {
+ return null;
+ }
+
+ const summaryAction =
+ form._tag === "BorrowPlusCollateral"
+ ? "borrowAndSupply"
+ : form._tag === "BorrowOnly"
+ ? "borrow"
+ : "supply";
+
+ return {
+ request: buildBorrowActionRequest({
+ address: walletAddress,
+ form,
+ }),
+ summary: {
+ action: summaryAction,
+ ...(form._tag !== "CollateralOnly"
+ ? {
+ borrowAmount: form.borrowAmount.toString(10),
+ loanTokenSymbol: selectedMarket.loanToken.symbol,
+ }
+ : {}),
+ ...(form._tag !== "BorrowOnly"
+ ? {
+ collateralAmount: form.collateralAmount.toString(10),
+ collateralTokenSymbol: form.selectedCollateralToken.token.symbol,
+ }
+ : {}),
+ existingCollateralUsd: projection.existingCollateralUsd.toString(10),
+ existingDebtUsd: projection.existingDebtUsd.toString(10),
+ projectedCollateralUsd: projection.projectedCollateralUsd.toString(10),
+ projectedDebtUsd: projection.projectedDebtUsd.toString(10),
+ ...(projection.projectedHealthFactor == null
+ ? {}
+ : {
+ projectedHealthFactor: projection.projectedHealthFactor.toString(),
+ }),
+ projectedLtv: projection.projectedLtv.toString(),
+ marketLabel: selectedCollateralToken
+ ? `${selectedCollateralToken.token.symbol} / ${selectedMarket.loanToken.symbol}`
+ : selectedMarket.loanToken.symbol,
+ network: selectedMarket.network,
+ providerName: selectedIntegration?.name ?? selectedMarket.integrationId,
+ },
+ };
+};
+
+export const resolveBorrowDashboardView = ({
+ integrationsResult,
+ intent,
+ key,
+ marketsResult,
+ positionsResult = AsyncResult.success([]),
+ tokenBalances,
+}: {
+ readonly integrationsResult: AtomAsyncResult<
+ ReadonlyArray,
+ BorrowAtomResultError
+ >;
+ readonly intent: BorrowFormIntent;
+ readonly key: BorrowDashboardKey;
+ readonly marketsResult: AtomAsyncResult<
+ ReadonlyArray,
+ BorrowAtomResultError
+ >;
+ readonly positionsResult?: AtomAsyncResult<
+ ReadonlyArray,
+ BorrowAtomResultError
+ >;
+ readonly tokenBalances: ReadonlyArray;
+}): BorrowDashboardView => {
+ const markets = AsyncResult.getOrElse(marketsResult, () => []);
+ const integrations = AsyncResult.getOrElse(integrationsResult, () => []);
+ const positions = AsyncResult.getOrElse(positionsResult, () => []);
+ const selectedMarket = getSelectedMarket({ intent, markets });
+ const selectedMarketId = selectedMarket?.id ?? null;
+ const selectedMarketPosition = selectedMarket
+ ? (positions.find((position) => position.id === selectedMarket.id) ?? null)
+ : null;
+ const selectedCollateralToken = getSelectedCollateralToken({
+ intent,
+ selectedMarket,
+ });
+ const selectedCollateralTokenAddress =
+ selectedCollateralToken?.token.address ?? null;
+ const integrationsById = new Map(
+ integrations.map((integration) => [integration.id, integration])
+ );
+ const selectedIntegration = selectedMarket
+ ? (integrationsById.get(selectedMarket.integrationId) ?? null)
+ : null;
+ const walletBalances = selectedMarket
+ ? deriveBorrowMarketWalletBalances({
+ balances: tokenBalances,
+ market: selectedMarket,
+ selectedCollateralTokenAddress,
+ })
+ : null;
+ const selectedCollateralBalance =
+ walletBalances?.selectedCollateralToken ?? null;
+ const borrowAmount = new BigNumber(intent.borrowAmount || 0);
+ const collateralAmount = new BigNumber(intent.collateralAmount || 0);
+ const borrowMaxAmount = new BigNumber(
+ selectedMarket?.availableLiquidity ?? 0
+ );
+ const collateralMaxAmount =
+ selectedCollateralBalance?.amountValue ?? new BigNumber(0);
+ const borrowAmountGreaterThanAvailable =
+ !!selectedMarket && borrowAmount.gt(borrowMaxAmount);
+ const collateralAmountGreaterThanBalance =
+ !!selectedMarket && collateralAmount.gt(collateralMaxAmount);
+ const borrowUsd = selectedMarket
+ ? borrowAmount.multipliedBy(selectedMarket.loanTokenPriceUsd)
+ : new BigNumber(0);
+ const collateralUsd =
+ selectedCollateralToken == null
+ ? new BigNumber(0)
+ : collateralAmount.multipliedBy(selectedCollateralToken.priceUsd);
+ const existingCollateralUsd = new BigNumber(
+ selectedMarketPosition?.getTotalCollateralUsd() ?? 0
+ );
+ const existingDebtUsd = new BigNumber(
+ selectedMarketPosition?.getTotalBorrowedUsd() ?? 0
+ );
+ const projectedCollateralUsd = existingCollateralUsd.plus(collateralUsd);
+ const projectedDebtUsd = existingDebtUsd.plus(borrowUsd);
+ const projectedLtv = projectLtvRatio({
+ collateralUsd: projectedCollateralUsd.toNumber(),
+ debtUsd: projectedDebtUsd.toNumber(),
+ });
+ const existingCollateralDetails =
+ selectedMarketPosition?.getCollateralTokenDetails();
+ const existingMaxLtv = existingCollateralDetails?.maxLtv;
+ const maxLtvCandidate =
+ selectedCollateralToken?.maxLtv ??
+ (existingMaxLtv != null && Number.isFinite(existingMaxLtv)
+ ? existingMaxLtv
+ : selectedMarket?.getMaxLtv());
+ const maxLtv =
+ maxLtvCandidate != null && Number.isFinite(maxLtvCandidate)
+ ? maxLtvCandidate
+ : null;
+ const liquidationThresholdCandidate =
+ selectedCollateralToken?.liquidationThreshold ??
+ existingCollateralDetails?.liquidationThreshold ??
+ selectedMarket?.getLiquidationThreshold();
+ const liquidationThreshold =
+ liquidationThresholdCandidate != null &&
+ Number.isFinite(liquidationThresholdCandidate)
+ ? liquidationThresholdCandidate
+ : null;
+ const projectedHealthFactor =
+ projectedLtv > 0 && liquidationThreshold != null
+ ? liquidationThreshold / projectedLtv
+ : null;
+ const hasAmounts = borrowAmount.gt(0) || collateralAmount.gt(0);
+ const ltvGreaterThanMax =
+ maxLtv != null && hasAmounts && projectedLtv > maxLtv;
+ const hasValidationError =
+ borrowAmountGreaterThanAvailable ||
+ collateralAmountGreaterThanBalance ||
+ ltvGreaterThanMax;
+ const isActionReady =
+ !!selectedMarket &&
+ !!selectedCollateralToken &&
+ hasAmounts &&
+ !hasValidationError;
+
+ return {
+ borrowAmount,
+ collateralAmount,
+ integrationsResult,
+ isActionReady,
+ markets,
+ marketsResult,
+ preparedReviewState: getPreparedReviewState({
+ borrowAmount,
+ collateralAmount,
+ isActionReady,
+ projection: {
+ borrowMaxAmount,
+ borrowUsd,
+ collateralMaxAmount,
+ collateralUsd,
+ existingCollateralUsd,
+ existingDebtUsd,
+ maxLtv,
+ projectedCollateralUsd,
+ projectedDebtUsd,
+ projectedHealthFactor,
+ projectedLtv,
+ },
+ selectedCollateralToken,
+ selectedIntegration,
+ selectedMarket,
+ walletAddress: key.walletAddress,
+ }),
+ projection: {
+ borrowMaxAmount,
+ borrowUsd,
+ collateralMaxAmount,
+ collateralUsd,
+ existingCollateralUsd,
+ existingDebtUsd,
+ maxLtv,
+ projectedCollateralUsd,
+ projectedDebtUsd,
+ projectedHealthFactor,
+ projectedLtv,
+ },
+ selectedCollateralBalance,
+ selectedCollateralToken,
+ selectedCollateralTokenAddress,
+ selectedIntegration,
+ selectedMarket,
+ selectedMarketPosition,
+ selectedMarketId,
+ validation: {
+ borrowAmountGreaterThanAvailable,
+ collateralAmountGreaterThanBalance,
+ hasAmounts,
+ hasValidationError,
+ ltvGreaterThanMax,
+ },
+ walletBalances,
+ };
+};
+
+export const borrowDashboardAtom = Atom.family((key: BorrowDashboardKey) => {
+ const scope = new BorrowFormScopeKey({
+ scopeId: `${key.walletAddress}:${key.network}`,
+ });
+
+ return Atom.writable(
+ (context) =>
+ resolveBorrowDashboardView({
+ integrationsResult: context.get(borrowIntegrationsAtom),
+ intent: context.get(borrowFormIntentAtom(scope)),
+ key,
+ marketsResult: context.get(
+ borrowMarketsAtom(new BorrowMarketsKey({ network: key.network }))
+ ),
+ positionsResult: context.get(
+ borrowPositionsAtom(
+ new BorrowPositionsKey({
+ address: key.walletAddress,
+ network: key.network,
+ })
+ )
+ ),
+ tokenBalances:
+ AsyncResult.getOrElse(
+ context.get(tokenBalancesScanAtom).result,
+ () => []
+ ) ?? [],
+ }),
+ (context, action) => {
+ const intentAtom = borrowFormIntentAtom(scope);
+ const intent = context.get(intentAtom);
+
+ context.set(
+ intentAtom,
+ applyBorrowFormAction({
+ action,
+ intent,
+ })
+ );
+ }
+ );
+});
+
+const currentBorrowDashboardKeyAtom = Atom.make((get) => {
+ const wallet = get(currentWalletStateAtom);
+
+ return wallet.status === "connected" && isBorrowNetwork(wallet.network)
+ ? new BorrowDashboardKey({
+ network: wallet.network,
+ walletAddress: wallet.address,
+ })
+ : null;
+}).pipe(Atom.withLabel("currentBorrowDashboardKeyAtom"));
+
+export const currentBorrowDashboardAtom = Atom.writable<
+ BorrowDashboardView | null,
+ BorrowFormAction
+>(
+ (context) => {
+ const key = context.get(currentBorrowDashboardKeyAtom);
+
+ return key ? context.get(borrowDashboardAtom(key)) : null;
+ },
+ (context, action) => {
+ const key = context.get(currentBorrowDashboardKeyAtom);
+
+ if (key) context.set(borrowDashboardAtom(key), action);
+ }
+).pipe(Atom.withLabel("currentBorrowDashboardAtom"));
diff --git a/packages/widget/src/features/borrow/atoms/index.ts b/packages/widget/src/features/borrow/atoms/index.ts
new file mode 100644
index 00000000..6508a419
--- /dev/null
+++ b/packages/widget/src/features/borrow/atoms/index.ts
@@ -0,0 +1,5 @@
+export * from "./action-creation";
+export * from "./action-form";
+export * from "./form";
+export * from "./refresh";
+export * from "./resources";
diff --git a/packages/widget/src/features/borrow/atoms/refresh.ts b/packages/widget/src/features/borrow/atoms/refresh.ts
new file mode 100644
index 00000000..fe95e3cc
--- /dev/null
+++ b/packages/widget/src/features/borrow/atoms/refresh.ts
@@ -0,0 +1,116 @@
+import { Effect, Stream } from "effect";
+import * as Atom from "effect/unstable/reactivity/Atom";
+import { appRuntime } from "../../../app/runtime";
+import { type Action, isBorrowNetwork } from "../../../domain/borrow";
+import {
+ tokenBalancesScanResourceAtom,
+ yieldBalancesScanResourceAtom,
+} from "../../../features/portfolio";
+import { transactionWorkflowMachineAtom } from "../../../features/transaction-flow/state/transaction-workflow-atoms";
+import type { NormalizedWalletState } from "../../../features/wallet";
+import { WalletService } from "../../../services/wallet/wallet-service";
+import type {
+ BorrowTransactionWorkflowKey,
+ TransactionWorkflowEvent,
+} from "../../../services/workflow/transaction-workflow-model";
+import { refreshAtomResources } from "../../../shared/effect/api-resource";
+import {
+ BorrowMarketsKey,
+ BorrowPositionsKey,
+ borrowIntegrationsAtom,
+ borrowMarketsAtom,
+ borrowPositionsAtom,
+} from "./resources";
+
+const getBorrowEventAction = (
+ event: TransactionWorkflowEvent
+): Action | null => {
+ if (
+ event._tag === "TransactionWorkflowSigned" ||
+ event.context.domain._tag !== "Borrow"
+ ) {
+ return null;
+ }
+
+ return event.context.domain.action;
+};
+
+const getBorrowRefreshNetwork = (event: TransactionWorkflowEvent) => {
+ const action = getBorrowEventAction(event);
+ const network =
+ event._tag === "TransactionWorkflowSubmitted" &&
+ event.submission.source._tag === "Borrow"
+ ? event.submission.source.transaction.network
+ : action?.transactions[0]?.network;
+
+ return network && isBorrowNetwork(network) ? network : null;
+};
+
+export const getBorrowExecutionRefreshResources = (
+ event: TransactionWorkflowEvent,
+ walletState: NormalizedWalletState
+): ReadonlyArray> => {
+ const action = getBorrowEventAction(event);
+
+ if (!action) return [];
+
+ const network = getBorrowRefreshNetwork(event);
+ const resources: Array> = [borrowIntegrationsAtom];
+
+ if (!network) return resources;
+
+ resources.push(borrowMarketsAtom(new BorrowMarketsKey({ network })));
+ resources.push(
+ borrowPositionsAtom(
+ new BorrowPositionsKey({
+ address: action.address,
+ network,
+ })
+ )
+ );
+
+ if (
+ walletState.status === "connected" &&
+ walletState.address === action.address &&
+ walletState.network === network
+ ) {
+ resources.push(
+ tokenBalancesScanResourceAtom,
+ yieldBalancesScanResourceAtom
+ );
+ }
+
+ return resources;
+};
+
+export const borrowExecutionRefreshAtom = Atom.family(
+ (key: BorrowTransactionWorkflowKey) => {
+ const machineAtom = transactionWorkflowMachineAtom(key);
+
+ return appRuntime.atom(
+ (context) =>
+ Effect.all([context.result(machineAtom), WalletService]).pipe(
+ Effect.map(([machine, wallet]) =>
+ machine.events.pipe(
+ Stream.filter(
+ (event) =>
+ event._tag === "TransactionWorkflowSubmitted" ||
+ event._tag === "TransactionWorkflowCompleted"
+ ),
+ Stream.tap((event) =>
+ Effect.sync(() =>
+ refreshAtomResources(
+ context,
+ getBorrowExecutionRefreshResources(event, wallet.getState())
+ )
+ )
+ ),
+ Stream.map(() => undefined)
+ )
+ ),
+ Stream.unwrap
+ ),
+ { initialValue: undefined }
+ );
+ }
+);
diff --git a/packages/widget/src/features/borrow/atoms/resources.ts b/packages/widget/src/features/borrow/atoms/resources.ts
new file mode 100644
index 00000000..eb79ce15
--- /dev/null
+++ b/packages/widget/src/features/borrow/atoms/resources.ts
@@ -0,0 +1,169 @@
+import { Data, Duration, Effect } from "effect";
+import * as Atom from "effect/unstable/reactivity/Atom";
+import { appRuntime } from "../../../app/runtime";
+import {
+ type BorrowNetwork,
+ deriveBorrowPositionItems,
+ type MarketId,
+ type Position,
+} from "../../../domain/borrow";
+import type { WalletAddress } from "../../../domain/schema/identifiers";
+import { BorrowApiService } from "../../../services/api/borrow-api-service";
+import { withApiResourcePolicy } from "../../../shared/effect/api-resource";
+import type { MissingBorrowApiClient } from "../runtime";
+
+export type BorrowAtomOperation =
+ | "borrow-integrations"
+ | "borrow-markets"
+ | "borrow-position"
+ | "borrow-positions";
+
+export class BorrowAtomError extends Data.TaggedError("BorrowAtomError")<{
+ readonly cause: unknown;
+ readonly operation: BorrowAtomOperation;
+}> {}
+
+export class BorrowPositionNotFound extends Data.TaggedError(
+ "BorrowPositionNotFound"
+)<{
+ readonly marketId: string;
+}> {}
+
+export type BorrowAtomResultError = BorrowAtomError | MissingBorrowApiClient;
+
+export class BorrowMarketsKey extends Data.Class<{
+ readonly network: BorrowNetwork;
+}> {}
+
+export class BorrowPositionsKey extends Data.Class<{
+ readonly address: WalletAddress | null;
+ readonly network: BorrowNetwork | null;
+}> {}
+
+export class BorrowPositionKey extends Data.Class<{
+ readonly address: WalletAddress | null;
+ readonly marketId: MarketId | string | null;
+ readonly network: BorrowNetwork | null;
+}> {}
+
+const DEFAULT_PAGE_SIZE = 100;
+const borrowSWR = withApiResourcePolicy({
+ staleTime: Duration.minutes(1),
+ idleTTL: Duration.minutes(5),
+ revalidateOnMount: true,
+});
+
+const withBorrowAtomError =
+ (operation: BorrowAtomOperation) =>
+ (effect: Effect.Effect) =>
+ effect.pipe(
+ Effect.mapError(
+ (cause) =>
+ new BorrowAtomError({
+ cause,
+ operation,
+ })
+ )
+ );
+
+export const borrowIntegrationsAtom = appRuntime
+ .atom(() =>
+ Effect.gen(function* () {
+ const api = yield* BorrowApiService;
+ return yield* api.getIntegrations();
+ }).pipe(withBorrowAtomError("borrow-integrations"))
+ )
+ .pipe(borrowSWR);
+
+export const borrowMarketsAtom = Atom.family((key: BorrowMarketsKey) =>
+ appRuntime
+ .atom(() =>
+ Effect.gen(function* () {
+ const api = yield* BorrowApiService;
+ const page = yield* api.getMarkets({
+ limit: DEFAULT_PAGE_SIZE,
+ network: key.network,
+ offset: 0,
+ scope: "all",
+ });
+
+ return page.items ?? [];
+ }).pipe(withBorrowAtomError("borrow-markets"))
+ )
+ .pipe(borrowSWR)
+);
+
+const loadBorrowPositions = ({
+ address,
+ network,
+}: {
+ readonly address: WalletAddress;
+ readonly network: BorrowNetwork;
+}) =>
+ Effect.gen(function* () {
+ const api = yield* BorrowApiService;
+ const integrations = (yield* api.getIntegrations()).filter((integration) =>
+ integration.networks.includes(network)
+ );
+ const marketsPage = yield* api.getMarkets({
+ limit: DEFAULT_PAGE_SIZE,
+ network,
+ offset: 0,
+ scope: "all",
+ });
+ const integrationPositions = yield* api.getPositionData({
+ address,
+ integrations,
+ network,
+ });
+
+ return deriveBorrowPositionItems({
+ integrationPositions,
+ markets: marketsPage.items ?? [],
+ });
+ });
+
+export const borrowPositionsAtom = Atom.family((key: BorrowPositionsKey) =>
+ appRuntime
+ .atom(() => {
+ if (!key.address || !key.network) {
+ return Effect.succeed([] as Position[]);
+ }
+
+ return loadBorrowPositions({
+ address: key.address,
+ network: key.network,
+ }).pipe(withBorrowAtomError("borrow-positions"));
+ })
+ .pipe(borrowSWR)
+);
+
+export const borrowPositionAtom = Atom.family((key: BorrowPositionKey) =>
+ appRuntime
+ .atom(() =>
+ Effect.gen(function* () {
+ if (!key.address || !key.network || !key.marketId) {
+ return yield* new BorrowPositionNotFound({
+ marketId: key.marketId ?? "",
+ });
+ }
+
+ const positions = yield* loadBorrowPositions({
+ address: key.address,
+ network: key.network,
+ });
+ const position = positions.find(
+ (candidate) => candidate.id === key.marketId
+ );
+
+ if (!position) {
+ return yield* new BorrowPositionNotFound({
+ marketId: key.marketId,
+ });
+ }
+
+ return position;
+ }).pipe(withBorrowAtomError("borrow-position"))
+ )
+ .pipe(borrowSWR)
+);
diff --git a/packages/widget/src/features/borrow/availability.ts b/packages/widget/src/features/borrow/availability.ts
new file mode 100644
index 00000000..b0d509aa
--- /dev/null
+++ b/packages/widget/src/features/borrow/availability.ts
@@ -0,0 +1,10 @@
+import type { WidgetConfig } from "../../app/config";
+
+export const isBorrowFeatureEnabled = ({
+ borrowEnabled,
+ dashboardVariant,
+ yieldGrouping,
+}: Pick<
+ WidgetConfig,
+ "borrowEnabled" | "dashboardVariant" | "yieldGrouping"
+>) => borrowEnabled && !!dashboardVariant && yieldGrouping === "category";
diff --git a/packages/widget/src/features/borrow/balances/index.ts b/packages/widget/src/features/borrow/balances/index.ts
new file mode 100644
index 00000000..fd15fa9b
--- /dev/null
+++ b/packages/widget/src/features/borrow/balances/index.ts
@@ -0,0 +1,123 @@
+import BigNumber from "bignumber.js";
+import type {
+ BorrowNetwork,
+ BorrowToken,
+ CollateralToken,
+ Market,
+ TokenAddress,
+} from "../../../domain/borrow";
+import type { TokenBalance } from "../../../domain/schema/financial-models";
+
+type BorrowBalanceToken = Pick<
+ BorrowToken,
+ "address" | "decimals" | "name" | "symbol"
+>;
+
+export type BorrowTokenWalletBalance = {
+ readonly amount: string;
+ readonly amountValue: BigNumber;
+ readonly balance: TokenBalance | null;
+ readonly network: BorrowNetwork;
+ readonly token: BorrowBalanceToken;
+};
+
+export type BorrowCollateralWalletBalance = BorrowTokenWalletBalance & {
+ readonly collateralToken: CollateralToken;
+};
+
+export type BorrowMarketWalletBalances = {
+ readonly loanToken: BorrowTokenWalletBalance;
+ readonly collateralTokens: ReadonlyArray;
+ readonly selectedCollateralToken: BorrowCollateralWalletBalance | null;
+};
+
+const normalizeAddress = (address?: string) => address?.toLowerCase();
+
+const sameAddress = (left?: string, right?: string) => {
+ const normalizedLeft = normalizeAddress(left);
+ const normalizedRight = normalizeAddress(right);
+
+ return (
+ !!normalizedLeft && !!normalizedRight && normalizedLeft === normalizedRight
+ );
+};
+
+const sameNativeToken = (token: BorrowBalanceToken, balance: TokenBalance) =>
+ !token.address &&
+ !balance.token.address &&
+ token.symbol.toLowerCase() === balance.token.symbol.toLowerCase();
+
+export const isBorrowTokenBalanceMatch = ({
+ balance,
+ network,
+ token,
+}: {
+ readonly balance: TokenBalance;
+ readonly network: BorrowNetwork;
+ readonly token: BorrowBalanceToken;
+}) =>
+ balance.token.network === network &&
+ (sameAddress(token.address, balance.token.address) ||
+ sameNativeToken(token, balance));
+
+export const deriveBorrowTokenWalletBalance = ({
+ balances,
+ network,
+ token,
+}: {
+ readonly balances: ReadonlyArray;
+ readonly network: BorrowNetwork;
+ readonly token: BorrowBalanceToken;
+}): BorrowTokenWalletBalance => {
+ const balance =
+ balances.find((candidate) =>
+ isBorrowTokenBalanceMatch({ balance: candidate, network, token })
+ ) ?? null;
+ const amountValue = balance?.amount ?? new BigNumber(0);
+
+ return {
+ amount: amountValue.toFixed(),
+ amountValue,
+ balance,
+ network,
+ token,
+ };
+};
+
+export const deriveBorrowMarketWalletBalances = ({
+ balances,
+ market,
+ selectedCollateralTokenAddress,
+}: {
+ readonly balances: ReadonlyArray;
+ readonly market: Market;
+ readonly selectedCollateralTokenAddress?: TokenAddress | string | null;
+}): BorrowMarketWalletBalances => {
+ const collateralTokens = market.collateralTokens.map((collateralToken) => ({
+ ...deriveBorrowTokenWalletBalance({
+ balances,
+ network: market.network,
+ token: collateralToken.token,
+ }),
+ collateralToken,
+ }));
+ const selectedCollateralToken =
+ (selectedCollateralTokenAddress
+ ? collateralTokens.find((balance) =>
+ sameAddress(
+ balance.collateralToken.token.address,
+ selectedCollateralTokenAddress
+ )
+ )
+ : collateralTokens[0]) ?? null;
+
+ return {
+ loanToken: deriveBorrowTokenWalletBalance({
+ balances,
+ network: market.network,
+ token: market.loanToken,
+ }),
+ collateralTokens,
+ selectedCollateralToken,
+ };
+};
diff --git a/packages/widget/src/features/borrow/core.ts b/packages/widget/src/features/borrow/core.ts
new file mode 100644
index 00000000..5e5e5ac0
--- /dev/null
+++ b/packages/widget/src/features/borrow/core.ts
@@ -0,0 +1,6 @@
+export * from "../../domain/borrow";
+export * from "./atoms";
+export * from "./balances";
+export * from "./runtime";
+export * from "./utils";
+export * from "./wallet";
diff --git a/packages/widget/src/features/borrow/positions.ts b/packages/widget/src/features/borrow/positions.ts
new file mode 100644
index 00000000..203ad4ca
--- /dev/null
+++ b/packages/widget/src/features/borrow/positions.ts
@@ -0,0 +1,4 @@
+export type { Position } from "../../domain/borrow";
+export { borrowTokenToTokenDto } from "./ui/position-details-model";
+export { useBorrowFeatureEnabled } from "./ui/use-borrow-feature-enabled";
+export { useBorrowPositions } from "./ui/use-borrow-positions";
diff --git a/packages/widget/src/features/borrow/runtime/index.ts b/packages/widget/src/features/borrow/runtime/index.ts
new file mode 100644
index 00000000..50193c32
--- /dev/null
+++ b/packages/widget/src/features/borrow/runtime/index.ts
@@ -0,0 +1,7 @@
+import { Data } from "effect";
+
+export class MissingBorrowApiClient extends Data.TaggedError(
+ "MissingBorrowApiClient"
+)<{
+ readonly message: string;
+}> {}
diff --git a/packages/widget/src/features/borrow/ui-entry.ts b/packages/widget/src/features/borrow/ui-entry.ts
new file mode 100644
index 00000000..ef8a5038
--- /dev/null
+++ b/packages/widget/src/features/borrow/ui-entry.ts
@@ -0,0 +1,11 @@
+export { BorrowFormPage, BorrowLayout } from "./ui";
+export { BorrowCompletePage } from "./ui/complete";
+export { BorrowConnectedWalletRoute } from "./ui/connected-wallet";
+export {
+ BorrowPositionActionPage,
+ BorrowPositionActionsPage,
+ BorrowPositionDetailsPage,
+} from "./ui/position-details";
+export { BorrowReviewPage } from "./ui/review";
+export { BorrowStepsPage } from "./ui/steps";
+export { useBorrowFeatureEnabled } from "./ui/use-borrow-feature-enabled";
diff --git a/packages/widget/src/features/borrow/ui/complete.tsx b/packages/widget/src/features/borrow/ui/complete.tsx
new file mode 100644
index 00000000..a6d49bf9
--- /dev/null
+++ b/packages/widget/src/features/borrow/ui/complete.tsx
@@ -0,0 +1,172 @@
+import { useAtomSet } from "@effect/atom-react";
+import { useTranslation } from "react-i18next";
+import { useLocation, useNavigate, useParams } from "react-router";
+import { useTrackPage } from "../../../features/tracking";
+import { AnimationPage } from "../../../features/widget-shell";
+import { Box } from "../../../shared/ui/primitives/box";
+import { Button } from "../../../shared/ui/primitives/button";
+import { CheckCircleIcon } from "../../../shared/ui/primitives/icons/check-circle";
+import { Text } from "../../../shared/ui/primitives/typography/text";
+import { DetailRow } from "../../earn/support";
+import { PageContainer, PageCtaButton } from "../../widget-shell";
+import { borrowActionFormAtom, currentBorrowDashboardAtom } from "../core";
+import { useBorrowConnectedWalletBridge } from "./connected-wallet";
+import { getBorrowFlowRoutes } from "./flow-routes";
+import { isBorrowCompleteState } from "./review-state";
+
+export const BorrowCompletePage = () => {
+ useTrackPage("borrowComplete");
+
+ const { t } = useTranslation();
+ const location = useLocation();
+ const navigate = useNavigate();
+ const { marketId } = useParams();
+ const { basePath } = getBorrowFlowRoutes(marketId);
+ useBorrowConnectedWalletBridge();
+ const resetActionForm = useAtomSet(borrowActionFormAtom);
+ const resetBorrowDashboard = useAtomSet(currentBorrowDashboardAtom);
+ const completeState = isBorrowCompleteState(location.state)
+ ? location.state
+ : null;
+ const onDone = () => {
+ resetActionForm({ type: "reset" });
+ resetBorrowDashboard({ type: "reset" });
+ navigate(basePath, { replace: true });
+ };
+
+ if (!completeState) {
+ return (
+
+
+
+
+ {t("dashboard.borrow.success_page.unavailable_title")}
+
+
+ {t("dashboard.borrow.success_page.unavailable_description")}
+
+ navigate(basePath),
+ }}
+ />
+
+
+
+ );
+ }
+
+ const { result, summary } = completeState;
+ const rows = [
+ summary.borrowAmount && summary.loanTokenSymbol
+ ? {
+ id: "borrow-amount",
+ label: t("dashboard.borrow.review_page.borrow_amount"),
+ value: `${summary.borrowAmount} ${summary.loanTokenSymbol}`,
+ }
+ : null,
+ summary.collateralAmount && summary.collateralTokenSymbol
+ ? {
+ id: "collateral-amount",
+ label: t("dashboard.borrow.review_page.collateral_amount"),
+ value: `${summary.collateralAmount} ${summary.collateralTokenSymbol}`,
+ }
+ : null,
+ {
+ id: "market",
+ label: t("dashboard.borrow.review_page.market"),
+ value: summary.marketLabel,
+ },
+ {
+ id: "provider",
+ label: t("dashboard.borrow.review_page.provider"),
+ value: summary.providerName,
+ },
+ {
+ id: "network",
+ label: t("dashboard.borrow.review_page.network"),
+ value: summary.network,
+ },
+ ].filter((row): row is NonNullable => !!row);
+
+ return (
+
+
+
+
+