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/package.json b/package.json
index 9487ca9e..43eb5ded 100644
--- a/package.json
+++ b/package.json
@@ -23,11 +23,5 @@
"husky": "catalog:",
"rev-dep": "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/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/package.json b/packages/widget/package.json
index c26d1b74..4458d1d4 100644
--- a/packages/widget/package.json
+++ b/packages/widget/package.json
@@ -75,6 +75,7 @@
"@cosmos-kit/keplr": "catalog:",
"@cosmos-kit/leap": "catalog:",
"@cosmos-kit/walletconnect": "catalog:",
+ "@effect/atom-react": "catalog:",
"@effect/openapi-generator": "catalog:",
"@effect/platform-node": "catalog:",
"@faker-js/faker": "catalog:",
@@ -117,7 +118,6 @@
"@vitejs/plugin-react": "catalog:",
"@vitest/browser-playwright": "catalog:",
"@xstate/react": "catalog:",
- "@xstate/store": "catalog:",
"autoprefixer": "catalog:",
"babel-plugin-react-compiler": "catalog:",
"bignumber.js": "catalog:",
diff --git a/packages/widget/scripts/generate-effect-openapi.ts b/packages/widget/scripts/generate-effect-openapi.ts
index 0512244a..dde1770e 100644
--- a/packages/widget/scripts/generate-effect-openapi.ts
+++ b/packages/widget/scripts/generate-effect-openapi.ts
@@ -11,9 +11,11 @@ type JsonPatchOperation = {
};
type SpecConfig = {
+ format?: "httpclient" | "httpclient-type-only";
name: string;
outputPath: string;
patches: JsonPatchOperation[];
+ prepareSpec?: (contents: string) => string;
specFileName: string;
url: string;
};
@@ -72,6 +74,22 @@ const specs: SpecConfig[] = [
},
],
},
+ {
+ name: "BorrowApi",
+ url:
+ process.env.BORROW_API_SPEC_URL ?? "https://borrow.yield.xyz/docs.json",
+ specFileName: "borrow-api.json",
+ outputPath: path.join(widgetRoot, "src/generated/api/borrow.ts"),
+ // Borrow domain code needs generated runtime schemas as well as the client,
+ // so this spec intentionally uses the full httpclient format.
+ format: "httpclient",
+ patches: [],
+ prepareSpec: (contents) =>
+ contents.replaceAll(
+ '"allOf":[{"$ref":"#/components/schemas/ArgumentSchemaPropertyDto"}]',
+ '"type":"object"'
+ ),
+ },
];
const run = async (
@@ -140,7 +158,8 @@ const generateSpec = async (spec: SpecConfig, tempDir: string) => {
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 });
@@ -153,7 +172,7 @@ const generateSpec = async (spec: SpecConfig, tempDir: string) => {
"--name",
spec.name,
"--format",
- "httpclient-type-only",
+ spec.format ?? "httpclient-type-only",
"--patch",
patchPath,
]);
@@ -171,9 +190,19 @@ const generateSpec = async (spec: SpecConfig, tempDir: string) => {
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);
}
@@ -183,7 +212,9 @@ const main = async () => {
"check",
"--write",
"--unsafe",
- ...specs.map((spec) => path.relative(widgetRoot, spec.outputPath)),
+ ...selectedSpecs.map((spec) =>
+ path.relative(widgetRoot, spec.outputPath)
+ ),
]);
} finally {
await rm(tempDir, { force: true, recursive: true });
diff --git a/packages/widget/src/Dashboard.tsx b/packages/widget/src/Dashboard.tsx
index 91fb2ca0..0e157a60 100644
--- a/packages/widget/src/Dashboard.tsx
+++ b/packages/widget/src/Dashboard.tsx
@@ -1,4 +1,4 @@
-import { Route, Routes } from "react-router";
+import { Navigate, 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";
@@ -6,7 +6,6 @@ import { PendingCompletePage } from "./pages/complete/pages/pending-complete.pag
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";
@@ -16,6 +15,17 @@ 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 { BorrowFormPage, BorrowLayout } from "./pages-dashboard/borrow";
+import { BorrowCompletePage } from "./pages-dashboard/borrow/complete";
+import { BorrowConnectedWalletRoute } from "./pages-dashboard/borrow/connected-wallet";
+import {
+ BorrowPositionActionPage,
+ BorrowPositionActionsPage,
+ BorrowPositionDetailsPage,
+} from "./pages-dashboard/borrow/position-details";
+import { BorrowReviewPage } from "./pages-dashboard/borrow/review";
+import { BorrowStepsPage } from "./pages-dashboard/borrow/steps";
+import { useBorrowFeatureEnabled } from "./pages-dashboard/borrow/use-borrow-feature-enabled";
import { DashboardWrapper } from "./pages-dashboard/common/components/wrapper";
import { OverviewPage } from "./pages-dashboard/overview";
import { EarnPageContent } from "./pages-dashboard/overview/earn-page";
@@ -34,83 +44,118 @@ export const shouldRegisterDashboardEarnFooterButton = (pathname: string) =>
export const Dashboard = () => {
const { current } = useSKLocation();
+ const borrowFeatureEnabled = useBorrowFeatureEnabled();
const registerEarnFooterButton = shouldRegisterDashboardEarnFooterButton(
current.pathname
);
+ const borrowRoutes = borrowFeatureEnabled ? (
+ <>
+ }>
+ } />
+ }>
+ } />
+ } />
+ } />
+
+
+ }>
+ }
+ >
+ } />
+ }
+ />
+ } />
+ } />
+ } />
+
+
+ >
+ ) : (
+ <>
+ } />
+ }
+ />
+ >
+ );
return (
-
-
-
- }>
- {/* Earn Tab */}
- }>
- } />
+
+
+ }>
+ {/* Earn Tab */}
+ }>
+ } />
- }>
- } />
- } />
- } />
-
+ }>
+ } />
+ } />
+ } />
+
- {/* Manage Tab */}
- } />
+ {/* Manage Tab */}
+ } />
- {/* Position Details */}
- }
- >
- } />
+ {/* Borrow Tab */}
+ {borrowRoutes}
+
+ {/* Position Details */}
+ }
+ >
+ } />
- {/* Staking */}
-
- } />
- } />
- } />
- } />
-
+ {/* Staking */}
+
+ } />
+ } />
+ } />
+ } />
+
- }
- />
+ }
+ />
- {/* Unstaking */}
-
- } />
- } />
- } />
- } />
-
+ {/* Unstaking */}
+
+ } />
+ } />
+ } />
+ } />
+
- {/* Pending Actions */}
-
- } />
- } />
- } />
-
+ {/* Pending Actions */}
+
+ } />
+ } />
+ } />
+
- {/* Rewards Tab */}
- {/* } /> */}
+ {/* Rewards Tab */}
+ {/* } /> */}
- {/* Activity Tab */}
- }>
- } />
- }
- />
-
+ {/* Activity Tab */}
+ }>
+ } />
+ }
+ />
-
-
-
+
+
+
diff --git a/packages/widget/src/borrow/atoms/action-form.ts b/packages/widget/src/borrow/atoms/action-form.ts
new file mode 100644
index 00000000..eb88de2c
--- /dev/null
+++ b/packages/widget/src/borrow/atoms/action-form.ts
@@ -0,0 +1,141 @@
+import * as Atom from "effect/unstable/reactivity/Atom";
+import type { ActionsControllerExecuteActionV1RequestJson } from "../../generated/api/borrow";
+import type {
+ Action,
+ CollateralToken,
+ DebtBalance,
+ DisableCollateralPendingAction,
+ EnableCollateralPendingAction,
+ Position,
+ RepayPendingAction,
+ SupplyBalance,
+ WithdrawPendingAction,
+} from "../domain";
+
+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: ActionsControllerExecuteActionV1RequestJson;
+ 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/borrow/atoms/execution.ts b/packages/widget/src/borrow/atoms/execution.ts
new file mode 100644
index 00000000..3e6c655c
--- /dev/null
+++ b/packages/widget/src/borrow/atoms/execution.ts
@@ -0,0 +1,665 @@
+import {
+ Data,
+ Duration,
+ Effect,
+ Match,
+ Ref,
+ Schedule,
+ Schema,
+ Stream,
+} from "effect";
+import * as Atom from "effect/unstable/reactivity/Atom";
+import type { Networks } from "../../domain/types/chains/networks";
+import type { ActionsControllerExecuteActionV1RequestJson } from "../../generated/api/borrow";
+import { Action, type Transaction } from "../domain";
+import {
+ BorrowActionCompletionFailedError,
+ BorrowActionStepFailedError,
+ BorrowApiService,
+ BorrowCheckFailedError,
+ BorrowExecutionEventsService,
+ type BorrowExecutionPhase,
+ type BorrowExecutionResult,
+ type BorrowSignedTransaction,
+ BorrowSubmitFailedError,
+ type BorrowSubmittedTransaction,
+ type BorrowTransactionExecutionError,
+ BorrowTransactionFailedError,
+ BorrowTransactionNotConfirmedError,
+ BorrowWalletExecutionService,
+ borrowAtomRuntime,
+ decodeBorrowEvmTransactionForWallet,
+ getBorrowSubmittedTransaction,
+ getBorrowTransactionMeta,
+ getBorrowTransactionSubmitPayload,
+} from "../runtime";
+
+export type BorrowExecutionStepId = "create" | "sign" | "submit" | "confirm";
+
+export type BorrowExecutionStepStatus =
+ | "pending"
+ | "active"
+ | "completed"
+ | "failed";
+
+export type BorrowExecutionStep = {
+ readonly id: BorrowExecutionStepId;
+ readonly status: BorrowExecutionStepStatus;
+};
+
+export class BorrowExecutionKey extends Data.Class<{
+ readonly action: Action;
+ readonly confirmationPollAttempts?: number;
+ readonly confirmationPollIntervalMs?: number;
+}> {}
+
+export type BorrowExecutionMachineState = {
+ readonly action: Action;
+ readonly currentTxIndex: number;
+ readonly isDone: boolean;
+ readonly phase: BorrowExecutionPhase;
+ readonly submissions: ReadonlyArray;
+ readonly transactions: ReadonlyArray;
+} & (
+ | {
+ readonly signedTransaction: null;
+ readonly step: "sign" | "step" | null;
+ }
+ | {
+ readonly signedTransaction: BorrowSignedTransaction;
+ readonly step: "submit" | "check";
+ }
+);
+
+const stepOrder: Record = {
+ create: 0,
+ sign: 1,
+ submit: 2,
+ confirm: 3,
+};
+
+const phaseToStepId = (phase: BorrowExecutionPhase): BorrowExecutionStepId => {
+ switch (phase) {
+ case "creating":
+ return "create";
+ case "signing":
+ return "sign";
+ case "submitting":
+ return "submit";
+ case "confirming":
+ case "stepping":
+ case "completed":
+ return "confirm";
+ }
+};
+
+export const getBorrowExecutionSteps = ({
+ error,
+ phase,
+}: {
+ readonly error: BorrowTransactionExecutionError | null;
+ readonly phase: BorrowExecutionPhase;
+}): ReadonlyArray => {
+ const activeStepId = phaseToStepId(phase);
+ const activeStepOrder = stepOrder[activeStepId];
+ const failedStepId = error ? phaseToStepId(error.phase) : null;
+
+ return (Object.keys(stepOrder) as BorrowExecutionStepId[]).map((id) => {
+ if (failedStepId === id) {
+ return { id, status: "failed" };
+ }
+
+ if (phase === "completed" || stepOrder[id] < activeStepOrder) {
+ return { id, status: "completed" };
+ }
+
+ if (id === activeStepId) {
+ return { id, status: "active" };
+ }
+
+ return { id, status: "pending" };
+ });
+};
+
+const decodeBorrowAction = ({
+ actionId,
+ input,
+ phase,
+}: {
+ readonly actionId?: string;
+ readonly input: unknown;
+ readonly phase: BorrowExecutionPhase;
+}) =>
+ Schema.decodeUnknownEffect(Action)(input).pipe(
+ Effect.mapError(
+ (cause) =>
+ new BorrowActionCompletionFailedError({
+ actionId,
+ cause,
+ message: "Borrow action response could not be decoded.",
+ phase,
+ })
+ )
+ );
+
+const getActionFailureMessage = (action: Action) =>
+ `Borrow action ended with ${action.status} status.`;
+
+const failIfActionTerminal = (action: Action, phase: BorrowExecutionPhase) => {
+ if (
+ action.status === "FAILED" ||
+ action.status === "CANCELED" ||
+ action.status === "STALE"
+ ) {
+ return Effect.fail(
+ new BorrowActionCompletionFailedError({
+ actionId: action.id,
+ message: getActionFailureMessage(action),
+ phase,
+ })
+ );
+ }
+
+ return Effect.void;
+};
+
+const isTransactionConfirmed = (transaction: Transaction) =>
+ transaction.status === "CONFIRMED" || transaction.status === "SKIPPED";
+
+const getCurrentTransaction = (state: BorrowExecutionMachineState) => {
+ const transaction = state.transactions[state.currentTxIndex];
+
+ return transaction
+ ? Effect.succeed(transaction)
+ : Effect.fail(
+ new BorrowActionCompletionFailedError({
+ actionId: state.action.id,
+ message: "Borrow action has no transaction to process.",
+ phase: state.phase,
+ })
+ );
+};
+
+const getSignedTransaction = (
+ state: BorrowExecutionMachineState,
+ transaction: Transaction
+) =>
+ state.signedTransaction
+ ? Effect.succeed(state.signedTransaction)
+ : Effect.fail(
+ new BorrowSubmitFailedError({
+ actionId: state.action.id,
+ message: "Signed borrow transaction is not available.",
+ phase: "submitting",
+ transactionId: transaction.id,
+ })
+ );
+
+const completeState = (
+ state: BorrowExecutionMachineState,
+ action: Action
+): BorrowExecutionMachineState => ({
+ ...state,
+ action,
+ isDone: true,
+ phase: "completed",
+ signedTransaction: null,
+ step: null,
+ transactions: action.transactions,
+});
+
+const advanceToNextTransaction = ({
+ state,
+ updatedAction,
+}: {
+ readonly state: BorrowExecutionMachineState;
+ readonly updatedAction: Action;
+}): BorrowExecutionMachineState => {
+ const transactions = state.transactions.map(
+ (transaction) =>
+ updatedAction.transactions.find((next) => next.id === transaction.id) ??
+ transaction
+ );
+
+ if (updatedAction.status === "SUCCESS") {
+ return completeState(
+ {
+ ...state,
+ transactions,
+ },
+ updatedAction
+ );
+ }
+
+ if (state.currentTxIndex < transactions.length - 1) {
+ return {
+ ...state,
+ action: updatedAction,
+ currentTxIndex: state.currentTxIndex + 1,
+ phase: "signing",
+ signedTransaction: null,
+ step: null,
+ transactions,
+ };
+ }
+
+ if (updatedAction.hasNextStep) {
+ return {
+ ...state,
+ action: updatedAction,
+ phase: "stepping",
+ signedTransaction: null,
+ step: "step",
+ transactions,
+ };
+ }
+
+ return completeState(
+ {
+ ...state,
+ transactions,
+ },
+ updatedAction
+ );
+};
+
+const createBorrowAction = ({
+ request,
+}: {
+ readonly request: ActionsControllerExecuteActionV1RequestJson;
+}) =>
+ Effect.gen(function* () {
+ const api = yield* BorrowApiService;
+ const response = yield* api
+ .ActionsControllerExecuteActionV1({ payload: request })
+ .pipe(
+ Effect.mapError(
+ (cause) =>
+ new BorrowActionCompletionFailedError({
+ cause,
+ message: "Borrow action could not be created.",
+ phase: "creating",
+ })
+ )
+ );
+
+ const action = yield* decodeBorrowAction({
+ input: response,
+ phase: "creating",
+ });
+
+ yield* failIfActionTerminal(action, "creating");
+
+ return action;
+ });
+
+const checkBorrowTransaction = ({
+ action,
+ intervalMs,
+ times,
+ transaction,
+}: {
+ readonly action: Action;
+ readonly intervalMs: number;
+ readonly times: number;
+ readonly transaction: Transaction;
+}) =>
+ Effect.gen(function* () {
+ const api = yield* BorrowApiService;
+ const response = yield* api
+ .ActionsControllerGetActionV1(action.id, undefined)
+ .pipe(
+ Effect.mapError(
+ (cause) =>
+ new BorrowCheckFailedError({
+ actionId: action.id,
+ cause,
+ message: "Borrow action status could not be checked.",
+ phase: "confirming",
+ transactionId: transaction.id,
+ })
+ )
+ );
+
+ if (!response) {
+ return yield* new BorrowCheckFailedError({
+ actionId: action.id,
+ message: "Borrow action was not found.",
+ phase: "confirming",
+ transactionId: transaction.id,
+ });
+ }
+
+ const updatedAction = yield* decodeBorrowAction({
+ actionId: action.id,
+ input: response,
+ phase: "confirming",
+ });
+
+ yield* failIfActionTerminal(updatedAction, "confirming");
+
+ if (updatedAction.status === "SUCCESS") {
+ return updatedAction;
+ }
+
+ const updatedTransaction = updatedAction.transactions.find(
+ (candidate) => candidate.id === transaction.id
+ );
+
+ if (!updatedTransaction) {
+ return yield* new BorrowCheckFailedError({
+ actionId: action.id,
+ message: "Borrow transaction was not present in the action response.",
+ phase: "confirming",
+ transactionId: transaction.id,
+ });
+ }
+
+ if (isTransactionConfirmed(updatedTransaction)) {
+ return updatedAction;
+ }
+
+ if (
+ updatedTransaction.status === "FAILED" ||
+ updatedTransaction.status === "NOT_FOUND"
+ ) {
+ return yield* new BorrowTransactionFailedError({
+ actionId: action.id,
+ message: `Borrow transaction ended with ${updatedTransaction.status} status.`,
+ phase: "confirming",
+ transactionId: transaction.id,
+ });
+ }
+
+ return yield* new BorrowTransactionNotConfirmedError({
+ actionId: action.id,
+ message: "Borrow transaction is not confirmed yet.",
+ phase: "confirming",
+ transactionId: transaction.id,
+ });
+ }).pipe(
+ Effect.retry({
+ schedule: Schedule.spaced(Duration.millis(intervalMs)),
+ times,
+ while: (error) => error._tag === "BorrowTransactionNotConfirmedError",
+ })
+ );
+
+const stepBorrowAction = (state: BorrowExecutionMachineState) =>
+ Effect.gen(function* () {
+ const api = yield* BorrowApiService;
+ const response = yield* api
+ .ActionsControllerStepV1(state.action.id, undefined)
+ .pipe(
+ Effect.mapError(
+ (cause) =>
+ new BorrowActionStepFailedError({
+ actionId: state.action.id,
+ cause,
+ message: "Borrow action could not advance to the next step.",
+ phase: "stepping",
+ })
+ )
+ );
+ const action = yield* decodeBorrowAction({
+ actionId: state.action.id,
+ input: response,
+ phase: "stepping",
+ });
+
+ yield* failIfActionTerminal(action, "stepping");
+
+ if (action.status === "SUCCESS") {
+ return completeState(state, action);
+ }
+
+ return {
+ ...state,
+ action,
+ currentTxIndex: 0,
+ phase: "signing",
+ signedTransaction: null,
+ step: null,
+ transactions: action.transactions,
+ } satisfies BorrowExecutionMachineState;
+ });
+
+const processBorrowExecutionStep = ({
+ intervalMs,
+ stateRef,
+ times,
+}: {
+ readonly intervalMs: number;
+ readonly stateRef: Ref.Ref;
+ readonly times: number;
+}) =>
+ Effect.gen(function* () {
+ const wallet = yield* BorrowWalletExecutionService;
+ const events = yield* BorrowExecutionEventsService;
+ const state = yield* Ref.get(stateRef);
+
+ if (state.isDone || state.action.status === "SUCCESS") {
+ const doneState = completeState(state, state.action);
+
+ yield* Ref.set(stateRef, doneState);
+ return doneState;
+ }
+
+ yield* failIfActionTerminal(state.action, state.phase);
+
+ if (state.step === "step") {
+ const result = yield* stepBorrowAction(state);
+
+ yield* Ref.set(stateRef, result);
+ return result;
+ }
+
+ if (state.transactions.length === 0) {
+ if (state.action.hasNextStep) {
+ const result = yield* stepBorrowAction({
+ ...state,
+ phase: "stepping",
+ signedTransaction: null,
+ step: "step",
+ });
+
+ yield* Ref.set(stateRef, result);
+ return result;
+ }
+
+ return yield* new BorrowActionCompletionFailedError({
+ actionId: state.action.id,
+ message: "Borrow action completed without a success status.",
+ phase: state.phase,
+ });
+ }
+
+ const transaction = yield* getCurrentTransaction(state);
+
+ if (!transaction.signablePayload || isTransactionConfirmed(transaction)) {
+ const updatedAction = yield* checkBorrowTransaction({
+ action: state.action,
+ intervalMs,
+ times,
+ transaction,
+ });
+ const result = advanceToNextTransaction({
+ state,
+ updatedAction,
+ });
+
+ yield* Ref.set(stateRef, result);
+ return result;
+ }
+
+ const result = yield* Match.value(state.step).pipe(
+ Match.when(null, () =>
+ Effect.succeed({
+ ...state,
+ phase: "signing" as const,
+ signedTransaction: null,
+ step: "sign" as const,
+ })
+ ),
+ Match.when("sign", () =>
+ Effect.gen(function* () {
+ const tx = yield* decodeBorrowEvmTransactionForWallet({
+ action: state.action,
+ transaction,
+ });
+ const signedTransaction = yield* wallet.signTransaction({
+ action: state.action,
+ network: transaction.network as Networks,
+ transaction,
+ tx,
+ txMeta: getBorrowTransactionMeta({
+ action: state.action,
+ transaction,
+ }),
+ });
+
+ return {
+ ...state,
+ phase: "submitting" as const,
+ signedTransaction,
+ step: "submit" as const,
+ };
+ })
+ ),
+ Match.when("submit", () =>
+ Effect.gen(function* () {
+ const api = yield* BorrowApiService;
+ const signedTransaction = yield* getSignedTransaction(
+ state,
+ transaction
+ );
+ const response = yield* api
+ .TransactionsControllerSubmitTransactionV1(transaction.id, {
+ payload: getBorrowTransactionSubmitPayload(signedTransaction),
+ })
+ .pipe(
+ Effect.mapError(
+ (cause) =>
+ new BorrowSubmitFailedError({
+ actionId: state.action.id,
+ cause,
+ message: "Borrow transaction could not be submitted.",
+ phase: "submitting",
+ transactionId: transaction.id,
+ })
+ )
+ );
+ const submission = getBorrowSubmittedTransaction({
+ response,
+ signedTransaction,
+ transaction,
+ });
+ const submissions = [...state.submissions, submission];
+
+ yield* events.publish({
+ _tag: "BorrowTransactionSubmitted",
+ action: state.action,
+ submissions,
+ transaction,
+ });
+
+ return {
+ ...state,
+ phase: "confirming" as const,
+ signedTransaction,
+ submissions,
+ step: "check" as const,
+ };
+ })
+ ),
+ Match.when("check", () =>
+ Effect.gen(function* () {
+ const updatedAction = yield* checkBorrowTransaction({
+ action: state.action,
+ intervalMs,
+ times,
+ transaction,
+ });
+
+ return advanceToNextTransaction({
+ state,
+ updatedAction,
+ });
+ })
+ ),
+ Match.exhaustive
+ );
+
+ yield* Ref.set(stateRef, result);
+
+ if (result.isDone) {
+ yield* events.publish({
+ _tag: "BorrowActionCompleted",
+ action: result.action,
+ submissions: result.submissions,
+ });
+ }
+
+ return result;
+ });
+
+export const borrowCreateActionAtom = borrowAtomRuntime.fn(
+ (request: ActionsControllerExecuteActionV1RequestJson) =>
+ createBorrowAction({ request })
+);
+
+export const borrowExecutionAtom = Atom.family((key: BorrowExecutionKey) => {
+ const machineAtom = borrowAtomRuntime.atom(
+ Effect.gen(function* () {
+ const action = key.action;
+ const stateRef = yield* Ref.make({
+ action,
+ currentTxIndex: 0,
+ isDone: action.status === "SUCCESS",
+ phase: action.status === "SUCCESS" ? "completed" : "signing",
+ signedTransaction: null,
+ step: null,
+ submissions: [],
+ transactions: action.transactions,
+ });
+
+ return processBorrowExecutionStep({
+ intervalMs: key.confirmationPollIntervalMs ?? 2_000,
+ stateRef,
+ times: key.confirmationPollAttempts ?? 20,
+ });
+ })
+ );
+
+ return borrowAtomRuntime.atom((context) =>
+ context.result(machineAtom).pipe(
+ Effect.flatten,
+ Stream.fromEffect,
+ Stream.repeat(Schedule.forever),
+ Stream.takeUntil((state) => state.isDone)
+ )
+ );
+});
+
+export const getBorrowExecutionResult = (
+ state: BorrowExecutionMachineState
+): BorrowExecutionResult => ({
+ action: state.action,
+ submissions: state.submissions,
+});
+
+export const getBorrowExecutionPhase = (
+ state: BorrowExecutionMachineState | null
+): BorrowExecutionPhase => state?.phase ?? "signing";
+
+export const getBorrowExecutionSubmissions = (
+ state: BorrowExecutionMachineState | null
+): ReadonlyArray => state?.submissions ?? [];
+
+export const getBorrowExecutionAction = (
+ state: BorrowExecutionMachineState | null
+): Action | null => state?.action ?? null;
+
+export const isBorrowExecutionDone = (
+ state: BorrowExecutionMachineState | null
+) => state?.isDone ?? false;
diff --git a/packages/widget/src/borrow/atoms/form.ts b/packages/widget/src/borrow/atoms/form.ts
new file mode 100644
index 00000000..f622981e
--- /dev/null
+++ b/packages/widget/src/borrow/atoms/form.ts
@@ -0,0 +1,514 @@
+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 { TokenBalanceScanResponseDto } from "../../generated/api/legacy";
+import {
+ type BorrowMarketWalletBalances,
+ deriveBorrowMarketWalletBalances,
+} from "../balances";
+import {
+ type BorrowNetwork,
+ buildBorrowActionRequest,
+ type CollateralToken,
+ decodeBorrowForm,
+ type Integration,
+ type Market,
+ type Position,
+ projectLtvRatio,
+} from "../domain";
+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 scopeId: string;
+ readonly tokenBalances: ReadonlyArray;
+ readonly walletAddress: string;
+}> {}
+
+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: string;
+}): 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([]),
+}: {
+ readonly integrationsResult: AtomAsyncResult<
+ ReadonlyArray,
+ BorrowAtomResultError
+ >;
+ readonly intent: BorrowFormIntent;
+ readonly key: BorrowDashboardKey;
+ readonly marketsResult: AtomAsyncResult<
+ ReadonlyArray,
+ BorrowAtomResultError
+ >;
+ readonly positionsResult?: AtomAsyncResult<
+ ReadonlyArray,
+ BorrowAtomResultError
+ >;
+}): 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: key.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.scopeId });
+
+ 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,
+ })
+ )
+ ),
+ }),
+ (context, action) => {
+ const intentAtom = borrowFormIntentAtom(scope);
+ const intent = context.get(intentAtom);
+
+ context.set(
+ intentAtom,
+ applyBorrowFormAction({
+ action,
+ intent,
+ })
+ );
+ }
+ );
+});
diff --git a/packages/widget/src/borrow/atoms/index.ts b/packages/widget/src/borrow/atoms/index.ts
new file mode 100644
index 00000000..bbe05821
--- /dev/null
+++ b/packages/widget/src/borrow/atoms/index.ts
@@ -0,0 +1,5 @@
+export * from "./action-form";
+export * from "./execution";
+export * from "./form";
+export * from "./refresh";
+export * from "./resources";
diff --git a/packages/widget/src/borrow/atoms/refresh.ts b/packages/widget/src/borrow/atoms/refresh.ts
new file mode 100644
index 00000000..62132f08
--- /dev/null
+++ b/packages/widget/src/borrow/atoms/refresh.ts
@@ -0,0 +1,65 @@
+import { Effect, Stream } from "effect";
+import type * as Atom from "effect/unstable/reactivity/Atom";
+import { isBorrowNetwork } from "../domain";
+import type { BorrowExecutionEvent } from "../runtime";
+import { BorrowExecutionEventsService, borrowAtomRuntime } from "../runtime";
+import {
+ BorrowMarketsKey,
+ BorrowPositionsKey,
+ borrowIntegrationsAtom,
+ borrowMarketsAtom,
+ borrowPositionsAtom,
+} from "./resources";
+
+const getBorrowRefreshNetwork = (event: BorrowExecutionEvent) => {
+ const network =
+ event._tag === "BorrowTransactionSubmitted"
+ ? event.transaction.network
+ : event.action.transactions[0]?.network;
+
+ return network && isBorrowNetwork(network) ? network : null;
+};
+
+const refreshBorrowResources = ({
+ context,
+ event,
+}: {
+ readonly context: Atom.AtomContext;
+ readonly event: BorrowExecutionEvent;
+}) => {
+ const network = getBorrowRefreshNetwork(event);
+
+ context.refresh(borrowIntegrationsAtom);
+
+ if (!network) {
+ return;
+ }
+
+ context.refresh(borrowMarketsAtom(new BorrowMarketsKey({ network })));
+ context.refresh(
+ borrowPositionsAtom(
+ new BorrowPositionsKey({
+ address: event.action.address,
+ network,
+ })
+ )
+ );
+};
+
+export const borrowExecutionEventsAtom = borrowAtomRuntime.atom(() =>
+ Stream.fromEffect(BorrowExecutionEventsService).pipe(
+ Stream.flatMap((events) => events.events)
+ )
+);
+
+export const borrowExecutionRuntimeRefreshAtom = borrowAtomRuntime.atom(
+ (context) =>
+ Stream.fromEffect(BorrowExecutionEventsService).pipe(
+ Stream.flatMap((events) => events.events),
+ Stream.tap((event) =>
+ Effect.sync(() => refreshBorrowResources({ context, event }))
+ ),
+ Stream.map(() => undefined)
+ ),
+ { initialValue: undefined }
+);
diff --git a/packages/widget/src/borrow/atoms/resources.ts b/packages/widget/src/borrow/atoms/resources.ts
new file mode 100644
index 00000000..32eb6add
--- /dev/null
+++ b/packages/widget/src/borrow/atoms/resources.ts
@@ -0,0 +1,202 @@
+import { Data, Duration, Effect, flow, Schema } from "effect";
+import * as Atom from "effect/unstable/reactivity/Atom";
+import type { PositionDto } from "../../generated/api/borrow";
+import {
+ type BorrowNetwork,
+ deriveBorrowPositionItems,
+ Integration,
+ Market,
+ type MarketId,
+ type Position,
+} from "../domain";
+import type { MissingBorrowApiClient } from "../runtime";
+import { BorrowApiService, borrowAtomRuntime } 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: string | null;
+ readonly network: BorrowNetwork | null;
+}> {}
+
+export class BorrowPositionKey extends Data.Class<{
+ readonly address: string | null;
+ readonly marketId: MarketId | string | null;
+ readonly network: BorrowNetwork | null;
+}> {}
+
+const DEFAULT_PAGE_SIZE = 100;
+const PREFERRED_PAGE_CONCURRENCY = 5;
+
+const borrowSWR = flow(
+ Atom.swr({
+ staleTime: Duration.minutes(1),
+ revalidateOnMount: true,
+ }),
+ Atom.setIdleTTL(Duration.minutes(5))
+);
+
+const withBorrowAtomError =
+ (operation: BorrowAtomOperation) =>
+ (effect: Effect.Effect) =>
+ effect.pipe(
+ Effect.mapError(
+ (cause) =>
+ new BorrowAtomError({
+ cause,
+ operation,
+ })
+ )
+ );
+
+const decodeIntegrations = Schema.decodeUnknownEffect(
+ Schema.Array(Integration)
+);
+const decodeMarkets = Schema.decodeUnknownEffect(Schema.Array(Market));
+
+export const borrowIntegrationsAtom = borrowAtomRuntime
+ .atom(() =>
+ Effect.gen(function* () {
+ const api = yield* BorrowApiService;
+ const response =
+ yield* api.IntegrationsControllerGetIntegrationsV1(undefined);
+
+ return yield* decodeIntegrations(response);
+ }).pipe(withBorrowAtomError("borrow-integrations"))
+ )
+ .pipe(borrowSWR);
+
+export const borrowMarketsAtom = Atom.family((key: BorrowMarketsKey) =>
+ borrowAtomRuntime
+ .atom(() =>
+ Effect.gen(function* () {
+ const api = yield* BorrowApiService;
+ const response = yield* api.MarketsControllerGetMarketsV1({
+ params: {
+ limit: DEFAULT_PAGE_SIZE,
+ network: key.network,
+ offset: 0,
+ scope: "all",
+ },
+ });
+
+ return yield* decodeMarkets(response.items ?? []);
+ }).pipe(withBorrowAtomError("borrow-markets"))
+ )
+ .pipe(borrowSWR)
+);
+
+const loadBorrowPositions = ({
+ address,
+ network,
+}: {
+ readonly address: string;
+ readonly network: BorrowNetwork;
+}) =>
+ Effect.gen(function* () {
+ const api = yield* BorrowApiService;
+ const integrationsResponse =
+ yield* api.IntegrationsControllerGetIntegrationsV1(undefined);
+ const integrations = (yield* decodeIntegrations(
+ integrationsResponse
+ )).filter((integration) => integration.networks.includes(network));
+ const marketsResponse = yield* api.MarketsControllerGetMarketsV1({
+ params: {
+ limit: DEFAULT_PAGE_SIZE,
+ network,
+ offset: 0,
+ scope: "all",
+ },
+ });
+ const markets = yield* decodeMarkets(marketsResponse.items ?? []);
+ const integrationPositions = yield* Effect.all(
+ integrations.map((integration) =>
+ Effect.gen(function* () {
+ const position = yield* api.PositionsControllerGetPositionsV1({
+ params: {
+ address,
+ integrationId: integration.id,
+ network,
+ },
+ });
+
+ return {
+ integration,
+ position: position as PositionDto,
+ };
+ })
+ ),
+ { concurrency: PREFERRED_PAGE_CONCURRENCY }
+ );
+
+ return deriveBorrowPositionItems({
+ integrationPositions,
+ markets,
+ });
+ });
+
+export const borrowPositionsAtom = Atom.family((key: BorrowPositionsKey) =>
+ borrowAtomRuntime
+ .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) =>
+ borrowAtomRuntime
+ .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/borrow/balances/index.ts b/packages/widget/src/borrow/balances/index.ts
new file mode 100644
index 00000000..d7b27ede
--- /dev/null
+++ b/packages/widget/src/borrow/balances/index.ts
@@ -0,0 +1,126 @@
+import BigNumber from "bignumber.js";
+import type { TokenBalanceScanResponseDto } from "../../generated/api/legacy";
+import type {
+ BorrowNetwork,
+ BorrowToken,
+ CollateralToken,
+ Market,
+ TokenAddress,
+} from "../domain";
+
+type BorrowBalanceToken = Pick<
+ BorrowToken,
+ "address" | "decimals" | "name" | "symbol"
+>;
+
+export type BorrowTokenWalletBalance = {
+ readonly amount: string;
+ readonly amountValue: BigNumber;
+ readonly balance: TokenBalanceScanResponseDto | 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: TokenBalanceScanResponseDto
+) =>
+ !token.address &&
+ !balance.token.address &&
+ token.symbol.toLowerCase() === balance.token.symbol.toLowerCase();
+
+export const isBorrowTokenBalanceMatch = ({
+ balance,
+ network,
+ token,
+}: {
+ readonly balance: TokenBalanceScanResponseDto;
+ 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 amount = balance?.amount ?? "0";
+
+ return {
+ amount,
+ amountValue: new BigNumber(amount),
+ 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/borrow/domain/action-definition.ts b/packages/widget/src/borrow/domain/action-definition.ts
new file mode 100644
index 00000000..562a117c
--- /dev/null
+++ b/packages/widget/src/borrow/domain/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/borrow/domain/action-request.ts b/packages/widget/src/borrow/domain/action-request.ts
new file mode 100644
index 00000000..77d1e68a
--- /dev/null
+++ b/packages/widget/src/borrow/domain/action-request.ts
@@ -0,0 +1,237 @@
+import BigNumber from "bignumber.js";
+import type { ActionsControllerExecuteActionV1RequestJson } from "../../generated/api/borrow";
+import type { CollateralToken } from "./collateral-token";
+import type {
+ 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;
+
+type ActionRequest = ActionsControllerExecuteActionV1RequestJson;
+
+type BaseActionRequestInput = {
+ readonly address: WalletAddress | string;
+ readonly integrationId: IntegrationId | string;
+ readonly marketId: MarketId | string;
+};
+
+type RepayActionRequestInput = BaseActionRequestInput &
+ (
+ | {
+ readonly amount: AmountValue;
+ readonly repayAll?: false;
+ }
+ | {
+ readonly amount?: never;
+ readonly repayAll: true;
+ }
+ ) & {
+ readonly tokenAddress?: TokenAddress | string;
+ };
+
+type WithdrawActionRequestInput = BaseActionRequestInput & {
+ readonly amount: AmountValue;
+ readonly tokenAddress?: TokenAddress | string;
+};
+
+type CollateralToggleActionRequestInput = BaseActionRequestInput & {
+ readonly action: "enableCollateral" | "disableCollateral";
+ readonly tokenAddress?: TokenAddress | string;
+};
+
+const toAmount = (amount: AmountValue) => new BigNumber(amount);
+
+const toActionAmount = (amount: AmountValue) => toAmount(amount).toString(10);
+
+const tokenAddressArg = (tokenAddress: string | undefined) =>
+ tokenAddress ? { tokenAddress } : {};
+
+const collateralTokenAddressArg = (tokenAddress: string | 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 | string;
+ readonly form: DecodedBorrowForm;
+ readonly integrationId?: IntegrationId | string;
+}): 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/borrow/domain/action.ts b/packages/widget/src/borrow/domain/action.ts
new file mode 100644
index 00000000..a799959f
--- /dev/null
+++ b/packages/widget/src/borrow/domain/action.ts
@@ -0,0 +1,37 @@
+import { Schema } from "effect";
+import * as BorrowApi from "../../generated/api/borrow";
+import { ActionId, IntegrationId, MarketId } from "./ids";
+import { BigIntFromString, NumberFromString } from "./scalars";
+import { Transaction } from "./transaction";
+
+class ActionRawArguments extends Schema.Class(
+ "BorrowActionRawArguments"
+)({
+ ...BorrowApi.ActionDto.fields.rawArguments.schema.fields,
+ marketId: MarketId,
+ amount: Schema.optionalKey(NumberFromString),
+ amountRaw: Schema.optionalKey(BigIntFromString),
+ collateralAmount: Schema.optionalKey(NumberFromString),
+ collateralAmountRaw: Schema.optionalKey(BigIntFromString),
+}) {}
+
+class ActionMetadata extends Schema.Class(
+ "BorrowActionMetadata"
+)({
+ currentHealthFactor: Schema.NullOr(NumberFromString),
+ predictedHealthFactor: Schema.NullOr(NumberFromString),
+ currentLtv: NumberFromString,
+ predictedLtv: NumberFromString,
+ liquidationThreshold: NumberFromString,
+ predictedTotalSupplyUsd: NumberFromString,
+ predictedTotalDebtUsd: NumberFromString,
+}) {}
+
+export class Action extends Schema.Class("BorrowAction")({
+ ...BorrowApi.ActionDto.fields,
+ id: ActionId,
+ integrationId: IntegrationId,
+ transactions: Schema.Array(Transaction),
+ rawArguments: Schema.optionalKey(ActionRawArguments),
+ metadata: Schema.optionalKey(ActionMetadata),
+}) {}
diff --git a/packages/widget/src/borrow/domain/collateral-token.ts b/packages/widget/src/borrow/domain/collateral-token.ts
new file mode 100644
index 00000000..61c41df4
--- /dev/null
+++ b/packages/widget/src/borrow/domain/collateral-token.ts
@@ -0,0 +1,16 @@
+import { Schema } from "effect";
+import * as BorrowApi from "../../generated/api/borrow";
+import { NumberFromString } from "./scalars";
+import { BorrowToken } from "./token";
+
+export class CollateralToken extends Schema.Class(
+ "BorrowCollateralToken"
+)({
+ ...BorrowApi.CollateralTokenDto.fields,
+ token: BorrowToken,
+ priceUsd: NumberFromString,
+ maxLtv: NumberFromString,
+ liquidationThreshold: NumberFromString,
+ liquidationPenalty: NumberFromString,
+ supplyRate: NumberFromString,
+}) {}
diff --git a/packages/widget/src/borrow/domain/ids.ts b/packages/widget/src/borrow/domain/ids.ts
new file mode 100644
index 00000000..37c8ab10
--- /dev/null
+++ b/packages/widget/src/borrow/domain/ids.ts
@@ -0,0 +1,59 @@
+import { identity, Schema, SchemaTransformation } from "effect";
+
+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.TemplateLiteral([
+ Schema.Literal("0x"),
+ Schema.String,
+]).pipe(Schema.brand("BorrowWalletAddress"));
+export type WalletAddress = typeof WalletAddress.Type;
+export const decodeWalletAddress = Schema.decodeUnknownSync(WalletAddress);
+
+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.brand("BorrowTokenAddress")
+);
+export type TokenAddress = typeof TokenAddress.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/borrow/domain/index.ts b/packages/widget/src/borrow/domain/index.ts
new file mode 100644
index 00000000..d8fc2186
--- /dev/null
+++ b/packages/widget/src/borrow/domain/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 "./scalars";
+export * from "./token";
+export * from "./transaction";
+export * from "./wallet";
diff --git a/packages/widget/src/borrow/domain/integration.ts b/packages/widget/src/borrow/domain/integration.ts
new file mode 100644
index 00000000..66dcd7a3
--- /dev/null
+++ b/packages/widget/src/borrow/domain/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/borrow/domain/market.ts b/packages/widget/src/borrow/domain/market.ts
new file mode 100644
index 00000000..8744f990
--- /dev/null
+++ b/packages/widget/src/borrow/domain/market.ts
@@ -0,0 +1,53 @@
+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 { BigIntFromString, NumberFromString } from "./scalars";
+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: NumberFromString,
+ totalSupply: NumberFromString,
+ totalSupplyRaw: BigIntFromString,
+ totalBorrow: NumberFromString,
+ totalBorrowRaw: BigIntFromString,
+ availableLiquidity: NumberFromString,
+ availableLiquidityRaw: BigIntFromString,
+ utilizationRate: NumberFromString,
+ loanTokenPriceUsd: NumberFromString,
+}) {
+ 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/borrow/domain/network.ts b/packages/widget/src/borrow/domain/network.ts
new file mode 100644
index 00000000..0be5e8b4
--- /dev/null
+++ b/packages/widget/src/borrow/domain/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/borrow/domain/pending-action.ts b/packages/widget/src/borrow/domain/pending-action.ts
new file mode 100644
index 00000000..821fe343
--- /dev/null
+++ b/packages/widget/src/borrow/domain/pending-action.ts
@@ -0,0 +1,71 @@
+import { Array as EffectArray, Schema, SchemaGetter } from "effect";
+import * as BorrowApi from "../../generated/api/borrow";
+import { MarketId, TokenAddress } from "./ids";
+import { BigIntFromString } from "./scalars";
+
+export class WithdrawPendingAction extends Schema.Class(
+ "BorrowWithdrawPendingAction"
+)({
+ type: Schema.Literal("withdraw"),
+ label: Schema.String,
+ args: Schema.Struct({
+ amountRaw: 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 PendingActionsFromDto = Schema.Array(
+ BorrowApi.BorrowPendingActionDto
+).pipe(
+ Schema.decodeTo(Schema.Array(PendingAction), {
+ decode: SchemaGetter.transform((items) =>
+ EffectArray.getSomes(
+ items.map((action) =>
+ Schema.decodeUnknownOption(Schema.toEncoded(PendingAction))(action)
+ )
+ )
+ ),
+ encode: SchemaGetter.forbidden(() => "Cannot encode PendingActionsFromDto"),
+ })
+);
diff --git a/packages/widget/src/borrow/domain/position-items.ts b/packages/widget/src/borrow/domain/position-items.ts
new file mode 100644
index 00000000..5e8ba3d3
--- /dev/null
+++ b/packages/widget/src/borrow/domain/position-items.ts
@@ -0,0 +1,132 @@
+import * as Schema from "effect/Schema";
+import type { PositionDto } from "../../generated/api/borrow";
+import type { MarketId } from "./ids";
+import type { Integration } from "./integration";
+import type { Market } from "./market";
+import type { PendingAction } from "./pending-action";
+import { DebtBalance, Position, SupplyBalance } from "./position";
+
+type IntegrationPosition = {
+ readonly integration: Integration;
+ readonly position: PositionDto;
+};
+
+const decodeDebtBalance = Schema.decodeUnknownSync(DebtBalance);
+const decodeSupplyBalance = Schema.decodeUnknownSync(SupplyBalance);
+
+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.map(
+ (balance) => decodeDebtBalance(balance)
+ );
+ const supplyBalances = integrationPosition.position.supplyBalances.map(
+ (balance) => decodeSupplyBalance(balance)
+ );
+ 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/borrow/domain/position-projection.ts b/packages/widget/src/borrow/domain/position-projection.ts
new file mode 100644
index 00000000..c32fde05
--- /dev/null
+++ b/packages/widget/src/borrow/domain/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/borrow/domain/position.ts b/packages/widget/src/borrow/domain/position.ts
new file mode 100644
index 00000000..8cda4142
--- /dev/null
+++ b/packages/widget/src/borrow/domain/position.ts
@@ -0,0 +1,193 @@
+import {
+ Array as EffectArray,
+ Option,
+ pipe,
+ Record,
+ Result,
+ Schema,
+} from "effect";
+import { sumAll } from "effect/Number";
+import * as BorrowApi from "../../generated/api/borrow";
+import { decodeTokenId, MarketId, TokenAddress } from "./ids";
+import { Integration } from "./integration";
+import { Market } from "./market";
+import { PendingActionsFromDto } from "./pending-action";
+import { BigIntFromString, NumberFromString } from "./scalars";
+
+export const SupplyBalance = Schema.Struct({
+ ...BorrowApi.SupplyBalanceDto.fields,
+ marketId: MarketId,
+ tokenAddress: TokenAddress,
+ balance: NumberFromString,
+ balanceRaw: BigIntFromString,
+ balanceUsd: NumberFromString,
+ apy: NumberFromString,
+ pendingActions: PendingActionsFromDto,
+});
+export type SupplyBalance = typeof SupplyBalance.Type;
+
+export const DebtBalance = Schema.Struct({
+ ...BorrowApi.DebtBalanceDto.fields,
+ marketId: MarketId,
+ tokenAddress: TokenAddress,
+ balance: NumberFromString,
+ balanceRaw: BigIntFromString,
+ balanceUsd: NumberFromString,
+ apy: NumberFromString,
+ pendingActions: PendingActionsFromDto,
+});
+export type DebtBalance = typeof DebtBalance.Type;
+
+export class Position extends Schema.Class("BorrowPosition")({
+ id: MarketId,
+ market: Market,
+ integration: Integration,
+ debtBalance: Schema.NullOr(DebtBalance),
+ supplyBalances: Schema.Array(SupplyBalance),
+ debtPendingActions: PendingActionsFromDto,
+ supplyPendingActions: PendingActionsFromDto,
+}) {
+ getCurrentLtv() {
+ if (this.debtBalance == null) {
+ return null;
+ }
+
+ const totalCollateralUsd = this.getTotalCollateralUsd();
+
+ if (totalCollateralUsd <= 0) {
+ return null;
+ }
+
+ return this.debtBalance.balanceUsd / totalCollateralUsd;
+ }
+
+ getTotalCollateralUsd() {
+ return pipe(
+ EffectArray.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 EffectArray.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/borrow/domain/scalars.ts b/packages/widget/src/borrow/domain/scalars.ts
new file mode 100644
index 00000000..bc5cc9d0
--- /dev/null
+++ b/packages/widget/src/borrow/domain/scalars.ts
@@ -0,0 +1,10 @@
+import { Schema } from "effect";
+import { bigintFromString } from "effect/SchemaTransformation";
+
+export const NumberFromString = Schema.NumberFromString;
+export type NumberFromString = typeof NumberFromString.Type;
+
+export const BigIntFromString = Schema.String.pipe(
+ Schema.decodeTo(Schema.BigInt, bigintFromString)
+);
+export type BigIntFromString = typeof BigIntFromString.Type;
diff --git a/packages/widget/src/borrow/domain/token.ts b/packages/widget/src/borrow/domain/token.ts
new file mode 100644
index 00000000..bc75e06c
--- /dev/null
+++ b/packages/widget/src/borrow/domain/token.ts
@@ -0,0 +1,10 @@
+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),
+}) {}
+
+export type Token = BorrowToken;
diff --git a/packages/widget/src/borrow/domain/transaction.ts b/packages/widget/src/borrow/domain/transaction.ts
new file mode 100644
index 00000000..fb831b23
--- /dev/null
+++ b/packages/widget/src/borrow/domain/transaction.ts
@@ -0,0 +1,12 @@
+import { Schema } from "effect";
+import * as BorrowApi from "../../generated/api/borrow";
+import { TransactionId } from "./ids";
+import { NumberFromString } from "./scalars";
+
+export class Transaction extends Schema.Class("BorrowTransaction")(
+ {
+ ...BorrowApi.TransactionDto.fields,
+ id: TransactionId,
+ chainId: NumberFromString,
+ }
+) {}
diff --git a/packages/widget/src/borrow/domain/wallet.ts b/packages/widget/src/borrow/domain/wallet.ts
new file mode 100644
index 00000000..92f2812c
--- /dev/null
+++ b/packages/widget/src/borrow/domain/wallet.ts
@@ -0,0 +1,95 @@
+import { Data, type Effect, Schema, type Stream } from "effect";
+import { ChainId, WalletAddress } from "./ids";
+import { BorrowNetwork } from "./network";
+
+const HexString = Schema.TemplateLiteral([Schema.Literal("0x"), Schema.String]);
+export type HexString = typeof HexString.Type;
+
+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 BorrowWalletTransactionRequest = Schema.Struct({
+ account: WalletAddress,
+ to: HexString,
+ data: HexString,
+ gas: Schema.BigInt,
+ value: Schema.optionalKey(Schema.BigInt),
+});
+export type BorrowWalletTransactionRequest =
+ typeof BorrowWalletTransactionRequest.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;
+
+export const WalletState = Schema.Union([
+ DisconnectedWalletState,
+ ConnectedWalletState,
+]);
+export type WalletState = typeof WalletState.Type;
+
+export class SendTransactionError extends Data.TaggedError(
+ "BorrowSendTransactionError"
+)<{
+ readonly cause: unknown;
+}> {}
+
+export class SwitchAccountError extends Data.TaggedError(
+ "BorrowSwitchAccountError"
+)<{
+ readonly cause: unknown;
+}> {}
+
+export class SwitchChainError extends Data.TaggedError(
+ "BorrowSwitchChainError"
+)<{
+ readonly cause: unknown;
+}> {}
+
+export type BorrowWalletAdapter = {
+ readonly mode: "default" | "external";
+ readonly getState: () => WalletState;
+ readonly changes: Stream.Stream;
+ readonly sendTransaction: (
+ request: BorrowWalletTransactionRequest
+ ) => Effect.Effect;
+ readonly switchAccount: (
+ address: WalletAddress
+ ) => Effect.Effect;
+ readonly switchChain: (
+ chainId: ChainId
+ ) => Effect.Effect;
+};
+
+export type ExternalBorrowWalletAdapter = {
+ readonly getState: () => ConnectedWalletState;
+ readonly subscribe: (
+ listener: (state: ConnectedWalletState) => void
+ ) => () => void;
+ readonly sendTransaction: (
+ request: BorrowWalletTransactionRequest
+ ) => Promise;
+ readonly switchAccount: (address: WalletAddress) => Promise;
+ readonly switchChain: (chainId: ChainId) => Promise;
+};
diff --git a/packages/widget/src/borrow/index.ts b/packages/widget/src/borrow/index.ts
new file mode 100644
index 00000000..400978ee
--- /dev/null
+++ b/packages/widget/src/borrow/index.ts
@@ -0,0 +1,6 @@
+export * from "./atoms";
+export * from "./balances";
+export * from "./domain";
+export * from "./runtime";
+export * from "./utils";
+export * from "./wallet";
diff --git a/packages/widget/src/borrow/runtime/index.ts b/packages/widget/src/borrow/runtime/index.ts
new file mode 100644
index 00000000..4b4b9942
--- /dev/null
+++ b/packages/widget/src/borrow/runtime/index.ts
@@ -0,0 +1,43 @@
+import { Context, Data, Effect, Layer } from "effect";
+import * as Atom from "effect/unstable/reactivity/Atom";
+import type { BorrowApi } from "../../generated/api/borrow";
+import { stakeKitEffectApiClientAtom } from "../../providers/effect-atom-runtime/stakekit-api-service";
+import {
+ BorrowExecutionEventsService,
+ BorrowWalletExecutionService,
+ borrowWalletExecutionAdapterAtom,
+} from "./transaction-execution";
+
+export * from "./transaction-execution";
+
+export class MissingBorrowApiClient extends Data.TaggedError(
+ "MissingBorrowApiClient"
+)<{
+ readonly message: string;
+}> {}
+
+export class BorrowApiService extends Context.Service<
+ BorrowApiService,
+ BorrowApi
+>()("stakekit/widget/borrow/BorrowApiService") {}
+
+export const borrowAtomRuntime = Atom.runtime((get) => {
+ const apiClient = get(stakeKitEffectApiClientAtom);
+ const walletExecutionAdapter = get(borrowWalletExecutionAdapterAtom);
+
+ return Layer.mergeAll(
+ apiClient
+ ? Layer.succeed(BorrowApiService, apiClient.borrow)
+ : Layer.effect(
+ BorrowApiService,
+ Effect.fail(
+ new MissingBorrowApiClient({
+ message:
+ "Borrow Effect API client was not initialized in the atom runtime.",
+ })
+ )
+ ),
+ Layer.succeed(BorrowWalletExecutionService, walletExecutionAdapter),
+ BorrowExecutionEventsService.layer
+ );
+});
diff --git a/packages/widget/src/borrow/runtime/transaction-execution.ts b/packages/widget/src/borrow/runtime/transaction-execution.ts
new file mode 100644
index 00000000..e8217c2f
--- /dev/null
+++ b/packages/widget/src/borrow/runtime/transaction-execution.ts
@@ -0,0 +1,391 @@
+import { Context, Data, Effect, Layer, PubSub, Schema, Stream } from "effect";
+import * as Atom from "effect/unstable/reactivity/Atom";
+import type { Networks } from "../../domain/types/chains/networks";
+import type { SKWallet } from "../../domain/types/wallet";
+import type { SKTxMeta } from "../../domain/types/wallets/generic-wallet";
+import type {
+ SubmitTransactionDto,
+ SubmitTransactionResponseDto,
+} from "../../generated/api/borrow";
+import type { Action, Transaction, WalletState } from "../domain";
+
+export type BorrowExecutionPhase =
+ | "creating"
+ | "signing"
+ | "submitting"
+ | "confirming"
+ | "stepping"
+ | "completed";
+
+export type BorrowSubmittedTransaction = {
+ readonly hash: string;
+ readonly link?: string;
+ readonly signedPayload?: string;
+ readonly status?: SubmitTransactionResponseDto["status"];
+ readonly transaction: Transaction;
+};
+
+export type BorrowExecutionResult = {
+ readonly action: Action;
+ readonly submissions: ReadonlyArray;
+};
+
+type BorrowExecutionErrorFields = {
+ readonly actionId?: string;
+ readonly cause?: unknown;
+ readonly message: string;
+ readonly phase: BorrowExecutionPhase;
+ readonly transactionId?: string;
+};
+
+export class BorrowWalletDisconnectedError extends Data.TaggedError(
+ "BorrowWalletDisconnectedError"
+) {}
+
+export class BorrowSigningFailedError extends Data.TaggedError(
+ "BorrowSigningFailedError"
+) {}
+
+export class BorrowPayloadDecodeError extends Data.TaggedError(
+ "BorrowPayloadDecodeError"
+) {}
+
+export class BorrowSubmitFailedError extends Data.TaggedError(
+ "BorrowSubmitFailedError"
+) {}
+
+export class BorrowCheckFailedError extends Data.TaggedError(
+ "BorrowCheckFailedError"
+) {}
+
+export class BorrowTransactionFailedError extends Data.TaggedError(
+ "BorrowTransactionFailedError"
+) {}
+
+export class BorrowTransactionNotConfirmedError extends Data.TaggedError(
+ "BorrowTransactionNotConfirmedError"
+) {}
+
+export class BorrowActionStepFailedError extends Data.TaggedError(
+ "BorrowActionStepFailedError"
+) {}
+
+export class BorrowActionCompletionFailedError extends Data.TaggedError(
+ "BorrowActionCompletionFailedError"
+) {}
+
+export type BorrowTransactionExecutionError =
+ | BorrowWalletDisconnectedError
+ | BorrowSigningFailedError
+ | BorrowPayloadDecodeError
+ | BorrowSubmitFailedError
+ | BorrowCheckFailedError
+ | BorrowTransactionFailedError
+ | BorrowTransactionNotConfirmedError
+ | BorrowActionStepFailedError
+ | BorrowActionCompletionFailedError;
+
+const borrowTransactionExecutionErrorTags = new Set([
+ "BorrowWalletDisconnectedError",
+ "BorrowSigningFailedError",
+ "BorrowPayloadDecodeError",
+ "BorrowSubmitFailedError",
+ "BorrowCheckFailedError",
+ "BorrowTransactionFailedError",
+ "BorrowTransactionNotConfirmedError",
+ "BorrowActionStepFailedError",
+ "BorrowActionCompletionFailedError",
+]);
+
+export const isBorrowTransactionExecutionError = (
+ value: unknown
+): value is BorrowTransactionExecutionError =>
+ typeof value === "object" &&
+ value !== null &&
+ "_tag" in value &&
+ typeof value._tag === "string" &&
+ borrowTransactionExecutionErrorTags.has(value._tag);
+
+export type BorrowSignedTransaction = {
+ readonly broadcasted: boolean;
+ readonly signedTx: string;
+};
+
+export type BorrowWalletSignRequest = {
+ readonly action: Action;
+ readonly network: Networks;
+ readonly transaction: Transaction;
+ readonly tx: string;
+ readonly txMeta: SKTxMeta;
+};
+
+export type BorrowWalletExecutionAdapter = {
+ readonly getState: () => WalletState;
+ readonly signTransaction: (
+ request: BorrowWalletSignRequest
+ ) => Effect.Effect;
+};
+
+export const disconnectedBorrowWalletState: WalletState = {
+ status: "disconnected",
+};
+
+export const makeDisconnectedBorrowWalletExecutionAdapter =
+ (): BorrowWalletExecutionAdapter => ({
+ getState: () => disconnectedBorrowWalletState,
+ signTransaction: (request) =>
+ Effect.fail(
+ new BorrowWalletDisconnectedError({
+ actionId: request.action.id,
+ message: "Wallet is disconnected.",
+ phase: "signing",
+ transactionId: request.transaction.id,
+ })
+ ),
+ });
+
+export const borrowWalletExecutionAdapterAtom =
+ Atom.make(
+ makeDisconnectedBorrowWalletExecutionAdapter()
+ ).pipe(Atom.withLabel("borrowWalletExecutionAdapterAtom"));
+
+export class BorrowWalletExecutionService extends Context.Service<
+ BorrowWalletExecutionService,
+ BorrowWalletExecutionAdapter
+>()("stakekit/widget/borrow/BorrowWalletExecutionService") {}
+
+export const makeSKWalletBorrowExecutionAdapter = ({
+ getState,
+ signTransaction,
+}: {
+ readonly getState: () => WalletState;
+ readonly signTransaction: SKWallet["signTransaction"];
+}): BorrowWalletExecutionAdapter => ({
+ getState,
+ signTransaction: (request) => {
+ const walletState = getState();
+
+ if (walletState.status !== "connected") {
+ return Effect.fail(
+ new BorrowWalletDisconnectedError({
+ actionId: request.action.id,
+ message: "Wallet is disconnected.",
+ phase: "signing",
+ transactionId: request.transaction.id,
+ })
+ );
+ }
+
+ return Effect.tryPromise({
+ try: () =>
+ signTransaction({
+ ledgerHwAppId: null,
+ network: request.network,
+ tx: request.tx,
+ txMeta: request.txMeta,
+ }).run(),
+ catch: (cause) =>
+ new BorrowSigningFailedError({
+ actionId: request.action.id,
+ cause,
+ message: "Wallet signing failed.",
+ phase: "signing",
+ transactionId: request.transaction.id,
+ }),
+ }).pipe(
+ Effect.flatMap((result) => {
+ if (result.isLeft()) {
+ return Effect.fail(
+ new BorrowSigningFailedError({
+ actionId: request.action.id,
+ cause: result.extract(),
+ message: "Wallet signing failed.",
+ phase: "signing",
+ transactionId: request.transaction.id,
+ })
+ );
+ }
+
+ return Effect.succeed(result.extract() as BorrowSignedTransaction);
+ })
+ );
+ },
+});
+
+export type BorrowExecutionEvent =
+ | {
+ readonly _tag: "BorrowActionCompleted";
+ readonly action: Action;
+ readonly submissions: ReadonlyArray;
+ }
+ | {
+ readonly _tag: "BorrowTransactionSubmitted";
+ readonly action: Action;
+ readonly submissions: ReadonlyArray;
+ readonly transaction: Transaction;
+ };
+
+const BORROW_EXECUTION_EVENT_REPLAY_SIZE = 8;
+
+export class BorrowExecutionEventsService extends Context.Service<
+ BorrowExecutionEventsService,
+ {
+ readonly events: Stream.Stream;
+ readonly publish: (event: BorrowExecutionEvent) => Effect.Effect;
+ }
+>()("stakekit/widget/borrow/BorrowExecutionEventsService") {
+ static readonly layer = Layer.effect(
+ BorrowExecutionEventsService,
+ Effect.gen(function* () {
+ const pubsub = yield* PubSub.sliding({
+ capacity: 64,
+ replay: BORROW_EXECUTION_EVENT_REPLAY_SIZE,
+ });
+
+ yield* Effect.addFinalizer(() => PubSub.shutdown(pubsub));
+
+ return BorrowExecutionEventsService.of({
+ events: Stream.fromPubSub(pubsub),
+ publish: (event) => PubSub.publish(pubsub, event).pipe(Effect.asVoid),
+ });
+ })
+ );
+}
+
+const HexString = Schema.TemplateLiteral([Schema.Literal("0x"), Schema.String]);
+
+const Numberish = Schema.Union([Schema.String, Schema.Number, Schema.BigInt]);
+
+export const BorrowEvmSignablePayload = 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 BorrowEvmSignablePayload = typeof BorrowEvmSignablePayload.Type;
+
+const BorrowEvmSignablePayloadInput = Schema.Union([
+ BorrowEvmSignablePayload,
+ Schema.fromJsonString(BorrowEvmSignablePayload),
+]);
+
+const decodeBorrowEvmSignablePayload = Schema.decodeUnknownEffect(
+ BorrowEvmSignablePayloadInput
+);
+
+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);
+
+const stringifyBorrowUnsignedEvmTransaction = ({
+ fallbackChainId,
+ payload,
+}: {
+ readonly fallbackChainId: number | string;
+ readonly payload: BorrowEvmSignablePayload;
+}) =>
+ JSON.stringify({
+ chainId: numberishToNumber(payload.chainId, fallbackChainId),
+ 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 decodeBorrowEvmTransactionForWallet = Effect.fn(
+ "decodeBorrowEvmTransactionForWallet"
+)(function* ({
+ action,
+ transaction,
+}: {
+ readonly action: Action;
+ readonly transaction: Transaction;
+}): Effect.fn.Return {
+ const payload = yield* decodeBorrowEvmSignablePayload(
+ transaction.signablePayload
+ ).pipe(
+ Effect.mapError(
+ (cause) =>
+ new BorrowPayloadDecodeError({
+ actionId: action.id,
+ cause,
+ message: "Borrow transaction payload could not be decoded.",
+ phase: "signing",
+ transactionId: transaction.id,
+ })
+ )
+ );
+
+ return stringifyBorrowUnsignedEvmTransaction({
+ fallbackChainId: transaction.chainId,
+ payload,
+ });
+});
+
+const getActionAmount = (action: Action) =>
+ action.rawArguments?.amount ??
+ action.rawArguments?.borrowAmount ??
+ action.rawArguments?.amountRaw ??
+ "0";
+
+export const getBorrowTransactionMeta = ({
+ action,
+ transaction,
+}: {
+ readonly action: Action;
+ readonly transaction: Transaction;
+}) =>
+ ({
+ actionId: action.id,
+ actionType: action.action,
+ address: action.address,
+ amount: getActionAmount(action).toString(),
+ amountRaw: action.rawArguments?.amountRaw?.toString(),
+ inputToken: undefined,
+ providersDetails: [],
+ rawArguments: action.rawArguments,
+ txId: transaction.id,
+ txType: transaction.type,
+ yieldId: action.integrationId,
+ }) as unknown as SKTxMeta;
+
+export const getBorrowTransactionSubmitPayload = (
+ signedTransaction: BorrowSignedTransaction
+): SubmitTransactionDto =>
+ signedTransaction.broadcasted
+ ? { transactionHash: signedTransaction.signedTx }
+ : { signedPayload: signedTransaction.signedTx };
+
+export const getBorrowSubmittedTransaction = ({
+ response,
+ signedTransaction,
+ transaction,
+}: {
+ readonly response: SubmitTransactionResponseDto;
+ readonly signedTransaction: BorrowSignedTransaction;
+ readonly transaction: Transaction;
+}): BorrowSubmittedTransaction => ({
+ hash: signedTransaction.broadcasted
+ ? signedTransaction.signedTx
+ : (response.transactionHash ?? signedTransaction.signedTx),
+ link: response.link,
+ signedPayload: signedTransaction.broadcasted
+ ? undefined
+ : signedTransaction.signedTx,
+ status: response.status,
+ transaction,
+});
diff --git a/packages/widget/src/borrow/utils/formatters.ts b/packages/widget/src/borrow/utils/formatters.ts
new file mode 100644
index 00000000..641b7a36
--- /dev/null
+++ b/packages/widget/src/borrow/utils/formatters.ts
@@ -0,0 +1,30 @@
+const tokenAmountFormatDefaults: Intl.NumberFormatOptions = {
+ maximumFractionDigits: 4,
+};
+
+export const formatPercentage = (value: number) =>
+ `${value.toLocaleString(undefined, { maximumFractionDigits: 2 })}%`;
+
+export const formatTokenAmount = (
+ value: number,
+ options?: Intl.NumberFormatOptions
+) =>
+ value.toLocaleString(undefined, {
+ ...tokenAmountFormatDefaults,
+ ...options,
+ });
+
+export const roundFormattedTokenAmount = (
+ value: number,
+ options?: Intl.NumberFormatOptions
+) =>
+ Number(
+ new Intl.NumberFormat("en-US", {
+ useGrouping: false,
+ ...tokenAmountFormatDefaults,
+ ...options,
+ }).format(value)
+ );
+
+export const formatUsdAmount = (value: number) =>
+ `$${value.toLocaleString(undefined, { maximumFractionDigits: 0 })}`;
diff --git a/packages/widget/src/borrow/utils/index.ts b/packages/widget/src/borrow/utils/index.ts
new file mode 100644
index 00000000..cb5e8dd3
--- /dev/null
+++ b/packages/widget/src/borrow/utils/index.ts
@@ -0,0 +1,2 @@
+export * from "./formatters";
+export * from "./market";
diff --git a/packages/widget/src/borrow/utils/market.ts b/packages/widget/src/borrow/utils/market.ts
new file mode 100644
index 00000000..644982d2
--- /dev/null
+++ b/packages/widget/src/borrow/utils/market.ts
@@ -0,0 +1,14 @@
+import type { Market } from "../domain";
+
+export const getBorrowNetworkLogo = (network: Market["network"]) =>
+ `https://assets.stakek.it/networks/${network}.svg`;
+
+export const getMarketTokenPairLabel = (market: Market) => {
+ const collateralToken = market.collateralTokens[0];
+
+ if (!collateralToken) {
+ return market.loanToken.symbol;
+ }
+
+ return `${collateralToken.token.symbol}/${market.loanToken.symbol}`;
+};
diff --git a/packages/widget/src/borrow/wallet/bridge.ts b/packages/widget/src/borrow/wallet/bridge.ts
new file mode 100644
index 00000000..00fbd405
--- /dev/null
+++ b/packages/widget/src/borrow/wallet/bridge.ts
@@ -0,0 +1,157 @@
+import { Effect } from "effect";
+import type { Chain } from "viem";
+import type { Connector } from "wagmi";
+import {
+ type BorrowNetwork,
+ borrowChainEntries,
+ borrowChainsByNetwork,
+ borrowViemChains,
+ type ChainId,
+ type ConnectedWalletState,
+ type DisconnectedWalletState,
+ decodeChainId,
+ decodeWalletAddress,
+ getBorrowNetworkForChainId,
+ isBorrowNetwork,
+ SwitchChainError,
+ type WalletChain,
+} from "../domain";
+
+type BorrowWalletBridgeInput = {
+ readonly address: string | null;
+ readonly chain: Chain | null;
+ readonly connector?: Pick | null;
+ readonly connectorChains: ReadonlyArray;
+ readonly isConnected: boolean;
+ readonly network: string | null;
+};
+
+export type BorrowWalletDisconnectedBridgeState = {
+ readonly status: "disconnected";
+ readonly wallet: typeof DisconnectedWalletState.Type;
+};
+
+export type BorrowWalletUnsupportedNetworkBridgeState = {
+ readonly status: "unsupported-network";
+ readonly chainId: number | null;
+ readonly network: string | null;
+ readonly supportedChains: ReadonlyArray;
+};
+
+export type BorrowWalletConnectedBridgeState = {
+ readonly status: "connected";
+ readonly switchChain: (
+ chainId: ChainId
+ ) => Effect.Effect;
+ readonly wallet: typeof ConnectedWalletState.Type;
+};
+
+export type BorrowWalletBridgeState =
+ | BorrowWalletConnectedBridgeState
+ | BorrowWalletDisconnectedBridgeState
+ | BorrowWalletUnsupportedNetworkBridgeState;
+
+const toWalletChain = ([network, chain]: readonly [
+ BorrowNetwork,
+ Chain,
+]): WalletChain => ({
+ chainId: decodeChainId(chain.id),
+ iconUrl: `https://assets.stakek.it/networks/${network}.svg`,
+ name: chain.name,
+ network,
+});
+
+export const getSupportedBorrowWalletChains = (
+ connectorChains: ReadonlyArray
+): ReadonlyArray => {
+ const connectorChainIds = new Set(connectorChains.map((chain) => chain.id));
+ const chains =
+ connectorChainIds.size > 0
+ ? borrowChainEntries.filter(([, chain]) =>
+ connectorChainIds.has(chain.id)
+ )
+ : borrowChainEntries;
+
+ return chains.map(toWalletChain);
+};
+
+export const switchBorrowWalletChain = ({
+ chainId,
+ connector,
+}: {
+ readonly chainId: ChainId;
+ readonly connector?: Pick | null;
+}) => {
+ const switchChain = connector?.switchChain;
+
+ return switchChain
+ ? Effect.tryPromise({
+ try: () => switchChain({ chainId: Number(chainId) }),
+ catch: (cause) => new SwitchChainError({ cause }),
+ }).pipe(Effect.asVoid)
+ : Effect.fail(
+ new SwitchChainError({
+ cause: new Error("Current wallet connector cannot switch chains."),
+ })
+ );
+};
+
+export const toBorrowWalletBridgeState = (
+ wallet: BorrowWalletBridgeInput
+): BorrowWalletBridgeState => {
+ const supportedChains = getSupportedBorrowWalletChains(
+ wallet.connectorChains
+ );
+
+ if (!wallet.isConnected || !wallet.address || !wallet.chain) {
+ return {
+ status: "disconnected",
+ wallet: { status: "disconnected" },
+ };
+ }
+
+ const network =
+ wallet.network && isBorrowNetwork(wallet.network) ? wallet.network : null;
+
+ if (!network || getBorrowNetworkForChainId(wallet.chain.id) !== network) {
+ return {
+ status: "unsupported-network",
+ chainId: wallet.chain.id,
+ network: wallet.network,
+ supportedChains,
+ };
+ }
+
+ const currentAccount = {
+ address: decodeWalletAddress(wallet.address),
+ };
+ const currentChain =
+ supportedChains.find((chain) => chain.network === network) ??
+ toWalletChain([network, borrowChainsByNetwork[network]]);
+ const chains =
+ supportedChains.length > 0
+ ? supportedChains
+ : borrowViemChains.map((chain) =>
+ toWalletChain([
+ getBorrowNetworkForChainId(chain.id) ?? network,
+ chain,
+ ])
+ );
+
+ return {
+ status: "connected",
+ switchChain: (chainId) =>
+ switchBorrowWalletChain({ chainId, connector: wallet.connector }),
+ wallet: {
+ status: "connected",
+ accounts: [currentAccount],
+ chains: [
+ currentChain,
+ ...chains.filter((chain) => chain !== currentChain),
+ ],
+ currentAccount,
+ currentChain,
+ network,
+ },
+ };
+};
diff --git a/packages/widget/src/borrow/wallet/index.ts b/packages/widget/src/borrow/wallet/index.ts
new file mode 100644
index 00000000..a88679f5
--- /dev/null
+++ b/packages/widget/src/borrow/wallet/index.ts
@@ -0,0 +1 @@
+export * from "./bridge";
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/content-loader/index.tsx b/packages/widget/src/components/atoms/content-loader/index.tsx
index 2933eeb1..fb221b8c 100644
--- a/packages/widget/src/components/atoms/content-loader/index.tsx
+++ b/packages/widget/src/components/atoms/content-loader/index.tsx
@@ -27,3 +27,38 @@ export const ContentLoaderSquare = ({
/>
);
};
+
+export const ContentLoaderLine = ({
+ heightPx = 12,
+ widthPx,
+ containerClassName,
+}: {
+ heightPx?: number;
+ widthPx?: number | string;
+ containerClassName?: ComponentProps["containerClassName"];
+}) => {
+ return (
+
+ );
+};
+
+export const ContentLoaderCircle = ({ sizePx }: { sizePx: number }) => {
+ return (
+
+ );
+};
diff --git a/packages/widget/src/components/molecules/select-validator/index.tsx b/packages/widget/src/components/molecules/select-validator/index.tsx
index 9b057f0d..bd8fc504 100644
--- a/packages/widget/src/components/molecules/select-validator/index.tsx
+++ b/packages/widget/src/components/molecules/select-validator/index.tsx
@@ -1,8 +1,8 @@
import type { PropsWithChildren } from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
+import type { Validator, ValidatorKey } from "../../../domain/types/validators";
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";
@@ -16,13 +16,13 @@ type SelectValidatorProps = PropsWithChildren<
SelectModalProps,
"isLoading" | "onClose" | "onOpen" | "state" | "trigger"
> & {
- selectedValidators: Set;
- onItemClick: (item: ValidatorDto) => void;
+ selectedValidators: Set;
+ onItemClick: (item: Validator) => void;
onViewMoreClick?: () => void;
onLoadMore?: () => void;
hasMore?: boolean;
isLoadingMore?: boolean;
- validators: ValidatorDto[];
+ validators: Validator[];
selectedStake: Yield;
multiSelect: boolean;
} & (
@@ -71,7 +71,7 @@ export const SelectValidator = ({
const showExpandedValidators = multiSelect || hasRequestedMoreValidators;
const data = useMemo<{
- tableData: ValidatorDto[];
+ tableData: Validator[];
groupedItems: GroupedItem[];
groupCounts: number[];
}>(() => {
@@ -106,11 +106,11 @@ export const SelectValidator = ({
},
{
preferred: {
- items: [] as ValidatorDto[],
+ items: [] as Validator[],
label: t("details.validators_preferred"),
},
other: {
- items: [] as ValidatorDto[],
+ items: [] as Validator[],
label: t("details.validators_other"),
},
}
diff --git a/packages/widget/src/components/molecules/select-validator/select-validator-list.tsx b/packages/widget/src/components/molecules/select-validator/select-validator-list.tsx
index 2047dfbb..2d7fe9bc 100644
--- a/packages/widget/src/components/molecules/select-validator/select-validator-list.tsx
+++ b/packages/widget/src/components/molecules/select-validator/select-validator-list.tsx
@@ -1,8 +1,8 @@
import type { ComponentProps } from "react";
import { memo } from "react";
import { useTranslation } from "react-i18next";
+import type { Validator, ValidatorKey } from "../../../domain/types/validators";
import type { Yield } from "../../../domain/types/yields";
-import type { ValidatorDto } from "../../../generated/api/yield";
import { vars } from "../../../styles/theme/contract.css";
import {
getRewardRateFormatted,
@@ -31,7 +31,7 @@ import {
validatorVirtuosoContainer,
} from "./styles.css";
-export type GroupedItem = { items: ValidatorDto[]; label: string };
+export type GroupedItem = { items: Validator[]; label: string };
export const SelectValidatorList = ({
multiSelect,
@@ -44,11 +44,11 @@ export const SelectValidatorList = ({
tableData,
}: {
multiSelect: boolean;
- selectedValidators: Set;
- onItemClick: (item: ValidatorDto) => void;
+ selectedValidators: Set;
+ onItemClick: (item: Validator) => void;
onViewMoreClick: () => void;
selectedStake: Yield;
- tableData: ValidatorDto[];
+ tableData: Validator[];
groupedItems: GroupedItem[];
groupCounts: number[];
}) => {
@@ -100,7 +100,7 @@ export const SelectValidatorList = ({
const status = item.status;
- const itemSelected = selectedValidators.has(item.address);
+ const itemSelected = selectedValidators.has(item.key);
const _onItemClick: ComponentProps<
typeof SelectModalItem
@@ -115,7 +115,7 @@ export const SelectValidatorList = ({
@@ -135,7 +135,7 @@ export const SelectValidatorList = ({
justifyContent="center"
alignItems="center"
>
- {selectedValidators.has(item.address) ? (
+ {itemSelected ? (
) : null}
diff --git a/packages/widget/src/config/index.ts b/packages/widget/src/config/index.ts
index 0a04004d..1f6f676e 100644
--- a/packages/widget/src/config/index.ts
+++ b/packages/widget/src/config/index.ts
@@ -15,6 +15,8 @@ export const config = {
appPrefix: "sk-widget",
env: {
apiUrl: import.meta.env.VITE_API_URL ?? "https://api.stakek.it/",
+ borrowApiUrl:
+ import.meta.env.VITE_BORROW_API_URL ?? "https://borrow.yield.xyz",
yieldsApiUrl:
import.meta.env.VITE_YIELDS_API_URL ?? "https://api.yield.xyz",
isTestMode: import.meta.env.MODE === "test",
diff --git a/packages/widget/src/domain/types/action.ts b/packages/widget/src/domain/types/action.ts
index 380597ae..119de2e2 100644
--- a/packages/widget/src/domain/types/action.ts
+++ b/packages/widget/src/domain/types/action.ts
@@ -17,8 +17,6 @@ export type TransactionStatus = TransactionDto["status"];
export type YieldCreateActionDto = YieldCreateActionDtoGenerated;
export type YieldCreateManageActionDto = YieldCreateManageActionDtoGenerated;
-export type YieldActionDto = ActionDto;
-export type YieldTransactionDto = TransactionDto;
type TransactionGasEstimate = {
amount: string;
gasLimit?: string;
diff --git a/packages/widget/src/domain/types/positions.ts b/packages/widget/src/domain/types/positions.ts
index 00a49368..5947ac6f 100644
--- a/packages/widget/src/domain/types/positions.ts
+++ b/packages/widget/src/domain/types/positions.ts
@@ -2,16 +2,26 @@ import BigNumber from "bignumber.js";
import type {
BalanceDto,
BalancesRequestDto,
- ValidatorDto,
YieldBalancesDto,
BalanceType as YieldBalanceTypeGenerated,
} from "../../generated/api/yield";
import { equalTokens } from "..";
import type { YieldRewardRateDto } from "./reward-rate";
import type { TokenDto } from "./tokens";
-
-export type YieldBalanceDto = BalanceDto;
-export type YieldBalancesByYieldDto = YieldBalancesDto;
+import {
+ toValidator,
+ toValidators,
+ type Validator,
+ type ValidatorKey,
+} from "./validators";
+
+export type YieldBalanceDto = Omit & {
+ readonly validator?: Validator;
+ readonly validators?: ReadonlyArray;
+};
+export type YieldBalancesByYieldDto = Omit & {
+ readonly balances: ReadonlyArray;
+};
export type YieldBalancesRequestDto = BalancesRequestDto;
export type YieldBalanceType = YieldBalanceTypeGenerated;
@@ -21,14 +31,13 @@ export type PositionBalancesByType = Map<
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"],
@@ -38,13 +47,30 @@ export type PositionsData = Map<
balanceData: Map<
BalanceDataKey,
{ balances: YieldBalanceDto[] } & (
- | { type: "validators"; validators: ReadonlyArray }
+ | { type: "validators"; validators: PositionValidators }
| { type: "default" }
)
>;
}
>;
+export const toYieldBalance = (balance: BalanceDto): YieldBalanceDto => ({
+ ...balance,
+ validator: balance.validator ? toValidator(balance.validator) : undefined,
+ validators: balance.validators ? toValidators(balance.validators) : undefined,
+});
+
+const toYieldBalancesByYield = (
+ balancesByYield: YieldBalancesDto
+): YieldBalancesByYieldDto => ({
+ ...balancesByYield,
+ balances: balancesByYield.balances.map(toYieldBalance),
+});
+
+export const toYieldBalancesByYields = (
+ balancesByYields: ReadonlyArray
+): YieldBalancesByYieldDto[] => balancesByYields.map(toYieldBalancesByYield);
+
export const getPositionBalanceDataKey = (
balance: YieldBalanceDto
): BalanceDataKey => {
@@ -52,8 +78,10 @@ export const getPositionBalanceDataKey = (
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";
diff --git a/packages/widget/src/domain/types/reward-rate.ts b/packages/widget/src/domain/types/reward-rate.ts
index d2bbe3af..5f759187 100644
--- a/packages/widget/src/domain/types/reward-rate.ts
+++ b/packages/widget/src/domain/types/reward-rate.ts
@@ -1,16 +1,13 @@
-import type {
- RewardDto,
- ValidatorDto,
- YieldDto,
-} from "../../generated/api/yield";
+import type { RewardDto, YieldDto } from "../../generated/api/yield";
+import type { Validator, ValidatorKey } from "./validators";
type YieldRewardDto = RewardDto;
export type YieldRewardRateDto = NonNullable;
type YieldWithRewardRate = Pick;
-type ValidatorRewardRateDto = NonNullable;
+type ValidatorRewardRateDto = NonNullable;
export type SelectedValidators =
- | ReadonlyArray
- | ReadonlyMap;
+ | ReadonlyArray
+ | ReadonlyMap;
type RewardRateBreakdownKey = "native" | "protocol_incentive" | "campaign";
diff --git a/packages/widget/src/domain/types/stake.ts b/packages/widget/src/domain/types/stake.ts
index 748cbd08..1f3ec9d2 100644
--- a/packages/widget/src/domain/types/stake.ts
+++ b/packages/widget/src/domain/types/stake.ts
@@ -1,13 +1,11 @@
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 { 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 type { Validator, ValidatorKey } from "./validators";
import {
getYieldActionArg,
isBittensorStaking,
@@ -15,81 +13,10 @@ import {
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;
};
-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 const canBeInitialYield = (args: {
initQueryParams: Maybe;
yieldDto: YieldBase;
@@ -127,7 +54,7 @@ const balanceValidForYield = ({
export const getInitSelectedValidators = (args: {
initQueryParams: Maybe;
- validators: ValidatorDto[];
+ validators: Validator[];
}) =>
args.initQueryParams
.chainNullable((params) => params.validator)
@@ -140,8 +67,8 @@ export const getInitSelectedValidators = (args: {
)
)
.altLazy(() => List.head(args.validators))
- .map((v) => new Map([[v.address, v]]))
- .orDefault(new Map());
+ .map((v) => new Map([[v.key, v]]))
+ .orDefault(new Map());
export const isForceMaxAmount = (
args: { minimum?: number | null; maximum?: number | null } | null | undefined
@@ -151,9 +78,6 @@ 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(
diff --git a/packages/widget/src/domain/types/token-balance.ts b/packages/widget/src/domain/types/token-balance.ts
index 72afea64..9bfd1dc8 100644
--- a/packages/widget/src/domain/types/token-balance.ts
+++ b/packages/widget/src/domain/types/token-balance.ts
@@ -1,15 +1,7 @@
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 = LegacyYieldBalanceLabelDto;
diff --git a/packages/widget/src/domain/types/validators.ts b/packages/widget/src/domain/types/validators.ts
new file mode 100644
index 00000000..fc58a3c6
--- /dev/null
+++ b/packages/widget/src/domain/types/validators.ts
@@ -0,0 +1,23 @@
+import type { ValidatorDto } from "../../generated/api/yield";
+
+export type ValidatorKey = string;
+
+export type Validator = ValidatorDto & {
+ readonly key: ValidatorKey;
+};
+
+const getValidatorKey = (
+ validator: Pick
+): ValidatorKey =>
+ validator.subnet?.id === undefined
+ ? validator.address
+ : `${validator.address}:${validator.subnet.id}`;
+
+export const toValidator = (validator: ValidatorDto): Validator => ({
+ ...validator,
+ key: getValidatorKey(validator),
+});
+
+export const toValidators = (
+ validators: ReadonlyArray
+): Validator[] => validators.map(toValidator);
diff --git a/packages/widget/src/domain/types/yields.ts b/packages/widget/src/domain/types/yields.ts
index b4dcb18a..be242774 100644
--- a/packages/widget/src/domain/types/yields.ts
+++ b/packages/widget/src/domain/types/yields.ts
@@ -169,17 +169,17 @@ export const getDashboardYieldCategory = (
return null;
};
-export const filterValidators = ({
+export const filterValidators = ({
validatorsConfig,
validators,
network,
yieldId,
}: {
validatorsConfig: ValidatorsConfig;
- validators: ValidatorDto[];
+ validators: ReadonlyArray;
network: Yield["token"]["network"];
yieldId?: Yield["id"];
-}) => {
+}): T[] => {
const valConfig = Maybe.fromNullable(
validatorsConfig.get(network as SupportedSKChains)
)
@@ -187,7 +187,7 @@ export const filterValidators = ({
.extractNullable();
const filtered = !valConfig
- ? validators
+ ? [...validators]
: (() => {
const {
allowed,
@@ -206,10 +206,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];
});
})();
diff --git a/packages/widget/src/generated/api/borrow.ts b/packages/widget/src/generated/api/borrow.ts
new file mode 100644
index 00000000..1aa9366b
--- /dev/null
+++ b/packages/widget/src/generated/api/borrow.ts
@@ -0,0 +1,3549 @@
+// @ts-nocheck
+// biome-ignore-all lint: generated by Effect OpenAPI
+import * as Data from "effect/Data";
+import * as Effect from "effect/Effect";
+import type { SchemaError } from "effect/Schema";
+import * as Schema from "effect/Schema";
+import type * as HttpClient from "effect/unstable/http/HttpClient";
+import * as HttpClientError from "effect/unstable/http/HttpClientError";
+import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
+import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
+// non-recursive definitions
+export type ActionDefinitionDto = {
+ readonly id:
+ | "supply"
+ | "borrow"
+ | "repay"
+ | "withdraw"
+ | "enableCollateral"
+ | "disableCollateral";
+ readonly label: string;
+ readonly schema: {
+ readonly type?: {};
+ readonly properties?: {};
+ readonly required?: ReadonlyArray;
+ readonly additionalProperties?: {};
+ readonly items?: {};
+ readonly enum?: ReadonlyArray;
+ readonly default?: {};
+ readonly notes?: string;
+ };
+};
+export const ActionDefinitionDto = Schema.Struct({
+ id: Schema.Literals([
+ "supply",
+ "borrow",
+ "repay",
+ "withdraw",
+ "enableCollateral",
+ "disableCollateral",
+ ]).annotate({ description: "Action identifier", examples: ["supply"] }),
+ label: Schema.String.annotate({
+ description: "Human-readable action label",
+ examples: ["Supply Collateral"],
+ }),
+ schema: Schema.Struct({
+ type: Schema.optionalKey(
+ Schema.Struct({}).annotate({
+ description: "Schema type",
+ examples: ["object"],
+ })
+ ),
+ properties: Schema.optionalKey(
+ Schema.Struct({}).annotate({
+ description: "Schema properties (fields)",
+ examples: [
+ {
+ asset: {
+ type: "string",
+ label: "Asset",
+ pattern: "^0x[a-fA-F0-9]{40}$",
+ },
+ amount: { type: "string", label: "Amount", pattern: "^\\d+$" },
+ },
+ ],
+ })
+ ),
+ required: Schema.optionalKey(
+ Schema.Array(Schema.String).annotate({
+ description: "Required field names",
+ examples: [["network", "asset", "amount"]],
+ })
+ ),
+ additionalProperties: Schema.optionalKey(
+ Schema.Struct({}).annotate({
+ description: "Allow additional properties",
+ examples: [false],
+ })
+ ),
+ items: Schema.optionalKey(
+ Schema.Struct({}).annotate({ description: "Array items schema" })
+ ),
+ enum: Schema.optionalKey(
+ Schema.Array(Schema.String).annotate({ description: "Enum values" })
+ ),
+ default: Schema.optionalKey(
+ Schema.Struct({}).annotate({ description: "Default value" })
+ ),
+ notes: Schema.optionalKey(
+ Schema.String.annotate({
+ description: "Notes about this action schema",
+ examples: [
+ "Supply assets to earn interest and optionally use as collateral for borrowing.",
+ ],
+ })
+ ),
+ }).annotate({
+ description: "Argument schema for this action (JSON Schema format)",
+ }),
+});
+export type CollateralTokenDto = {
+ readonly token: {
+ readonly address?: string;
+ readonly symbol: string;
+ readonly name: string;
+ readonly decimals: number;
+ readonly logoURI?: string;
+ };
+ readonly priceUsd: string;
+ readonly maxLtv: string;
+ readonly liquidationThreshold: string;
+ readonly liquidationPenalty: string;
+ readonly supplyRate: string;
+};
+export const CollateralTokenDto = Schema.Struct({
+ token: Schema.Struct({
+ address: Schema.optionalKey(
+ Schema.String.annotate({
+ description: "Token contract address",
+ examples: ["0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"],
+ })
+ ),
+ symbol: Schema.String.annotate({
+ description: "Token symbol",
+ examples: ["USDC"],
+ }),
+ name: Schema.String.annotate({
+ description: "Token name",
+ examples: ["USD Coin"],
+ }),
+ decimals: Schema.Number.annotate({
+ description: "Token decimals",
+ examples: [6],
+ }).check(Schema.isFinite()),
+ logoURI: Schema.optionalKey(
+ Schema.String.annotate({ description: "Token logo URI" })
+ ),
+ }).annotate({ description: "Collateral token details" }),
+ priceUsd: Schema.String.annotate({
+ description: "Token price in USD",
+ examples: ["2800.00"],
+ }),
+ maxLtv: Schema.String.annotate({
+ description:
+ 'Maximum loan-to-value ratio as decimal (e.g. "0.80" = 80% borrowing power)',
+ examples: ["0.80"],
+ }),
+ liquidationThreshold: Schema.String.annotate({
+ description: "Liquidation threshold as decimal",
+ examples: ["0.825"],
+ }),
+ liquidationPenalty: Schema.String.annotate({
+ description: "Liquidation penalty as decimal",
+ examples: ["0.05"],
+ }),
+ supplyRate: Schema.String.annotate({
+ description:
+ 'APY earned on this collateral while deposited (decimal, e.g. "0.02" = 2%)',
+ examples: ["0.02"],
+ }),
+});
+export type BorrowPendingActionDto = {
+ readonly type:
+ | "supply"
+ | "borrow"
+ | "repay"
+ | "withdraw"
+ | "enableCollateral"
+ | "disableCollateral";
+ readonly label: string;
+ readonly args: {
+ readonly amount?: string;
+ readonly amountRaw?: string;
+ readonly repayAll?: boolean;
+ readonly tokenAddress?: string;
+ readonly collateralTokenAddress?: string;
+ readonly collateralAmount?: string;
+ readonly collateralAmountRaw?: string;
+ readonly borrowAmount?: string;
+ readonly targetLtv?: string;
+ readonly marketId: string;
+ };
+};
+export const BorrowPendingActionDto = Schema.Struct({
+ type: Schema.Literals([
+ "supply",
+ "borrow",
+ "repay",
+ "withdraw",
+ "enableCollateral",
+ "disableCollateral",
+ ]).annotate({
+ description:
+ "Action type — pass this value to POST /v1/actions as the action field",
+ examples: ["withdraw"],
+ }),
+ label: Schema.String.annotate({
+ description: "Human-readable action label",
+ examples: ["Withdraw USDC"],
+ }),
+ args: Schema.Struct({
+ amount: Schema.optionalKey(
+ Schema.String.annotate({
+ description:
+ 'Amount in human-readable units (e.g. "1.5" for 1.5 USDC). Provide either amount or amountRaw.',
+ examples: ["1.5"],
+ })
+ ),
+ amountRaw: Schema.optionalKey(
+ Schema.String.annotate({
+ description:
+ 'Amount in raw token units (e.g. "1500000" for 1.5 USDC with 6 decimals). Provide either amount or amountRaw.',
+ examples: ["1500000"],
+ })
+ ),
+ repayAll: Schema.optionalKey(
+ Schema.Boolean.annotate({
+ description:
+ "Repay the full outstanding debt using protocol-specific full-repay semantics. Only valid for repay actions. When true, omit amount and amountRaw.",
+ examples: [true],
+ })
+ ),
+ tokenAddress: Schema.optionalKey(
+ Schema.String.annotate({
+ description: "Token address to supply/borrow/repay/withdraw",
+ examples: ["0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"],
+ })
+ ),
+ collateralTokenAddress: Schema.optionalKey(
+ Schema.String.annotate({
+ description:
+ "Collateral token address. Required when providing collateralAmount for pool-based protocols (Aave). Inferred from marketId for isolated-market protocols (Morpho Blue).",
+ examples: ["0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"],
+ })
+ ),
+ collateralAmount: Schema.optionalKey(
+ Schema.String.annotate({
+ description:
+ 'Optional collateral amount in human-readable units (e.g. "0.5" for 0.5 ETH). For borrow: collateral to deposit before borrowing. For repay: collateral to withdraw after repaying. Provide either collateralAmount or collateralAmountRaw.',
+ examples: ["0.5"],
+ })
+ ),
+ collateralAmountRaw: Schema.optionalKey(
+ Schema.String.annotate({
+ description:
+ 'Optional collateral amount in raw token units (e.g. "50000000" for 0.5 ETH with 8 decimals). Provide either collateralAmount or collateralAmountRaw.',
+ examples: ["50000000"],
+ })
+ ),
+ borrowAmount: Schema.optionalKey(
+ Schema.String.annotate({
+ description:
+ "Supply action only. Desired borrow amount in human-readable units of the market loan token. Provide together with targetLtv to have the API derive the required collateral (post-fee). Mutually exclusive with amount/amountRaw.",
+ examples: ["100"],
+ })
+ ),
+ targetLtv: Schema.optionalKey(
+ Schema.String.annotate({
+ description:
+ 'Supply action only. Target loan-to-value as a decimal (e.g. "0.1" = 10%). Provide together with borrowAmount. Must not exceed the market max LTV.',
+ examples: ["0.1"],
+ })
+ ),
+ marketId: Schema.String.annotate({
+ description:
+ "Market ID as returned by GET /v1/markets. Identifies the specific lending market for the action.",
+ examples: ["morpho-blue-borrow-base-cbbtc-usdc-86"],
+ }),
+ }).annotate({ description: "Pre-filled arguments for the action" }),
+});
+export type TransactionDto = {
+ readonly id: string;
+ readonly network:
+ | "ethereum"
+ | "ethereum-goerli"
+ | "ethereum-holesky"
+ | "ethereum-sepolia"
+ | "ethereum-hoodi"
+ | "arbitrum"
+ | "base"
+ | "base-sepolia"
+ | "gnosis"
+ | "optimism"
+ | "polygon"
+ | "polygon-amoy"
+ | "starknet"
+ | "zksync"
+ | "linea"
+ | "unichain"
+ | "monad-testnet"
+ | "monad"
+ | "robinhood"
+ | "robinhood-testnet"
+ | "avalanche-c"
+ | "avalanche-c-atomic"
+ | "avalanche-p"
+ | "binance"
+ | "celo"
+ | "fantom"
+ | "harmony"
+ | "moonriver"
+ | "okc"
+ | "viction"
+ | "core"
+ | "sonic"
+ | "plasma"
+ | "katana"
+ | "hyperevm"
+ | "tempo"
+ | "agoric"
+ | "akash"
+ | "axelar"
+ | "band-protocol"
+ | "bitsong"
+ | "canto"
+ | "chihuahua"
+ | "comdex"
+ | "coreum"
+ | "cosmos"
+ | "crescent"
+ | "cronos"
+ | "cudos"
+ | "desmos"
+ | "dydx"
+ | "evmos"
+ | "fetch-ai"
+ | "gravity-bridge"
+ | "injective"
+ | "irisnet"
+ | "juno"
+ | "kava"
+ | "ki-network"
+ | "mars-protocol"
+ | "nym"
+ | "okex-chain"
+ | "onomy"
+ | "osmosis"
+ | "persistence"
+ | "quicksilver"
+ | "regen"
+ | "secret"
+ | "sentinel"
+ | "sommelier"
+ | "stafi"
+ | "stargaze"
+ | "stride"
+ | "teritori"
+ | "tgrade"
+ | "umee"
+ | "sei"
+ | "mantra"
+ | "celestia"
+ | "saga"
+ | "zetachain"
+ | "dymension"
+ | "humansai"
+ | "neutron"
+ | "polkadot"
+ | "kusama"
+ | "westend"
+ | "bittensor"
+ | "aptos"
+ | "binancebeacon"
+ | "cardano"
+ | "near"
+ | "solana"
+ | "solana-devnet"
+ | "stellar"
+ | "stellar-testnet"
+ | "sui"
+ | "tezos"
+ | "tron"
+ | "ton"
+ | "ton-testnet"
+ | "hyperliquid";
+ readonly chainId: string;
+ readonly type:
+ | "APPROVAL"
+ | "AUTHORIZE"
+ | "SUPPLY"
+ | "BORROW"
+ | "REPAY"
+ | "WITHDRAW"
+ | "ENABLE_COLLATERAL"
+ | "DISABLE_COLLATERAL";
+ readonly status:
+ | "NOT_FOUND"
+ | "CREATED"
+ | "BLOCKED"
+ | "WAITING_FOR_SIGNATURE"
+ | "SIGNED"
+ | "BROADCASTED"
+ | "PENDING"
+ | "CONFIRMED"
+ | "FAILED"
+ | "SKIPPED";
+ readonly address: string;
+ readonly signingFormat?:
+ | "EVM_TRANSACTION"
+ | "EIP712_TYPED_DATA"
+ | "SOLANA_TRANSACTION"
+ | "COSMOS_TRANSACTION";
+ readonly signablePayload?: string | {};
+};
+export const TransactionDto = Schema.Struct({
+ id: Schema.String.annotate({
+ description: "Transaction ID (UUID)",
+ examples: ["550e8400-e29b-41d4-a716-446655440000"],
+ }),
+ network: Schema.Literals([
+ "ethereum",
+ "ethereum-goerli",
+ "ethereum-holesky",
+ "ethereum-sepolia",
+ "ethereum-hoodi",
+ "arbitrum",
+ "base",
+ "base-sepolia",
+ "gnosis",
+ "optimism",
+ "polygon",
+ "polygon-amoy",
+ "starknet",
+ "zksync",
+ "linea",
+ "unichain",
+ "monad-testnet",
+ "monad",
+ "robinhood",
+ "robinhood-testnet",
+ "avalanche-c",
+ "avalanche-c-atomic",
+ "avalanche-p",
+ "binance",
+ "celo",
+ "fantom",
+ "harmony",
+ "moonriver",
+ "okc",
+ "viction",
+ "core",
+ "sonic",
+ "plasma",
+ "katana",
+ "hyperevm",
+ "tempo",
+ "agoric",
+ "akash",
+ "axelar",
+ "band-protocol",
+ "bitsong",
+ "canto",
+ "chihuahua",
+ "comdex",
+ "coreum",
+ "cosmos",
+ "crescent",
+ "cronos",
+ "cudos",
+ "desmos",
+ "dydx",
+ "evmos",
+ "fetch-ai",
+ "gravity-bridge",
+ "injective",
+ "irisnet",
+ "juno",
+ "kava",
+ "ki-network",
+ "mars-protocol",
+ "nym",
+ "okex-chain",
+ "onomy",
+ "osmosis",
+ "persistence",
+ "quicksilver",
+ "regen",
+ "secret",
+ "sentinel",
+ "sommelier",
+ "stafi",
+ "stargaze",
+ "stride",
+ "teritori",
+ "tgrade",
+ "umee",
+ "sei",
+ "mantra",
+ "celestia",
+ "saga",
+ "zetachain",
+ "dymension",
+ "humansai",
+ "neutron",
+ "polkadot",
+ "kusama",
+ "westend",
+ "bittensor",
+ "aptos",
+ "binancebeacon",
+ "cardano",
+ "near",
+ "solana",
+ "solana-devnet",
+ "stellar",
+ "stellar-testnet",
+ "sui",
+ "tezos",
+ "tron",
+ "ton",
+ "ton-testnet",
+ "hyperliquid",
+ ]).annotate({ description: "Network identifier", examples: ["ethereum"] }),
+ chainId: Schema.String.annotate({ description: "Chain ID", examples: ["1"] }),
+ type: Schema.Literals([
+ "APPROVAL",
+ "AUTHORIZE",
+ "SUPPLY",
+ "BORROW",
+ "REPAY",
+ "WITHDRAW",
+ "ENABLE_COLLATERAL",
+ "DISABLE_COLLATERAL",
+ ]).annotate({ description: "Transaction type", examples: ["SUPPLY"] }),
+ status: Schema.Literals([
+ "NOT_FOUND",
+ "CREATED",
+ "BLOCKED",
+ "WAITING_FOR_SIGNATURE",
+ "SIGNED",
+ "BROADCASTED",
+ "PENDING",
+ "CONFIRMED",
+ "FAILED",
+ "SKIPPED",
+ ]).annotate({
+ description: "Current transaction status",
+ examples: ["CREATED"],
+ }),
+ address: Schema.String.annotate({
+ description: "User address",
+ examples: ["0x742d35Cc6634C0532925a3b844Bc9e7595f8fB28"],
+ }),
+ signingFormat: Schema.optionalKey(
+ Schema.Literals([
+ "EVM_TRANSACTION",
+ "EIP712_TYPED_DATA",
+ "SOLANA_TRANSACTION",
+ "COSMOS_TRANSACTION",
+ ]).annotate({
+ description: "Signing format required",
+ examples: ["EVM_TRANSACTION"],
+ })
+ ),
+ signablePayload: Schema.optionalKey(
+ Schema.Union([Schema.String, Schema.Struct({})], {
+ mode: "oneOf",
+ }).annotate({
+ description:
+ "Unsigned transaction payload to sign. For EVM transactions, this is a JSON string containing from, to, data, gasLimit, and value fields.",
+ examples: [
+ '{"from":"0x6877BB79...","gasLimit":"350000","to":"0xA238Dd80...","data":"0x617ba037..."}',
+ ],
+ })
+ ),
+});
+export type ActionRequestDto = {
+ readonly integrationId: string;
+ readonly action:
+ | "supply"
+ | "borrow"
+ | "repay"
+ | "withdraw"
+ | "enableCollateral"
+ | "disableCollateral";
+ readonly address: string;
+ readonly args: {
+ readonly amount?: string;
+ readonly amountRaw?: string;
+ readonly repayAll?: boolean;
+ readonly tokenAddress?: string;
+ readonly collateralTokenAddress?: string;
+ readonly collateralAmount?: string;
+ readonly collateralAmountRaw?: string;
+ readonly borrowAmount?: string;
+ readonly targetLtv?: string;
+ readonly marketId: string;
+ };
+};
+export const ActionRequestDto = Schema.Struct({
+ integrationId: Schema.String.annotate({
+ description: "Integration identifier",
+ examples: ["aave-borrow"],
+ }),
+ action: Schema.Literals([
+ "supply",
+ "borrow",
+ "repay",
+ "withdraw",
+ "enableCollateral",
+ "disableCollateral",
+ ]).annotate({ description: "Action to execute", examples: ["supply"] }),
+ address: Schema.String.annotate({
+ description: "User wallet address",
+ examples: ["0x742d35Cc6634C0532925a3b844Bc9e7595f8fB28"],
+ }),
+ args: Schema.Struct({
+ amount: Schema.optionalKey(
+ Schema.String.annotate({
+ description:
+ 'Amount in human-readable units (e.g. "1.5" for 1.5 USDC). Provide either amount or amountRaw.',
+ examples: ["1.5"],
+ })
+ ),
+ amountRaw: Schema.optionalKey(
+ Schema.String.annotate({
+ description:
+ 'Amount in raw token units (e.g. "1500000" for 1.5 USDC with 6 decimals). Provide either amount or amountRaw.',
+ examples: ["1500000"],
+ })
+ ),
+ repayAll: Schema.optionalKey(
+ Schema.Boolean.annotate({
+ description:
+ "Repay the full outstanding debt using protocol-specific full-repay semantics. Only valid for repay actions. When true, omit amount and amountRaw.",
+ examples: [true],
+ })
+ ),
+ tokenAddress: Schema.optionalKey(
+ Schema.String.annotate({
+ description: "Token address to supply/borrow/repay/withdraw",
+ examples: ["0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"],
+ })
+ ),
+ collateralTokenAddress: Schema.optionalKey(
+ Schema.String.annotate({
+ description:
+ "Collateral token address. Required when providing collateralAmount for pool-based protocols (Aave). Inferred from marketId for isolated-market protocols (Morpho Blue).",
+ examples: ["0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"],
+ })
+ ),
+ collateralAmount: Schema.optionalKey(
+ Schema.String.annotate({
+ description:
+ 'Optional collateral amount in human-readable units (e.g. "0.5" for 0.5 ETH). For borrow: collateral to deposit before borrowing. For repay: collateral to withdraw after repaying. Provide either collateralAmount or collateralAmountRaw.',
+ examples: ["0.5"],
+ })
+ ),
+ collateralAmountRaw: Schema.optionalKey(
+ Schema.String.annotate({
+ description:
+ 'Optional collateral amount in raw token units (e.g. "50000000" for 0.5 ETH with 8 decimals). Provide either collateralAmount or collateralAmountRaw.',
+ examples: ["50000000"],
+ })
+ ),
+ borrowAmount: Schema.optionalKey(
+ Schema.String.annotate({
+ description:
+ "Supply action only. Desired borrow amount in human-readable units of the market loan token. Provide together with targetLtv to have the API derive the required collateral (post-fee). Mutually exclusive with amount/amountRaw.",
+ examples: ["100"],
+ })
+ ),
+ targetLtv: Schema.optionalKey(
+ Schema.String.annotate({
+ description:
+ 'Supply action only. Target loan-to-value as a decimal (e.g. "0.1" = 10%). Provide together with borrowAmount. Must not exceed the market max LTV.',
+ examples: ["0.1"],
+ })
+ ),
+ marketId: Schema.String.annotate({
+ description:
+ "Market ID as returned by GET /v1/markets. Identifies the specific lending market for the action.",
+ examples: ["morpho-blue-borrow-base-cbbtc-usdc-86"],
+ }),
+ }).annotate({ description: "Action arguments" }),
+});
+export type SubmitTransactionDto = {
+ readonly signedPayload?: string;
+ readonly transactionHash?: string;
+};
+export const SubmitTransactionDto = Schema.Struct({
+ signedPayload: Schema.optionalKey(
+ Schema.String.annotate({
+ description:
+ "Signed transaction payload. Required if transactionHash is not provided.",
+ examples: ["0x1234567890abcdef..."],
+ })
+ ),
+ transactionHash: Schema.optionalKey(
+ Schema.String.annotate({
+ description:
+ "Transaction hash if already submitted. Required if signedPayload is not provided.",
+ examples: ["0xabcdef1234567890..."],
+ })
+ ),
+});
+export type SubmitTransactionResponseDto = {
+ readonly transactionHash?: string;
+ readonly link: string;
+ readonly status:
+ | "NOT_FOUND"
+ | "CREATED"
+ | "BLOCKED"
+ | "WAITING_FOR_SIGNATURE"
+ | "SIGNED"
+ | "BROADCASTED"
+ | "PENDING"
+ | "CONFIRMED"
+ | "FAILED"
+ | "SKIPPED";
+ readonly error?: string;
+ readonly details?: {};
+};
+export const SubmitTransactionResponseDto = Schema.Struct({
+ transactionHash: Schema.optionalKey(
+ Schema.String.annotate({
+ description: "Transaction hash (if available)",
+ examples: ["0x1234567890abcdef..."],
+ })
+ ),
+ link: Schema.String.annotate({
+ description: "Link to view transaction on block explorer",
+ examples: ["https://etherscan.io/tx/0x..."],
+ }),
+ status: Schema.Literals([
+ "NOT_FOUND",
+ "CREATED",
+ "BLOCKED",
+ "WAITING_FOR_SIGNATURE",
+ "SIGNED",
+ "BROADCASTED",
+ "PENDING",
+ "CONFIRMED",
+ "FAILED",
+ "SKIPPED",
+ ]).annotate({
+ description: "Transaction status after submission",
+ examples: ["BROADCASTED"],
+ }),
+ error: Schema.optionalKey(
+ Schema.String.annotate({
+ description: "Error message if submission failed",
+ })
+ ),
+ details: Schema.optionalKey(
+ Schema.Struct({}).annotate({ description: "Additional details" })
+ ),
+});
+export type HealthStatusDto = {
+ readonly status: "OK" | "FAIL";
+ readonly timestamp: string;
+};
+export const HealthStatusDto = Schema.Struct({
+ status: Schema.Literals(["OK", "FAIL"]).annotate({
+ description: "The health status of the service",
+ examples: ["OK"],
+ }),
+ timestamp: Schema.String.annotate({
+ description: "Timestamp when the health check was performed",
+ examples: ["2024-01-15T10:30:00.000Z"],
+ format: "date-time",
+ }),
+});
+export type IntegrationDto = {
+ readonly id: string;
+ readonly providerId: string;
+ readonly name: string;
+ readonly networks: ReadonlyArray<
+ | "ethereum"
+ | "ethereum-goerli"
+ | "ethereum-holesky"
+ | "ethereum-sepolia"
+ | "ethereum-hoodi"
+ | "arbitrum"
+ | "base"
+ | "base-sepolia"
+ | "gnosis"
+ | "optimism"
+ | "polygon"
+ | "polygon-amoy"
+ | "starknet"
+ | "zksync"
+ | "linea"
+ | "unichain"
+ | "monad-testnet"
+ | "monad"
+ | "robinhood"
+ | "robinhood-testnet"
+ | "avalanche-c"
+ | "avalanche-c-atomic"
+ | "avalanche-p"
+ | "binance"
+ | "celo"
+ | "fantom"
+ | "harmony"
+ | "moonriver"
+ | "okc"
+ | "viction"
+ | "core"
+ | "sonic"
+ | "plasma"
+ | "katana"
+ | "hyperevm"
+ | "tempo"
+ | "agoric"
+ | "akash"
+ | "axelar"
+ | "band-protocol"
+ | "bitsong"
+ | "canto"
+ | "chihuahua"
+ | "comdex"
+ | "coreum"
+ | "cosmos"
+ | "crescent"
+ | "cronos"
+ | "cudos"
+ | "desmos"
+ | "dydx"
+ | "evmos"
+ | "fetch-ai"
+ | "gravity-bridge"
+ | "injective"
+ | "irisnet"
+ | "juno"
+ | "kava"
+ | "ki-network"
+ | "mars-protocol"
+ | "nym"
+ | "okex-chain"
+ | "onomy"
+ | "osmosis"
+ | "persistence"
+ | "quicksilver"
+ | "regen"
+ | "secret"
+ | "sentinel"
+ | "sommelier"
+ | "stafi"
+ | "stargaze"
+ | "stride"
+ | "teritori"
+ | "tgrade"
+ | "umee"
+ | "sei"
+ | "mantra"
+ | "celestia"
+ | "saga"
+ | "zetachain"
+ | "dymension"
+ | "humansai"
+ | "neutron"
+ | "polkadot"
+ | "kusama"
+ | "westend"
+ | "bittensor"
+ | "aptos"
+ | "binancebeacon"
+ | "cardano"
+ | "near"
+ | "solana"
+ | "solana-devnet"
+ | "stellar"
+ | "stellar-testnet"
+ | "sui"
+ | "tezos"
+ | "tron"
+ | "ton"
+ | "ton-testnet"
+ | "hyperliquid"
+ >;
+ readonly metadata: {
+ readonly description: string;
+ readonly externalLink: string;
+ readonly logoURI: string;
+ };
+ readonly actions: ReadonlyArray;
+};
+export const IntegrationDto = Schema.Struct({
+ id: Schema.String.annotate({
+ description: "Integration identifier",
+ examples: ["aave-borrow"],
+ }),
+ providerId: Schema.String.annotate({
+ description: "Provider identifier",
+ examples: ["aave"],
+ }),
+ name: Schema.String.annotate({
+ description: "Integration display name",
+ examples: ["Aave V3"],
+ }),
+ networks: Schema.Array(
+ Schema.Literals([
+ "ethereum",
+ "ethereum-goerli",
+ "ethereum-holesky",
+ "ethereum-sepolia",
+ "ethereum-hoodi",
+ "arbitrum",
+ "base",
+ "base-sepolia",
+ "gnosis",
+ "optimism",
+ "polygon",
+ "polygon-amoy",
+ "starknet",
+ "zksync",
+ "linea",
+ "unichain",
+ "monad-testnet",
+ "monad",
+ "robinhood",
+ "robinhood-testnet",
+ "avalanche-c",
+ "avalanche-c-atomic",
+ "avalanche-p",
+ "binance",
+ "celo",
+ "fantom",
+ "harmony",
+ "moonriver",
+ "okc",
+ "viction",
+ "core",
+ "sonic",
+ "plasma",
+ "katana",
+ "hyperevm",
+ "tempo",
+ "agoric",
+ "akash",
+ "axelar",
+ "band-protocol",
+ "bitsong",
+ "canto",
+ "chihuahua",
+ "comdex",
+ "coreum",
+ "cosmos",
+ "crescent",
+ "cronos",
+ "cudos",
+ "desmos",
+ "dydx",
+ "evmos",
+ "fetch-ai",
+ "gravity-bridge",
+ "injective",
+ "irisnet",
+ "juno",
+ "kava",
+ "ki-network",
+ "mars-protocol",
+ "nym",
+ "okex-chain",
+ "onomy",
+ "osmosis",
+ "persistence",
+ "quicksilver",
+ "regen",
+ "secret",
+ "sentinel",
+ "sommelier",
+ "stafi",
+ "stargaze",
+ "stride",
+ "teritori",
+ "tgrade",
+ "umee",
+ "sei",
+ "mantra",
+ "celestia",
+ "saga",
+ "zetachain",
+ "dymension",
+ "humansai",
+ "neutron",
+ "polkadot",
+ "kusama",
+ "westend",
+ "bittensor",
+ "aptos",
+ "binancebeacon",
+ "cardano",
+ "near",
+ "solana",
+ "solana-devnet",
+ "stellar",
+ "stellar-testnet",
+ "sui",
+ "tezos",
+ "tron",
+ "ton",
+ "ton-testnet",
+ "hyperliquid",
+ ])
+ ).annotate({
+ description: "Supported networks",
+ examples: [["ethereum", "polygon", "arbitrum"]],
+ }),
+ metadata: Schema.Struct({
+ description: Schema.String.annotate({
+ description: "Integration description",
+ examples: [
+ "Aave is a decentralized non-custodial liquidity protocol for lending and borrowing.",
+ ],
+ }),
+ externalLink: Schema.String.annotate({
+ description: "External link to the protocol website",
+ examples: ["https://aave.com"],
+ }),
+ logoURI: Schema.String.annotate({
+ description: "Protocol logo URI",
+ examples: ["https://assets.stakek.it/protocols/aave.svg"],
+ }),
+ }).annotate({
+ description: "Integration metadata (description, logo, links)",
+ }),
+ actions: Schema.Array(ActionDefinitionDto).annotate({
+ description: "Supported actions with labels and argument schemas",
+ }),
+});
+export type MarketDto = {
+ readonly id: string;
+ readonly integrationId: string;
+ readonly network:
+ | "ethereum"
+ | "ethereum-goerli"
+ | "ethereum-holesky"
+ | "ethereum-sepolia"
+ | "ethereum-hoodi"
+ | "arbitrum"
+ | "base"
+ | "base-sepolia"
+ | "gnosis"
+ | "optimism"
+ | "polygon"
+ | "polygon-amoy"
+ | "starknet"
+ | "zksync"
+ | "linea"
+ | "unichain"
+ | "monad-testnet"
+ | "monad"
+ | "robinhood"
+ | "robinhood-testnet"
+ | "avalanche-c"
+ | "avalanche-c-atomic"
+ | "avalanche-p"
+ | "binance"
+ | "celo"
+ | "fantom"
+ | "harmony"
+ | "moonriver"
+ | "okc"
+ | "viction"
+ | "core"
+ | "sonic"
+ | "plasma"
+ | "katana"
+ | "hyperevm"
+ | "tempo"
+ | "agoric"
+ | "akash"
+ | "axelar"
+ | "band-protocol"
+ | "bitsong"
+ | "canto"
+ | "chihuahua"
+ | "comdex"
+ | "coreum"
+ | "cosmos"
+ | "crescent"
+ | "cronos"
+ | "cudos"
+ | "desmos"
+ | "dydx"
+ | "evmos"
+ | "fetch-ai"
+ | "gravity-bridge"
+ | "injective"
+ | "irisnet"
+ | "juno"
+ | "kava"
+ | "ki-network"
+ | "mars-protocol"
+ | "nym"
+ | "okex-chain"
+ | "onomy"
+ | "osmosis"
+ | "persistence"
+ | "quicksilver"
+ | "regen"
+ | "secret"
+ | "sentinel"
+ | "sommelier"
+ | "stafi"
+ | "stargaze"
+ | "stride"
+ | "teritori"
+ | "tgrade"
+ | "umee"
+ | "sei"
+ | "mantra"
+ | "celestia"
+ | "saga"
+ | "zetachain"
+ | "dymension"
+ | "humansai"
+ | "neutron"
+ | "polkadot"
+ | "kusama"
+ | "westend"
+ | "bittensor"
+ | "aptos"
+ | "binancebeacon"
+ | "cardano"
+ | "near"
+ | "solana"
+ | "solana-devnet"
+ | "stellar"
+ | "stellar-testnet"
+ | "sui"
+ | "tezos"
+ | "tron"
+ | "ton"
+ | "ton-testnet"
+ | "hyperliquid";
+ readonly type: "pool" | "isolated";
+ readonly poolAddress: string;
+ readonly loanToken: {
+ readonly address?: string;
+ readonly symbol: string;
+ readonly name: string;
+ readonly decimals: number;
+ readonly logoURI?: string;
+ };
+ readonly collateralTokens: ReadonlyArray;
+ readonly borrowRate: string;
+ readonly totalSupply: string;
+ readonly totalSupplyRaw: string;
+ readonly totalBorrow: string;
+ readonly totalBorrowRaw: string;
+ readonly availableLiquidity: string;
+ readonly availableLiquidityRaw: string;
+ readonly utilizationRate: string;
+ readonly loanTokenPriceUsd: string;
+ readonly isBorrowEnabled: boolean;
+ readonly supplyCollateralFeeBps: string;
+ readonly feeWrapperAddress: string | null;
+ readonly minLoan: string | null;
+};
+export const MarketDto = Schema.Struct({
+ id: Schema.String.annotate({
+ description: "Market ID",
+ examples: ["aave-v3-ethereum-usdc"],
+ }),
+ integrationId: Schema.String.annotate({
+ description: "Integration ID",
+ examples: ["aave-borrow"],
+ }),
+ network: Schema.Literals([
+ "ethereum",
+ "ethereum-goerli",
+ "ethereum-holesky",
+ "ethereum-sepolia",
+ "ethereum-hoodi",
+ "arbitrum",
+ "base",
+ "base-sepolia",
+ "gnosis",
+ "optimism",
+ "polygon",
+ "polygon-amoy",
+ "starknet",
+ "zksync",
+ "linea",
+ "unichain",
+ "monad-testnet",
+ "monad",
+ "robinhood",
+ "robinhood-testnet",
+ "avalanche-c",
+ "avalanche-c-atomic",
+ "avalanche-p",
+ "binance",
+ "celo",
+ "fantom",
+ "harmony",
+ "moonriver",
+ "okc",
+ "viction",
+ "core",
+ "sonic",
+ "plasma",
+ "katana",
+ "hyperevm",
+ "tempo",
+ "agoric",
+ "akash",
+ "axelar",
+ "band-protocol",
+ "bitsong",
+ "canto",
+ "chihuahua",
+ "comdex",
+ "coreum",
+ "cosmos",
+ "crescent",
+ "cronos",
+ "cudos",
+ "desmos",
+ "dydx",
+ "evmos",
+ "fetch-ai",
+ "gravity-bridge",
+ "injective",
+ "irisnet",
+ "juno",
+ "kava",
+ "ki-network",
+ "mars-protocol",
+ "nym",
+ "okex-chain",
+ "onomy",
+ "osmosis",
+ "persistence",
+ "quicksilver",
+ "regen",
+ "secret",
+ "sentinel",
+ "sommelier",
+ "stafi",
+ "stargaze",
+ "stride",
+ "teritori",
+ "tgrade",
+ "umee",
+ "sei",
+ "mantra",
+ "celestia",
+ "saga",
+ "zetachain",
+ "dymension",
+ "humansai",
+ "neutron",
+ "polkadot",
+ "kusama",
+ "westend",
+ "bittensor",
+ "aptos",
+ "binancebeacon",
+ "cardano",
+ "near",
+ "solana",
+ "solana-devnet",
+ "stellar",
+ "stellar-testnet",
+ "sui",
+ "tezos",
+ "tron",
+ "ton",
+ "ton-testnet",
+ "hyperliquid",
+ ]).annotate({ description: "Network", examples: ["ethereum"] }),
+ type: Schema.Literals(["pool", "isolated"]).annotate({
+ description:
+ 'Market type. "pool" for shared-pool protocols (Aave), "isolated" for pair-based protocols (Morpho Blue).',
+ examples: ["pool"],
+ }),
+ poolAddress: Schema.String.annotate({
+ description: "Pool/protocol contract address",
+ examples: ["0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2"],
+ }),
+ loanToken: Schema.Struct({
+ address: Schema.optionalKey(
+ Schema.String.annotate({
+ description: "Token contract address",
+ examples: ["0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"],
+ })
+ ),
+ symbol: Schema.String.annotate({
+ description: "Token symbol",
+ examples: ["USDC"],
+ }),
+ name: Schema.String.annotate({
+ description: "Token name",
+ examples: ["USD Coin"],
+ }),
+ decimals: Schema.Number.annotate({
+ description: "Token decimals",
+ examples: [6],
+ }).check(Schema.isFinite()),
+ logoURI: Schema.optionalKey(
+ Schema.String.annotate({ description: "Token logo URI" })
+ ),
+ }).annotate({ description: "The borrowable token" }),
+ collateralTokens: Schema.Array(CollateralTokenDto).annotate({
+ description:
+ "Accepted collateral tokens with per-collateral risk parameters. For isolated markets: single entry. For pool markets: all eligible collateral assets.",
+ }),
+ borrowRate: Schema.String.annotate({
+ description: "Current variable borrow rate as decimal",
+ examples: ["0.0567"],
+ }),
+ totalSupply: Schema.String.annotate({
+ description: "Total supplied amount in human-readable token units",
+ examples: ["1234567.89"],
+ }),
+ totalSupplyRaw: Schema.String.annotate({
+ description: "Total supplied amount in raw token units",
+ examples: ["1234567890000000"],
+ }),
+ totalBorrow: Schema.String.annotate({
+ description: "Total borrowed amount in human-readable token units",
+ examples: ["987654.321"],
+ }),
+ totalBorrowRaw: Schema.String.annotate({
+ description: "Total borrowed amount in raw token units",
+ examples: ["987654321000000"],
+ }),
+ availableLiquidity: Schema.String.annotate({
+ description: "Available liquidity in human-readable token units",
+ examples: ["246913.569"],
+ }),
+ availableLiquidityRaw: Schema.String.annotate({
+ description: "Available liquidity in raw token units",
+ examples: ["246913569000000"],
+ }),
+ utilizationRate: Schema.String.annotate({
+ description: "Utilization rate as decimal",
+ examples: ["0.80"],
+ }),
+ loanTokenPriceUsd: Schema.String.annotate({
+ description: "Loan token price in USD",
+ examples: ["1.00"],
+ }),
+ isBorrowEnabled: Schema.Boolean.annotate({
+ description: "Whether borrowing is enabled for this asset",
+ examples: [true],
+ }),
+ supplyCollateralFeeBps: Schema.String.annotate({
+ description:
+ "Deposit fee charged on supplyCollateral, in basis points (1 bp = 0.01%; 50 = 0.5%). Ranges from 0 to 500 (0% to 5%).",
+ examples: ["50"],
+ }),
+ feeWrapperAddress: Schema.Union([Schema.String, Schema.Null]).annotate({
+ description:
+ "Address of the fee wrapper contract charging the deposit fee for the requesting project. null when no wrapper is configured for the (project, integration) pair.",
+ examples: ["0x29ac0c53Eb4b19295875D3895Fb1514ffC8e1616"],
+ }),
+ minLoan: Schema.Union([Schema.String, Schema.Null]).annotate({
+ description:
+ "Minimum borrowable amount in human-readable loan-token units. Borrows that would leave debt below this floor, and partial repays that leave remaining debt below it, revert on-chain. null when the market enforces no minimum (e.g. non-Lista integrations).",
+ examples: ["0.01"],
+ }),
+});
+export type SupplyBalanceDto = {
+ readonly marketId: string;
+ readonly tokenAddress: string;
+ readonly tokenSymbol: string;
+ readonly balance: string;
+ readonly balanceRaw: string;
+ readonly balanceUsd: string;
+ readonly apy: string;
+ readonly isCollateral: boolean;
+ readonly positionState?: {
+ readonly currentLtv: string;
+ readonly liquidationThreshold: string;
+ readonly healthFactor: string | null;
+ readonly availableToBorrowUsd: string;
+ };
+ readonly pendingActions: ReadonlyArray;
+};
+export const SupplyBalanceDto = Schema.Struct({
+ marketId: Schema.String.annotate({
+ description: "Market ID",
+ examples: ["aave-v3-ethereum-usdc"],
+ }),
+ tokenAddress: Schema.String.annotate({
+ description: "Token contract address",
+ examples: ["0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"],
+ }),
+ tokenSymbol: Schema.String.annotate({
+ description: "Token symbol",
+ examples: ["USDC"],
+ }),
+ balance: Schema.String.annotate({
+ description: "Supplied balance in human-readable token units",
+ examples: ["1000.00"],
+ }),
+ balanceRaw: Schema.String.annotate({
+ description: "Supplied balance in raw token units",
+ examples: ["1000000000"],
+ }),
+ balanceUsd: Schema.String.annotate({
+ description: "Supplied balance in USD",
+ examples: ["1000.00"],
+ }),
+ apy: Schema.String.annotate({
+ description: "Current supply APY as percentage",
+ examples: ["3.45"],
+ }),
+ isCollateral: Schema.Boolean.annotate({
+ description: "Whether this supply is being used as collateral",
+ examples: [true],
+ }),
+ positionState: Schema.optionalKey(
+ Schema.Struct({
+ currentLtv: Schema.String.annotate({
+ description:
+ 'Loan-to-value for this market as a decimal (e.g., "0.20" = 20%)',
+ examples: ["0.1980"],
+ }),
+ liquidationThreshold: Schema.String.annotate({
+ description:
+ 'Liquidation LTV for this market as a decimal (e.g., "0.86" = 86%)',
+ examples: ["0.8600"],
+ }),
+ healthFactor: Schema.Union([Schema.String, Schema.Null]).annotate({
+ description:
+ "Health factor for this market (>1 is safe, <1 means liquidatable). Null when this market has no debt.",
+ examples: ["4.3430"],
+ }),
+ availableToBorrowUsd: Schema.String.annotate({
+ description: "Remaining borrowing power in USD within this market",
+ examples: ["2.01"],
+ }),
+ }).annotate({
+ description:
+ "Per-market risk state for isolated-market protocols (e.g. Morpho Blue), where borrowing power and liquidation are scoped to this market. Omitted for pool-based protocols (e.g. Aave), where the account-level fields apply.",
+ })
+ ),
+ pendingActions: Schema.Array(BorrowPendingActionDto).annotate({
+ description: "Available actions for this supply balance",
+ }),
+});
+export type DebtBalanceDto = {
+ readonly marketId: string;
+ readonly tokenAddress: string;
+ readonly tokenSymbol: string;
+ readonly balance: string;
+ readonly balanceRaw: string;
+ readonly balanceUsd: string;
+ readonly apy: string;
+ readonly pendingActions: ReadonlyArray;
+};
+export const DebtBalanceDto = Schema.Struct({
+ marketId: Schema.String.annotate({
+ description: "Market ID",
+ examples: ["aave-v3-ethereum-weth"],
+ }),
+ tokenAddress: Schema.String.annotate({
+ description: "Token contract address",
+ examples: ["0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"],
+ }),
+ tokenSymbol: Schema.String.annotate({
+ description: "Token symbol",
+ examples: ["WETH"],
+ }),
+ balance: Schema.String.annotate({
+ description: "Borrowed balance in human-readable token units",
+ examples: ["0.5"],
+ }),
+ balanceRaw: Schema.String.annotate({
+ description: "Borrowed balance in raw token units",
+ examples: ["500000000000000000"],
+ }),
+ balanceUsd: Schema.String.annotate({
+ description: "Borrowed balance in USD",
+ examples: ["1500.00"],
+ }),
+ apy: Schema.String.annotate({
+ description: "Current borrow APY as percentage",
+ examples: ["5.67"],
+ }),
+ pendingActions: Schema.Array(BorrowPendingActionDto).annotate({
+ description: "Available actions for this debt balance",
+ }),
+});
+export type ActionDto = {
+ readonly id: string;
+ readonly integrationId: string;
+ readonly action:
+ | "supply"
+ | "borrow"
+ | "repay"
+ | "withdraw"
+ | "enableCollateral"
+ | "disableCollateral";
+ readonly address: string;
+ readonly status:
+ | "CANCELED"
+ | "CREATED"
+ | "WAITING_FOR_NEXT"
+ | "PROCESSING"
+ | "FAILED"
+ | "SUCCESS"
+ | "STALE";
+ readonly transactions: ReadonlyArray;
+ readonly hasNextStep: boolean;
+ readonly currentStep: number;
+ readonly totalSteps: number;
+ readonly rawArguments?: {
+ readonly amount?: string;
+ readonly amountRaw?: string;
+ readonly repayAll?: boolean;
+ readonly tokenAddress?: string;
+ readonly collateralTokenAddress?: string;
+ readonly collateralAmount?: string;
+ readonly collateralAmountRaw?: string;
+ readonly borrowAmount?: string;
+ readonly targetLtv?: string;
+ readonly marketId: string;
+ };
+ readonly metadata?: {
+ readonly currentHealthFactor: string | null;
+ readonly predictedHealthFactor: string | null;
+ readonly currentLtv: string;
+ readonly predictedLtv: string;
+ readonly liquidationThreshold: string;
+ readonly predictedTotalSupplyUsd: string;
+ readonly predictedTotalDebtUsd: string;
+ readonly originationFeeBps?: number;
+ readonly originationFeeAmount?: string;
+ readonly effectivePrincipalAmount?: string;
+ readonly feeAmount?: string;
+ readonly feeBps?: number;
+ readonly effectiveCollateralAmount?: string;
+ };
+ readonly createdAt: string;
+};
+export const ActionDto = Schema.Struct({
+ id: Schema.String.annotate({
+ description: "Unique action identifier (UUID)",
+ examples: ["550e8400-e29b-41d4-a716-446655440000"],
+ }),
+ integrationId: Schema.String.annotate({
+ description: "Integration identifier",
+ examples: ["aave-borrow"],
+ }),
+ action: Schema.Literals([
+ "supply",
+ "borrow",
+ "repay",
+ "withdraw",
+ "enableCollateral",
+ "disableCollateral",
+ ]).annotate({ description: "Action type executed", examples: ["supply"] }),
+ address: Schema.String.annotate({
+ description: "User wallet address",
+ examples: ["0x742d35Cc6634C0532925a3b844Bc9e7595f8fB28"],
+ }),
+ status: Schema.Literals([
+ "CANCELED",
+ "CREATED",
+ "WAITING_FOR_NEXT",
+ "PROCESSING",
+ "FAILED",
+ "SUCCESS",
+ "STALE",
+ ]).annotate({ description: "Current action status", examples: ["CREATED"] }),
+ transactions: Schema.Array(TransactionDto).annotate({
+ description: "Transactions to sign (current step only)",
+ }),
+ hasNextStep: Schema.Boolean.annotate({
+ description:
+ "Whether there are more transactions to sign after the current step completes. When true, call POST /actions/:id/step after confirming to get the next transaction(s). Used for async multi-step flows such as cross-chain bridges or delayed withdrawals.",
+ examples: [false],
+ }),
+ currentStep: Schema.Number.annotate({
+ description: "Current step number (1-indexed)",
+ examples: [1],
+ }).check(Schema.isFinite()),
+ totalSteps: Schema.Number.annotate({
+ description:
+ "Total number of steps in this action. Most actions are single-step. Multi-step actions occur when an async operation (e.g., bridge confirmation) must complete before the next transaction can be constructed.",
+ examples: [1],
+ }).check(Schema.isFinite()),
+ rawArguments: Schema.optionalKey(
+ Schema.Struct({
+ amount: Schema.optionalKey(
+ Schema.String.annotate({
+ description:
+ 'Amount in human-readable units (e.g. "1.5" for 1.5 USDC). Provide either amount or amountRaw.',
+ examples: ["1.5"],
+ })
+ ),
+ amountRaw: Schema.optionalKey(
+ Schema.String.annotate({
+ description:
+ 'Amount in raw token units (e.g. "1500000" for 1.5 USDC with 6 decimals). Provide either amount or amountRaw.',
+ examples: ["1500000"],
+ })
+ ),
+ repayAll: Schema.optionalKey(
+ Schema.Boolean.annotate({
+ description:
+ "Repay the full outstanding debt using protocol-specific full-repay semantics. Only valid for repay actions. When true, omit amount and amountRaw.",
+ examples: [true],
+ })
+ ),
+ tokenAddress: Schema.optionalKey(
+ Schema.String.annotate({
+ description: "Token address to supply/borrow/repay/withdraw",
+ examples: ["0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"],
+ })
+ ),
+ collateralTokenAddress: Schema.optionalKey(
+ Schema.String.annotate({
+ description:
+ "Collateral token address. Required when providing collateralAmount for pool-based protocols (Aave). Inferred from marketId for isolated-market protocols (Morpho Blue).",
+ examples: ["0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"],
+ })
+ ),
+ collateralAmount: Schema.optionalKey(
+ Schema.String.annotate({
+ description:
+ 'Optional collateral amount in human-readable units (e.g. "0.5" for 0.5 ETH). For borrow: collateral to deposit before borrowing. For repay: collateral to withdraw after repaying. Provide either collateralAmount or collateralAmountRaw.',
+ examples: ["0.5"],
+ })
+ ),
+ collateralAmountRaw: Schema.optionalKey(
+ Schema.String.annotate({
+ description:
+ 'Optional collateral amount in raw token units (e.g. "50000000" for 0.5 ETH with 8 decimals). Provide either collateralAmount or collateralAmountRaw.',
+ examples: ["50000000"],
+ })
+ ),
+ borrowAmount: Schema.optionalKey(
+ Schema.String.annotate({
+ description:
+ "Supply action only. Desired borrow amount in human-readable units of the market loan token. Provide together with targetLtv to have the API derive the required collateral (post-fee). Mutually exclusive with amount/amountRaw.",
+ examples: ["100"],
+ })
+ ),
+ targetLtv: Schema.optionalKey(
+ Schema.String.annotate({
+ description:
+ 'Supply action only. Target loan-to-value as a decimal (e.g. "0.1" = 10%). Provide together with borrowAmount. Must not exceed the market max LTV.',
+ examples: ["0.1"],
+ })
+ ),
+ marketId: Schema.String.annotate({
+ description:
+ "Market ID as returned by GET /v1/markets. Identifies the specific lending market for the action.",
+ examples: ["morpho-blue-borrow-base-cbbtc-usdc-86"],
+ }),
+ }).annotate({ description: "Raw arguments as submitted" })
+ ),
+ metadata: Schema.optionalKey(
+ Schema.Struct({
+ currentHealthFactor: Schema.Union([Schema.String, Schema.Null]).annotate({
+ description: "Current health factor. Null when there is no debt.",
+ examples: ["1.85"],
+ }),
+ predictedHealthFactor: Schema.Union([
+ Schema.String,
+ Schema.Null,
+ ]).annotate({
+ description:
+ "Predicted health factor after the action. Null when predicted debt is zero.",
+ examples: ["1.42"],
+ }),
+ currentLtv: Schema.String.annotate({
+ description: "Current loan-to-value ratio as decimal",
+ examples: ["0.55"],
+ }),
+ predictedLtv: Schema.String.annotate({
+ description: "Predicted loan-to-value ratio after the action",
+ examples: ["0.72"],
+ }),
+ liquidationThreshold: Schema.String.annotate({
+ description: "Liquidation threshold as decimal",
+ examples: ["0.85"],
+ }),
+ predictedTotalSupplyUsd: Schema.String.annotate({
+ description: "Predicted total supply in USD after the action",
+ examples: ["10000.00"],
+ }),
+ predictedTotalDebtUsd: Schema.String.annotate({
+ description: "Predicted total debt in USD after the action",
+ examples: ["7200.00"],
+ }),
+ originationFeeBps: Schema.optionalKey(
+ Schema.Number.annotate({
+ description:
+ "Origination fee in basis points when a borrow wrapper is applied",
+ examples: [100],
+ }).check(Schema.isFinite())
+ ),
+ originationFeeAmount: Schema.optionalKey(
+ Schema.String.annotate({
+ description:
+ "Origination fee amount in borrowed-token units when a borrow wrapper is applied",
+ examples: ["1.00"],
+ })
+ ),
+ effectivePrincipalAmount: Schema.optionalKey(
+ Schema.String.annotate({
+ description:
+ "Actual principal recorded onchain in borrowed-token units when a borrow wrapper is applied",
+ examples: ["101.00"],
+ })
+ ),
+ feeAmount: Schema.optionalKey(
+ Schema.String.annotate({
+ description:
+ 'Fee charged on supplied collateral, in human-readable units of the supplied token. Present on supply actions; may also be present on borrow actions that bundle a collateral leg, depending on the integration. "0" when no supply fee wrapper is configured or its rate is 0.',
+ examples: ["0.0005"],
+ })
+ ),
+ feeBps: Schema.optionalKey(
+ Schema.Number.annotate({
+ description:
+ "Fee rate in basis points on supplied collateral. Present on supply actions; may also be present on borrow actions that bundle a collateral leg, depending on the integration. 0 when no supply fee wrapper is configured or its rate is 0.",
+ examples: [50],
+ }).check(Schema.isFinite())
+ ),
+ effectiveCollateralAmount: Schema.optionalKey(
+ Schema.String.annotate({
+ description:
+ "Collateral derived from borrowAmount + targetLtv, in human-readable units of the collateral token (post-fee). Present only when a supply action is called in borrow-derived mode.",
+ examples: ["0.0125"],
+ })
+ ),
+ }).annotate({
+ description:
+ "Action metadata with predicted position impact (health factor, LTV, USD totals)",
+ })
+ ),
+ createdAt: Schema.String.annotate({
+ description: "When the action was created",
+ format: "date-time",
+ }),
+});
+export type PositionDto = {
+ readonly address: string;
+ readonly integrationId: string;
+ readonly network:
+ | "ethereum"
+ | "ethereum-goerli"
+ | "ethereum-holesky"
+ | "ethereum-sepolia"
+ | "ethereum-hoodi"
+ | "arbitrum"
+ | "base"
+ | "base-sepolia"
+ | "gnosis"
+ | "optimism"
+ | "polygon"
+ | "polygon-amoy"
+ | "starknet"
+ | "zksync"
+ | "linea"
+ | "unichain"
+ | "monad-testnet"
+ | "monad"
+ | "robinhood"
+ | "robinhood-testnet"
+ | "avalanche-c"
+ | "avalanche-c-atomic"
+ | "avalanche-p"
+ | "binance"
+ | "celo"
+ | "fantom"
+ | "harmony"
+ | "moonriver"
+ | "okc"
+ | "viction"
+ | "core"
+ | "sonic"
+ | "plasma"
+ | "katana"
+ | "hyperevm"
+ | "tempo"
+ | "agoric"
+ | "akash"
+ | "axelar"
+ | "band-protocol"
+ | "bitsong"
+ | "canto"
+ | "chihuahua"
+ | "comdex"
+ | "coreum"
+ | "cosmos"
+ | "crescent"
+ | "cronos"
+ | "cudos"
+ | "desmos"
+ | "dydx"
+ | "evmos"
+ | "fetch-ai"
+ | "gravity-bridge"
+ | "injective"
+ | "irisnet"
+ | "juno"
+ | "kava"
+ | "ki-network"
+ | "mars-protocol"
+ | "nym"
+ | "okex-chain"
+ | "onomy"
+ | "osmosis"
+ | "persistence"
+ | "quicksilver"
+ | "regen"
+ | "secret"
+ | "sentinel"
+ | "sommelier"
+ | "stafi"
+ | "stargaze"
+ | "stride"
+ | "teritori"
+ | "tgrade"
+ | "umee"
+ | "sei"
+ | "mantra"
+ | "celestia"
+ | "saga"
+ | "zetachain"
+ | "dymension"
+ | "humansai"
+ | "neutron"
+ | "polkadot"
+ | "kusama"
+ | "westend"
+ | "bittensor"
+ | "aptos"
+ | "binancebeacon"
+ | "cardano"
+ | "near"
+ | "solana"
+ | "solana-devnet"
+ | "stellar"
+ | "stellar-testnet"
+ | "sui"
+ | "tezos"
+ | "tron"
+ | "ton"
+ | "ton-testnet"
+ | "hyperliquid";
+ readonly totalSuppliedUsd: string;
+ readonly totalCollateralUsd: string;
+ readonly totalBorrowedUsd: string;
+ readonly netWorthUsd: string;
+ readonly healthFactor: string | null;
+ readonly currentLtv: string;
+ readonly availableToBorrowUsd: string | null;
+ readonly netApy: string;
+ readonly supplyBalances: ReadonlyArray;
+ readonly debtBalances: ReadonlyArray;
+};
+export const PositionDto = Schema.Struct({
+ address: Schema.String.annotate({
+ description: "User wallet address",
+ examples: ["0x742d35Cc6634C0532925a3b844Bc9e7595f8fB28"],
+ }),
+ integrationId: Schema.String.annotate({
+ description: "Integration ID",
+ examples: ["aave-borrow"],
+ }),
+ network: Schema.Literals([
+ "ethereum",
+ "ethereum-goerli",
+ "ethereum-holesky",
+ "ethereum-sepolia",
+ "ethereum-hoodi",
+ "arbitrum",
+ "base",
+ "base-sepolia",
+ "gnosis",
+ "optimism",
+ "polygon",
+ "polygon-amoy",
+ "starknet",
+ "zksync",
+ "linea",
+ "unichain",
+ "monad-testnet",
+ "monad",
+ "robinhood",
+ "robinhood-testnet",
+ "avalanche-c",
+ "avalanche-c-atomic",
+ "avalanche-p",
+ "binance",
+ "celo",
+ "fantom",
+ "harmony",
+ "moonriver",
+ "okc",
+ "viction",
+ "core",
+ "sonic",
+ "plasma",
+ "katana",
+ "hyperevm",
+ "tempo",
+ "agoric",
+ "akash",
+ "axelar",
+ "band-protocol",
+ "bitsong",
+ "canto",
+ "chihuahua",
+ "comdex",
+ "coreum",
+ "cosmos",
+ "crescent",
+ "cronos",
+ "cudos",
+ "desmos",
+ "dydx",
+ "evmos",
+ "fetch-ai",
+ "gravity-bridge",
+ "injective",
+ "irisnet",
+ "juno",
+ "kava",
+ "ki-network",
+ "mars-protocol",
+ "nym",
+ "okex-chain",
+ "onomy",
+ "osmosis",
+ "persistence",
+ "quicksilver",
+ "regen",
+ "secret",
+ "sentinel",
+ "sommelier",
+ "stafi",
+ "stargaze",
+ "stride",
+ "teritori",
+ "tgrade",
+ "umee",
+ "sei",
+ "mantra",
+ "celestia",
+ "saga",
+ "zetachain",
+ "dymension",
+ "humansai",
+ "neutron",
+ "polkadot",
+ "kusama",
+ "westend",
+ "bittensor",
+ "aptos",
+ "binancebeacon",
+ "cardano",
+ "near",
+ "solana",
+ "solana-devnet",
+ "stellar",
+ "stellar-testnet",
+ "sui",
+ "tezos",
+ "tron",
+ "ton",
+ "ton-testnet",
+ "hyperliquid",
+ ]).annotate({ description: "Network", examples: ["ethereum"] }),
+ totalSuppliedUsd: Schema.String.annotate({
+ description: "Total supplied value in USD (all supplied assets)",
+ examples: ["5000.00"],
+ }),
+ totalCollateralUsd: Schema.String.annotate({
+ description:
+ "Total collateral value in USD (only assets enabled as collateral)",
+ examples: ["4200.00"],
+ }),
+ totalBorrowedUsd: Schema.String.annotate({
+ description: "Total borrowed value in USD",
+ examples: ["2000.00"],
+ }),
+ netWorthUsd: Schema.String.annotate({
+ description: "Net worth (supplied - borrowed) in USD",
+ examples: ["3000.00"],
+ }),
+ healthFactor: Schema.Union([Schema.String, Schema.Null]).annotate({
+ description:
+ "Account-level health factor (>1 is safe, <1 means liquidatable). Null when there is no debt, and always null for isolated-market protocols (e.g. Morpho) — read each supply balance's positionState.healthFactor instead.",
+ examples: ["1.85"],
+ }),
+ currentLtv: Schema.String.annotate({
+ description: 'Current loan-to-value ratio as decimal (e.g., "0.55" = 55%)',
+ examples: ["0.55"],
+ }),
+ availableToBorrowUsd: Schema.Union([Schema.String, Schema.Null]).annotate({
+ description:
+ "Account-level available to borrow in USD based on collateral. Null for isolated-market protocols (e.g. Morpho), where headroom is per-market — read each supply balance's positionState.availableToBorrowUsd instead.",
+ examples: ["1500.00"],
+ }),
+ netApy: Schema.String.annotate({
+ description: "Net APY (supply earnings - borrow costs)",
+ examples: ["2.34"],
+ }),
+ supplyBalances: Schema.Array(SupplyBalanceDto).annotate({
+ description: "Supply positions",
+ }),
+ debtBalances: Schema.Array(DebtBalanceDto).annotate({
+ description: "Debt positions",
+ }),
+});
+// schemas
+export type IntegrationsControllerGetIntegrationsV1200 =
+ ReadonlyArray;
+export const IntegrationsControllerGetIntegrationsV1200 =
+ Schema.Array(IntegrationDto);
+export type IntegrationsControllerGetIntegrationsV1401 = {
+ readonly message?: string;
+ readonly error?: string;
+ readonly statusCode?: number;
+};
+export const IntegrationsControllerGetIntegrationsV1401 = Schema.Struct({
+ message: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Invalid API key"] })
+ ),
+ error: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Unauthorized"] })
+ ),
+ statusCode: Schema.optionalKey(
+ Schema.Number.annotate({ examples: [401] }).check(Schema.isFinite())
+ ),
+});
+export type IntegrationsControllerGetIntegrationsV1429 = {
+ readonly message?: string;
+ readonly error?: string;
+ readonly statusCode?: number;
+ readonly retryAfter?: number;
+};
+export const IntegrationsControllerGetIntegrationsV1429 = Schema.Struct({
+ message: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Rate limit exceeded"] })
+ ),
+ error: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Too Many Requests"] })
+ ),
+ statusCode: Schema.optionalKey(
+ Schema.Number.annotate({ examples: [429] }).check(Schema.isFinite())
+ ),
+ retryAfter: Schema.optionalKey(
+ Schema.Number.annotate({ examples: [30] }).check(Schema.isFinite())
+ ),
+});
+export type IntegrationsControllerGetIntegrationV1200 = IntegrationDto;
+export const IntegrationsControllerGetIntegrationV1200 = IntegrationDto;
+export type IntegrationsControllerGetIntegrationV1401 = {
+ readonly message?: string;
+ readonly error?: string;
+ readonly statusCode?: number;
+};
+export const IntegrationsControllerGetIntegrationV1401 = Schema.Struct({
+ message: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Invalid API key"] })
+ ),
+ error: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Unauthorized"] })
+ ),
+ statusCode: Schema.optionalKey(
+ Schema.Number.annotate({ examples: [401] }).check(Schema.isFinite())
+ ),
+});
+export type IntegrationsControllerGetIntegrationV1429 = {
+ readonly message?: string;
+ readonly error?: string;
+ readonly statusCode?: number;
+ readonly retryAfter?: number;
+};
+export const IntegrationsControllerGetIntegrationV1429 = Schema.Struct({
+ message: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Rate limit exceeded"] })
+ ),
+ error: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Too Many Requests"] })
+ ),
+ statusCode: Schema.optionalKey(
+ Schema.Number.annotate({ examples: [429] }).check(Schema.isFinite())
+ ),
+ retryAfter: Schema.optionalKey(
+ Schema.Number.annotate({ examples: [30] }).check(Schema.isFinite())
+ ),
+});
+export type MarketsControllerGetMarketsV1Params = {
+ readonly offset?: number;
+ readonly limit?: number;
+ readonly integrationId?: string;
+ readonly network?:
+ | "ethereum"
+ | "ethereum-goerli"
+ | "ethereum-holesky"
+ | "ethereum-sepolia"
+ | "ethereum-hoodi"
+ | "arbitrum"
+ | "base"
+ | "base-sepolia"
+ | "gnosis"
+ | "optimism"
+ | "polygon"
+ | "polygon-amoy"
+ | "starknet"
+ | "zksync"
+ | "linea"
+ | "unichain"
+ | "monad-testnet"
+ | "monad"
+ | "robinhood"
+ | "robinhood-testnet"
+ | "avalanche-c"
+ | "avalanche-c-atomic"
+ | "avalanche-p"
+ | "binance"
+ | "celo"
+ | "fantom"
+ | "harmony"
+ | "moonriver"
+ | "okc"
+ | "viction"
+ | "core"
+ | "sonic"
+ | "plasma"
+ | "katana"
+ | "hyperevm"
+ | "tempo"
+ | "agoric"
+ | "akash"
+ | "axelar"
+ | "band-protocol"
+ | "bitsong"
+ | "canto"
+ | "chihuahua"
+ | "comdex"
+ | "coreum"
+ | "cosmos"
+ | "crescent"
+ | "cronos"
+ | "cudos"
+ | "desmos"
+ | "dydx"
+ | "evmos"
+ | "fetch-ai"
+ | "gravity-bridge"
+ | "injective"
+ | "irisnet"
+ | "juno"
+ | "kava"
+ | "ki-network"
+ | "mars-protocol"
+ | "nym"
+ | "okex-chain"
+ | "onomy"
+ | "osmosis"
+ | "persistence"
+ | "quicksilver"
+ | "regen"
+ | "secret"
+ | "sentinel"
+ | "sommelier"
+ | "stafi"
+ | "stargaze"
+ | "stride"
+ | "teritori"
+ | "tgrade"
+ | "umee"
+ | "sei"
+ | "mantra"
+ | "celestia"
+ | "saga"
+ | "zetachain"
+ | "dymension"
+ | "humansai"
+ | "neutron"
+ | "polkadot"
+ | "kusama"
+ | "westend"
+ | "bittensor"
+ | "aptos"
+ | "binancebeacon"
+ | "cardano"
+ | "near"
+ | "solana"
+ | "solana-devnet"
+ | "stellar"
+ | "stellar-testnet"
+ | "sui"
+ | "tezos"
+ | "tron"
+ | "ton"
+ | "ton-testnet"
+ | "hyperliquid";
+ readonly scope?: "enabled" | "all";
+};
+export const MarketsControllerGetMarketsV1Params = Schema.Struct({
+ offset: Schema.optionalKey(
+ Schema.Number.annotate({ default: 0, examples: [0] })
+ .check(Schema.isFinite())
+ .check(Schema.isGreaterThanOrEqualTo(0))
+ ),
+ limit: Schema.optionalKey(
+ Schema.Number.annotate({ default: 25, examples: [25] })
+ .check(Schema.isFinite())
+ .check(Schema.isGreaterThanOrEqualTo(1))
+ .check(Schema.isLessThanOrEqualTo(100))
+ ),
+ integrationId: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["aave-borrow"] })
+ ),
+ network: Schema.optionalKey(
+ Schema.Literals([
+ "ethereum",
+ "ethereum-goerli",
+ "ethereum-holesky",
+ "ethereum-sepolia",
+ "ethereum-hoodi",
+ "arbitrum",
+ "base",
+ "base-sepolia",
+ "gnosis",
+ "optimism",
+ "polygon",
+ "polygon-amoy",
+ "starknet",
+ "zksync",
+ "linea",
+ "unichain",
+ "monad-testnet",
+ "monad",
+ "robinhood",
+ "robinhood-testnet",
+ "avalanche-c",
+ "avalanche-c-atomic",
+ "avalanche-p",
+ "binance",
+ "celo",
+ "fantom",
+ "harmony",
+ "moonriver",
+ "okc",
+ "viction",
+ "core",
+ "sonic",
+ "plasma",
+ "katana",
+ "hyperevm",
+ "tempo",
+ "agoric",
+ "akash",
+ "axelar",
+ "band-protocol",
+ "bitsong",
+ "canto",
+ "chihuahua",
+ "comdex",
+ "coreum",
+ "cosmos",
+ "crescent",
+ "cronos",
+ "cudos",
+ "desmos",
+ "dydx",
+ "evmos",
+ "fetch-ai",
+ "gravity-bridge",
+ "injective",
+ "irisnet",
+ "juno",
+ "kava",
+ "ki-network",
+ "mars-protocol",
+ "nym",
+ "okex-chain",
+ "onomy",
+ "osmosis",
+ "persistence",
+ "quicksilver",
+ "regen",
+ "secret",
+ "sentinel",
+ "sommelier",
+ "stafi",
+ "stargaze",
+ "stride",
+ "teritori",
+ "tgrade",
+ "umee",
+ "sei",
+ "mantra",
+ "celestia",
+ "saga",
+ "zetachain",
+ "dymension",
+ "humansai",
+ "neutron",
+ "polkadot",
+ "kusama",
+ "westend",
+ "bittensor",
+ "aptos",
+ "binancebeacon",
+ "cardano",
+ "near",
+ "solana",
+ "solana-devnet",
+ "stellar",
+ "stellar-testnet",
+ "sui",
+ "tezos",
+ "tron",
+ "ton",
+ "ton-testnet",
+ "hyperliquid",
+ ]).annotate({ examples: ["ethereum"] })
+ ),
+ scope: Schema.optionalKey(
+ Schema.Literals(["enabled", "all"]).annotate({
+ default: "enabled",
+ examples: ["enabled"],
+ })
+ ),
+});
+export type MarketsControllerGetMarketsV1200 = {
+ readonly total: number;
+ readonly offset: number;
+ readonly limit: number;
+ readonly items?: ReadonlyArray;
+};
+export const MarketsControllerGetMarketsV1200 = Schema.Struct({
+ total: Schema.Number.annotate({
+ description: "Total number of items available",
+ examples: [150],
+ }).check(Schema.isFinite()),
+ offset: Schema.Number.annotate({
+ description: "Offset of the current page",
+ examples: [0],
+ }).check(Schema.isFinite()),
+ limit: Schema.Number.annotate({
+ description: "Limit of the current page",
+ examples: [100],
+ }).check(Schema.isFinite()),
+ items: Schema.optionalKey(Schema.Array(MarketDto)),
+});
+export type MarketsControllerGetMarketsV1401 = {
+ readonly message?: string;
+ readonly error?: string;
+ readonly statusCode?: number;
+};
+export const MarketsControllerGetMarketsV1401 = Schema.Struct({
+ message: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Invalid API key"] })
+ ),
+ error: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Unauthorized"] })
+ ),
+ statusCode: Schema.optionalKey(
+ Schema.Number.annotate({ examples: [401] }).check(Schema.isFinite())
+ ),
+});
+export type MarketsControllerGetMarketsV1429 = {
+ readonly message?: string;
+ readonly error?: string;
+ readonly statusCode?: number;
+ readonly retryAfter?: number;
+};
+export const MarketsControllerGetMarketsV1429 = Schema.Struct({
+ message: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Rate limit exceeded"] })
+ ),
+ error: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Too Many Requests"] })
+ ),
+ statusCode: Schema.optionalKey(
+ Schema.Number.annotate({ examples: [429] }).check(Schema.isFinite())
+ ),
+ retryAfter: Schema.optionalKey(
+ Schema.Number.annotate({ examples: [30] }).check(Schema.isFinite())
+ ),
+});
+export type MarketsControllerGetMarketByIdV1200 = MarketDto;
+export const MarketsControllerGetMarketByIdV1200 = MarketDto;
+export type MarketsControllerGetMarketByIdV1401 = {
+ readonly message?: string;
+ readonly error?: string;
+ readonly statusCode?: number;
+};
+export const MarketsControllerGetMarketByIdV1401 = Schema.Struct({
+ message: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Invalid API key"] })
+ ),
+ error: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Unauthorized"] })
+ ),
+ statusCode: Schema.optionalKey(
+ Schema.Number.annotate({ examples: [401] }).check(Schema.isFinite())
+ ),
+});
+export type MarketsControllerGetMarketByIdV1429 = {
+ readonly message?: string;
+ readonly error?: string;
+ readonly statusCode?: number;
+ readonly retryAfter?: number;
+};
+export const MarketsControllerGetMarketByIdV1429 = Schema.Struct({
+ message: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Rate limit exceeded"] })
+ ),
+ error: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Too Many Requests"] })
+ ),
+ statusCode: Schema.optionalKey(
+ Schema.Number.annotate({ examples: [429] }).check(Schema.isFinite())
+ ),
+ retryAfter: Schema.optionalKey(
+ Schema.Number.annotate({ examples: [30] }).check(Schema.isFinite())
+ ),
+});
+export type PositionsControllerGetPositionsV1Params = {
+ readonly integrationId: string;
+ readonly network:
+ | "ethereum"
+ | "ethereum-goerli"
+ | "ethereum-holesky"
+ | "ethereum-sepolia"
+ | "ethereum-hoodi"
+ | "arbitrum"
+ | "base"
+ | "base-sepolia"
+ | "gnosis"
+ | "optimism"
+ | "polygon"
+ | "polygon-amoy"
+ | "starknet"
+ | "zksync"
+ | "linea"
+ | "unichain"
+ | "monad-testnet"
+ | "monad"
+ | "robinhood"
+ | "robinhood-testnet"
+ | "avalanche-c"
+ | "avalanche-c-atomic"
+ | "avalanche-p"
+ | "binance"
+ | "celo"
+ | "fantom"
+ | "harmony"
+ | "moonriver"
+ | "okc"
+ | "viction"
+ | "core"
+ | "sonic"
+ | "plasma"
+ | "katana"
+ | "hyperevm"
+ | "tempo"
+ | "agoric"
+ | "akash"
+ | "axelar"
+ | "band-protocol"
+ | "bitsong"
+ | "canto"
+ | "chihuahua"
+ | "comdex"
+ | "coreum"
+ | "cosmos"
+ | "crescent"
+ | "cronos"
+ | "cudos"
+ | "desmos"
+ | "dydx"
+ | "evmos"
+ | "fetch-ai"
+ | "gravity-bridge"
+ | "injective"
+ | "irisnet"
+ | "juno"
+ | "kava"
+ | "ki-network"
+ | "mars-protocol"
+ | "nym"
+ | "okex-chain"
+ | "onomy"
+ | "osmosis"
+ | "persistence"
+ | "quicksilver"
+ | "regen"
+ | "secret"
+ | "sentinel"
+ | "sommelier"
+ | "stafi"
+ | "stargaze"
+ | "stride"
+ | "teritori"
+ | "tgrade"
+ | "umee"
+ | "sei"
+ | "mantra"
+ | "celestia"
+ | "saga"
+ | "zetachain"
+ | "dymension"
+ | "humansai"
+ | "neutron"
+ | "polkadot"
+ | "kusama"
+ | "westend"
+ | "bittensor"
+ | "aptos"
+ | "binancebeacon"
+ | "cardano"
+ | "near"
+ | "solana"
+ | "solana-devnet"
+ | "stellar"
+ | "stellar-testnet"
+ | "sui"
+ | "tezos"
+ | "tron"
+ | "ton"
+ | "ton-testnet"
+ | "hyperliquid";
+ readonly address: string;
+};
+export const PositionsControllerGetPositionsV1Params = Schema.Struct({
+ integrationId: Schema.String.annotate({ examples: ["aave-borrow"] }),
+ network: Schema.Literals([
+ "ethereum",
+ "ethereum-goerli",
+ "ethereum-holesky",
+ "ethereum-sepolia",
+ "ethereum-hoodi",
+ "arbitrum",
+ "base",
+ "base-sepolia",
+ "gnosis",
+ "optimism",
+ "polygon",
+ "polygon-amoy",
+ "starknet",
+ "zksync",
+ "linea",
+ "unichain",
+ "monad-testnet",
+ "monad",
+ "robinhood",
+ "robinhood-testnet",
+ "avalanche-c",
+ "avalanche-c-atomic",
+ "avalanche-p",
+ "binance",
+ "celo",
+ "fantom",
+ "harmony",
+ "moonriver",
+ "okc",
+ "viction",
+ "core",
+ "sonic",
+ "plasma",
+ "katana",
+ "hyperevm",
+ "tempo",
+ "agoric",
+ "akash",
+ "axelar",
+ "band-protocol",
+ "bitsong",
+ "canto",
+ "chihuahua",
+ "comdex",
+ "coreum",
+ "cosmos",
+ "crescent",
+ "cronos",
+ "cudos",
+ "desmos",
+ "dydx",
+ "evmos",
+ "fetch-ai",
+ "gravity-bridge",
+ "injective",
+ "irisnet",
+ "juno",
+ "kava",
+ "ki-network",
+ "mars-protocol",
+ "nym",
+ "okex-chain",
+ "onomy",
+ "osmosis",
+ "persistence",
+ "quicksilver",
+ "regen",
+ "secret",
+ "sentinel",
+ "sommelier",
+ "stafi",
+ "stargaze",
+ "stride",
+ "teritori",
+ "tgrade",
+ "umee",
+ "sei",
+ "mantra",
+ "celestia",
+ "saga",
+ "zetachain",
+ "dymension",
+ "humansai",
+ "neutron",
+ "polkadot",
+ "kusama",
+ "westend",
+ "bittensor",
+ "aptos",
+ "binancebeacon",
+ "cardano",
+ "near",
+ "solana",
+ "solana-devnet",
+ "stellar",
+ "stellar-testnet",
+ "sui",
+ "tezos",
+ "tron",
+ "ton",
+ "ton-testnet",
+ "hyperliquid",
+ ]).annotate({ examples: ["ethereum"] }),
+ address: Schema.String.annotate({
+ examples: ["0x742d35Cc6634C0532925a3b844Bc9e7595f8fB28"],
+ }),
+});
+export type PositionsControllerGetPositionsV1200 = PositionDto;
+export const PositionsControllerGetPositionsV1200 = PositionDto;
+export type PositionsControllerGetPositionsV1401 = {
+ readonly message?: string;
+ readonly error?: string;
+ readonly statusCode?: number;
+};
+export const PositionsControllerGetPositionsV1401 = Schema.Struct({
+ message: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Invalid API key"] })
+ ),
+ error: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Unauthorized"] })
+ ),
+ statusCode: Schema.optionalKey(
+ Schema.Number.annotate({ examples: [401] }).check(Schema.isFinite())
+ ),
+});
+export type PositionsControllerGetPositionsV1429 = {
+ readonly message?: string;
+ readonly error?: string;
+ readonly statusCode?: number;
+ readonly retryAfter?: number;
+};
+export const PositionsControllerGetPositionsV1429 = Schema.Struct({
+ message: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Rate limit exceeded"] })
+ ),
+ error: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Too Many Requests"] })
+ ),
+ statusCode: Schema.optionalKey(
+ Schema.Number.annotate({ examples: [429] }).check(Schema.isFinite())
+ ),
+ retryAfter: Schema.optionalKey(
+ Schema.Number.annotate({ examples: [30] }).check(Schema.isFinite())
+ ),
+});
+export type ActionsControllerGetActionsV1Params = {
+ readonly offset?: number;
+ readonly limit?: number;
+ readonly address: string;
+ readonly integrationId?: string;
+ readonly action?:
+ | "supply"
+ | "borrow"
+ | "repay"
+ | "withdraw"
+ | "enableCollateral"
+ | "disableCollateral";
+ readonly status?:
+ | "CANCELED"
+ | "CREATED"
+ | "WAITING_FOR_NEXT"
+ | "PROCESSING"
+ | "FAILED"
+ | "SUCCESS"
+ | "STALE";
+ readonly statuses?: ReadonlyArray<
+ | "CANCELED"
+ | "CREATED"
+ | "WAITING_FOR_NEXT"
+ | "PROCESSING"
+ | "FAILED"
+ | "SUCCESS"
+ | "STALE"
+ >;
+};
+export const ActionsControllerGetActionsV1Params = Schema.Struct({
+ offset: Schema.optionalKey(
+ Schema.Number.annotate({ default: 0, examples: [0] })
+ .check(Schema.isFinite())
+ .check(Schema.isGreaterThanOrEqualTo(0))
+ ),
+ limit: Schema.optionalKey(
+ Schema.Number.annotate({ default: 25, examples: [25] })
+ .check(Schema.isFinite())
+ .check(Schema.isGreaterThanOrEqualTo(1))
+ .check(Schema.isLessThanOrEqualTo(100))
+ ),
+ address: Schema.String.annotate({
+ examples: ["0x742d35Cc6634C0532925a3b844Bc9e7595f8fB28"],
+ }),
+ integrationId: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["aave-borrow"] })
+ ),
+ action: Schema.optionalKey(
+ Schema.Literals([
+ "supply",
+ "borrow",
+ "repay",
+ "withdraw",
+ "enableCollateral",
+ "disableCollateral",
+ ]).annotate({ examples: ["supply"] })
+ ),
+ status: Schema.optionalKey(
+ Schema.Literals([
+ "CANCELED",
+ "CREATED",
+ "WAITING_FOR_NEXT",
+ "PROCESSING",
+ "FAILED",
+ "SUCCESS",
+ "STALE",
+ ])
+ ),
+ statuses: Schema.optionalKey(
+ Schema.Array(
+ Schema.Literals([
+ "CANCELED",
+ "CREATED",
+ "WAITING_FOR_NEXT",
+ "PROCESSING",
+ "FAILED",
+ "SUCCESS",
+ "STALE",
+ ])
+ )
+ ),
+});
+export type ActionsControllerGetActionsV1200 = {
+ readonly total: number;
+ readonly offset: number;
+ readonly limit: number;
+ readonly items?: ReadonlyArray;
+};
+export const ActionsControllerGetActionsV1200 = Schema.Struct({
+ total: Schema.Number.annotate({
+ description: "Total number of items available",
+ examples: [150],
+ }).check(Schema.isFinite()),
+ offset: Schema.Number.annotate({
+ description: "Offset of the current page",
+ examples: [0],
+ }).check(Schema.isFinite()),
+ limit: Schema.Number.annotate({
+ description: "Limit of the current page",
+ examples: [100],
+ }).check(Schema.isFinite()),
+ items: Schema.optionalKey(Schema.Array(ActionDto)),
+});
+export type ActionsControllerGetActionsV1401 = {
+ readonly message?: string;
+ readonly error?: string;
+ readonly statusCode?: number;
+};
+export const ActionsControllerGetActionsV1401 = Schema.Struct({
+ message: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Invalid API key"] })
+ ),
+ error: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Unauthorized"] })
+ ),
+ statusCode: Schema.optionalKey(
+ Schema.Number.annotate({ examples: [401] }).check(Schema.isFinite())
+ ),
+});
+export type ActionsControllerGetActionsV1429 = {
+ readonly message?: string;
+ readonly error?: string;
+ readonly statusCode?: number;
+ readonly retryAfter?: number;
+};
+export const ActionsControllerGetActionsV1429 = Schema.Struct({
+ message: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Rate limit exceeded"] })
+ ),
+ error: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Too Many Requests"] })
+ ),
+ statusCode: Schema.optionalKey(
+ Schema.Number.annotate({ examples: [429] }).check(Schema.isFinite())
+ ),
+ retryAfter: Schema.optionalKey(
+ Schema.Number.annotate({ examples: [30] }).check(Schema.isFinite())
+ ),
+});
+export type ActionsControllerExecuteActionV1RequestJson = ActionRequestDto;
+export const ActionsControllerExecuteActionV1RequestJson = ActionRequestDto;
+export type ActionsControllerExecuteActionV1201 = ActionDto;
+export const ActionsControllerExecuteActionV1201 = ActionDto;
+export type ActionsControllerExecuteActionV1401 = {
+ readonly message?: string;
+ readonly error?: string;
+ readonly statusCode?: number;
+};
+export const ActionsControllerExecuteActionV1401 = Schema.Struct({
+ message: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Invalid API key"] })
+ ),
+ error: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Unauthorized"] })
+ ),
+ statusCode: Schema.optionalKey(
+ Schema.Number.annotate({ examples: [401] }).check(Schema.isFinite())
+ ),
+});
+export type ActionsControllerExecuteActionV1429 = {
+ readonly message?: string;
+ readonly error?: string;
+ readonly statusCode?: number;
+ readonly retryAfter?: number;
+};
+export const ActionsControllerExecuteActionV1429 = Schema.Struct({
+ message: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Rate limit exceeded"] })
+ ),
+ error: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Too Many Requests"] })
+ ),
+ statusCode: Schema.optionalKey(
+ Schema.Number.annotate({ examples: [429] }).check(Schema.isFinite())
+ ),
+ retryAfter: Schema.optionalKey(
+ Schema.Number.annotate({ examples: [30] }).check(Schema.isFinite())
+ ),
+});
+export type ActionsControllerGetActionV1200 = ActionDto;
+export const ActionsControllerGetActionV1200 = ActionDto;
+export type ActionsControllerGetActionV1401 = {
+ readonly message?: string;
+ readonly error?: string;
+ readonly statusCode?: number;
+};
+export const ActionsControllerGetActionV1401 = Schema.Struct({
+ message: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Invalid API key"] })
+ ),
+ error: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Unauthorized"] })
+ ),
+ statusCode: Schema.optionalKey(
+ Schema.Number.annotate({ examples: [401] }).check(Schema.isFinite())
+ ),
+});
+export type ActionsControllerGetActionV1429 = {
+ readonly message?: string;
+ readonly error?: string;
+ readonly statusCode?: number;
+ readonly retryAfter?: number;
+};
+export const ActionsControllerGetActionV1429 = Schema.Struct({
+ message: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Rate limit exceeded"] })
+ ),
+ error: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Too Many Requests"] })
+ ),
+ statusCode: Schema.optionalKey(
+ Schema.Number.annotate({ examples: [429] }).check(Schema.isFinite())
+ ),
+ retryAfter: Schema.optionalKey(
+ Schema.Number.annotate({ examples: [30] }).check(Schema.isFinite())
+ ),
+});
+export type ActionsControllerStepV1200 = ActionDto;
+export const ActionsControllerStepV1200 = ActionDto;
+export type ActionsControllerStepV1401 = {
+ readonly message?: string;
+ readonly error?: string;
+ readonly statusCode?: number;
+};
+export const ActionsControllerStepV1401 = Schema.Struct({
+ message: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Invalid API key"] })
+ ),
+ error: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Unauthorized"] })
+ ),
+ statusCode: Schema.optionalKey(
+ Schema.Number.annotate({ examples: [401] }).check(Schema.isFinite())
+ ),
+});
+export type ActionsControllerStepV1429 = {
+ readonly message?: string;
+ readonly error?: string;
+ readonly statusCode?: number;
+ readonly retryAfter?: number;
+};
+export const ActionsControllerStepV1429 = Schema.Struct({
+ message: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Rate limit exceeded"] })
+ ),
+ error: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Too Many Requests"] })
+ ),
+ statusCode: Schema.optionalKey(
+ Schema.Number.annotate({ examples: [429] }).check(Schema.isFinite())
+ ),
+ retryAfter: Schema.optionalKey(
+ Schema.Number.annotate({ examples: [30] }).check(Schema.isFinite())
+ ),
+});
+export type TransactionsControllerSubmitTransactionV1RequestJson =
+ SubmitTransactionDto;
+export const TransactionsControllerSubmitTransactionV1RequestJson =
+ SubmitTransactionDto;
+export type TransactionsControllerSubmitTransactionV1200 =
+ SubmitTransactionResponseDto;
+export const TransactionsControllerSubmitTransactionV1200 =
+ SubmitTransactionResponseDto;
+export type TransactionsControllerSubmitTransactionV1401 = {
+ readonly message?: string;
+ readonly error?: string;
+ readonly statusCode?: number;
+};
+export const TransactionsControllerSubmitTransactionV1401 = Schema.Struct({
+ message: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Invalid API key"] })
+ ),
+ error: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Unauthorized"] })
+ ),
+ statusCode: Schema.optionalKey(
+ Schema.Number.annotate({ examples: [401] }).check(Schema.isFinite())
+ ),
+});
+export type TransactionsControllerSubmitTransactionV1429 = {
+ readonly message?: string;
+ readonly error?: string;
+ readonly statusCode?: number;
+ readonly retryAfter?: number;
+};
+export const TransactionsControllerSubmitTransactionV1429 = Schema.Struct({
+ message: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Rate limit exceeded"] })
+ ),
+ error: Schema.optionalKey(
+ Schema.String.annotate({ examples: ["Too Many Requests"] })
+ ),
+ statusCode: Schema.optionalKey(
+ Schema.Number.annotate({ examples: [429] }).check(Schema.isFinite())
+ ),
+ retryAfter: Schema.optionalKey(
+ Schema.Number.annotate({ examples: [30] }).check(Schema.isFinite())
+ ),
+});
+export type HealthControllerHealth200 = HealthStatusDto;
+export const HealthControllerHealth200 = HealthStatusDto;
+
+export interface OperationConfig {
+ /**
+ * Whether or not the response should be included in the value returned from
+ * an operation.
+ *
+ * If set to `true`, a tuple of `[A, HttpClientResponse]` will be returned,
+ * where `A` is the success type of the operation.
+ *
+ * If set to `false`, only the success type of the operation will be returned.
+ */
+ readonly includeResponse?: boolean | undefined;
+}
+
+/**
+ * A utility type which optionally includes the response in the return result
+ * of an operation based upon the value of the `includeResponse` configuration
+ * option.
+ */
+export type WithOptionalResponse<
+ A,
+ Config extends OperationConfig,
+> = Config extends {
+ readonly includeResponse: true;
+}
+ ? [A, HttpClientResponse.HttpClientResponse]
+ : A;
+
+export const make = (
+ httpClient: HttpClient.HttpClient,
+ options: {
+ readonly transformClient?:
+ | ((
+ client: HttpClient.HttpClient
+ ) => Effect.Effect)
+ | undefined;
+ } = {}
+): BorrowApi => {
+ const unexpectedStatus = (response: HttpClientResponse.HttpClientResponse) =>
+ Effect.flatMap(
+ Effect.orElseSucceed(response.json, () => "Unexpected status code"),
+ (description) =>
+ Effect.fail(
+ new HttpClientError.HttpClientError({
+ reason: new HttpClientError.StatusCodeError({
+ request: response.request,
+ response,
+ description:
+ typeof description === "string"
+ ? description
+ : JSON.stringify(description),
+ }),
+ })
+ )
+ );
+ const withResponse =
+ (config: Config | undefined) =>
+ (
+ f: (
+ response: HttpClientResponse.HttpClientResponse
+ ) => Effect.Effect
+ ): ((
+ request: HttpClientRequest.HttpClientRequest
+ ) => Effect.Effect) => {
+ const withOptionalResponse = (
+ config?.includeResponse
+ ? (response: HttpClientResponse.HttpClientResponse) =>
+ Effect.map(f(response), (a) => [a, response])
+ : (response: HttpClientResponse.HttpClientResponse) => f(response)
+ ) as any;
+ return options?.transformClient
+ ? (request) =>
+ Effect.flatMap(
+ Effect.flatMap(options.transformClient!(httpClient), (client) =>
+ client.execute(request)
+ ),
+ withOptionalResponse
+ )
+ : (request) =>
+ Effect.flatMap(httpClient.execute(request), withOptionalResponse);
+ };
+ const decodeSuccess =
+ (schema: Schema) =>
+ (response: HttpClientResponse.HttpClientResponse) =>
+ HttpClientResponse.schemaBodyJson(schema)(response);
+ const decodeError =
+ (
+ tag: Tag,
+ schema: Schema
+ ) =>
+ (response: HttpClientResponse.HttpClientResponse) =>
+ Effect.flatMap(
+ HttpClientResponse.schemaBodyJson(schema)(response),
+ (cause) => Effect.fail(BorrowApiError(tag, cause, response))
+ );
+ return {
+ httpClient,
+ IntegrationsControllerGetIntegrationsV1: (options) =>
+ HttpClientRequest.get(`/v1/integrations`).pipe(
+ withResponse(options?.config)(
+ HttpClientResponse.matchStatus({
+ "2xx": decodeSuccess(IntegrationsControllerGetIntegrationsV1200),
+ "401": decodeError(
+ "IntegrationsControllerGetIntegrationsV1401",
+ IntegrationsControllerGetIntegrationsV1401
+ ),
+ "429": decodeError(
+ "IntegrationsControllerGetIntegrationsV1429",
+ IntegrationsControllerGetIntegrationsV1429
+ ),
+ orElse: unexpectedStatus,
+ })
+ )
+ ),
+ IntegrationsControllerGetIntegrationV1: (integrationId, options) =>
+ HttpClientRequest.get(`/v1/integrations/${integrationId}`).pipe(
+ withResponse(options?.config)(
+ HttpClientResponse.matchStatus({
+ "2xx": decodeSuccess(IntegrationsControllerGetIntegrationV1200),
+ "401": decodeError(
+ "IntegrationsControllerGetIntegrationV1401",
+ IntegrationsControllerGetIntegrationV1401
+ ),
+ "429": decodeError(
+ "IntegrationsControllerGetIntegrationV1429",
+ IntegrationsControllerGetIntegrationV1429
+ ),
+ "404": () => Effect.void,
+ orElse: unexpectedStatus,
+ })
+ )
+ ),
+ MarketsControllerGetMarketsV1: (options) =>
+ HttpClientRequest.get(`/v1/markets`).pipe(
+ HttpClientRequest.setUrlParams({
+ offset: options?.params?.["offset"] as any,
+ limit: options?.params?.["limit"] as any,
+ integrationId: options?.params?.["integrationId"] as any,
+ network: options?.params?.["network"] as any,
+ scope: options?.params?.["scope"] as any,
+ }),
+ withResponse(options?.config)(
+ HttpClientResponse.matchStatus({
+ "2xx": decodeSuccess(MarketsControllerGetMarketsV1200),
+ "401": decodeError(
+ "MarketsControllerGetMarketsV1401",
+ MarketsControllerGetMarketsV1401
+ ),
+ "429": decodeError(
+ "MarketsControllerGetMarketsV1429",
+ MarketsControllerGetMarketsV1429
+ ),
+ orElse: unexpectedStatus,
+ })
+ )
+ ),
+ MarketsControllerGetMarketByIdV1: (marketId, options) =>
+ HttpClientRequest.get(`/v1/markets/${marketId}`).pipe(
+ withResponse(options?.config)(
+ HttpClientResponse.matchStatus({
+ "2xx": decodeSuccess(MarketsControllerGetMarketByIdV1200),
+ "401": decodeError(
+ "MarketsControllerGetMarketByIdV1401",
+ MarketsControllerGetMarketByIdV1401
+ ),
+ "429": decodeError(
+ "MarketsControllerGetMarketByIdV1429",
+ MarketsControllerGetMarketByIdV1429
+ ),
+ "404": () => Effect.void,
+ orElse: unexpectedStatus,
+ })
+ )
+ ),
+ PositionsControllerGetPositionsV1: (options) =>
+ HttpClientRequest.get(`/v1/positions`).pipe(
+ HttpClientRequest.setUrlParams({
+ integrationId: options.params["integrationId"] as any,
+ network: options.params["network"] as any,
+ address: options.params["address"] as any,
+ }),
+ withResponse(options.config)(
+ HttpClientResponse.matchStatus({
+ "2xx": decodeSuccess(PositionsControllerGetPositionsV1200),
+ "401": decodeError(
+ "PositionsControllerGetPositionsV1401",
+ PositionsControllerGetPositionsV1401
+ ),
+ "429": decodeError(
+ "PositionsControllerGetPositionsV1429",
+ PositionsControllerGetPositionsV1429
+ ),
+ orElse: unexpectedStatus,
+ })
+ )
+ ),
+ ActionsControllerGetActionsV1: (options) =>
+ HttpClientRequest.get(`/v1/actions`).pipe(
+ HttpClientRequest.setUrlParams({
+ offset: options.params["offset"] as any,
+ limit: options.params["limit"] as any,
+ address: options.params["address"] as any,
+ integrationId: options.params["integrationId"] as any,
+ action: options.params["action"] as any,
+ status: options.params["status"] as any,
+ statuses: options.params["statuses"] as any,
+ }),
+ withResponse(options.config)(
+ HttpClientResponse.matchStatus({
+ "2xx": decodeSuccess(ActionsControllerGetActionsV1200),
+ "401": decodeError(
+ "ActionsControllerGetActionsV1401",
+ ActionsControllerGetActionsV1401
+ ),
+ "429": decodeError(
+ "ActionsControllerGetActionsV1429",
+ ActionsControllerGetActionsV1429
+ ),
+ orElse: unexpectedStatus,
+ })
+ )
+ ),
+ ActionsControllerExecuteActionV1: (options) =>
+ HttpClientRequest.post(`/v1/actions`).pipe(
+ HttpClientRequest.bodyJsonUnsafe(options.payload),
+ withResponse(options.config)(
+ HttpClientResponse.matchStatus({
+ "2xx": decodeSuccess(ActionsControllerExecuteActionV1201),
+ "401": decodeError(
+ "ActionsControllerExecuteActionV1401",
+ ActionsControllerExecuteActionV1401
+ ),
+ "429": decodeError(
+ "ActionsControllerExecuteActionV1429",
+ ActionsControllerExecuteActionV1429
+ ),
+ orElse: unexpectedStatus,
+ })
+ )
+ ),
+ ActionsControllerGetActionV1: (id, options) =>
+ HttpClientRequest.get(`/v1/actions/${id}`).pipe(
+ withResponse(options?.config)(
+ HttpClientResponse.matchStatus({
+ "2xx": decodeSuccess(ActionsControllerGetActionV1200),
+ "401": decodeError(
+ "ActionsControllerGetActionV1401",
+ ActionsControllerGetActionV1401
+ ),
+ "429": decodeError(
+ "ActionsControllerGetActionV1429",
+ ActionsControllerGetActionV1429
+ ),
+ "404": () => Effect.void,
+ orElse: unexpectedStatus,
+ })
+ )
+ ),
+ ActionsControllerStepV1: (id, options) =>
+ HttpClientRequest.post(`/v1/actions/${id}/step`).pipe(
+ withResponse(options?.config)(
+ HttpClientResponse.matchStatus({
+ "2xx": decodeSuccess(ActionsControllerStepV1200),
+ "401": decodeError(
+ "ActionsControllerStepV1401",
+ ActionsControllerStepV1401
+ ),
+ "429": decodeError(
+ "ActionsControllerStepV1429",
+ ActionsControllerStepV1429
+ ),
+ "404": () => Effect.void,
+ orElse: unexpectedStatus,
+ })
+ )
+ ),
+ TransactionsControllerSubmitTransactionV1: (transactionId, options) =>
+ HttpClientRequest.post(`/v1/transactions/${transactionId}/submit`).pipe(
+ HttpClientRequest.bodyJsonUnsafe(options.payload),
+ withResponse(options.config)(
+ HttpClientResponse.matchStatus({
+ "2xx": decodeSuccess(TransactionsControllerSubmitTransactionV1200),
+ "401": decodeError(
+ "TransactionsControllerSubmitTransactionV1401",
+ TransactionsControllerSubmitTransactionV1401
+ ),
+ "429": decodeError(
+ "TransactionsControllerSubmitTransactionV1429",
+ TransactionsControllerSubmitTransactionV1429
+ ),
+ "403": () => Effect.void,
+ "404": () => Effect.void,
+ orElse: unexpectedStatus,
+ })
+ )
+ ),
+ HealthControllerHealth: (options) =>
+ HttpClientRequest.get(`/health`).pipe(
+ withResponse(options?.config)(
+ HttpClientResponse.matchStatus({
+ "2xx": decodeSuccess(HealthControllerHealth200),
+ orElse: unexpectedStatus,
+ })
+ )
+ ),
+ };
+};
+
+export interface BorrowApi {
+ readonly httpClient: HttpClient.HttpClient;
+ /**
+ * Retrieve a list of available lending/borrowing integrations (e.g., Aave, Spark, Morpho) with their supported actions and networks.
+ */
+ readonly IntegrationsControllerGetIntegrationsV1: <
+ Config extends OperationConfig,
+ >(
+ options: { readonly config?: Config | undefined } | undefined
+ ) => Effect.Effect<
+ WithOptionalResponse<
+ typeof IntegrationsControllerGetIntegrationsV1200.Type,
+ Config
+ >,
+ | HttpClientError.HttpClientError
+ | SchemaError
+ | BorrowApiError<
+ "IntegrationsControllerGetIntegrationsV1401",
+ typeof IntegrationsControllerGetIntegrationsV1401.Type
+ >
+ | BorrowApiError<
+ "IntegrationsControllerGetIntegrationsV1429",
+ typeof IntegrationsControllerGetIntegrationsV1429.Type
+ >
+ >;
+ /**
+ * Retrieve detailed information about a specific lending/borrowing integration
+ */
+ readonly IntegrationsControllerGetIntegrationV1: <
+ Config extends OperationConfig,
+ >(
+ integrationId: string,
+ options: { readonly config?: Config | undefined } | undefined
+ ) => Effect.Effect<
+ WithOptionalResponse<
+ typeof IntegrationsControllerGetIntegrationV1200.Type,
+ Config
+ >,
+ | HttpClientError.HttpClientError
+ | SchemaError
+ | BorrowApiError<
+ "IntegrationsControllerGetIntegrationV1401",
+ typeof IntegrationsControllerGetIntegrationV1401.Type
+ >
+ | BorrowApiError<
+ "IntegrationsControllerGetIntegrationV1429",
+ typeof IntegrationsControllerGetIntegrationV1429.Type
+ >
+ >;
+ /**
+ * Retrieve a paginated list of available lending markets across all supported integrations and networks. Each market represents a token that can be supplied or borrowed.
+ */
+ readonly MarketsControllerGetMarketsV1: (
+ options:
+ | {
+ readonly params?:
+ | typeof MarketsControllerGetMarketsV1Params.Encoded
+ | undefined;
+ readonly config?: Config | undefined;
+ }
+ | undefined
+ ) => Effect.Effect<
+ WithOptionalResponse,
+ | HttpClientError.HttpClientError
+ | SchemaError
+ | BorrowApiError<
+ "MarketsControllerGetMarketsV1401",
+ typeof MarketsControllerGetMarketsV1401.Type
+ >
+ | BorrowApiError<
+ "MarketsControllerGetMarketsV1429",
+ typeof MarketsControllerGetMarketsV1429.Type
+ >
+ >;
+ /**
+ * Retrieve details for a specific lending market. Useful for deep linking into a specific market (e.g., cbBTC/USDT on Morpho Blue).
+ */
+ readonly MarketsControllerGetMarketByIdV1: (
+ marketId: string,
+ options: { readonly config?: Config | undefined } | undefined
+ ) => Effect.Effect<
+ WithOptionalResponse<
+ typeof MarketsControllerGetMarketByIdV1200.Type,
+ Config
+ >,
+ | HttpClientError.HttpClientError
+ | SchemaError
+ | BorrowApiError<
+ "MarketsControllerGetMarketByIdV1401",
+ typeof MarketsControllerGetMarketByIdV1401.Type
+ >
+ | BorrowApiError<
+ "MarketsControllerGetMarketByIdV1429",
+ typeof MarketsControllerGetMarketByIdV1429.Type
+ >
+ >;
+ /**
+ * Retrieve all supply and borrow positions for a user address across specified integration and network.
+ */
+ readonly PositionsControllerGetPositionsV1: <
+ Config extends OperationConfig,
+ >(options: {
+ readonly params: typeof PositionsControllerGetPositionsV1Params.Encoded;
+ readonly config?: Config | undefined;
+ }) => Effect.Effect<
+ WithOptionalResponse<
+ typeof PositionsControllerGetPositionsV1200.Type,
+ Config
+ >,
+ | HttpClientError.HttpClientError
+ | SchemaError
+ | BorrowApiError<
+ "PositionsControllerGetPositionsV1401",
+ typeof PositionsControllerGetPositionsV1401.Type
+ >
+ | BorrowApiError<
+ "PositionsControllerGetPositionsV1429",
+ typeof PositionsControllerGetPositionsV1429.Type
+ >
+ >;
+ /**
+ * Get a paginated list of actions, with optional filtering by address, integration, status, etc.
+ */
+ readonly ActionsControllerGetActionsV1: <
+ Config extends OperationConfig,
+ >(options: {
+ readonly params: typeof ActionsControllerGetActionsV1Params.Encoded;
+ readonly config?: Config | undefined;
+ }) => Effect.Effect<
+ WithOptionalResponse,
+ | HttpClientError.HttpClientError
+ | SchemaError
+ | BorrowApiError<
+ "ActionsControllerGetActionsV1401",
+ typeof ActionsControllerGetActionsV1401.Type
+ >
+ | BorrowApiError<
+ "ActionsControllerGetActionsV1429",
+ typeof ActionsControllerGetActionsV1429.Type
+ >
+ >;
+ /**
+ * Generate unsigned transactions for a lending/borrowing action. Returns transaction(s) to sign. Submit each signed transaction via POST /transactions/:id/submit. For async multi-step flows (e.g., cross-chain bridges), hasNextStep will be true — call POST /actions/:id/step after confirmation to get the next transaction(s).
+ */
+ readonly ActionsControllerExecuteActionV1: <
+ Config extends OperationConfig,
+ >(options: {
+ readonly payload: typeof ActionsControllerExecuteActionV1RequestJson.Encoded;
+ readonly config?: Config | undefined;
+ }) => Effect.Effect<
+ WithOptionalResponse<
+ typeof ActionsControllerExecuteActionV1201.Type,
+ Config
+ >,
+ | HttpClientError.HttpClientError
+ | SchemaError
+ | BorrowApiError<
+ "ActionsControllerExecuteActionV1401",
+ typeof ActionsControllerExecuteActionV1401.Type
+ >
+ | BorrowApiError<
+ "ActionsControllerExecuteActionV1429",
+ typeof ActionsControllerExecuteActionV1429.Type
+ >
+ >;
+ /**
+ * Retrieve detailed information about a specific action including current status, transactions, and step progress.
+ */
+ readonly ActionsControllerGetActionV1: (
+ id: string,
+ options: { readonly config?: Config | undefined } | undefined
+ ) => Effect.Effect<
+ WithOptionalResponse,
+ | HttpClientError.HttpClientError
+ | SchemaError
+ | BorrowApiError<
+ "ActionsControllerGetActionV1401",
+ typeof ActionsControllerGetActionV1401.Type
+ >
+ | BorrowApiError<
+ "ActionsControllerGetActionV1429",
+ typeof ActionsControllerGetActionV1429.Type
+ >
+ >;
+ /**
+ * For async multi-step actions (e.g., cross-chain bridges, delayed withdrawals), retrieve the next transaction(s) after the previous step has been confirmed on-chain. Call this when hasNextStep is true on the action response.
+ */
+ readonly ActionsControllerStepV1: (
+ id: string,
+ options: { readonly config?: Config | undefined } | undefined
+ ) => Effect.Effect<
+ WithOptionalResponse,
+ | HttpClientError.HttpClientError
+ | SchemaError
+ | BorrowApiError<
+ "ActionsControllerStepV1401",
+ typeof ActionsControllerStepV1401.Type
+ >
+ | BorrowApiError<
+ "ActionsControllerStepV1429",
+ typeof ActionsControllerStepV1429.Type
+ >
+ >;
+ /**
+ * Submit a signed transaction. Provide signedPayload to have us broadcast it to the blockchain, or transactionHash if already submitted by the client.
+ */
+ readonly TransactionsControllerSubmitTransactionV1: <
+ Config extends OperationConfig,
+ >(
+ transactionId: string,
+ options: {
+ readonly payload: typeof TransactionsControllerSubmitTransactionV1RequestJson.Encoded;
+ readonly config?: Config | undefined;
+ }
+ ) => Effect.Effect<
+ WithOptionalResponse<
+ typeof TransactionsControllerSubmitTransactionV1200.Type,
+ Config
+ >,
+ | HttpClientError.HttpClientError
+ | SchemaError
+ | BorrowApiError<
+ "TransactionsControllerSubmitTransactionV1401",
+ typeof TransactionsControllerSubmitTransactionV1401.Type
+ >
+ | BorrowApiError<
+ "TransactionsControllerSubmitTransactionV1429",
+ typeof TransactionsControllerSubmitTransactionV1429.Type
+ >
+ >;
+ /**
+ * Get the health status of the borrow API with current timestamp
+ */
+ readonly HealthControllerHealth: (
+ options: { readonly config?: Config | undefined } | undefined
+ ) => Effect.Effect<
+ WithOptionalResponse,
+ HttpClientError.HttpClientError | SchemaError
+ >;
+}
+
+export interface BorrowApiError {
+ readonly _tag: Tag;
+ readonly request: HttpClientRequest.HttpClientRequest;
+ readonly response: HttpClientResponse.HttpClientResponse;
+ readonly cause: E;
+}
+
+class BorrowApiErrorImpl extends Data.Error<{
+ _tag: string;
+ cause: any;
+ request: HttpClientRequest.HttpClientRequest;
+ response: HttpClientResponse.HttpClientResponse;
+}> {}
+
+export const BorrowApiError = (
+ tag: Tag,
+ cause: E,
+ response: HttpClientResponse.HttpClientResponse
+): BorrowApiError =>
+ new BorrowApiErrorImpl({
+ _tag: tag,
+ cause,
+ response,
+ request: response.request,
+ }) as any;
diff --git a/packages/widget/src/hooks/api/use-activity-actions.ts b/packages/widget/src/hooks/api/use-activity-actions.ts
index 3bfc3d5a..ae8ddde2 100644
--- a/packages/widget/src/hooks/api/use-activity-actions.ts
+++ b/packages/widget/src/hooks/api/use-activity-actions.ts
@@ -7,14 +7,11 @@ import { EitherAsync } from "purify-ts";
import { useEffect, useMemo } from "react";
import {
type ActionDto,
- getActionInputToken,
getActionValidatorAddresses,
} from "../../domain/types/action";
+import type { Validator } from "../../domain/types/validators";
import type { Yield } from "../../domain/types/yields";
-import type {
- ActionsControllerGetActionsParams,
- ValidatorDto,
-} from "../../generated/api/yield";
+import type { ActionsControllerGetActionsParams } from "../../generated/api/yield";
import {
type ActivityFilter,
activityFilterCategories,
@@ -25,7 +22,7 @@ import type { ApiClient } from "../../providers/api/api-client";
import { useApiClient } from "../../providers/api/api-client-provider";
import { useSKQueryClient } from "../../providers/query-client";
import { useSKWallet } from "../../providers/sk-wallet";
-import { getYieldOpportunity } from "./use-yield-opportunity/get-yield-opportunity";
+import { fetchYieldSummariesWithProvidersByIds } from "./use-yield-summaries";
import { getYieldValidatorsByAddresses } from "./use-yield-validators";
const PAGE_SIZE = 50;
@@ -38,8 +35,8 @@ const ACTIVITY_ACTION_STATUSES = [
type ActivityActionItem = {
actionData: ActionDto;
- yieldData: Yield;
- validatorsData: ValidatorDto[];
+ yieldData: Yield | null;
+ validatorsData: Validator[];
};
type ActivityActionBaseItem = Omit;
@@ -72,7 +69,6 @@ type FetchActivityActionsPageParams = {
address: string;
apiClient: ApiClient;
filter: ActivityFilter;
- isLedgerLive: boolean;
network: NonNullable;
offset: number;
queryClient: QueryClient;
@@ -193,13 +189,15 @@ const getItemsWithValidators = async ({
...(await Promise.all(
chunk.map(async (item) => ({
...item,
- validatorsData: await getYieldValidatorsByAddresses({
- apiClient,
- queryClient,
- yieldId: item.actionData.yieldId,
- addresses: getActionValidatorAddresses(item.actionData) ?? [],
- suppressRichErrors: true,
- }),
+ validatorsData: item.yieldData
+ ? await getYieldValidatorsByAddresses({
+ apiClient,
+ queryClient,
+ yieldId: item.actionData.yieldId,
+ addresses: getActionValidatorAddresses(item.actionData) ?? [],
+ suppressRichErrors: true,
+ })
+ : [],
}))
))
);
@@ -214,11 +212,40 @@ const getNextActivityActionsPageParam = (lastPage: ActivityActionsPage) => {
return nextOffset < (lastPage.total ?? 0) ? nextOffset : undefined;
};
-const fetchActivityActionsPage = async ({
+const getActionsWithYieldData = async ({
+ actions,
+ apiClient,
+ queryClient,
+ signal,
+ suppressRichErrors,
+}: {
+ actions: ReadonlyArray;
+ apiClient: ApiClient;
+ queryClient: QueryClient;
+ signal?: AbortSignal;
+ suppressRichErrors?: boolean;
+}): Promise => {
+ const yieldData = await fetchYieldSummariesWithProvidersByIds({
+ apiClient,
+ queryClient,
+ signal,
+ suppressRichErrors,
+ yieldIds: actions.map((action) => action.yieldId),
+ }).catch(() => []);
+ const yieldDataById = new Map(
+ yieldData.map((yieldDto) => [yieldDto.id, yieldDto])
+ );
+
+ return actions.map((action) => ({
+ actionData: action,
+ yieldData: yieldDataById.get(action.yieldId) ?? null,
+ }));
+};
+
+export const fetchActivityActionsPage = async ({
address,
apiClient,
filter,
- isLedgerLive,
network,
offset,
queryClient,
@@ -241,32 +268,15 @@ const fetchActivityActionsPage = async ({
)
.mapLeft(() => new Error("Could not get action list"))
.chain(async (actionList) =>
- EitherAsync.all(
- (actionList.items ?? []).map((action) =>
- getYieldOpportunity({
- yieldId: action.yieldId,
- queryClient,
- isLedgerLive,
- apiClient,
- suppressRichErrors: true,
- })
- .map((yieldData) => ({
- actionData: action as ActionDto,
- yieldData,
- }))
- .chainLeft(() => EitherAsync(() => Promise.resolve(null)))
- )
+ EitherAsync(() =>
+ getActionsWithYieldData({
+ actions: (actionList.items ?? []) as ActionDto[],
+ apiClient,
+ queryClient,
+ signal,
+ suppressRichErrors: true,
+ })
)
- .map((res) => res.filter((x) => x !== null))
- .map((res) =>
- res.filter(
- (x) =>
- !!getActionInputToken({
- actionDto: x.actionData,
- yieldDto: x.yieldData,
- })
- )
- )
.chain((items) =>
EitherAsync(() =>
getItemsWithValidators({
@@ -306,7 +316,7 @@ export const usePrefetchActivityActionFilters = ({
}: {
filterOptions: ActivityFilterOption[];
}) => {
- const { address, isLedgerLive, network } = useSKWallet();
+ const { address, network } = useSKWallet();
const queryClient = useSKQueryClient();
const apiClient = useApiClient();
@@ -322,7 +332,6 @@ export const usePrefetchActivityActionFilters = ({
address,
apiClient,
filter,
- isLedgerLive,
network,
offset: pageParam as number,
queryClient,
@@ -334,13 +343,13 @@ export const usePrefetchActivityActionFilters = ({
})
.catch(() => undefined);
}
- }, [address, apiClient, filterOptions, isLedgerLive, network, queryClient]);
+ }, [address, apiClient, filterOptions, network, queryClient]);
};
export const useActivityActions = (
filter: ActivityFilter = "all"
): UseActivityActionsResult => {
- const { address, isLedgerLive, network } = useSKWallet();
+ const { address, network } = useSKWallet();
const queryClient = useSKQueryClient();
const apiClient = useApiClient();
@@ -352,7 +361,6 @@ export const useActivityActions = (
address: address!,
apiClient,
filter,
- isLedgerLive,
network: network!,
offset: pageParam as number,
queryClient,
diff --git a/packages/widget/src/hooks/api/use-dashboard-yield-catalog.ts b/packages/widget/src/hooks/api/use-dashboard-yield-catalog.ts
deleted file mode 100644
index 6ee2dfa8..00000000
--- a/packages/widget/src/hooks/api/use-dashboard-yield-catalog.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-import { useQueries } from "@tanstack/react-query";
-import { useMemo } from "react";
-import type { TokenDto } from "../../domain/types/tokens";
-import {
- type DashboardYieldCategory,
- getApiYieldTypesForDashboardCategory,
-} from "../../domain/types/yields";
-import { useApiClient } from "../../providers/api/api-client-provider";
-import { useSettings } from "../../providers/settings";
-import { useSKWallet } from "../../providers/sk-wallet";
-import {
- DEFAULT_YIELD_SUMMARIES_PAGE_LIMIT,
- fetchYieldSummariesPage,
- getYieldSummariesQueryKey,
- isVisibleYieldSummary,
- type YieldSummariesParams,
- type YieldSummary,
-} from "./use-yield-summaries";
-
-type DashboardCategoryInitialSelection = {
- token: TokenDto;
- yieldDto: YieldSummary;
- yieldId: string;
-};
-
-const staleTime = 1000 * 60 * 2;
-
-/**
- * Discovers available dashboard earn categories with one network-scoped,
- * reward-rate-sorted probe per category (no dependency on the wallet's token
- * holdings, no per-yield legacy hydration). The first visible summary of each
- * probe seeds that category's initial token + yield selection.
- */
-export const useDashboardYieldCatalog = ({
- enabled = true,
-}: {
- enabled?: boolean;
-} = {}) => {
- const { network, isConnecting } = useSKWallet();
- const apiClient = useApiClient();
- const { dashboardYieldCategoryOrder } = useSettings();
-
- const probeEnabled = enabled && !isConnecting;
-
- const results = useQueries({
- queries: dashboardYieldCategoryOrder.map((category) => {
- const params: YieldSummariesParams = {
- ...(network ? { network: network } : {}),
- types: getApiYieldTypesForDashboardCategory(category),
- sort: "rewardRateDesc",
- limit: DEFAULT_YIELD_SUMMARIES_PAGE_LIMIT,
- };
-
- return {
- enabled: probeEnabled,
- staleTime,
- queryKey: getYieldSummariesQueryKey(params),
- queryFn: ({ signal }: { signal: AbortSignal }) =>
- fetchYieldSummariesPage({ apiClient, params, signal }),
- };
- }),
- });
-
- return useMemo(() => {
- const initialSelectionByCategory = new Map<
- DashboardYieldCategory,
- DashboardCategoryInitialSelection
- >();
- const availableCategories: DashboardYieldCategory[] = [];
-
- dashboardYieldCategoryOrder.forEach((category, index) => {
- const firstVisible = (results[index]?.data ?? []).find(
- isVisibleYieldSummary
- );
-
- if (!firstVisible) return;
-
- availableCategories.push(category);
- initialSelectionByCategory.set(category, {
- token: firstVisible.token,
- yieldDto: firstVisible,
- yieldId: firstVisible.id,
- });
- });
-
- return {
- availableCategories,
- initialSelectionByCategory,
- isLoading: probeEnabled && results.some((result) => result.isLoading),
- };
- }, [dashboardYieldCategoryOrder, results, probeEnabled]);
-};
diff --git a/packages/widget/src/hooks/api/use-default-tokens.ts b/packages/widget/src/hooks/api/use-default-tokens.ts
deleted file mode 100644
index d6f28f80..00000000
--- a/packages/widget/src/hooks/api/use-default-tokens.ts
+++ /dev/null
@@ -1,312 +0,0 @@
-import {
- type InfiniteData,
- type QueryClient,
- useInfiniteQuery,
- useQuery,
-} from "@tanstack/react-query";
-import { EitherAsync } from "purify-ts";
-import type { TokenBalanceScanResponseDto } from "../../domain/types/token-balance";
-import type { TokenDto } from "../../domain/types/tokens";
-import {
- type DashboardYieldCategory,
- getApiYieldTypesForDashboardCategory,
-} from "../../domain/types/yields";
-import type {
- TokenControllerGetTokensParams as LegacyTokenGetTokensParams,
- TokenWithAvailableYieldsDto as LegacyTokenWithAvailableYieldsDto,
-} from "../../generated/api/legacy";
-import type {
- TokensControllerGetTokensParams as YieldTokenGetTokensParams,
- TokenWithAvailableYieldsDto as YieldTokenWithAvailableYieldsDto,
-} from "../../generated/api/yield";
-import type { ApiClient } from "../../providers/api/api-client";
-import { useApiClient } from "../../providers/api/api-client-provider";
-import { useSettings } from "../../providers/settings";
-import { useSKWallet } from "../../providers/sk-wallet";
-
-const DEFAULT_TOKENS_PAGE_LIMIT = 100;
-const DEFAULT_TOKENS_PAGE_CONCURRENCY = 5;
-
-type YieldTokenTypes = YieldTokenGetTokensParams["yieldTypes"];
-type DefaultTokensQueryParams = {
- enabledYieldsOnly?: boolean;
- network?: TokenDto["network"];
- yieldTypes?: YieldTokenTypes;
-};
-type DefaultTokensPage = {
- limit?: number;
- nextOffset?: number;
- offset?: number;
- tokens: TokenBalanceScanResponseDto[];
- total?: number;
-};
-type DefaultTokensPages = {
- pages: DefaultTokensPage[];
- pageParams: number[];
-};
-type FetchDefaultTokensPageParams = DefaultTokensQueryParams & {
- apiClient: ApiClient;
- limit?: number;
- offset?: number;
- signal?: AbortSignal;
-};
-
-const noopFetchNextPage = () => undefined;
-
-const getTokenGetTokensQueryKey = (params?: DefaultTokensQueryParams) =>
- ["/v1/tokens", ...(params ? [params] : [])] as const;
-
-const getAllDefaultTokensQueryKey = (params?: DefaultTokensQueryParams) =>
- ["/v1/tokens/all-pages", ...(params ? [params] : [])] as const;
-
-const getNextOffset = ({
- limit,
- offset,
- total,
-}: {
- limit: number;
- offset: number;
- total: number;
-}) => {
- const nextOffset = offset + limit;
-
- return nextOffset < total ? nextOffset : undefined;
-};
-
-const shouldUseYieldTokensApi = ({
- enabledYieldsOnly,
- yieldTypes,
-}: Pick) =>
- !!enabledYieldsOnly || !!yieldTypes?.length;
-
-const toTokenBalanceScanResponse = (
- tokenWithYields:
- | LegacyTokenWithAvailableYieldsDto
- | YieldTokenWithAvailableYieldsDto
-): TokenBalanceScanResponseDto => ({
- token: tokenWithYields.token,
- availableYields: tokenWithYields.availableYields,
- amount: "0",
-});
-
-export const getYieldTypesForDashboardCategory = (
- yieldCategory?: DashboardYieldCategory | null
-): YieldTokenTypes =>
- yieldCategory
- ? getApiYieldTypesForDashboardCategory(yieldCategory)
- : undefined;
-
-export const fetchDefaultTokens = async ({
- apiClient,
- enabledYieldsOnly,
- limit = DEFAULT_TOKENS_PAGE_LIMIT,
- network,
- offset: firstOffset = 0,
- signal,
- yieldTypes,
-}: FetchDefaultTokensPageParams): Promise => {
- if (shouldUseYieldTokensApi({ enabledYieldsOnly, yieldTypes })) {
- const yieldTokenParams = {
- networks: network
- ? [
- network as NonNullable<
- YieldTokenGetTokensParams["networks"]
- >[number],
- ]
- : undefined,
- yieldTypes,
- limit,
- };
-
- const fetchYieldTokensPage = async (
- offset: number
- ): Promise => {
- const page = await apiClient
- .withOptions({ signal })
- .yield.TokensControllerGetTokens({
- params: { ...yieldTokenParams, offset },
- });
-
- return {
- limit: page.limit,
- tokens: (page.items ?? []).map(toTokenBalanceScanResponse),
- nextOffset: getNextOffset(page),
- offset: page.offset,
- total: page.total,
- };
- };
-
- const firstPage = await fetchYieldTokensPage(firstOffset);
-
- if (firstPage.nextOffset === undefined) {
- return { pages: [firstPage], pageParams: [firstOffset] };
- }
-
- const remainingOffsets: number[] = [];
- for (
- let offset = firstPage.offset! + firstPage.limit!;
- offset < firstPage.total!;
- offset += firstPage.limit!
- ) {
- remainingOffsets.push(offset);
- }
-
- const pages = [firstPage];
- const pageParams = [firstOffset, ...remainingOffsets];
-
- for (
- let i = 0;
- i < remainingOffsets.length;
- i += DEFAULT_TOKENS_PAGE_CONCURRENCY
- ) {
- const chunk = remainingOffsets.slice(
- i,
- i + DEFAULT_TOKENS_PAGE_CONCURRENCY
- );
- const chunkPages = await Promise.all(
- chunk.map((offset) => fetchYieldTokensPage(offset))
- );
-
- pages.push(...chunkPages);
- }
-
- return { pages, pageParams };
- }
-
- const tokens = await apiClient
- .withOptions({ signal })
- .legacy.TokenControllerGetTokens({
- params: {
- enabledYieldsOnly: enabledYieldsOnly || undefined,
- network: network as LegacyTokenGetTokensParams["network"],
- },
- });
-
- return {
- pages: [{ tokens: tokens.map(toTokenBalanceScanResponse) }],
- pageParams: [firstOffset],
- };
-};
-
-export const useDefaultTokens = ({
- yieldCategory,
-}: {
- yieldCategory?: DashboardYieldCategory | null;
-} = {}) => {
- const { network } = useSKWallet();
- const { tokensForEnabledYieldsOnly } = useSettings();
- const apiClient = useApiClient();
- const queryParams: DefaultTokensQueryParams = {
- enabledYieldsOnly: !!tokensForEnabledYieldsOnly,
- network: network ?? undefined,
- yieldTypes: getYieldTypesForDashboardCategory(yieldCategory),
- };
- const shouldFetchAllPages = !!queryParams.yieldTypes?.length;
-
- const allPagesQuery = useQuery({
- queryKey: getAllDefaultTokensQueryKey(queryParams),
- enabled: shouldFetchAllPages,
- queryFn: async ({ signal }) => {
- const data = await fetchDefaultTokens({
- ...queryParams,
- apiClient,
- signal,
- });
-
- return data.pages.flatMap((page) => page.tokens);
- },
- staleTime: 1000 * 60 * 5,
- });
-
- const infiniteQuery = useInfiniteQuery({
- queryKey: getTokenGetTokensQueryKey(queryParams),
- enabled: !shouldFetchAllPages,
- initialPageParam: 0,
- queryFn: async ({ pageParam, signal }) => {
- if (shouldUseYieldTokensApi(queryParams)) {
- const page = await apiClient
- .withOptions({ signal })
- .yield.TokensControllerGetTokens({
- params: {
- networks: queryParams.network
- ? [
- queryParams.network as NonNullable<
- YieldTokenGetTokensParams["networks"]
- >[number],
- ]
- : undefined,
- yieldTypes: queryParams.yieldTypes,
- offset: pageParam,
- limit: DEFAULT_TOKENS_PAGE_LIMIT,
- },
- });
-
- return {
- limit: page.limit,
- tokens: (page.items ?? []).map(toTokenBalanceScanResponse),
- nextOffset: getNextOffset(page),
- offset: page.offset,
- total: page.total,
- };
- }
-
- const tokens = await apiClient
- .withOptions({ signal })
- .legacy.TokenControllerGetTokens({
- params: {
- enabledYieldsOnly: queryParams.enabledYieldsOnly || undefined,
- network:
- queryParams.network as LegacyTokenGetTokensParams["network"],
- },
- });
-
- return {
- tokens: tokens.map(toTokenBalanceScanResponse),
- };
- },
- getNextPageParam: (lastPage) => lastPage.nextOffset,
- select: (data) => data.pages.flatMap((page) => page.tokens),
- staleTime: 1000 * 60 * 5,
- });
-
- if (shouldFetchAllPages) {
- return {
- ...allPagesQuery,
- fetchNextPage: noopFetchNextPage,
- hasNextPage: false,
- isFetchingNextPage: false,
- };
- }
-
- return infiniteQuery;
-};
-
-export const getDefaultTokens = (
- params: Omit & {
- queryClient: QueryClient;
- }
-) => {
- const queryParams: DefaultTokensQueryParams = {
- enabledYieldsOnly: params.enabledYieldsOnly,
- network: params.network,
- yieldTypes: params.yieldTypes,
- };
-
- return EitherAsync(() =>
- params.queryClient.fetchQuery({
- queryKey: getAllDefaultTokensQueryKey(queryParams),
- queryFn: async () => {
- const data = await fetchDefaultTokens(params);
- params.queryClient.setQueryData<
- InfiniteData
- >(getTokenGetTokensQueryKey(queryParams), data);
-
- return data.pages.flatMap((page) => page.tokens);
- },
- staleTime: 1000 * 60 * 5,
- })
- ).mapLeft((e) => {
- console.log(e);
- return new Error("could not get default tokens");
- });
-};
diff --git a/packages/widget/src/hooks/api/use-multi-yields.ts b/packages/widget/src/hooks/api/use-multi-yields.ts
index a00b2fe5..9d8c9202 100644
--- a/packages/widget/src/hooks/api/use-multi-yields.ts
+++ b/packages/widget/src/hooks/api/use-multi-yields.ts
@@ -1,118 +1,27 @@
-import { hashKey, type QueryClient, useQuery } from "@tanstack/react-query";
-import { useSelector } from "@xstate/react";
-import { createStore } from "@xstate/store";
-import type BigNumber from "bignumber.js";
-import { EitherAsync, Maybe } from "purify-ts";
-import { useEffect, useMemo } from "react";
+import { type QueryClient, useQuery } from "@tanstack/react-query";
import { createSelector } from "reselect";
import {
- defaultIfEmpty,
EMPTY,
filter,
firstValueFrom,
from,
map,
mergeMap,
- Observable,
- of,
- repeat,
- take,
- tap,
- timer,
toArray,
} from "rxjs";
-import { tokenString } from "../../domain";
-import {
- isSupportedChain,
- type SupportedSKChains,
-} from "../../domain/types/chains";
-import type { InitParams } from "../../domain/types/init-params";
-import type { PositionsData } from "../../domain/types/positions";
-import {
- canBeInitialYield,
- type PreferredTokenYieldsPerNetwork,
-} from "../../domain/types/stake";
+import { isSupportedChain } from "../../domain/types/chains";
import type { SKWallet } from "../../domain/types/wallet";
-import {
- type DashboardYieldCategory,
- getDashboardYieldCategory,
- isNonZeroRewardRateYield,
- type ValidatorsConfig,
- type Yield,
- type YieldBase,
+import type {
+ ValidatorsConfig,
+ Yield,
+ YieldBase,
} from "../../domain/types/yields";
import { useApiClient } from "../../providers/api/api-client-provider";
import { useSKQueryClient } from "../../providers/query-client";
import { useSKWallet } from "../../providers/sk-wallet";
import { useSavedRef } from "../use-saved-ref";
import { useValidatorsConfig } from "../use-validators-config";
-import {
- getYieldOpportunities,
- getYieldOpportunityFromSummary,
-} from "./use-yield-opportunity/get-yield-opportunity";
-import {
- fetchYieldSummariesWithProvidersByIds,
- type YieldSummaryWithProvider,
-} from "./use-yield-summaries";
-
-const multiYieldsStore = createStore({
- context: { data: new Map>() },
- on: {
- "yield-opportunity": (
- context,
- event: { data: { key: string; yieldDto: YieldSummaryWithProvider } }
- ) => {
- const newMap = new Map(context.data);
- const prev = newMap.get(event.data.key) ?? new Map();
-
- prev.set(event.data.yieldDto.id, event.data.yieldDto);
- newMap.set(event.data.key, prev);
-
- return { data: newMap };
- },
- },
-});
-
-export const useStreamYieldSummaries = (yieldIds: ReadonlyArray) => {
- const { network, isConnected, isLedgerLive } = useSKWallet();
- const apiClient = useApiClient();
-
- const argsRef = useSavedRef({
- isLedgerLive,
- queryClient: useSKQueryClient(),
- apiClient,
- network,
- isConnected,
- });
-
- const hashedKey = useMemo(() => hashKey(yieldIds), [yieldIds]);
-
- const validatorsConfig = useValidatorsConfig();
-
- useEffect(() => {
- const sub = multipleYieldSummaries$({
- ...argsRef.current,
- yieldIds,
- validatorsConfig,
- })
- .pipe(repeat({ delay: () => timer(1000 * 60 * 2) }))
- .subscribe({
- next: (v) =>
- multiYieldsStore.send({
- type: "yield-opportunity",
- data: { yieldDto: v, key: hashedKey },
- }),
- });
-
- return () => sub.unsubscribe();
- }, [argsRef, yieldIds, hashedKey, validatorsConfig]);
-
- return useSelector(multiYieldsStore, (state) => {
- const map = state.context.data.get(hashedKey);
-
- return map ? Array.from(map.values()) : [];
- });
-};
+import { getYieldOpportunities } from "./use-yield-opportunity/get-yield-opportunity";
export const useMultiYields = (
yieldIds: ReadonlyArray,
@@ -149,22 +58,6 @@ export const useMultiYields = (
});
};
-export const getFirstEligibleYield = (
- params: Parameters[0]
-) =>
- EitherAsync(() =>
- params.queryClient.fetchQuery({
- queryKey: getFirstEligibleYieldQueryKey({
- yieldIds: params.yieldIds,
- dashboardYieldCategory: params.dashboardYieldCategory,
- }),
- queryFn: () => firstValueFrom(firstEligibleYield$(params)),
- })
- ).mapLeft((e) => {
- console.log(e);
- return new Error("could not get first eligible yield");
- });
-
const multipleYields$ = (args: {
isLedgerLive: boolean;
queryClient: QueryClient;
@@ -200,119 +93,6 @@ const multipleYields$ = (args: {
)
);
-const multipleYieldSummaries$ = (args: {
- isLedgerLive: boolean;
- queryClient: QueryClient;
- apiClient: ReturnType;
- isConnected: boolean;
- network: SKWallet["network"];
- yieldIds: ReadonlyArray;
- validatorsConfig: ValidatorsConfig;
-}) =>
- args.yieldIds.length === 0
- ? EMPTY
- : from(
- fetchYieldSummariesWithProvidersByIds({
- apiClient: args.apiClient,
- queryClient: args.queryClient,
- yieldIds: args.yieldIds,
- })
- ).pipe(
- mergeMap((v) => from(v)),
- filter(
- (v): v is YieldSummaryWithProvider =>
- !!(
- v &&
- defaultFiltered({
- data: [v],
- isConnected: args.isConnected,
- network: args.network,
- isLedgerLive: args.isLedgerLive,
- }).length > 0
- )
- )
- );
-
-const firstEligibleYield$ = (args: {
- isLedgerLive: boolean;
- queryClient: QueryClient;
- apiClient: ReturnType;
- isConnected: boolean;
- network: SKWallet["network"];
- yieldIds: ReadonlyArray;
- dashboardYieldCategory?: DashboardYieldCategory | null;
- initParams: InitParams;
- positionsData: PositionsData;
- tokenBalanceAmount: BigNumber;
- validatorsConfig: ValidatorsConfig;
- preferredTokenYieldsPerNetwork: PreferredTokenYieldsPerNetwork | null;
-}) => {
- let defaultYield: YieldSummaryWithProvider | null = null;
-
- const successStream = multipleYieldSummaries$(args).pipe(
- filter(
- (y) =>
- !args.dashboardYieldCategory ||
- getDashboardYieldCategory(y) === args.dashboardYieldCategory
- ),
- tap((v) => {
- if (isNonZeroRewardRateYield(v) || !defaultYield) {
- defaultYield = v;
- }
- }),
- filter((y) => {
- const preferredYieldId = Maybe.fromNullable(
- args.preferredTokenYieldsPerNetwork?.[
- y.token.network as SupportedSKChains
- ]?.[tokenString(y.token)]
- )
- .altLazy(() =>
- Maybe.fromNullable(args.preferredTokenYieldsPerNetwork).chainNullable(
- (v) => Object.values(v)[0][tokenString(y.token)]
- )
- )
- .extractNullable();
-
- if (preferredYieldId) {
- return y.id === preferredYieldId || preferredYieldId === "*";
- }
-
- return canBeInitialYield({
- initQueryParams: Maybe.fromNullable(args.initParams),
- yieldDto: y,
- tokenBalanceAmount: args.tokenBalanceAmount,
- positionsData: args.positionsData,
- });
- }),
- take(1),
- defaultIfEmpty(null),
- mergeMap((yieldSummary) => {
- const selectedYield = yieldSummary ?? defaultYield;
-
- if (!selectedYield) {
- return of(null);
- }
-
- return from(
- getYieldOpportunityFromSummary({
- isLedgerLive: args.isLedgerLive,
- yieldDto: selectedYield,
- queryClient: args.queryClient,
- apiClient: args.apiClient,
- })
- ).pipe(map((v) => (v.isRight() ? v.extract() : null)));
- })
- );
-
- return new Observable((subscriber) => {
- successStream.subscribe({
- complete: () => subscriber.complete(),
- next: (v) => subscriber.next(v),
- error: (e) => subscriber.error(e),
- });
- });
-};
-
type SelectorInputData = {
data: YieldBase[];
isConnected: boolean;
@@ -342,26 +122,3 @@ const defaultFiltered = createSelector(
return network === o.token.network && defaultFilter;
})
);
-
-const getFirstEligibleYieldQueryKey = ({
- yieldIds,
- dashboardYieldCategory,
-}: {
- yieldIds: ReadonlyArray;
- dashboardYieldCategory?: DashboardYieldCategory | null;
-}) => ["first-eligible-yield", yieldIds, dashboardYieldCategory ?? null];
-
-export const getCachedFirstEligibleYield = ({
- queryClient,
- yieldIds,
- dashboardYieldCategory,
-}: {
- queryClient: QueryClient;
- yieldIds: ReadonlyArray;
- dashboardYieldCategory?: DashboardYieldCategory | null;
-}) =>
- Maybe.fromNullable(
- queryClient.getQueryData(
- getFirstEligibleYieldQueryKey({ yieldIds, dashboardYieldCategory })
- )
- );
diff --git a/packages/widget/src/hooks/api/use-token-balances-scan.ts b/packages/widget/src/hooks/api/use-token-balances-scan.ts
index e1183999..9c686832 100644
--- a/packages/widget/src/hooks/api/use-token-balances-scan.ts
+++ b/packages/widget/src/hooks/api/use-token-balances-scan.ts
@@ -1,4 +1,3 @@
-import type { QueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { EitherAsync, Just, Maybe } from "purify-ts";
import { useCallback, useMemo } from "react";
@@ -59,19 +58,6 @@ export const useTokenBalancesScan = () => {
});
};
-export const getTokenBalancesScan = (
- params: Parameters[0] & { queryClient: QueryClient }
-) =>
- EitherAsync(() =>
- params.queryClient.fetchQuery({
- queryKey: getTokenTokenBalancesScanQueryKey(params.tokenBalanceScanDto),
- queryFn: async () => (await queryFn(params)).unsafeCoerce(),
- })
- ).mapLeft((e) => {
- console.log(e);
- return new Error("could not get multi yields");
- });
-
const queryFn = ({
apiClient,
tokenBalanceScanDto,
diff --git a/packages/widget/src/hooks/api/use-yield-balances-scan.ts b/packages/widget/src/hooks/api/use-yield-balances-scan.ts
index 8ad83024..cd3b4beb 100644
--- a/packages/widget/src/hooks/api/use-yield-balances-scan.ts
+++ b/packages/widget/src/hooks/api/use-yield-balances-scan.ts
@@ -5,6 +5,7 @@ import type {
YieldBalancesByYieldDto,
YieldBalancesRequestDto,
} from "../../domain/types/positions";
+import { toYieldBalancesByYields } from "../../domain/types/positions";
import { useApiClient } from "../../providers/api/api-client-provider";
import { useSKQueryClient } from "../../providers/query-client";
import { useSKWallet } from "../../providers/sk-wallet";
@@ -64,7 +65,7 @@ export const useYieldBalancesScan = (opts?: {
payload: param.dto,
}),
select: (data) => {
- const items = data.items as YieldBalancesByYieldDto[];
+ const items = toYieldBalancesByYields(data.items);
if (opts?.select) {
return opts.select(items);
diff --git a/packages/widget/src/hooks/api/use-yield-opportunity/get-yield-opportunity.ts b/packages/widget/src/hooks/api/use-yield-opportunity/get-yield-opportunity.ts
index 058614f7..4c335469 100644
--- a/packages/widget/src/hooks/api/use-yield-opportunity/get-yield-opportunity.ts
+++ b/packages/widget/src/hooks/api/use-yield-opportunity/get-yield-opportunity.ts
@@ -24,9 +24,6 @@ type MultiParams = Omit & {
type ParamsWithQueryClient = Params & {
queryClient: QueryClient;
};
-type ParamsWithYieldDto = Omit & {
- yieldDto: YieldBase;
-};
type MultiParamsWithQueryClient = MultiParams & {
queryClient: QueryClient;
};
@@ -78,18 +75,6 @@ export const getYieldOpportunity = (params: ParamsWithQueryClient) =>
return new Error("Could not get yield opportunity");
});
-export const getYieldOpportunityFromSummary = (params: ParamsWithYieldDto) =>
- EitherAsync(() =>
- params.queryClient.fetchQuery({
- queryKey: getKey({ ...params, yieldId: params.yieldDto.id }),
- staleTime,
- queryFn: () => hydrateYieldSummaryQueryFn(params),
- })
- ).mapLeft((e) => {
- console.log(e);
- return new Error("Could not get yield opportunity");
- });
-
export const getYieldOpportunities = (params: MultiParamsWithQueryClient) =>
EitherAsync(() =>
params.queryClient.fetchQuery({
@@ -171,32 +156,6 @@ const fn = ({
});
};
-const hydrateYieldSummaryQueryFn = async ({
- yieldDto,
- queryClient,
- signal,
- suppressRichErrors,
- apiClient,
-}: ParamsWithYieldDto & {
- signal?: AbortSignal;
-}) => {
- const client = apiClient.withOptions({ signal, suppressRichErrors });
- const provider =
- yieldDto.provider ??
- (await fetchYieldProvider({
- client,
- providerId: yieldDto.providerId,
- queryClient,
- }));
-
- return applyYieldOverrides(
- createYield({
- provider,
- yieldDto,
- })
- );
-};
-
const multiFn = ({
queryClient,
yieldIds,
diff --git a/packages/widget/src/hooks/api/use-yield-summaries.ts b/packages/widget/src/hooks/api/use-yield-summaries.ts
index eb697dd4..4c81102c 100644
--- a/packages/widget/src/hooks/api/use-yield-summaries.ts
+++ b/packages/widget/src/hooks/api/use-yield-summaries.ts
@@ -1,14 +1,10 @@
import type { QueryClient } from "@tanstack/react-query";
-import { isSupportedChain } from "../../domain/types/chains";
+import { chunksOf } from "effect/Array";
import {
isEthenaUsdeStaking,
- isNonZeroRewardRateYield,
type YieldProviderDetails,
} from "../../domain/types/yields";
-import type {
- YieldDto,
- YieldsControllerGetYieldsParams,
-} from "../../generated/api/yield";
+import type { YieldDto } from "../../generated/api/yield";
import type { useApiClient } from "../../providers/api/api-client-provider";
import { fetchYieldProviders } from "./use-yield-providers";
@@ -18,38 +14,13 @@ import { fetchYieldProviders } from "./use-yield-providers";
* list rendering need (`token`, `rewardRate`, `status`, `metadata`,
* `mechanics.type`, `providerId`).
*/
-export type YieldSummary = YieldDto;
-export type YieldSummaryWithProvider = YieldSummary & {
+type YieldSummary = YieldDto;
+type YieldSummaryWithProvider = YieldSummary & {
provider?: YieldProviderDetails;
};
-export const DEFAULT_YIELD_SUMMARIES_PAGE_LIMIT = 50;
const DEFAULT_YIELD_IDS_CHUNK_SIZE = 100;
-export type YieldSummariesParams = Pick<
- YieldsControllerGetYieldsParams,
- "network" | "types" | "inputToken" | "sort" | "limit"
->;
-
-/**
- * A summary is "visible" when it is enterable, on a supported chain, and has a
- * non-zero reward rate (matching the dashboard catalog's display semantics).
- */
-export const isVisibleYieldSummary = (summary: YieldSummary): boolean =>
- summary.status.enter &&
- isSupportedChain(summary.token.network) &&
- isNonZeroRewardRateYield(summary);
-
-export const getYieldSummariesQueryKey = (
- params: YieldSummariesParams & { allPages?: boolean }
-) => ["yield-summaries", params];
-
-type FetchArgs = {
- apiClient: ReturnType;
- params: YieldSummariesParams;
- signal?: AbortSignal;
-};
-
type FetchByIdsArgs = {
apiClient: ReturnType;
chunkSize?: number;
@@ -64,30 +35,6 @@ type FetchByIdsWithProvidersArgs = FetchByIdsArgs & {
const unique = (items: ReadonlyArray) => [...new Set(items)];
-const chunks = (items: ReadonlyArray, chunkSize: number): T[][] => {
- const result: T[][] = [];
-
- for (let index = 0; index < items.length; index += chunkSize) {
- result.push(items.slice(index, index + chunkSize));
- }
-
- return result;
-};
-
-/**
- * Fetch a single page of yield summaries.
- */
-export const fetchYieldSummariesPage = async ({
- apiClient,
- params,
- signal,
-}: FetchArgs): Promise => {
- const client = apiClient.withOptions({ signal });
- const result = await client.yield.YieldsControllerGetYields({ params });
-
- return [...(result.items ?? [])];
-};
-
/**
* Fetch yield summaries by ID without ever sending an unbounded `yieldIds`
* query array. Results are ordered according to the first occurrence of each
@@ -110,7 +57,7 @@ export const fetchYieldSummariesByIds = async ({
const normalizedChunkSize = Math.max(1, chunkSize);
const summariesById = new Map();
- for (const chunk of chunks(ids, normalizedChunkSize)) {
+ for (const chunk of chunksOf(ids, normalizedChunkSize)) {
const result = await client.yield.YieldsControllerGetYields({
params: {
yieldIds: chunk,
diff --git a/packages/widget/src/hooks/api/use-yield-validators.ts b/packages/widget/src/hooks/api/use-yield-validators.ts
index 18d9fc8c..5c88c467 100644
--- a/packages/widget/src/hooks/api/use-yield-validators.ts
+++ b/packages/widget/src/hooks/api/use-yield-validators.ts
@@ -3,6 +3,11 @@ import {
type QueryClient,
useInfiniteQuery,
} from "@tanstack/react-query";
+import {
+ toValidator,
+ toValidators,
+ type Validator,
+} from "../../domain/types/validators";
import type { ValidatorsConfig } from "../../domain/types/yields";
import { filterValidators, type Yield } from "../../domain/types/yields";
import type { ValidatorDto } from "../../generated/api/yield";
@@ -31,7 +36,7 @@ type PageParam =
| { type: "search"; offset: number; search: string };
type Page = {
- validators: ValidatorDto[];
+ validators: Validator[];
nextPageParam?: PageParam;
};
@@ -56,7 +61,7 @@ const setYieldValidatorsQueryData = ({
}: {
queryClient: QueryClient;
yieldId: Yield["id"];
- validators: ReadonlyArray;
+ validators: ReadonlyArray;
}) => {
validators.forEach((validator) => {
queryClient.setQueryData(
@@ -78,12 +83,12 @@ export const getYieldValidatorsByAddresses = async ({
yieldId: Yield["id"];
addresses: ReadonlyArray;
suppressRichErrors?: boolean;
-}): Promise => {
+}): Promise => {
const uniqueAddresses = [...new Set(addresses)];
const validators = new Map(
await Promise.all(
uniqueAddresses.map(
- async (address): Promise<[ValidatorDto["address"], ValidatorDto]> => {
+ async (address): Promise<[ValidatorDto["address"], Validator]> => {
try {
const validator = await queryClient.fetchQuery({
queryKey: getYieldValidatorQueryKey({ yieldId, address }),
@@ -100,18 +105,21 @@ export const getYieldValidatorsByAddresses = async ({
});
const lowerCaseAddress = address.toLowerCase();
- return (
+ const matchingValidator =
validatorsPage.items?.find(
(validator) =>
validator.address.toLowerCase() === lowerCaseAddress
- ) ?? null
- );
+ ) ?? null;
+
+ return matchingValidator
+ ? toValidator(matchingValidator)
+ : null;
},
});
- return [address, validator ?? { address }];
+ return [address, validator ?? toValidator({ address })];
} catch {
- return [address, { address }];
+ return [address, toValidator({ address })];
}
}
)
@@ -140,7 +148,7 @@ const getFilteredValidators = ({
validatorsConfig,
yieldId,
}: Pick & {
- validators: ValidatorDto[];
+ validators: Validator[];
}) =>
network
? filterValidators({
@@ -151,19 +159,19 @@ const getFilteredValidators = ({
})
: validators;
-const deduplicateValidatorsByAddress = (
+const deduplicateValidatorsByKey = (
validators: ReadonlyArray
) => {
- const seenAddresses = new Set();
+ const seenKeys = new Set();
return validators.filter((validator) => {
- const address = validator.address.toLowerCase();
+ const key = toValidator(validator).key;
- if (seenAddresses.has(address)) {
+ if (seenKeys.has(key)) {
return false;
}
- seenAddresses.add(address);
+ seenKeys.add(key);
return true;
});
@@ -212,7 +220,7 @@ const fetchPagedValidators = async ({
fetchPage({ offset, name: search }),
fetchPage({ offset, address: search }),
]);
- const items = deduplicateValidatorsByAddress([
+ const items = deduplicateValidatorsByKey([
...(namePage.items ?? []),
...(addressPage.items ?? []),
]);
@@ -227,15 +235,16 @@ const fetchPagedValidators = async ({
return fetchPage({ offset, preferred: false });
})();
const rawValidators = [...(rawPage.items ?? [])];
+ const validatorOptions = toValidators(rawValidators);
setYieldValidatorsQueryData({
queryClient,
yieldId,
- validators: rawValidators,
+ validators: validatorOptions,
});
const validators = getFilteredValidators({
- validators: rawValidators,
+ validators: validatorOptions,
network,
validatorsConfig,
yieldId,
@@ -330,15 +339,16 @@ const fetchValidatorsPage = async ({
const rawValidators = [firstPage, ...remainingPages].flatMap(
(page) => page.items ?? []
);
+ const validatorOptions = toValidators(rawValidators);
setYieldValidatorsQueryData({
queryClient,
yieldId,
- validators: [...rawValidators, ...(otherPage.items ?? [])],
+ validators: [...validatorOptions, ...toValidators(otherPage.items ?? [])],
});
const validators = getFilteredValidators({
- validators: rawValidators,
+ validators: validatorOptions,
network,
validatorsConfig,
yieldId,
diff --git a/packages/widget/src/hooks/use-estimated-rewards.ts b/packages/widget/src/hooks/use-estimated-rewards.ts
index 5adf51d1..391fd985 100644
--- a/packages/widget/src/hooks/use-estimated-rewards.ts
+++ b/packages/widget/src/hooks/use-estimated-rewards.ts
@@ -1,8 +1,8 @@
import BigNumber from "bignumber.js";
import { List, Maybe } from "purify-ts";
import { useMemo } from "react";
+import type { Validator, ValidatorKey } from "../domain/types/validators";
import { isBittensorStaking, type Yield } from "../domain/types/yields";
-import type { ValidatorDto } from "../generated/api/yield";
import { formatNumber } from "../utils";
import { getRewardRateFormatted } from "../utils/formatters";
import { useProvidersDetails } from "./use-provider-details";
@@ -15,7 +15,7 @@ export const useEstimatedRewards = ({
}: {
selectedStake: Maybe;
stakeAmount: BigNumber;
- selectedValidators: Map;
+ selectedValidators: Map;
selectedProviderYieldId: Maybe;
}) => {
const providersDetails = useProvidersDetails({
diff --git a/packages/widget/src/hooks/use-handle-deep-links.ts b/packages/widget/src/hooks/use-handle-deep-links.ts
index 82b1ce27..caa9acc6 100644
--- a/packages/widget/src/hooks/use-handle-deep-links.ts
+++ b/packages/widget/src/hooks/use-handle-deep-links.ts
@@ -3,7 +3,7 @@ import { useEffect } from "react";
import { useNavigate } from "react-router";
import { usePendingActionDeepLink } from "../pages/details/earn-page/state/use-pending-action-deep-link";
import { useMountAnimation } from "../providers/mount-animation";
-import { usePendingActionStore } from "../providers/pending-action-store";
+import { useSetPendingActionRequest } from "../providers/pending-action-store";
import { useSKWallet } from "../providers/sk-wallet";
import { useInitQueryParams } from "./use-init-query-params";
import { useSavedRef } from "./use-saved-ref";
@@ -11,7 +11,7 @@ import { useSavedRef } from "./use-saved-ref";
export const useHandleDeepLinks = () => {
const pendingActionDeepLinkCheck = usePendingActionDeepLink();
const navigateRef = useSavedRef(useNavigate());
- const pendignActionStore = usePendingActionStore();
+ const setPendingActionRequest = useSetPendingActionRequest();
const initQueryParams = useInitQueryParams();
const { mountAnimationFinished } = useMountAnimation();
@@ -52,9 +52,9 @@ export const useHandleDeepLinks = () => {
appReady && val.type === "review"
)
.ifJust((val) => {
- pendignActionStore.send({
- type: "initFlow",
- data: {
+ setPendingActionRequest(
+ Maybe.of({
+ actionDto: Maybe.empty(),
requestDto: val.pendingActionDto.requestDto,
addresses: {
address: val.pendingActionDto.address,
@@ -64,14 +64,14 @@ export const useHandleDeepLinks = () => {
integrationData: val.pendingActionDto.integrationData,
interactedToken: val.balance.token,
pendingActionType: val.pendingActionDto.requestDto.action,
- },
- });
+ })
+ );
navigateRef.current(
`positions/${val.yieldOp.id}/${val.balanceId}/pending-action/review`
);
});
}, [
- pendignActionStore,
+ setPendingActionRequest,
pendingActionDeepLinkCheck.data,
appReady,
navigateRef,
diff --git a/packages/widget/src/hooks/use-positions-data.ts b/packages/widget/src/hooks/use-positions-data.ts
index fcffacce..0db549db 100644
--- a/packages/widget/src/hooks/use-positions-data.ts
+++ b/packages/widget/src/hooks/use-positions-data.ts
@@ -4,6 +4,7 @@ import {
type BalanceDataKey,
getPositionBalanceDataKey,
type PositionsData,
+ type PositionValidators,
type YieldBalanceDto,
type YieldBalancesByYieldDto,
} from "../domain/types/positions";
@@ -65,7 +66,7 @@ const positionsDataSelector = createSelector(
{ balances: YieldBalanceDto[] } & (
| {
type: "validators";
- validators: NonNullable;
+ validators: PositionValidators;
}
| { type: "default" }
)
diff --git a/packages/widget/src/hooks/use-provider-details.ts b/packages/widget/src/hooks/use-provider-details.ts
index 999d042a..65aa4a61 100644
--- a/packages/widget/src/hooks/use-provider-details.ts
+++ b/packages/widget/src/hooks/use-provider-details.ts
@@ -1,11 +1,11 @@
import { List, Maybe } from "purify-ts";
import { useMemo } from "react";
+import type { Validator, ValidatorKey } from "../domain/types/validators";
import {
getYieldProviderYieldIds,
isYieldWithProviderOptions,
type Yield,
} from "../domain/types/yields";
-import type { ValidatorDto } from "../generated/api/yield";
import type { GetMaybeJust } from "../types/utils";
import { getRewardRateFormatted } from "../utils/formatters";
import { useMultiYields } from "./api/use-multi-yields";
@@ -17,12 +17,12 @@ type Res = Maybe<{
rewardRate: number | undefined;
rewardType: string | undefined;
address?: string;
- stakedBalance?: ValidatorDto["tvl"];
- votingPower?: ValidatorDto["votingPower"];
- commission?: ValidatorDto["commission"];
- website?: ValidatorDto["website"];
- status?: ValidatorDto["status"];
- preferred?: ValidatorDto["preferred"];
+ stakedBalance?: Validator["tvl"];
+ votingPower?: Validator["votingPower"];
+ commission?: Validator["commission"];
+ website?: Validator["website"];
+ status?: Validator["status"];
+ preferred?: Validator["preferred"];
}>;
export const getProviderDetails = ({
@@ -32,7 +32,7 @@ export const getProviderDetails = ({
selectedProviderYieldId,
}: {
integrationData: Maybe;
- validator: Maybe;
+ validator: Maybe;
yields: Maybe>;
selectedProviderYieldId: Maybe;
}): Res => {
@@ -119,7 +119,7 @@ export const useProvidersDetails = ({
selectedProviderYieldId,
}: {
integrationData: Maybe;
- validators: Maybe | Map>;
+ validators: Maybe | Map>;
selectedProviderYieldId: Maybe;
}) => {
const yields = useMultiYields(
diff --git a/packages/widget/src/hooks/use-rich-errors.ts b/packages/widget/src/hooks/use-rich-errors.ts
index a761cae7..8edd6567 100644
--- a/packages/widget/src/hooks/use-rich-errors.ts
+++ b/packages/widget/src/hooks/use-rich-errors.ts
@@ -20,7 +20,11 @@ const isRichError = (error: unknown): error is RichError =>
const resetRichError = () => $richError.next(null);
-const allowedUrls = [config.env.apiUrl, config.env.yieldsApiUrl];
+const allowedUrls = [
+ config.env.apiUrl,
+ config.env.borrowApiUrl,
+ config.env.yieldsApiUrl,
+];
export const handleRichErrorResponse = ({
data,
diff --git a/packages/widget/src/main.tsx b/packages/widget/src/main.tsx
index 264da889..6e79ef86 100644
--- a/packages/widget/src/main.tsx
+++ b/packages/widget/src/main.tsx
@@ -15,8 +15,9 @@ type StandaloneVariant = Exclude;
const variant: StandaloneVariant =
import.meta.env.VITE_APP_VARIANT ?? "default";
-const dashboardVariant: SKAppProps["dashboardVariant"] =
- import.meta.env.VITE_FORCE_DASHBOARD === "true";
+// TODO: Remove this once the borrow feature is enabled
+const dashboardVariant: SKAppProps["dashboardVariant"] = true;
+const borrowEnabled: SKAppProps["borrowEnabled"] = true;
const StandaloneApp = () => {
const [themeVariant, setThemeVariant] = useState<"dark" | "light">("dark");
@@ -33,6 +34,7 @@ const StandaloneApp = () => {
const props: SKAppProps = {
variant,
+ borrowEnabled,
dashboardVariant,
theme: themeVariant === "dark" ? darkTheme : lightTheme,
apiKey: import.meta.env.VITE_API_KEY,
diff --git a/packages/widget/src/pages-dashboard/activity/activity-details.page.tsx b/packages/widget/src/pages-dashboard/activity/activity-details.page.tsx
index f8671356..d8cb240a 100644
--- a/packages/widget/src/pages-dashboard/activity/activity-details.page.tsx
+++ b/packages/widget/src/pages-dashboard/activity/activity-details.page.tsx
@@ -1,4 +1,3 @@
-import { useSelector } from "@xstate/store/react";
import { Box } from "../../components/atoms/box";
import { ActionStatus, type TransactionType } from "../../domain/types/action";
import { useActivityComplete } from "../../pages/complete/hooks/use-activity-complete.hook";
@@ -6,20 +5,14 @@ import { useComplete } from "../../pages/complete/hooks/use-complete.hook";
import { CompletePageComponent } from "../../pages/complete/pages/common.page";
import { CompleteCommonContextProvider } from "../../pages/complete/state";
import { ActionReviewPage } from "../../pages/review/pages/action-review.page";
-import { useActivityContext } from "../../providers/activity-provider";
+import {
+ useActivitySelectedAction,
+ useActivitySelectedYield,
+} from "../../providers/activity-provider";
export const ActivityDetailsPage = () => {
- const activityContext = useActivityContext();
-
- const selectedAction = useSelector(
- activityContext,
- (state) => state.context.selectedAction
- ).extractNullable();
-
- const selectedYield = useSelector(
- activityContext,
- (state) => state.context.selectedYield
- ).extractNullable();
+ const selectedAction = useActivitySelectedAction().extractNullable();
+ const selectedYield = useActivitySelectedYield().extractNullable();
if (!selectedYield || !selectedAction) {
return null;
diff --git a/packages/widget/src/pages-dashboard/activity/activity.page.tsx b/packages/widget/src/pages-dashboard/activity/activity.page.tsx
index bf8e16ec..6aa7662e 100644
--- a/packages/widget/src/pages-dashboard/activity/activity.page.tsx
+++ b/packages/widget/src/pages-dashboard/activity/activity.page.tsx
@@ -1,5 +1,4 @@
import { useConnectModal } from "@stakekit/rainbowkit";
-import { useSelector } from "@xstate/store/react";
import { Maybe } from "purify-ts";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
@@ -17,7 +16,10 @@ import {
} from "../../pages/details/activity-page/state/activity-page.context";
import type { ActionYieldDto } from "../../pages/details/activity-page/types";
import { FallbackContent } from "../../pages/details/positions-page/components/fallback-content";
-import { useActivityContext } from "../../providers/activity-provider";
+import {
+ useActivitySelectedAction,
+ useSetActivitySelection,
+} from "../../providers/activity-provider";
import { useSKWallet } from "../../providers/sk-wallet";
import { container } from "./styles.css";
@@ -100,25 +102,25 @@ const ActivityPageComponent = () => {
const _ActivityPage = () => {
const value = useActivityPageContext();
- const activityStore = useActivityContext();
+ const setActivitySelection = useSetActivitySelection();
const { openConnectModal } = useConnectModal();
const { isConnected, network } = useSKWallet();
const onActionSelect = (data: ActionYieldDto) => {
if (!isConnected) return openConnectModal?.();
+ if (!data.yieldData) return;
if (
data.actionData.status === ActionStatus.SUCCESS ||
data.actionData.status === ActionStatus.PROCESSING
) {
- activityStore.send({
- type: "setSelectedAction",
- data: Maybe.of({
+ setActivitySelection(
+ Maybe.of({
selectedAction: data.actionData,
selectedYield: data.yieldData,
selectedValidators: data.validatorsData,
- }),
- });
+ })
+ );
}
if (
@@ -126,40 +128,30 @@ const _ActivityPage = () => {
data.actionData.status === ActionStatus.WAITING_FOR_NEXT ||
data.actionData.status === ActionStatus.FAILED
) {
- activityStore.send({
- type: "setSelectedAction",
- data: Maybe.of({
+ setActivitySelection(
+ Maybe.of({
selectedAction: data.actionData,
selectedYield: data.yieldData,
selectedValidators: data.validatorsData,
- }),
- });
+ })
+ );
}
return;
};
- const selectedAction = useSelector(
- activityStore,
- (state) => state.context.selectedAction
- );
+ const selectedAction = useActivitySelectedAction();
// biome-ignore lint: false
useEffect(() => {
- activityStore.send({
- type: "setSelectedAction",
- data: Maybe.empty(),
- });
- }, [network, activityStore]);
+ setActivitySelection(Maybe.empty());
+ }, [network, setActivitySelection]);
useEffect(() => {
if (!isConnected && selectedAction.isJust()) {
- activityStore.send({
- type: "setSelectedAction",
- data: Maybe.empty(),
- });
+ setActivitySelection(Maybe.empty());
}
- }, [isConnected, selectedAction, activityStore]);
+ }, [isConnected, selectedAction, setActivitySelection]);
return (
diff --git a/packages/widget/src/pages-dashboard/activity/index.tsx b/packages/widget/src/pages-dashboard/activity/index.tsx
index 385f71a7..b74d4abb 100644
--- a/packages/widget/src/pages-dashboard/activity/index.tsx
+++ b/packages/widget/src/pages-dashboard/activity/index.tsx
@@ -1,10 +1,12 @@
-import { useSelector } from "@xstate/store/react";
import { Maybe } from "purify-ts";
import { Outlet, useNavigate } from "react-router";
import { Box } from "../../components/atoms/box";
import { CaretLeftIcon } from "../../components/atoms/icons/caret-left";
import { AnimationPage } from "../../navigation/containers/animation-page";
-import { useActivityContext } from "../../providers/activity-provider";
+import {
+ useActivitySelectedAction,
+ useSetActivitySelection,
+} from "../../providers/activity-provider";
import { useSettings } from "../../providers/settings";
import { combineRecipeWithVariant } from "../../utils/styles";
import { ActivityPage } from "./activity.page";
@@ -13,17 +15,13 @@ import { activityDetailsContainer } from "./styles.css";
export const ActivityTabPage = () => {
const { variant } = useSettings();
const navigate = useNavigate();
- const activityStore = useActivityContext();
-
- const selectedAction = useSelector(
- activityStore,
- (state) => state.context.selectedAction
- );
+ const selectedAction = useActivitySelectedAction();
+ const setActivitySelection = useSetActivitySelection();
const showDetails = selectedAction.isJust();
const onBack = () => {
- activityStore.send({ type: "setSelectedAction", data: Maybe.empty() });
+ setActivitySelection(Maybe.empty());
navigate("/activity");
};
diff --git a/packages/widget/src/pages-dashboard/borrow/complete.tsx b/packages/widget/src/pages-dashboard/borrow/complete.tsx
new file mode 100644
index 00000000..6a0ecd44
--- /dev/null
+++ b/packages/widget/src/pages-dashboard/borrow/complete.tsx
@@ -0,0 +1,186 @@
+import { useAtomSet } from "@effect/atom-react";
+import { useTranslation } from "react-i18next";
+import { useLocation, useNavigate, useParams } from "react-router";
+import {
+ BorrowDashboardKey,
+ borrowActionFormAtom,
+ borrowDashboardAtom,
+} from "../../borrow";
+import { Box } from "../../components/atoms/box";
+import { Button } from "../../components/atoms/button";
+import { CheckCircleIcon } from "../../components/atoms/icons/check-circle";
+import { Text } from "../../components/atoms/typography/text";
+import { useTokenBalancesScan } from "../../hooks/api/use-token-balances-scan";
+import { useTrackPage } from "../../hooks/tracking/use-track-page";
+import { AnimationPage } from "../../navigation/containers/animation-page";
+import { PageContainer } from "../../pages/components/page-container";
+import { PageCtaButton } from "../../pages/components/page-cta";
+import { DetailRow } from "../overview/earn-details/components/details-section";
+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);
+ const walletBridge = useBorrowConnectedWalletBridge();
+ const tokenBalances = useTokenBalancesScan();
+ const resetActionForm = useAtomSet(borrowActionFormAtom);
+ const resetBorrowDashboard = useAtomSet(
+ borrowDashboardAtom(
+ new BorrowDashboardKey({
+ network: walletBridge.wallet.network,
+ scopeId: `${walletBridge.wallet.currentAccount.address}:${walletBridge.wallet.network}`,
+ tokenBalances: tokenBalances.data ?? [],
+ walletAddress: walletBridge.wallet.currentAccount.address,
+ })
+ )
+ );
+ 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 (
+
+
+
+
+
+
+
+
+ {marketId ? (
+
+
+
+ ) : null}
+
+
+ {t("dashboard.borrow.success_page.title")}
+
+
+ {t(`dashboard.borrow.review_page.actions.${summary.action}`)}
+
+
+
+
+ {rows.map((row) => (
+
+ ))}
+
+
+ {result.submissions.length > 0 && (
+
+ {result.submissions.map((submission) =>
+ submission.link ? (
+ window.open(submission.link, "_blank")}
+ >
+
+ {t("dashboard.borrow.success_page.view_transaction")}
+
+
+ ) : null
+ )}
+
+ )}
+
+
+
+
+
+ );
+};
diff --git a/packages/widget/src/pages-dashboard/borrow/connected-wallet.tsx b/packages/widget/src/pages-dashboard/borrow/connected-wallet.tsx
new file mode 100644
index 00000000..a3a38b22
--- /dev/null
+++ b/packages/widget/src/pages-dashboard/borrow/connected-wallet.tsx
@@ -0,0 +1,82 @@
+import {
+ createContext,
+ type PropsWithChildren,
+ type ReactNode,
+ useContext,
+ useMemo,
+} from "react";
+import { Navigate, Outlet } from "react-router";
+import {
+ type BorrowWalletBridgeState,
+ type BorrowWalletConnectedBridgeState,
+ toBorrowWalletBridgeState,
+} from "../../borrow";
+import { useSKWallet } from "../../providers/sk-wallet";
+
+const BorrowWalletContext = createContext(
+ undefined
+);
+
+export const BorrowWalletProvider = ({ children }: PropsWithChildren) => {
+ const skWallet = useSKWallet();
+ const walletBridge = useMemo(
+ () =>
+ toBorrowWalletBridgeState({
+ address: skWallet.address,
+ chain: skWallet.chain,
+ connector: skWallet.connector,
+ connectorChains: skWallet.connectorChains,
+ isConnected: skWallet.isConnected,
+ network: skWallet.network,
+ }),
+ [
+ skWallet.address,
+ skWallet.chain,
+ skWallet.connector,
+ skWallet.connectorChains,
+ skWallet.isConnected,
+ skWallet.network,
+ ]
+ );
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useBorrowWalletBridge = () => {
+ const walletBridge = useContext(BorrowWalletContext);
+
+ if (!walletBridge) {
+ throw new Error(
+ "useBorrowWalletBridge must be used within a BorrowWalletProvider"
+ );
+ }
+
+ return walletBridge;
+};
+
+export const useBorrowConnectedWalletBridge =
+ (): BorrowWalletConnectedBridgeState => {
+ const walletBridge = useBorrowWalletBridge();
+
+ if (walletBridge.status !== "connected") {
+ throw new Error(
+ "useBorrowConnectedWalletBridge requires a connected borrow wallet"
+ );
+ }
+
+ return walletBridge;
+ };
+
+export const BorrowConnectedWalletRoute = (): ReactNode => {
+ const walletBridge = useBorrowWalletBridge();
+
+ return walletBridge.status === "connected" ? (
+
+ ) : (
+
+ );
+};
diff --git a/packages/widget/src/pages-dashboard/borrow/flow-routes.ts b/packages/widget/src/pages-dashboard/borrow/flow-routes.ts
new file mode 100644
index 00000000..ca9e9a55
--- /dev/null
+++ b/packages/widget/src/pages-dashboard/borrow/flow-routes.ts
@@ -0,0 +1,10 @@
+export const getBorrowFlowRoutes = (marketId?: string) => {
+ const basePath = marketId ? `/positions/borrow/${marketId}` : "/borrow";
+
+ return {
+ basePath,
+ completePath: `${basePath}/complete`,
+ reviewPath: `${basePath}/review`,
+ stepsPath: `${basePath}/steps`,
+ };
+};
diff --git a/packages/widget/src/pages-dashboard/borrow/index.tsx b/packages/widget/src/pages-dashboard/borrow/index.tsx
new file mode 100644
index 00000000..3f5f3b93
--- /dev/null
+++ b/packages/widget/src/pages-dashboard/borrow/index.tsx
@@ -0,0 +1,1220 @@
+import { Trigger } from "@radix-ui/react-dialog";
+import type BigNumber from "bignumber.js";
+import clsx from "clsx";
+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
+import { type ReactNode, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Outlet, useNavigate } from "react-router";
+import type {
+ BorrowMarketWalletBalances,
+ BorrowNetwork,
+ BorrowToken,
+ BorrowWalletBridgeState,
+ BorrowWalletConnectedBridgeState,
+ CollateralToken,
+ Market,
+} from "../../borrow";
+import { Box } from "../../components/atoms/box";
+import {
+ pressAnimation,
+ selectTokenButton,
+} from "../../components/atoms/button/styles.css";
+import { ContentLoaderSquare } from "../../components/atoms/content-loader";
+import { CaretDownIcon } from "../../components/atoms/icons/caret-down";
+import { Image } from "../../components/atoms/image";
+import { MaxButton } from "../../components/atoms/max-button";
+import { NumberInput } from "../../components/atoms/number-input";
+import { SelectModal } from "../../components/atoms/select-modal";
+import { TokenIcon } from "../../components/atoms/token-icon";
+import { Text } from "../../components/atoms/typography/text";
+import * as AmountToggle from "../../components/molecules/amount-toggle";
+import { ConnectButton } from "../../components/molecules/connect-button";
+import type { TokenDto } from "../../domain/types/tokens";
+import { useTrackEvent } from "../../hooks/tracking/use-track-event";
+import { useTrackPage } from "../../hooks/tracking/use-track-page";
+import { AnimationPage } from "../../navigation/containers/animation-page";
+import { PageCtaButton } from "../../pages/components/page-cta";
+import { useSettings } from "../../providers/settings";
+import { defaultFormattedNumber, formatNumber } from "../../utils";
+import { combineRecipeWithVariant } from "../../utils/styles";
+import { VerticalDivider } from "../common/components/divider";
+import { TabPageContainer } from "../common/components/tab-page-container";
+import {
+ AddressRow,
+ DetailRow,
+ DetailsSection,
+} from "../overview/earn-details/components/details-section";
+import { useBorrowWalletBridge } from "./connected-wallet";
+import { getBorrowDetailsModel, getBorrowMarketPairLabel } from "./model";
+import * as styles from "./styles.css";
+import { useBorrowDashboard } from "./use-borrow-dashboard";
+
+type DashboardBorrowToken = TokenDto & { network: BorrowNetwork };
+
+type BorrowMarketGroup = {
+ readonly bestRate: number;
+ readonly key: string;
+ readonly loanToken: BorrowToken;
+ readonly marketItems: readonly Market[];
+ readonly network: BorrowNetwork;
+};
+
+const toDashboardToken = ({
+ network,
+ token,
+}: {
+ readonly network: BorrowNetwork;
+ readonly token: BorrowToken;
+}): DashboardBorrowToken => ({
+ decimals: token.decimals,
+ name: token.name,
+ network,
+ symbol: token.symbol,
+ ...(token.address ? { address: token.address } : {}),
+ ...(token.logoURI ? { logoURI: token.logoURI } : {}),
+});
+
+const getBorrowMarketGroupKey = (market: Market) =>
+ `${market.network}:${market.loanToken.address ?? market.loanToken.symbol}`;
+
+const getBorrowMarketGroups = (
+ markets: readonly Market[]
+): readonly BorrowMarketGroup[] => {
+ const groups = new Map();
+
+ for (const market of markets) {
+ const key = getBorrowMarketGroupKey(market);
+ const existing = groups.get(key);
+
+ groups.set(key, {
+ bestRate: Math.min(
+ existing?.bestRate ?? market.borrowRate,
+ market.borrowRate
+ ),
+ key,
+ loanToken: market.loanToken,
+ marketItems: [...(existing?.marketItems ?? []), market],
+ network: market.network,
+ });
+ }
+
+ return [...groups.values()];
+};
+
+const normalizeSearch = (value: string) => value.trim().toLowerCase();
+
+const searchableText = (values: readonly (string | null | undefined)[]) =>
+ values.filter(Boolean).join(" ").toLowerCase();
+
+const marketMatchesSearch = ({
+ integrationName,
+ market,
+ search,
+}: {
+ readonly integrationName: string | null;
+ readonly market: Market;
+ readonly search: string;
+}) => {
+ if (!search) {
+ return true;
+ }
+
+ return searchableText([
+ getBorrowMarketPairLabel(market),
+ integrationName,
+ market.integrationId,
+ market.loanToken.address,
+ market.loanToken.name,
+ market.loanToken.symbol,
+ ...market.collateralTokens.flatMap((collateralToken) => [
+ collateralToken.token.address,
+ collateralToken.token.name,
+ collateralToken.token.symbol,
+ ]),
+ ]).includes(search);
+};
+
+const borrowGroupMatchesSearch = ({
+ group,
+ search,
+}: {
+ readonly group: BorrowMarketGroup;
+ readonly search: string;
+}) =>
+ !search ||
+ searchableText([
+ group.loanToken.address,
+ group.loanToken.name,
+ group.loanToken.symbol,
+ ]).includes(search);
+
+type BorrowEntryWalletBridge = Exclude<
+ BorrowWalletBridgeState,
+ BorrowWalletConnectedBridgeState
+>;
+
+/**
+ * Persistent split layout for the borrow flow. The left pane swaps between the
+ * form, review, steps and complete pages via the router outlet while the right
+ * details pane stays mounted, mirroring the earn flow's `OverviewPage`.
+ */
+export const BorrowLayout = () => {
+ const { t } = useTranslation();
+ const walletBridge = useBorrowWalletBridge();
+
+ return (
+
+
+
+
+
+
+
+
+
+ {walletBridge.status === "connected" ? (
+
+ ) : (
+
+ {t("dashboard.borrow.details.empty_description")}
+
+ )}
+
+
+
+ );
+};
+
+const BorrowConnectedDetailsPane = () => {
+ const borrowDashboard = useBorrowDashboard();
+
+ return ;
+};
+
+export const BorrowFormPage = () => {
+ useTrackPage("borrow");
+
+ const walletBridge = useBorrowWalletBridge();
+
+ return walletBridge.status === "connected" ? (
+
+ ) : (
+
+ );
+};
+
+const BorrowConnectedFormPage = () => {
+ const borrowDashboard = useBorrowDashboard();
+
+ return ;
+};
+
+const BorrowEntryFormPage = ({
+ walletBridge,
+}: {
+ readonly walletBridge: BorrowEntryWalletBridge;
+}) => {
+ const { t } = useTranslation();
+
+ return walletBridge.status === "disconnected" ? (
+ <>
+
+ {t("dashboard.borrow.connect_description")}
+
+
+
+ >
+ ) : (
+ <>
+
+ {t("dashboard.borrow.unsupported_network_description")}
+
+
+ undefined,
+ }}
+ />
+ >
+ );
+};
+
+const BorrowFormPanel = ({
+ borrowAmount,
+ collateralAmount,
+ integrationsResult,
+ isActionReady,
+ markets,
+ marketsResult,
+ preparedReviewState,
+ projection,
+ selectedCollateralBalance,
+ selectedCollateralTokenAddress,
+ selectedCollateralToken,
+ selectedMarket,
+ selectedMarketId,
+ setBorrowAmount,
+ setCollateralAmount,
+ setSelectedCollateralTokenAddress,
+ setSelectedMarketId,
+ stageReviewState,
+ validation,
+ walletBalances,
+}: ReturnType) => {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const trackEvent = useTrackEvent();
+ const {
+ borrowAmountGreaterThanAvailable,
+ collateralAmountGreaterThanBalance,
+ hasValidationError,
+ ltvGreaterThanMax,
+ } = validation;
+ const onSelectMarket = (marketId: string) => {
+ setSelectedMarketId(marketId);
+ trackEvent("borrowMarketSelected", { marketId });
+ };
+ const onSelectCollateral = (collateralToken: CollateralToken) => {
+ setSelectedCollateralTokenAddress(collateralToken.token.address ?? null);
+ trackEvent("borrowCollateralSelected", {
+ collateralTokenAddress: collateralToken.token.address,
+ collateralTokenSymbol: collateralToken.token.symbol,
+ marketId: selectedMarket?.id,
+ });
+ };
+ const onBorrowMaxClick =
+ selectedMarket && projection.borrowMaxAmount.gt(0)
+ ? () => {
+ setBorrowAmount(projection.borrowMaxAmount);
+ trackEvent("borrowPageMaxClicked", {
+ field: "borrow",
+ marketId: selectedMarket.id,
+ });
+ }
+ : null;
+ const onCollateralMaxClick =
+ selectedMarket && projection.collateralMaxAmount.gt(0)
+ ? () => {
+ setCollateralAmount(projection.collateralMaxAmount);
+ trackEvent("borrowPageMaxClicked", {
+ collateralTokenAddress: selectedCollateralToken?.token.address,
+ collateralTokenSymbol: selectedCollateralToken?.token.symbol,
+ field: "collateral",
+ marketId: selectedMarket.id,
+ });
+ }
+ : null;
+ const onReviewClick = () => {
+ if (!isActionReady || !preparedReviewState || !selectedMarket) {
+ return;
+ }
+
+ stageReviewState();
+ trackEvent("borrowReviewClicked", {
+ borrowAmount: borrowAmount.toString(10),
+ collateralAmount: collateralAmount.toString(10),
+ collateralTokenAddress: selectedCollateralToken?.token.address,
+ collateralTokenSymbol: selectedCollateralToken?.token.symbol,
+ marketId: selectedMarket.id,
+ });
+ navigate("/borrow/review", { state: preparedReviewState });
+ };
+ const integrations = AsyncResult.getOrElse(integrationsResult, () => []);
+
+ return (
+ <>
+ {AsyncResult.isInitial(marketsResult) ||
+ AsyncResult.isWaiting(marketsResult) ? (
+
+ ) : AsyncResult.isFailure(marketsResult) ? (
+
+ {t("dashboard.borrow.error_description")}
+
+ ) : selectedMarket && selectedCollateralToken ? (
+ <>
+
+ }
+ isInvalid={borrowAmountGreaterThanAvailable}
+ label={t("dashboard.borrow.form.borrow")}
+ highlight
+ onMaxClick={onBorrowMaxClick}
+ onAmountChange={setBorrowAmount}
+ tokenSelector={
+
+ }
+ usdValue={borrowAmount.multipliedBy(
+ selectedMarket.loanTokenPriceUsd
+ )}
+ validationText={
+ borrowAmountGreaterThanAvailable
+ ? t("dashboard.borrow.form.validation.available_liquidity")
+ : null
+ }
+ />
+
+
+ }
+ isInvalid={collateralAmountGreaterThanBalance}
+ label={t("dashboard.borrow.form.collateral")}
+ onMaxClick={onCollateralMaxClick}
+ onAmountChange={setCollateralAmount}
+ tokenSelector={
+
+ }
+ usdValue={collateralAmount.multipliedBy(
+ selectedCollateralToken.priceUsd
+ )}
+ validationText={
+ collateralAmountGreaterThanBalance
+ ? t("dashboard.borrow.form.validation.wallet_balance")
+ : null
+ }
+ />
+
+
+ >
+ ) : (
+
+ {t("dashboard.borrow.empty_description")}
+
+ )}
+
+
+ >
+ );
+};
+
+const AmountField = ({
+ amount,
+ balanceLabel,
+ highlight = false,
+ isInvalid,
+ label,
+ onMaxClick,
+ onAmountChange,
+ tokenSelector,
+ usdValue,
+ validationText,
+}: {
+ readonly amount: BigNumber;
+ readonly balanceLabel: ReactNode;
+ readonly highlight?: boolean;
+ readonly isInvalid?: boolean;
+ readonly label: string;
+ readonly onMaxClick: (() => void) | null;
+ readonly onAmountChange: (amount: BigNumber) => void;
+ readonly tokenSelector: ReactNode;
+ readonly usdValue: BigNumber;
+ readonly validationText?: string | null;
+}) => (
+
+ {label}
+
+
+
+
+ {tokenSelector}
+
+
+
+
+ {usdValue.gt(0) ? `$${formatNumber(usdValue, 2)}` : "$0"}
+
+
+
+ {balanceLabel}
+
+ {onMaxClick ? : null}
+
+
+ {validationText ? (
+
+ {validationText}
+
+ ) : null}
+
+
+);
+
+const BorrowBalanceLabel = ({
+ amount,
+ translationKey,
+ symbol,
+}: {
+ readonly amount: string | number | BigNumber;
+ readonly translationKey:
+ | "dashboard.borrow.form.available"
+ | "dashboard.borrow.form.wallet_balance";
+ readonly symbol: string;
+}) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+ {({ state }) =>
+ t(translationKey, {
+ amount:
+ state === "full"
+ ? formatNumber(amount)
+ : defaultFormattedNumber(amount),
+ symbol,
+ })
+ }
+
+
+ );
+};
+
+const BorrowFormDetails = ({
+ borrowAmount,
+ collateralAmount,
+ ltvGreaterThanMax,
+ market,
+ projection,
+ walletBalances,
+}: {
+ readonly borrowAmount: BigNumber;
+ readonly collateralAmount: BigNumber;
+ readonly ltvGreaterThanMax: boolean;
+ readonly market: Market;
+ readonly projection: ReturnType["projection"];
+ readonly walletBalances: BorrowMarketWalletBalances | null;
+}) => {
+ const { t } = useTranslation();
+ const model = getBorrowDetailsModel({
+ balances: walletBalances,
+ borrowAmount,
+ collateralAmount,
+ integration: null,
+ market,
+ projection,
+ t,
+ });
+
+ return (
+
+
+ {t("dashboard.borrow.form.details")}
+
+
+ {model.formRows.map((row) => (
+
+ ))}
+
+
+ {ltvGreaterThanMax
+ ? t("dashboard.borrow.form.validation.ltv")
+ : t("dashboard.borrow.form.ltv_note")}
+
+
+ );
+};
+
+const AmountTokenButtonContent = ({
+ showCaret,
+ token,
+}: {
+ readonly showCaret: boolean;
+ readonly token: DashboardBorrowToken;
+}) => (
+ <>
+
+
+ {token.symbol}
+
+ {showCaret ? (
+
+
+
+ ) : null}
+ >
+);
+
+const MarketSelectModal = ({
+ integrations,
+ markets,
+ onSelect,
+ selectedMarketId,
+}: {
+ readonly integrations: readonly {
+ readonly id: string;
+ readonly name: string;
+ }[];
+ readonly markets: readonly Market[];
+ readonly onSelect: (marketId: string) => void;
+ readonly selectedMarketId: string | null;
+}) => {
+ const { t } = useTranslation();
+ const { variant } = useSettings();
+ const [isOpen, setIsOpen] = useState(false);
+ const [search, setSearch] = useState("");
+ const [expandedGroupKey, setExpandedGroupKey] = useState(null);
+ const selectedMarket =
+ markets.find((market) => market.id === selectedMarketId) ??
+ markets[0] ??
+ null;
+
+ if (!selectedMarket) {
+ return null;
+ }
+
+ const token = toDashboardToken({
+ network: selectedMarket.network,
+ token: selectedMarket.loanToken,
+ });
+ const integrationsById = new Map(
+ integrations.map((integration) => [integration.id, integration])
+ );
+ const normalizedSearch = normalizeSearch(search);
+ const marketGroups = getBorrowMarketGroups(markets).flatMap(
+ (group): BorrowMarketGroup[] => {
+ const groupMatches = borrowGroupMatchesSearch({
+ group,
+ search: normalizedSearch,
+ });
+ const marketItems = group.marketItems.filter((market) =>
+ groupMatches
+ ? true
+ : marketMatchesSearch({
+ integrationName:
+ integrationsById.get(market.integrationId)?.name ?? null,
+ market,
+ search: normalizedSearch,
+ })
+ );
+
+ return groupMatches || marketItems.length > 0
+ ? [{ ...group, marketItems }]
+ : [];
+ }
+ );
+
+ const onOpenChange = (open: boolean) => {
+ setIsOpen(open);
+
+ if (!open) {
+ setExpandedGroupKey(null);
+ setSearch("");
+ }
+ };
+
+ if (markets.length <= 1) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ }
+ >
+
+ {marketGroups.length === 0 ? (
+
+ {t("dashboard.borrow.form.no_assets")}
+
+ ) : (
+ marketGroups.map((group) => {
+ const isExpanded = expandedGroupKey === group.key;
+ const token = toDashboardToken({
+ network: group.network,
+ token: group.loanToken,
+ });
+
+ return (
+
+
+ setExpandedGroupKey((prev) =>
+ prev === group.key ? null : group.key
+ )
+ }
+ rate={`${formatNumber(group.bestRate * 100, 2)}%`}
+ selected={isExpanded}
+ testId={`borrow-market-select__group_${group.loanToken.symbol.toLowerCase()}`}
+ token={token}
+ />
+
+ {isExpanded
+ ? group.marketItems.map((market) => (
+ {
+ onSelect(market.id);
+ onOpenChange(false);
+ }}
+ rate={`${formatNumber(market.borrowRate * 100, 2)}%`}
+ selected={market.id === selectedMarketId}
+ testId={`borrow-market-select__item_${market.id}`}
+ token={toDashboardToken({
+ network: market.network,
+ token: market.loanToken,
+ })}
+ />
+ ))
+ : null}
+
+ );
+ })
+ )}
+
+
+ );
+};
+
+const CollateralSelectModal = ({
+ collateralTokens,
+ marketNetwork,
+ onSelect,
+ selectedCollateralTokenAddress,
+}: {
+ readonly collateralTokens: readonly CollateralToken[];
+ readonly marketNetwork: BorrowNetwork;
+ readonly onSelect: (collateralToken: CollateralToken) => void;
+ readonly selectedCollateralTokenAddress: string | null;
+}) => {
+ const { t } = useTranslation();
+ const { variant } = useSettings();
+ const [isOpen, setIsOpen] = useState(false);
+ const [search, setSearch] = useState("");
+ const selectedCollateralToken =
+ collateralTokens.find(
+ (collateralToken) =>
+ collateralToken.token.address === selectedCollateralTokenAddress
+ ) ??
+ collateralTokens[0] ??
+ null;
+
+ if (!selectedCollateralToken) {
+ return null;
+ }
+
+ const token = toDashboardToken({
+ network: marketNetwork,
+ token: selectedCollateralToken.token,
+ });
+ const normalizedSearch = normalizeSearch(search);
+ const filteredCollateralTokens = normalizedSearch
+ ? collateralTokens.filter((collateralToken) =>
+ searchableText([
+ collateralToken.token.address,
+ collateralToken.token.name,
+ collateralToken.token.symbol,
+ ]).includes(normalizedSearch)
+ )
+ : collateralTokens;
+
+ const onOpenChange = (open: boolean) => {
+ setIsOpen(open);
+
+ if (!open) {
+ setSearch("");
+ }
+ };
+
+ if (collateralTokens.length <= 1) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ }
+ >
+
+ {filteredCollateralTokens.length === 0 ? (
+
+ {t("dashboard.borrow.form.no_assets")}
+
+ ) : (
+ filteredCollateralTokens.map((collateralToken) => (
+ {
+ onSelect(collateralToken);
+ onOpenChange(false);
+ }}
+ rate={`${formatNumber(collateralToken.supplyRate * 100, 2)}%`}
+ selected={
+ collateralToken.token.address === selectedCollateralTokenAddress
+ }
+ testId={`borrow-collateral-select__item_${
+ collateralToken.token.address ?? collateralToken.token.symbol
+ }`}
+ token={toDashboardToken({
+ network: marketNetwork,
+ token: collateralToken.token,
+ })}
+ />
+ ))
+ )}
+
+
+ );
+};
+
+const BorrowAssetSelectorList = ({
+ children,
+ title,
+}: {
+ readonly children: ReactNode;
+ readonly title: string;
+}) => (
+
+
+ {title}
+
+ {children}
+
+);
+
+const BorrowSelectorEmpty = ({ children }: { readonly children: string }) => (
+
+ {children}
+
+);
+
+const BorrowAssetSelectorRow = ({
+ expandable = false,
+ expanded = false,
+ indented = false,
+ label,
+ maxAmount,
+ meta,
+ onClick,
+ rate,
+ selected = false,
+ testId,
+ token,
+}: {
+ readonly expandable?: boolean;
+ readonly expanded?: boolean;
+ readonly indented?: boolean;
+ readonly label: string;
+ readonly maxAmount?: string;
+ readonly meta?: string;
+ readonly onClick: () => void;
+ readonly rate: string;
+ readonly selected?: boolean;
+ readonly testId?: string;
+ readonly token: DashboardBorrowToken;
+}) => (
+
+ {expandable ? (
+
+
+
+ ) : null}
+
+
+
+ {label}
+
+ {meta ? (
+
+ {meta}
+
+ ) : null}
+
+
+ {rate}
+ {maxAmount ? (
+
+ {maxAmount}
+
+ ) : null}
+
+
+);
+
+const BorrowInfoNote = ({
+ children,
+ tone = "default",
+}: {
+ readonly children: string;
+ readonly tone?: "default" | "error";
+}) => (
+
+
+ {children}
+
+
+);
+
+const BorrowDetailsPanel = ({
+ borrowAmount,
+ collateralAmount,
+ integrationsResult,
+ marketsResult,
+ projection,
+ selectedIntegration,
+ selectedMarket,
+ walletBalances,
+}: ReturnType) => {
+ const { t } = useTranslation();
+
+ if (
+ AsyncResult.isInitial(marketsResult) ||
+ AsyncResult.isWaiting(marketsResult) ||
+ AsyncResult.isInitial(integrationsResult) ||
+ AsyncResult.isWaiting(integrationsResult)
+ ) {
+ return ;
+ }
+
+ if (
+ AsyncResult.isFailure(marketsResult) ||
+ AsyncResult.isFailure(integrationsResult)
+ ) {
+ return (
+
+ {t("dashboard.borrow.error_description")}
+
+ );
+ }
+
+ if (!selectedMarket) {
+ return (
+
+ {t("dashboard.borrow.details.empty_description")}
+
+ );
+ }
+
+ const model = getBorrowDetailsModel({
+ balances: walletBalances,
+ borrowAmount,
+ collateralAmount,
+ integration: selectedIntegration,
+ market: selectedMarket,
+ projection,
+ t,
+ });
+ const loanToken = toDashboardToken({
+ network: selectedMarket.network,
+ token: selectedMarket.loanToken,
+ });
+
+ return (
+
+
+
+
+
+ {model.title}
+
+
+
+ {selectedIntegration?.name ?? selectedMarket.integrationId}
+ {" · "}
+ {selectedMarket.network}
+
+
+
+ {t(`dashboard.borrow.market_type.${selectedMarket.type}`)}
+
+
+
+
+
+
+
+
+
+
+ {selectedIntegration?.metadata.description ??
+ t("dashboard.borrow.details.about_fallback", {
+ market: model.title,
+ provider:
+ selectedIntegration?.name ?? selectedMarket.integrationId,
+ })}
+
+
+
+
+ {model.marketRows.map((row) => (
+
+ ))}
+
+
+
+ {model.protocolRows.map((row) => (
+
+ ))}
+
+ {selectedMarket.poolAddress ? (
+
+
+
+ ) : null}
+
+
+ );
+};
+
+const BorrowMetricGrid = ({
+ cards,
+}: {
+ readonly cards: ReturnType["metricCards"];
+}) => (
+
+ {cards.map((card) => (
+
+ {card.label}
+ {card.value}
+ {card.subValue ? (
+
+ {card.subValue}
+
+ ) : null}
+
+ ))}
+
+);
+
+const BorrowNotice = ({
+ children,
+ title,
+ tone,
+}: {
+ readonly children: string;
+ readonly title: string;
+ readonly tone?: "error";
+}) => (
+
+
+ {title}
+
+ {children}
+
+);
+
+const BorrowDetailsEmpty = ({
+ children,
+ title,
+}: {
+ readonly children: string;
+ readonly title: string;
+}) => (
+
+ {title}
+ {children}
+
+);
diff --git a/packages/widget/src/pages-dashboard/borrow/model.ts b/packages/widget/src/pages-dashboard/borrow/model.ts
new file mode 100644
index 00000000..3ba7f6f5
--- /dev/null
+++ b/packages/widget/src/pages-dashboard/borrow/model.ts
@@ -0,0 +1,246 @@
+import BigNumber from "bignumber.js";
+import type { TFunction } from "i18next";
+import type { ReactNode } from "react";
+import type {
+ BorrowFormProjection,
+ BorrowMarketWalletBalances,
+ Market,
+} from "../../borrow";
+import { projectLtvRatio } from "../../borrow";
+import type { Integration } from "../../borrow/domain/integration";
+import { formatNumber } from "../../utils";
+import { formatCompactUsd } from "../../utils/formatters";
+
+type BorrowDetailsRow = {
+ readonly id: string;
+ readonly label: string;
+ readonly value: ReactNode;
+};
+
+type BorrowMetricCard = {
+ readonly id: string;
+ readonly label: string;
+ readonly subValue?: string;
+ readonly value: string;
+};
+
+const formatDecimalPercent = (value: number | null | undefined) =>
+ value == null || !Number.isFinite(value)
+ ? "-"
+ : `${formatNumber(value * 100, 2)}%`;
+
+const formatUsd = (value: BigNumber) =>
+ value.isFinite() ? `$${formatNumber(value, 2)}` : "$0";
+
+const formatTransition = ({
+ current,
+ projected,
+}: {
+ readonly current: string;
+ readonly projected: string;
+}) => (current === projected ? projected : `${current} -> ${projected}`);
+
+const getTokenUsdValue = ({
+ amount,
+ price,
+}: {
+ readonly amount: BigNumber;
+ readonly price: number;
+}) => amount.multipliedBy(price);
+
+export const getBorrowMarketPairLabel = (market: Market) => {
+ const collateralToken = market.collateralTokens[0];
+
+ return collateralToken
+ ? `${collateralToken.token.symbol} / ${market.loanToken.symbol}`
+ : market.loanToken.symbol;
+};
+
+export const getBorrowDetailsModel = ({
+ balances,
+ borrowAmount,
+ collateralAmount,
+ integration,
+ market,
+ projection,
+ t,
+}: {
+ readonly balances: BorrowMarketWalletBalances | null;
+ readonly borrowAmount: BigNumber;
+ readonly collateralAmount: BigNumber;
+ readonly integration: Integration | null;
+ readonly market: Market;
+ readonly projection?: BorrowFormProjection;
+ readonly t: TFunction;
+}) => {
+ const collateralToken = balances?.selectedCollateralToken?.collateralToken;
+ const borrowUsd = getTokenUsdValue({
+ amount: borrowAmount,
+ price: market.loanTokenPriceUsd,
+ });
+ const collateralUsd = getTokenUsdValue({
+ amount: collateralAmount,
+ price: collateralToken?.priceUsd ?? 0,
+ });
+ const projectedLtv = projectLtvRatio({
+ collateralUsd: collateralUsd.toNumber(),
+ debtUsd: borrowUsd.toNumber(),
+ });
+ const maxLtv = collateralToken?.maxLtv ?? market.getMaxLtv();
+ const displayedProjectedLtv = projection?.projectedLtv ?? projectedLtv;
+ const existingLtv = projection
+ ? projectLtvRatio({
+ collateralUsd: projection.existingCollateralUsd.toNumber(),
+ debtUsd: projection.existingDebtUsd.toNumber(),
+ })
+ : 0;
+ const displayedCollateralUsd =
+ projection?.projectedCollateralUsd ?? collateralUsd;
+ const displayedDebtUsd = projection?.projectedDebtUsd ?? borrowUsd;
+ const healthFactor =
+ projection?.projectedHealthFactor ??
+ (displayedProjectedLtv > 0 && collateralToken
+ ? collateralToken.liquidationThreshold / displayedProjectedLtv
+ : null);
+ const hasExistingPosition =
+ projection != null &&
+ (projection.existingCollateralUsd.gt(0) ||
+ projection.existingDebtUsd.gt(0));
+
+ const metricCards: BorrowMetricCard[] = [
+ {
+ id: "borrow-apy",
+ label: t("dashboard.borrow.details.borrow_apy"),
+ subValue: market.loanToken.symbol,
+ value: formatDecimalPercent(market.borrowRate),
+ },
+ {
+ id: "supply-apy",
+ label: t("dashboard.borrow.details.supply_apy"),
+ subValue: collateralToken?.token.symbol,
+ value: formatDecimalPercent(collateralToken?.supplyRate),
+ },
+ {
+ id: "max-ltv",
+ label: t("dashboard.borrow.details.max_ltv"),
+ value: formatDecimalPercent(maxLtv),
+ },
+ ];
+
+ const formRows: BorrowDetailsRow[] = [
+ {
+ id: "ltv",
+ label: t("dashboard.borrow.form.ltv_ratio"),
+ value: displayedCollateralUsd.isZero()
+ ? "-"
+ : hasExistingPosition
+ ? formatTransition({
+ current: formatDecimalPercent(existingLtv),
+ projected: formatDecimalPercent(displayedProjectedLtv),
+ })
+ : formatDecimalPercent(displayedProjectedLtv),
+ },
+ {
+ id: "max-ltv",
+ label: t("dashboard.borrow.details.max_ltv"),
+ value: formatDecimalPercent(maxLtv),
+ },
+ {
+ id: "collateral-value",
+ label: t("dashboard.borrow.form.collateral_value"),
+ value: hasExistingPosition
+ ? formatTransition({
+ current: formatUsd(projection.existingCollateralUsd),
+ projected: formatUsd(displayedCollateralUsd),
+ })
+ : formatUsd(displayedCollateralUsd),
+ },
+ {
+ id: "loan",
+ label: t("dashboard.borrow.form.loan"),
+ value: hasExistingPosition
+ ? formatTransition({
+ current: formatUsd(projection.existingDebtUsd),
+ projected: formatUsd(displayedDebtUsd),
+ })
+ : borrowAmount.isZero()
+ ? "-"
+ : `${formatNumber(borrowAmount, 6)} ${market.loanToken.symbol}`,
+ },
+ {
+ id: "borrow-rate",
+ label: t("dashboard.borrow.form.borrow_rate"),
+ value: formatDecimalPercent(market.borrowRate),
+ },
+ {
+ id: "health-factor",
+ label: t("dashboard.borrow.form.health_factor"),
+ value: healthFactor == null ? "-" : formatNumber(healthFactor, 2),
+ },
+ ];
+
+ const marketRows: BorrowDetailsRow[] = [
+ {
+ id: "total-supply",
+ label: t("dashboard.borrow.details.total_supply"),
+ value: formatCompactUsd(
+ new BigNumber(market.totalSupply)
+ .multipliedBy(market.loanTokenPriceUsd)
+ .toString()
+ ),
+ },
+ {
+ id: "total-borrow",
+ label: t("dashboard.borrow.details.total_borrow"),
+ value: formatCompactUsd(
+ new BigNumber(market.totalBorrow)
+ .multipliedBy(market.loanTokenPriceUsd)
+ .toString()
+ ),
+ },
+ {
+ id: "available-liquidity",
+ label: t("dashboard.borrow.details.available_liquidity"),
+ value: `${formatNumber(market.availableLiquidity, 4)} ${
+ market.loanToken.symbol
+ }`,
+ },
+ {
+ id: "utilization",
+ label: t("dashboard.borrow.details.utilization"),
+ value: formatDecimalPercent(market.utilizationRate),
+ },
+ ];
+
+ const protocolRows: BorrowDetailsRow[] = [
+ {
+ id: "provider",
+ label: t("dashboard.borrow.details.provider"),
+ value: integration?.name ?? market.integrationId,
+ },
+ {
+ id: "network",
+ label: t("dashboard.borrow.details.network"),
+ value: market.network,
+ },
+ {
+ id: "market-type",
+ label: t("dashboard.borrow.details.market_type"),
+ value: t(`dashboard.borrow.market_type.${market.type}`),
+ },
+ ];
+
+ return {
+ borrowUsd,
+ collateralToken,
+ collateralUsd,
+ formRows,
+ healthFactor,
+ marketRows,
+ metricCards,
+ protocolRows,
+ title: collateralToken
+ ? `${collateralToken.token.symbol} / ${market.loanToken.symbol}`
+ : getBorrowMarketPairLabel(market),
+ };
+};
diff --git a/packages/widget/src/pages-dashboard/borrow/position-details-model.ts b/packages/widget/src/pages-dashboard/borrow/position-details-model.ts
new file mode 100644
index 00000000..607d9338
--- /dev/null
+++ b/packages/widget/src/pages-dashboard/borrow/position-details-model.ts
@@ -0,0 +1,430 @@
+import type { TFunction } from "i18next";
+import type { ReactNode } from "react";
+import {
+ type BorrowPositionPendingActionContext,
+ type BorrowWithdrawTokenOption,
+ buildCollateralToggleActionRequest,
+ buildRepayActionRequest,
+ buildWithdrawActionRequest,
+ type PendingAction,
+ type Position,
+ type SupplyBalance,
+} from "../../borrow";
+import type { TokenDto } from "../../domain/types/tokens";
+import { formatNumber } from "../../utils";
+import { formatCompactUsd } from "../../utils/formatters";
+import { getBorrowMarketPairLabel } from "./model";
+import type { BorrowReviewState } from "./review-state";
+
+type BorrowPositionMetricCard = {
+ readonly id: string;
+ readonly label: string;
+ readonly subValue?: string;
+ readonly value: ReactNode;
+};
+
+type BorrowPositionRow = {
+ readonly id: string;
+ readonly label: string;
+ readonly subValue?: string;
+ readonly value: string;
+};
+
+type BorrowPositionDetailRow = {
+ readonly id: string;
+ readonly label: string;
+ readonly value: ReactNode;
+};
+
+export type BorrowPositionAction = {
+ readonly id: string;
+ readonly label: string;
+ readonly pendingContext: BorrowPositionPendingActionContext;
+ readonly reviewState: BorrowReviewState;
+ readonly type:
+ | "disableCollateral"
+ | "enableCollateral"
+ | "repay"
+ | "withdraw";
+};
+
+const formatPercent = (value: number | null | undefined) =>
+ value == null || !Number.isFinite(value)
+ ? "-"
+ : `${formatNumber(value * 100, 2)}%`;
+
+const formatUsd = (value: number | null | undefined) =>
+ value == null || !Number.isFinite(value)
+ ? "-"
+ : formatCompactUsd(value.toString());
+
+export const borrowTokenToTokenDto = ({
+ network,
+ token,
+}: {
+ readonly network: string;
+ readonly token: {
+ readonly address?: string;
+ readonly decimals: number;
+ readonly name: string;
+ readonly symbol: string;
+ };
+}): TokenDto => ({
+ address: token.address,
+ decimals: token.decimals,
+ name: token.name,
+ network: network as TokenDto["network"],
+ symbol: token.symbol,
+});
+
+const getSupplyBalanceToken = ({
+ position,
+ supplyBalance,
+}: {
+ readonly position: Position;
+ readonly supplyBalance: SupplyBalance | undefined;
+}) => {
+ if (!supplyBalance) {
+ return position.market.loanToken;
+ }
+
+ return (
+ position.market.collateralTokens.find(
+ (collateralToken) =>
+ collateralToken.token.address === supplyBalance.tokenAddress
+ )?.token ?? {
+ address: supplyBalance.tokenAddress,
+ decimals: 18,
+ name: supplyBalance.tokenSymbol,
+ symbol: supplyBalance.tokenSymbol,
+ }
+ );
+};
+
+const getPositionHeaderToken = (position: Position) =>
+ borrowTokenToTokenDto({
+ network: position.market.network,
+ token: position.debtBalance
+ ? position.market.loanToken
+ : getSupplyBalanceToken({
+ position,
+ supplyBalance: position.supplyBalances[0],
+ }),
+ });
+
+const getPositionActionLabel = (action: PendingAction, t: TFunction) =>
+ t(`dashboard.borrow.position_details.actions.${action.type}`);
+
+const getPositionActionSummaryAction = (action: PendingAction) => action.type;
+
+const getBorrowWithdrawTokenOptions = (
+ position: Position
+): BorrowWithdrawTokenOption[] =>
+ position.supplyPendingActions.flatMap((action) => {
+ if (action.type !== "withdraw") {
+ return [];
+ }
+
+ const supplyBalance = position.supplyBalances.find(
+ (balance) => balance.tokenAddress === action.args.tokenAddress
+ );
+ const collateralToken = position.market.collateralTokens.find(
+ (candidate) => candidate.token.address === action.args.tokenAddress
+ );
+
+ if (!supplyBalance || !collateralToken) {
+ return [];
+ }
+
+ return [
+ {
+ action,
+ collateralToken,
+ supplyBalance,
+ },
+ ];
+ });
+
+export const getBorrowPositionActions = ({
+ address,
+ position,
+ t,
+}: {
+ readonly address: string;
+ readonly position: Position;
+ readonly t: TFunction;
+}): BorrowPositionAction[] => {
+ const marketLabel = getBorrowMarketPairLabel(position.market);
+ const providerName = position.integration.name;
+ const commonSummary = {
+ marketLabel,
+ network: position.market.network,
+ providerName,
+ };
+ const actions: BorrowPositionAction[] = [];
+
+ for (const action of position.debtPendingActions) {
+ if (action.type !== "repay" || !position.debtBalance) {
+ continue;
+ }
+
+ actions.push({
+ id: `${action.type}-${action.args.tokenAddress}`,
+ label: getPositionActionLabel(action, t),
+ pendingContext: {
+ action,
+ debtBalance: position.debtBalance,
+ position,
+ type: "repay",
+ },
+ reviewState: {
+ request: buildRepayActionRequest({
+ address,
+ integrationId: position.integration.id,
+ marketId: action.args.marketId,
+ repayAll: true,
+ tokenAddress: action.args.tokenAddress,
+ }),
+ summary: {
+ ...commonSummary,
+ action: getPositionActionSummaryAction(action),
+ borrowAmount: position.debtBalance.balance.toString(),
+ loanTokenSymbol: position.debtBalance.tokenSymbol,
+ },
+ },
+ type: "repay",
+ });
+ }
+
+ const withdrawTokens = getBorrowWithdrawTokenOptions(position);
+ const defaultWithdrawToken = withdrawTokens[0];
+ if (defaultWithdrawToken) {
+ actions.push({
+ id: "withdraw",
+ label: getPositionActionLabel(defaultWithdrawToken.action, t),
+ pendingContext: {
+ position,
+ tokens: withdrawTokens,
+ type: "withdraw",
+ },
+ reviewState: {
+ request: buildWithdrawActionRequest({
+ address,
+ amount: defaultWithdrawToken.supplyBalance.balance,
+ integrationId: position.integration.id,
+ marketId: defaultWithdrawToken.action.args.marketId,
+ tokenAddress: defaultWithdrawToken.action.args.tokenAddress,
+ }),
+ summary: {
+ ...commonSummary,
+ action: getPositionActionSummaryAction(defaultWithdrawToken.action),
+ collateralAmount:
+ defaultWithdrawToken.supplyBalance.balance.toString(),
+ collateralTokenSymbol: defaultWithdrawToken.supplyBalance.tokenSymbol,
+ },
+ },
+ type: "withdraw",
+ });
+ }
+
+ for (const action of position.supplyPendingActions) {
+ const supplyBalance = position.supplyBalances.find(
+ (balance) => balance.tokenAddress === action.args.tokenAddress
+ );
+
+ if (!supplyBalance) {
+ continue;
+ }
+
+ if (action.type === "withdraw") {
+ continue;
+ }
+
+ if (
+ action.type === "enableCollateral" ||
+ action.type === "disableCollateral"
+ ) {
+ actions.push({
+ id: `${action.type}-${action.args.tokenAddress}`,
+ label: getPositionActionLabel(action, t),
+ pendingContext: {
+ action,
+ position,
+ supplyBalance,
+ type: action.type,
+ },
+ reviewState: {
+ request: buildCollateralToggleActionRequest({
+ action: action.type,
+ address,
+ integrationId: position.integration.id,
+ marketId: action.args.marketId,
+ tokenAddress: action.args.tokenAddress,
+ }),
+ summary: {
+ ...commonSummary,
+ action: getPositionActionSummaryAction(action),
+ collateralTokenSymbol: supplyBalance.tokenSymbol,
+ },
+ },
+ type: action.type,
+ });
+ }
+ }
+
+ return actions;
+};
+
+export const getBorrowPositionDetailsModel = ({
+ position,
+ t,
+}: {
+ readonly position: Position;
+ readonly t: TFunction;
+}) => {
+ const meta = position.getMeta();
+ const currentLtv = position.getCurrentLtv();
+ const healthFactor = position.getHealthFactor();
+ const collateralDetails = position.getCollateralTokenDetails();
+ const metricCards: BorrowPositionMetricCard[] = [
+ {
+ id: "net-worth",
+ label: t("dashboard.borrow.position_details.net_worth"),
+ value: formatUsd(position.getNetWorthUsd()),
+ },
+ {
+ id: "debt",
+ label: t("dashboard.borrow.position_details.debt"),
+ subValue: position.debtBalance?.tokenSymbol,
+ value: formatUsd(position.getTotalBorrowedUsd()),
+ },
+ {
+ id: "ltv",
+ label: t("dashboard.borrow.position_details.ltv"),
+ value: formatPercent(currentLtv),
+ },
+ {
+ id: "health-factor",
+ label: t("dashboard.borrow.position_details.health_factor"),
+ value: healthFactor == null ? "-" : formatNumber(healthFactor, 2),
+ },
+ ];
+ const breakdownRows: BorrowPositionRow[] = [
+ ...position.supplyBalances.map((balance) => ({
+ id: `supply-${balance.tokenAddress}`,
+ label: balance.isCollateral
+ ? t("dashboard.borrow.position_details.collateral")
+ : t("dashboard.borrow.position_details.supplied"),
+ subValue: formatUsd(balance.balanceUsd),
+ value: `${formatNumber(balance.balance, 6)} ${balance.tokenSymbol}`,
+ })),
+ ...(position.debtBalance
+ ? [
+ {
+ id: `debt-${position.debtBalance.tokenAddress}`,
+ label: t("dashboard.borrow.position_details.borrowed"),
+ subValue: formatUsd(position.debtBalance.balanceUsd),
+ value: `${formatNumber(position.debtBalance.balance, 6)} ${
+ position.debtBalance.tokenSymbol
+ }`,
+ },
+ ]
+ : []),
+ ];
+ const detailRows: BorrowPositionDetailRow[] = [
+ {
+ id: "provider",
+ label: t("dashboard.borrow.details.provider"),
+ value: position.integration.name,
+ },
+ {
+ id: "network",
+ label: t("dashboard.borrow.details.network"),
+ value: position.market.network,
+ },
+ {
+ id: "market-type",
+ label: t("dashboard.borrow.details.market_type"),
+ value: t(`dashboard.borrow.market_type.${position.market.type}`),
+ },
+ {
+ id: "max-ltv",
+ label: t("dashboard.borrow.details.max_ltv"),
+ value: formatPercent(
+ Number.isFinite(collateralDetails.maxLtv)
+ ? collateralDetails.maxLtv
+ : position.market.getMaxLtv()
+ ),
+ },
+ {
+ id: "liquidation-threshold",
+ label: t("dashboard.borrow.position_details.liquidation_threshold"),
+ value: formatPercent(
+ Number.isFinite(collateralDetails.liquidationThreshold)
+ ? collateralDetails.liquidationThreshold
+ : position.market.getLiquidationThreshold()
+ ),
+ },
+ {
+ id: "liquidation-penalty",
+ label: t("dashboard.borrow.position_details.liquidation_penalty"),
+ value: formatPercent(
+ Number.isFinite(collateralDetails.liquidationPenalty)
+ ? collateralDetails.liquidationPenalty
+ : position.market.getLiquidationPenalty()
+ ),
+ },
+ {
+ id: "net-apy",
+ label: t("dashboard.borrow.position_details.net_apy"),
+ value: formatPercent(position.getNetApy()),
+ },
+ ];
+ const collateralItems = position.supplyBalances.map((balance) => {
+ const collateralToken = position.market.collateralTokens.find(
+ (candidate) => candidate.token.address === balance.tokenAddress
+ );
+ const collateralToggleAction = balance.pendingActions.find(
+ (action) =>
+ action.type === "enableCollateral" ||
+ action.type === "disableCollateral"
+ );
+
+ return {
+ balance: `${formatNumber(balance.balance, 6)} ${balance.tokenSymbol}`,
+ balanceUsd: formatUsd(balance.balanceUsd),
+ collateralToggleAction:
+ collateralToggleAction && collateralToken
+ ? ({
+ action: collateralToggleAction,
+ collateralToken,
+ supplyBalance: balance,
+ } as const)
+ : null,
+ id: balance.tokenAddress,
+ isCollateral: balance.isCollateral,
+ label: balance.tokenSymbol,
+ supplyRate: formatPercent(balance.apy),
+ };
+ });
+ const liquidationThreshold = Number.isFinite(
+ collateralDetails.liquidationThreshold
+ )
+ ? collateralDetails.liquidationThreshold
+ : position.market.getLiquidationThreshold();
+
+ return {
+ breakdownRows,
+ collateralItems,
+ currentLtv,
+ detailRows,
+ headerToken: getPositionHeaderToken(position),
+ healthFactor,
+ liquidationThreshold,
+ marketLabel: getBorrowMarketPairLabel(position.market),
+ metricCards,
+ providerName: position.integration.name,
+ totalCollateralUsd: formatUsd(position.getTotalCollateralUsd()),
+ title: meta.name,
+ };
+};
diff --git a/packages/widget/src/pages-dashboard/borrow/position-details.tsx b/packages/widget/src/pages-dashboard/borrow/position-details.tsx
new file mode 100644
index 00000000..1a699cad
--- /dev/null
+++ b/packages/widget/src/pages-dashboard/borrow/position-details.tsx
@@ -0,0 +1,1306 @@
+import { useAtomSet } from "@effect/atom-react";
+import BigNumber from "bignumber.js";
+import clsx from "clsx";
+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Outlet, useNavigate, useOutletContext, useParams } from "react-router";
+import {
+ type BorrowWithdrawTokenOption,
+ borrowActionFormAtom,
+ buildCollateralToggleActionRequest,
+ buildRepayActionRequest,
+ buildWithdrawActionRequest,
+ deriveBorrowTokenWalletBalance,
+ projectLtvRatio,
+} from "../../borrow";
+import { Box } from "../../components/atoms/box";
+import { Button } from "../../components/atoms/button";
+import {
+ CollapsibleArrow,
+ CollapsibleContent,
+ CollapsibleRoot,
+ CollapsibleTrigger,
+} from "../../components/atoms/collapsible";
+import {
+ ContentLoaderCircle,
+ ContentLoaderLine,
+} from "../../components/atoms/content-loader";
+import { Divider } from "../../components/atoms/divider";
+import { Image } from "../../components/atoms/image";
+import { ListItem } from "../../components/atoms/list/list-item";
+import { MaxButton } from "../../components/atoms/max-button";
+import { NumberInput } from "../../components/atoms/number-input";
+import { TokenIcon } from "../../components/atoms/token-icon";
+import { Text } from "../../components/atoms/typography/text";
+import { useTokenBalancesScan } from "../../hooks/api/use-token-balances-scan";
+import { useTrackPage } from "../../hooks/tracking/use-track-page";
+import { AnimationPage } from "../../navigation/containers/animation-page";
+import { PageCtaButton } from "../../pages/components/page-cta";
+import { formatNumber } from "../../utils";
+import { formatCompactUsd } from "../../utils/formatters";
+import {
+ BackButton,
+ BackButtonProvider,
+} from "../common/components/back-button";
+import { VerticalDivider } from "../common/components/divider";
+import { TabPageContainer } from "../common/components/tab-page-container";
+import {
+ AddressRow,
+ DetailRow,
+ DetailsSection,
+} from "../overview/earn-details/components/details-section";
+import * as positionDetailsStyles from "../position-details/components/styles.css";
+import {
+ breadcrumb,
+ breadcrumbName,
+ posistionDetailsInfoContainer,
+ positionDetailsActionsContainer,
+} from "../position-details/styles.css";
+import { getBorrowMarketPairLabel } from "./model";
+import {
+ type BorrowPositionAction,
+ borrowTokenToTokenDto,
+ getBorrowPositionActions,
+ getBorrowPositionDetailsModel,
+} from "./position-details-model";
+import type { BorrowReviewState } from "./review-state";
+import * as styles from "./styles.css";
+import { useBorrowPosition } from "./use-borrow-positions";
+
+type BorrowPositionContext = {
+ readonly actions: BorrowPositionAction[];
+ readonly borrowPosition: ReturnType;
+ readonly model: ReturnType | null;
+ readonly position: ReturnType;
+};
+
+const getPositionFromResult = (
+ borrowPosition: ReturnType
+) =>
+ AsyncResult.isSuccess(borrowPosition.positionResult)
+ ? borrowPosition.positionResult.value
+ : null;
+
+const formatPercent = (value: number | null | undefined) =>
+ value == null || !Number.isFinite(value)
+ ? "-"
+ : `${formatNumber(value * 100, 2)}%`;
+
+const BorrowPositionBreadcrumb = ({
+ backPath = "/manage",
+ positionName,
+}: {
+ readonly backPath?: string;
+ readonly positionName: string | null;
+}) => {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+
+ return (
+
+
+ navigate(backPath)}
+ />
+
+
+ {t("dashboard.position_details.breadcrumb_root")}
+
+
+ {positionName ? (
+
+ {`/ ${positionName}`}
+
+ ) : null}
+
+
+ );
+};
+
+const MetricCards = ({
+ cards,
+ healthFactor,
+}: {
+ cards: ReturnType["metricCards"];
+ readonly healthFactor: number | null | undefined;
+}) => (
+
+ {cards.map((card) => {
+ const isHealthCard = card.id === "health-factor";
+ const toneClass =
+ !isHealthCard || healthFactor == null
+ ? undefined
+ : healthFactor < 1
+ ? styles.healthValueDanger
+ : healthFactor < 2
+ ? styles.healthValueWarning
+ : styles.healthValue;
+
+ return (
+
+
+ {card.label}
+
+
+ {typeof card.value === "string" ? (
+
+ {card.value}
+
+ ) : (
+ {card.value}
+ )}
+
+ {card.subValue && (
+
+ {card.subValue}
+
+ )}
+
+ );
+ })}
+
+);
+
+const LtvGauge = ({
+ currentLtv,
+ liquidationThreshold,
+}: {
+ readonly currentLtv: number | null;
+ readonly liquidationThreshold: number | null;
+}) => {
+ const { t } = useTranslation();
+
+ if (currentLtv == null) {
+ return null;
+ }
+
+ const clampedLtv = Math.max(0, Math.min(100, currentLtv * 100));
+ const clampedThreshold =
+ liquidationThreshold == null
+ ? null
+ : Math.max(0, Math.min(100, liquidationThreshold * 100));
+
+ return (
+
+
+
+ {t("dashboard.borrow.position_details.loan_to_value")}
+
+
+ {formatPercent(currentLtv)}
+
+
+
+
+ {clampedThreshold == null ? null : (
+
+ )}
+
+
+
+
+
+ {t("dashboard.borrow.position_details.low_risk")}
+
+ {liquidationThreshold == null ? null : (
+
+ {t("dashboard.borrow.position_details.liquidation_at", {
+ value: formatPercent(liquidationThreshold),
+ })}
+
+ )}
+
+
+ );
+};
+
+const CollateralList = ({
+ actions,
+ items,
+ onActionSelect,
+ totalCollateralUsd,
+}: {
+ readonly actions: BorrowPositionAction[];
+ readonly items: ReturnType<
+ typeof getBorrowPositionDetailsModel
+ >["collateralItems"];
+ readonly onActionSelect: (action: BorrowPositionAction) => void;
+ readonly totalCollateralUsd: string;
+}) => {
+ const { t } = useTranslation();
+ const [expanded, setExpanded] = useState(items.length <= 1);
+
+ if (items.length === 0) {
+ return null;
+ }
+
+ const getToggleAction = (
+ item: (typeof items)[number]
+ ): BorrowPositionAction | null => {
+ const toggle = item.collateralToggleAction;
+
+ if (!toggle) {
+ return null;
+ }
+
+ return (
+ actions.find(
+ (action) =>
+ action.type === toggle.action.type &&
+ action.pendingContext.type === toggle.action.type &&
+ action.pendingContext.supplyBalance.tokenAddress ===
+ toggle.supplyBalance.tokenAddress
+ ) ?? null
+ );
+ };
+
+ return (
+
+ setExpanded((value) => !value)}
+ >
+
+
+ {t("dashboard.borrow.position_details.collateral_list")}
+
+
+
+
+ {totalCollateralUsd}
+
+
+
+
+
+
+ {items.map((item) => {
+ const toggleAction = getToggleAction(item);
+
+ return (
+
+
+ {item.label}
+
+ {item.supplyRate}
+
+
+
+
+ {item.balance}
+
+ {item.balanceUsd}
+
+
+
+ {toggleAction ? (
+ onActionSelect(toggleAction)}
+ type="button"
+ >
+
+
+ ) : null}
+
+ );
+ })}
+
+
+
+
+ );
+};
+
+const BorrowPositionInfo = ({
+ actions,
+ content,
+ model,
+ onActionSelect,
+ position,
+}: {
+ readonly actions: BorrowPositionAction[];
+ readonly content: "details" | "fallback";
+ readonly model: ReturnType | null;
+ readonly onActionSelect: (action: BorrowPositionAction) => void;
+ readonly position: NonNullable | null;
+}) => {
+ const { t } = useTranslation();
+
+ if (content === "fallback" || !position || !model) {
+ return (
+
+ {t("dashboard.borrow.position_details.empty")}
+
+ );
+ }
+
+ return (
+
+
+
+
+ {model.title}
+
+
+
+ {t("positions.via", {
+ providerName: model.providerName,
+ count: 1,
+ })}
+
+
+ {" · "}
+ {model.marketLabel}
+
+
+
+
+
+
+
+
+
+
+
+ {model.breakdownRows.length > 0 && (
+
+
+ {model.breakdownRows.map((row) => (
+
+
+ {row.label}
+
+
+
+
+ {row.value}
+
+ {row.subValue && (
+
+ {row.subValue}
+
+ )}
+
+
+ ))}
+
+
+ )}
+
+
+ {model.detailRows.map((row) => (
+
+ ))}
+
+
+
+
+
+
+ );
+};
+
+const useBorrowPositionContext = () =>
+ useOutletContext();
+
+const getBorrowPositionBasePath = (marketId: string | undefined) =>
+ marketId ? `/positions/borrow/${marketId}` : "/manage";
+
+export const BorrowPositionActionsPage = () => {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const stageBorrowActionForm = useAtomSet(borrowActionFormAtom);
+ const { actions, model, position } = useBorrowPositionContext();
+
+ const onActionSelect = (action: BorrowPositionAction) => {
+ stageBorrowActionForm({
+ context: action.pendingContext,
+ type: "preparePositionAction",
+ });
+ navigate(`action/${action.id}`);
+ };
+
+ return (
+ <>
+
+
+
+
+ {t("dashboard.borrow.position_details.actions_title")}
+
+
+ {!position || actions.length === 0 ? (
+
+ {t("dashboard.borrow.position_details.no_actions")}
+
+ ) : (
+ actions.map((action) => (
+
+
+ {action.label}
+
+ {t(
+ `dashboard.borrow.position_details.action_descriptions.${action.type}`
+ )}
+
+
+
+
+ ))
+ )}
+
+ >
+ );
+};
+
+const AmountInputCard = ({
+ amount,
+ balanceLabel,
+ disabled,
+ error,
+ label,
+ onAmountChange,
+ onMaxClick,
+ tokenSymbol,
+ usdValue,
+}: {
+ readonly amount: BigNumber;
+ readonly balanceLabel: string;
+ readonly disabled?: boolean;
+ readonly error?: string | null;
+ readonly label: string;
+ readonly onAmountChange: (amount: BigNumber) => void;
+ readonly onMaxClick?: (() => void) | null;
+ readonly tokenSymbol: string;
+ readonly usdValue?: BigNumber;
+}) => (
+
+ {label}
+
+
+
+
+
+ {tokenSymbol}
+
+
+
+
+
+ {usdValue?.gt(0) ? `$${formatNumber(usdValue, 2)}` : "$0"}
+
+
+
+ {balanceLabel}
+
+ {onMaxClick ? : null}
+
+
+
+ {error ? (
+ {error}
+ ) : null}
+
+
+);
+
+const getCommonSummary = (
+ position: NonNullable
+) => ({
+ marketLabel: getBorrowMarketPairLabel(position.market),
+ network: position.market.network,
+ providerName: position.integration.name,
+});
+
+const usePrepareReview = () => {
+ const navigate = useNavigate();
+ const stageBorrowActionForm = useAtomSet(borrowActionFormAtom);
+
+ return (reviewState: BorrowReviewState) => {
+ stageBorrowActionForm({
+ reviewState,
+ type: "prepareReview",
+ });
+ navigate("../review", { state: reviewState });
+ };
+};
+
+const RepayActionForm = ({
+ action,
+}: {
+ readonly action: BorrowPositionAction;
+}) => {
+ const { t } = useTranslation();
+ const tokenBalances = useTokenBalancesScan();
+ const prepareReview = usePrepareReview();
+ const [amount, setAmount] = useState(new BigNumber(0));
+ const [repayAll, setRepayAll] = useState(false);
+
+ const context = action.pendingContext;
+ if (context.type !== "repay") {
+ return null;
+ }
+
+ const position = context.position;
+ const debtBalance = context.debtBalance;
+ const repayAmount = repayAll ? new BigNumber(debtBalance.balance) : amount;
+ const walletBalance = deriveBorrowTokenWalletBalance({
+ balances: tokenBalances.data ?? [],
+ network: position.market.network,
+ token: position.market.loanToken,
+ });
+ const exceedsDebt = repayAmount.gt(debtBalance.balance);
+ const insufficientWalletBalance =
+ !!tokenBalances.data && repayAmount.gt(walletBalance.amountValue);
+ const repayUsd = repayAmount.multipliedBy(position.market.loanTokenPriceUsd);
+ const projectedDebtUsd = Math.max(
+ debtBalance.balanceUsd - repayUsd.toNumber(),
+ 0
+ );
+ const projectedLtv = projectLtvRatio({
+ collateralUsd: position.getTotalCollateralUsd(),
+ debtUsd: projectedDebtUsd,
+ });
+ const collateralDetails = position.getCollateralTokenDetails();
+ const projectedHealthFactor =
+ projectedLtv > 0 && Number.isFinite(collateralDetails.liquidationThreshold)
+ ? collateralDetails.liquidationThreshold / projectedLtv
+ : null;
+ const hasAmount = repayAll || repayAmount.gt(0);
+ const canSubmit = hasAmount && !exceedsDebt && !insufficientWalletBalance;
+ const error = exceedsDebt
+ ? t("dashboard.borrow.position_details.validation.repay_debt")
+ : insufficientWalletBalance
+ ? t("dashboard.borrow.position_details.validation.wallet_balance")
+ : null;
+
+ const onContinue = () => {
+ if (!canSubmit) {
+ return;
+ }
+
+ const reviewState: BorrowReviewState = {
+ request: buildRepayActionRequest({
+ address: action.reviewState.request.address,
+ integrationId: position.integration.id,
+ marketId: context.action.args.marketId,
+ ...(repayAll
+ ? { repayAll: true }
+ : {
+ amount: repayAmount,
+ }),
+ tokenAddress: context.action.args.tokenAddress,
+ }),
+ summary: {
+ ...getCommonSummary(position),
+ action: "repay",
+ borrowAmount: repayAmount.toString(10),
+ existingDebtUsd: debtBalance.balanceUsd.toString(),
+ loanTokenSymbol: debtBalance.tokenSymbol,
+ projectedDebtUsd: projectedDebtUsd.toString(),
+ projectedHealthFactor: projectedHealthFactor?.toString(),
+ projectedLtv: projectedLtv.toString(),
+ },
+ };
+
+ prepareReview(reviewState);
+ };
+
+ return (
+
+
+
+
+
+
+
+ {t("dashboard.borrow.position_details.repay_full")}
+
+
+ {t("dashboard.borrow.position_details.repay_full_description")}
+
+
+ setRepayAll(event.target.checked)}
+ type="checkbox"
+ />
+
+
+
+
+ ${formatPercent(
+ projectedLtv
+ )}`}
+ />
+ ${formatNumber(
+ Math.max(debtBalance.balance - repayAmount.toNumber(), 0),
+ 6
+ )} ${debtBalance.tokenSymbol}`}
+ />
+
+
+
+
+ );
+};
+
+const WithdrawTokenPicker = ({
+ onSelect,
+ selectedToken,
+ tokens,
+ network,
+}: {
+ readonly network: NonNullable<
+ BorrowPositionContext["position"]
+ >["market"]["network"];
+ readonly onSelect: (token: BorrowWithdrawTokenOption) => void;
+ readonly selectedToken: BorrowWithdrawTokenOption;
+ readonly tokens: ReadonlyArray;
+}) => {
+ const { t } = useTranslation();
+
+ if (tokens.length <= 1) {
+ return null;
+ }
+
+ return (
+
+
+ {tokens.map((token) => {
+ const selected =
+ token.action.args.tokenAddress ===
+ selectedToken.action.args.tokenAddress;
+ const tokenDto = borrowTokenToTokenDto({
+ network,
+ token: token.collateralToken.token,
+ });
+
+ return (
+
+ onSelect(token)}
+ style={{ border: 0 }}
+ type="button"
+ width="full"
+ >
+
+
+
+ {token.supplyBalance.tokenSymbol}
+
+
+ {formatCompactUsd(
+ token.supplyBalance.balanceUsd.toString()
+ )}
+
+
+ {selected ? (
+ {t("dashboard.borrow.position_details.selected")}
+ ) : null}
+
+
+ );
+ })}
+
+
+ );
+};
+
+const WithdrawActionForm = ({
+ action,
+}: {
+ readonly action: BorrowPositionAction;
+}) => {
+ const { t } = useTranslation();
+ const prepareReview = usePrepareReview();
+ const context = action.pendingContext;
+ const withdrawContext = context.type === "withdraw" ? context : null;
+ const [selectedToken, setSelectedToken] = useState<
+ BorrowWithdrawTokenOption | undefined
+ >(() => withdrawContext?.tokens[0]);
+ const [amount, setAmount] = useState(new BigNumber(0));
+
+ if (!withdrawContext || !selectedToken) {
+ return null;
+ }
+
+ const position = withdrawContext.position;
+ const withdrawUsd = amount.multipliedBy(
+ selectedToken.collateralToken.priceUsd
+ );
+ const exceedsBalance = amount.gt(selectedToken.supplyBalance.balance);
+ const projectedCollateralUsd = Math.max(
+ position.getTotalCollateralUsd() - withdrawUsd.toNumber(),
+ 0
+ );
+ const projectedLtv = projectLtvRatio({
+ collateralUsd: projectedCollateralUsd,
+ debtUsd: position.getTotalBorrowedUsd(),
+ });
+ const maxLtv = selectedToken.collateralToken.maxLtv;
+ const ltvTooHigh =
+ position.getTotalBorrowedUsd() > 0 && amount.gt(0) && projectedLtv > maxLtv;
+ const canSubmit = amount.gt(0) && !exceedsBalance && !ltvTooHigh;
+ const error = exceedsBalance
+ ? t("dashboard.borrow.position_details.validation.withdraw_balance")
+ : ltvTooHigh
+ ? t("dashboard.borrow.position_details.validation.withdraw_ltv")
+ : null;
+
+ const onContinue = () => {
+ if (!canSubmit) {
+ return;
+ }
+
+ const reviewState: BorrowReviewState = {
+ request: buildWithdrawActionRequest({
+ address: action.reviewState.request.address,
+ amount,
+ integrationId: position.integration.id,
+ marketId: selectedToken.action.args.marketId,
+ tokenAddress: selectedToken.action.args.tokenAddress,
+ }),
+ summary: {
+ ...getCommonSummary(position),
+ action: "withdraw",
+ collateralAmount: amount.toString(10),
+ collateralTokenSymbol: selectedToken.supplyBalance.tokenSymbol,
+ existingCollateralUsd: position.getTotalCollateralUsd().toString(),
+ projectedCollateralUsd: projectedCollateralUsd.toString(),
+ projectedLtv: projectedLtv.toString(),
+ },
+ };
+
+ prepareReview(reviewState);
+ };
+
+ return (
+
+ {
+ setSelectedToken(token);
+ setAmount(new BigNumber(0));
+ }}
+ selectedToken={selectedToken}
+ tokens={withdrawContext.tokens}
+ />
+
+
+ setAmount(new BigNumber(selectedToken.supplyBalance.balance))
+ }
+ tokenSymbol={selectedToken.supplyBalance.tokenSymbol}
+ usdValue={withdrawUsd}
+ />
+
+
+ ${formatPercent(
+ projectedLtv
+ )}`}
+ />
+ ${formatCompactUsd(projectedCollateralUsd.toString())}`}
+ />
+
+
+
+
+ );
+};
+
+const ToggleCollateralActionForm = ({
+ action,
+}: {
+ readonly action: BorrowPositionAction;
+}) => {
+ const { t } = useTranslation();
+ const prepareReview = usePrepareReview();
+ const context = action.pendingContext;
+ if (
+ context.type !== "disableCollateral" &&
+ context.type !== "enableCollateral"
+ ) {
+ return null;
+ }
+
+ const position = context.position;
+ const isDisable = context.type === "disableCollateral";
+ const tokenSymbol = context.supplyBalance.tokenSymbol;
+ const healthFactor = position.getHealthFactor();
+
+ const onContinue = () => {
+ const reviewState: BorrowReviewState = {
+ request: buildCollateralToggleActionRequest({
+ action: context.type,
+ address: action.reviewState.request.address,
+ integrationId: position.integration.id,
+ marketId: context.action.args.marketId,
+ tokenAddress: context.action.args.tokenAddress,
+ }),
+ summary: {
+ ...getCommonSummary(position),
+ action: context.type,
+ collateralTokenSymbol: tokenSymbol,
+ existingCollateralUsd: position.getTotalCollateralUsd().toString(),
+ projectedHealthFactor: position.getHealthFactor()?.toString(),
+ projectedLtv: position.getCurrentLtv()?.toString(),
+ },
+ };
+
+ prepareReview(reviewState);
+ };
+
+ return (
+
+
+
+ {isDisable
+ ? t("dashboard.borrow.position_details.disable_collateral_title", {
+ symbol: tokenSymbol,
+ })
+ : t("dashboard.borrow.position_details.enable_collateral_title", {
+ symbol: tokenSymbol,
+ })}
+
+
+ {isDisable
+ ? t("dashboard.borrow.position_details.disable_collateral_warning")
+ : t("dashboard.borrow.position_details.enable_collateral_warning")}
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export const BorrowPositionActionPage = () => {
+ const { actionId, marketId } = useParams();
+ const { t } = useTranslation();
+ const { actions, model, position } = useBorrowPositionContext();
+ const action = actions.find((candidate) => candidate.id === actionId);
+
+ return (
+ <>
+
+
+
+
+
+ {action?.label ??
+ t("dashboard.borrow.position_details.actions_title")}
+
+
+ {model?.marketLabel}
+
+
+
+ {!position || !action ? (
+
+ {t("dashboard.borrow.position_details.empty")}
+
+ ) : action.type === "repay" ? (
+
+ ) : action.type === "withdraw" ? (
+
+ ) : (
+
+ )}
+
+ >
+ );
+};
+
+const BorrowPositionActionsSkeleton = () => (
+
+
+
+
+
+ {[0, 1].map((index) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+);
+
+const BorrowPositionInfoSkeleton = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {[0, 1, 2, 3].map((index) => (
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {[0, 1, 2, 3].map((index) => (
+
+
+
+
+
+
+
+
+ ))}
+
+
+);
+
+export const BorrowPositionDetailsPage = () => {
+ useTrackPage("positionDetails");
+
+ const { marketId } = useParams();
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const stageBorrowActionForm = useAtomSet(borrowActionFormAtom);
+ const borrowPosition = useBorrowPosition(marketId);
+ const position = getPositionFromResult(borrowPosition);
+ const model = position
+ ? getBorrowPositionDetailsModel({ position, t })
+ : null;
+ const actions = position
+ ? getBorrowPositionActions({
+ address: borrowPosition.walletBridge.wallet.currentAccount.address,
+ position,
+ t,
+ })
+ : [];
+ const isPositionLoading =
+ AsyncResult.isInitial(borrowPosition.positionResult) ||
+ AsyncResult.isWaiting(borrowPosition.positionResult);
+ const shouldShowLeftPane = actions.length > 0 || !!position;
+ const context: BorrowPositionContext = {
+ actions,
+ borrowPosition,
+ model,
+ position,
+ };
+
+ const openAction = (action: BorrowPositionAction) => {
+ stageBorrowActionForm({
+ context: action.pendingContext,
+ type: "preparePositionAction",
+ });
+ navigate(`${getBorrowPositionBasePath(marketId)}/action/${action.id}`);
+ };
+
+ if (isPositionLoading) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ const rightContent = (() => {
+ if (AsyncResult.isFailure(borrowPosition.positionResult)) {
+ return (
+
+ {t("shared.something_went_wrong")}
+
+ );
+ }
+
+ return (
+
+ );
+ })();
+
+ return (
+
+
+ {shouldShowLeftPane ? (
+
+
+
+ ) : null}
+
+ {shouldShowLeftPane ? : null}
+
+
+ {shouldShowLeftPane ? null : (
+
+ )}
+
+ {rightContent}
+
+
+
+ );
+};
diff --git a/packages/widget/src/pages-dashboard/borrow/review-state.ts b/packages/widget/src/pages-dashboard/borrow/review-state.ts
new file mode 100644
index 00000000..5adee95e
--- /dev/null
+++ b/packages/widget/src/pages-dashboard/borrow/review-state.ts
@@ -0,0 +1,77 @@
+import type { Action, BorrowExecutionResult } from "../../borrow";
+import type { ActionsControllerExecuteActionV1RequestJson } from "../../generated/api/borrow";
+
+export type BorrowReviewState = {
+ readonly request: ActionsControllerExecuteActionV1RequestJson;
+ 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 BorrowStepsState = BorrowReviewState & {
+ readonly action: Action;
+};
+
+type BorrowCompleteState = BorrowStepsState & {
+ readonly result: BorrowExecutionResult;
+};
+
+export const isBorrowReviewState = (
+ value: unknown
+): value is BorrowReviewState => {
+ if (!value || typeof value !== "object") {
+ return false;
+ }
+
+ const maybeState = value as Partial;
+
+ return (
+ !!maybeState.request &&
+ !!maybeState.summary &&
+ typeof maybeState.summary.marketLabel === "string" &&
+ typeof maybeState.summary.providerName === "string"
+ );
+};
+
+export const isBorrowStepsState = (
+ value: unknown
+): value is BorrowStepsState => {
+ if (!isBorrowReviewState(value)) {
+ return false;
+ }
+
+ const maybeState = value as Partial;
+
+ return (
+ !!maybeState.action &&
+ typeof maybeState.action.id === "string" &&
+ Array.isArray(maybeState.action.transactions)
+ );
+};
+
+export const isBorrowCompleteState = (
+ value: unknown
+): value is BorrowCompleteState =>
+ isBorrowStepsState(value) &&
+ "result" in value &&
+ !!(value as Partial).result;
diff --git a/packages/widget/src/pages-dashboard/borrow/review.tsx b/packages/widget/src/pages-dashboard/borrow/review.tsx
new file mode 100644
index 00000000..3a89ea9d
--- /dev/null
+++ b/packages/widget/src/pages-dashboard/borrow/review.tsx
@@ -0,0 +1,313 @@
+import { useAtom, useAtomSet, useAtomValue } from "@effect/atom-react";
+import { Cause, Option } from "effect";
+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useLocation, useNavigate, useParams } from "react-router";
+import { borrowActionFormAtom, borrowCreateActionAtom } from "../../borrow";
+import { Box } from "../../components/atoms/box";
+import { Divider } from "../../components/atoms/divider";
+import { CaretLeftIcon } from "../../components/atoms/icons/caret-left";
+import { Text } from "../../components/atoms/typography/text";
+import { useTrackPage } from "../../hooks/tracking/use-track-page";
+import { AnimationPage } from "../../navigation/containers/animation-page";
+import { PageContainer } from "../../pages/components/page-container";
+import { PageCtaButton } from "../../pages/components/page-cta";
+import { formatNumber } from "../../utils";
+import { DetailRow } from "../overview/earn-details/components/details-section";
+import { getBorrowFlowRoutes } from "./flow-routes";
+import { isBorrowReviewState } from "./review-state";
+import * as styles from "./styles.css";
+
+const formatPercentSummary = (value: string | undefined) => {
+ const numericValue = Number(value);
+
+ return Number.isFinite(numericValue)
+ ? `${formatNumber(numericValue * 100, 2)}%`
+ : null;
+};
+
+const formatUsdSummary = (value: string | undefined) => {
+ const numericValue = Number(value);
+
+ return Number.isFinite(numericValue)
+ ? `$${formatNumber(numericValue, 2)}`
+ : null;
+};
+
+const formatTransition = ({
+ current,
+ projected,
+}: {
+ readonly current: string | null;
+ readonly projected: string | null;
+}) => {
+ if (!projected) {
+ return null;
+ }
+
+ return current && current !== projected
+ ? `${current} -> ${projected}`
+ : projected;
+};
+
+export const BorrowReviewPage = () => {
+ useTrackPage("borrowReview");
+
+ const { t } = useTranslation();
+ const location = useLocation();
+ const navigate = useNavigate();
+ const { marketId } = useParams();
+ const routes = getBorrowFlowRoutes(marketId);
+ const stageBorrowActionForm = useAtomSet(borrowActionFormAtom);
+ const [createActionResult, createAction] = useAtom(borrowCreateActionAtom, {
+ mode: "promise",
+ });
+ const actionFormState = useAtomValue(borrowActionFormAtom);
+ const locationReviewState = isBorrowReviewState(location.state)
+ ? location.state
+ : null;
+ const reviewState =
+ locationReviewState ??
+ (actionFormState.type === "review" ? actionFormState.reviewState : null);
+ const [confirmAttempted, setConfirmAttempted] = useState(false);
+
+ useEffect(() => {
+ if (!reviewState) {
+ return;
+ }
+
+ setConfirmAttempted(false);
+ }, [reviewState]);
+
+ if (!reviewState) {
+ return (
+
+
+
+
+ {t("dashboard.borrow.review_page.unavailable_title")}
+
+
+ {t("dashboard.borrow.review_page.unavailable_description")}
+
+ navigate(routes.basePath),
+ }}
+ />
+
+
+
+ );
+ }
+
+ const { request, summary } = reviewState;
+ const createActionErrorMessage = (() => {
+ if (
+ !confirmAttempted ||
+ !AsyncResult.isFailure(createActionResult) ||
+ AsyncResult.isWaiting(createActionResult)
+ ) {
+ return null;
+ }
+
+ const error = Cause.findErrorOption(createActionResult.cause);
+
+ if (
+ Option.isSome(error) &&
+ error.value &&
+ typeof error.value === "object" &&
+ "message" in error.value &&
+ typeof error.value.message === "string"
+ ) {
+ return error.value.message;
+ }
+
+ return t("dashboard.borrow.error_description");
+ })();
+ const onConfirm = () => {
+ setConfirmAttempted(true);
+
+ void createAction(request)
+ .then((action) => {
+ const executionState = { ...reviewState, action };
+
+ stageBorrowActionForm({
+ executionState,
+ type: "prepareExecution",
+ });
+ navigate(routes.stepsPath, { state: executionState });
+ })
+ .catch(() => undefined);
+ };
+ const projectedLtv = formatTransition({
+ current: null,
+ projected: formatPercentSummary(summary.projectedLtv),
+ });
+ const projectedHealthFactor = summary.projectedHealthFactor
+ ? formatNumber(Number(summary.projectedHealthFactor), 2)
+ : null;
+ const collateralValue = formatTransition({
+ current: formatUsdSummary(summary.existingCollateralUsd),
+ projected: formatUsdSummary(summary.projectedCollateralUsd),
+ });
+ const debtValue = formatTransition({
+ current: formatUsdSummary(summary.existingDebtUsd),
+ projected: formatUsdSummary(summary.projectedDebtUsd),
+ });
+ const actionRows = [
+ {
+ id: "action",
+ label: t("dashboard.borrow.review_page.action"),
+ value: t(`dashboard.borrow.review_page.actions.${summary.action}`),
+ },
+ 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,
+ },
+ collateralValue
+ ? {
+ id: "collateral-value",
+ label: t("dashboard.borrow.form.collateral_value"),
+ value: collateralValue,
+ }
+ : null,
+ debtValue
+ ? {
+ id: "debt-value",
+ label: t("dashboard.borrow.position_details.debt"),
+ value: debtValue,
+ }
+ : null,
+ projectedLtv
+ ? {
+ id: "projected-ltv",
+ label: t("dashboard.borrow.review_page.projected_ltv"),
+ value: projectedLtv,
+ }
+ : null,
+ projectedHealthFactor
+ ? {
+ id: "projected-health-factor",
+ label: t("dashboard.borrow.review_page.projected_health_factor"),
+ value: projectedHealthFactor,
+ }
+ : null,
+ {
+ id: "estimated-fee",
+ label: t("dashboard.borrow.review_page.estimated_fee"),
+ value: t("dashboard.borrow.review_page.estimated_fee_pending"),
+ },
+ ].filter((row): row is NonNullable => !!row);
+
+ return (
+
+
+
+
+ navigate(routes.basePath)}
+ type="button"
+ >
+
+
+
+
+ {t("dashboard.borrow.review_page.title")}
+
+
+ {summary.marketLabel}
+
+
+
+
+
+
+ {actionRows.map((row) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ {t("dashboard.borrow.review_page.execution_pending")}
+
+
+ {createActionErrorMessage ? (
+
+
+ {t("dashboard.borrow.execution_page.error_title")}
+
+
+ {createActionErrorMessage}
+
+
+ ) : null}
+
+
+ {t(
+ `dashboard.borrow.review_page.risk_disclosures.${summary.action}`
+ )}
+
+
+
+
+
+
+ );
+};
diff --git a/packages/widget/src/pages-dashboard/borrow/steps.tsx b/packages/widget/src/pages-dashboard/borrow/steps.tsx
new file mode 100644
index 00000000..4c40fe8a
--- /dev/null
+++ b/packages/widget/src/pages-dashboard/borrow/steps.tsx
@@ -0,0 +1,265 @@
+import { useAtomValue } from "@effect/atom-react";
+import { useEffect } from "react";
+import { useTranslation } from "react-i18next";
+import { useLocation, useNavigate, useParams } from "react-router";
+import {
+ type BorrowExecutionStepStatus,
+ borrowActionFormAtom,
+} from "../../borrow";
+import { Box } from "../../components/atoms/box";
+import { Button } from "../../components/atoms/button";
+import { CaretLeftIcon } from "../../components/atoms/icons/caret-left";
+import { CheckCircleIcon } from "../../components/atoms/icons/check-circle";
+import { XIcon } from "../../components/atoms/icons/x-icon";
+import { Spinner } from "../../components/atoms/spinner";
+import { Text } from "../../components/atoms/typography/text";
+import { useTrackPage } from "../../hooks/tracking/use-track-page";
+import { AnimationPage } from "../../navigation/containers/animation-page";
+import { PageContainer } from "../../pages/components/page-container";
+import { PageCtaButton } from "../../pages/components/page-cta";
+import { useSettings } from "../../providers/settings";
+import { getBorrowFlowRoutes } from "./flow-routes";
+import { type BorrowStepsState, isBorrowStepsState } from "./review-state";
+import * as styles from "./styles.css";
+import { useBorrowExecution } from "./use-borrow-execution";
+
+const StepIcon = ({
+ status,
+}: {
+ readonly status: BorrowExecutionStepStatus;
+}) =>
+ status === "active" ? (
+
+ ) : status === "completed" ? (
+
+ ) : status === "failed" ? (
+
+ ) : (
+
+ );
+
+export const BorrowStepsPage = () => {
+ useTrackPage("borrowSteps");
+
+ const { t } = useTranslation();
+ const location = useLocation();
+ const navigate = useNavigate();
+ const { marketId } = useParams();
+ const { basePath } = getBorrowFlowRoutes(marketId);
+ const actionFormState = useAtomValue(borrowActionFormAtom);
+ const locationExecutionState = isBorrowStepsState(location.state)
+ ? location.state
+ : null;
+ const executionState =
+ locationExecutionState ??
+ (actionFormState.type === "execution"
+ ? actionFormState.executionState
+ : null);
+
+ if (!executionState) {
+ return (
+
+
+
+
+ {t("dashboard.borrow.execution_page.unavailable_title")}
+
+
+ {t("dashboard.borrow.execution_page.unavailable_description")}
+
+ navigate(basePath),
+ }}
+ />
+
+
+
+ );
+ }
+
+ return ;
+};
+
+const BorrowStepsContent = ({
+ executionState,
+}: {
+ readonly executionState: BorrowStepsState;
+}) => {
+ const { dashboardVariant } = useSettings();
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const { marketId } = useParams();
+ const { basePath, completePath } = getBorrowFlowRoutes(marketId);
+ const execution = useBorrowExecution({ action: executionState.action });
+ const totalTransactions = execution.action?.transactions.length ?? 0;
+ const transactionPosition =
+ execution.currentTransactionIndex == null
+ ? null
+ : execution.currentTransactionIndex + 1;
+
+ useEffect(() => {
+ if (!execution.completionResult) {
+ return;
+ }
+
+ navigate(completePath, {
+ replace: true,
+ state: { ...executionState, result: execution.completionResult },
+ });
+ }, [completePath, execution.completionResult, executionState, navigate]);
+
+ return (
+
+
+
+
+ {marketId ? (
+ navigate(basePath)}
+ type="button"
+ >
+
+
+ ) : null}
+
+
+ {t("dashboard.borrow.execution_page.title")}
+
+
+ {executionState.summary.marketLabel}
+
+
+
+
+ {execution.steps.map((step) => (
+
+
+
+
+
+
+ {t(
+ `dashboard.borrow.execution_page.steps.${step.id}.label`
+ )}
+
+
+ {t(
+ `dashboard.borrow.execution_page.steps.${step.id}.description`
+ )}
+
+
+
+ ))}
+
+
+ {execution.error && (
+
+
+ {t("dashboard.borrow.execution_page.error_title")}
+
+
+ {execution.error.message}
+
+
+ )}
+
+ {(execution.currentTransaction ||
+ execution.submissions.length > 0) && (
+
+ {execution.currentTransaction && transactionPosition ? (
+ <>
+
+ {t("dashboard.borrow.execution_page.current_transaction", {
+ current: transactionPosition,
+ total: totalTransactions,
+ })}
+
+
+ {execution.currentTransaction.type}
+ {" · "}
+ {execution.currentTransaction.status}
+
+ >
+ ) : null}
+
+ {execution.submissions.map((submission) =>
+ submission.link ? (
+ window.open(submission.link, "_blank")}
+ style={{ border: 0 }}
+ type="button"
+ >
+
+ {t("dashboard.borrow.success_page.view_transaction")}
+
+
+ ) : null
+ )}
+
+ )}
+
+ {execution.error && (
+
+ )}
+
+ navigate(basePath),
+ variant: "secondary",
+ }}
+ />
+
+
+
+ );
+};
diff --git a/packages/widget/src/pages-dashboard/borrow/styles.css.ts b/packages/widget/src/pages-dashboard/borrow/styles.css.ts
new file mode 100644
index 00000000..c32983f4
--- /dev/null
+++ b/packages/widget/src/pages-dashboard/borrow/styles.css.ts
@@ -0,0 +1,531 @@
+import { globalStyle, style } from "@vanilla-extract/css";
+import { atoms } from "../../styles/theme/atoms.css";
+import { vars } from "../../styles/theme/contract.css";
+import { OUTLET_PADDING } from "../common/components/styles.css";
+
+export const pane = style({
+ minWidth: 0,
+});
+
+export const formPane = style([
+ pane,
+ atoms({
+ display: "flex",
+ flex: 1,
+ flexDirection: "column",
+ gap: "4",
+ }),
+ {
+ maxWidth: "380px",
+ },
+]);
+
+export const detailsPaneWrapper = style([
+ pane,
+ {
+ alignSelf: "stretch",
+ minHeight: "620px",
+ position: "relative",
+ },
+]);
+
+export const detailsScroll = style({
+ bottom: 0,
+ boxSizing: "border-box",
+ left: 0,
+ marginRight: `calc(-1 * ${OUTLET_PADDING})`,
+ overflowY: "auto",
+ paddingRight: OUTLET_PADDING,
+ position: "absolute",
+ right: 0,
+ scrollbarGutter: "stable",
+ top: 0,
+});
+
+export const amountCard = style([
+ atoms({
+ background: "transparent",
+ borderRadius: "xl",
+ display: "flex",
+ flexDirection: "column",
+ gap: "3",
+ px: "4",
+ py: "4",
+ }),
+ {
+ borderColor: vars.color.backgroundMuted,
+ borderStyle: "solid",
+ borderWidth: "1px",
+ minHeight: 116,
+ },
+]);
+
+export const amountCardHighlighted = style({
+ borderColor: vars.color.tokenSelectBorder,
+});
+
+export const amountCardInvalid = style({
+ borderColor: vars.color.textDanger,
+});
+
+export const amountCardHeader = style({
+ alignItems: "center",
+ display: "grid",
+ gap: "12px",
+ gridTemplateColumns: "minmax(0, 1fr) auto",
+});
+
+export const amountCardFooter = style({
+ alignItems: "center",
+ display: "flex",
+ flexWrap: "wrap",
+ gap: "4px 10px",
+ justifyContent: "space-between",
+ minWidth: 0,
+});
+
+export const amountBalanceGroup = style({
+ alignItems: "center",
+ display: "flex",
+ flexGrow: 1,
+ gap: "8px",
+ justifyContent: "flex-end",
+ minWidth: 0,
+ textAlign: "right",
+});
+
+export const amountTokenButton = style([
+ atoms({
+ alignItems: "center",
+ borderRadius: "2xl",
+ display: "flex",
+ gap: "2",
+ px: "3",
+ py: "2",
+ }),
+ {
+ border: 0,
+ font: "inherit",
+ maxWidth: "min(240px, 100%)",
+ minWidth: 0,
+ },
+]);
+
+export const amountTokenButtonSelectable = style({
+ cursor: "pointer",
+});
+
+export const amountTokenButtonText = style({
+ minWidth: 0,
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+});
+
+export const amountTokenButtonCaret = style({
+ alignItems: "center",
+ display: "flex",
+ flexShrink: 0,
+ height: "12px",
+ justifyContent: "center",
+ width: "12px",
+});
+
+export const mutedPanel = style([
+ atoms({
+ background: "stakeSectionBackground",
+ borderRadius: "xl",
+ display: "flex",
+ flexDirection: "column",
+ gap: "2",
+ px: "4",
+ py: "4",
+ }),
+]);
+
+export const marketList = style([
+ atoms({
+ display: "flex",
+ flexDirection: "column",
+ gap: "2",
+ }),
+]);
+
+export const marketButton = style([
+ atoms({
+ alignItems: "center",
+ background: "background",
+ borderColor: "backgroundMuted",
+ borderRadius: "xl",
+ display: "flex",
+ gap: "3",
+ px: "3",
+ py: "3",
+ }),
+ {
+ borderStyle: "solid",
+ borderWidth: "1px",
+ cursor: "pointer",
+ font: "inherit",
+ textAlign: "left",
+ width: "100%",
+ },
+]);
+
+export const marketButtonSelected = style({
+ borderColor: vars.color.text,
+});
+
+export const assetSelectorList = style({
+ display: "flex",
+ flexDirection: "column",
+ gap: "8px",
+ maxHeight: "min(520px, calc(100vh - 220px))",
+ overflowY: "auto",
+ paddingBottom: "8px",
+ paddingTop: "8px",
+ scrollbarGutter: "stable",
+});
+
+export const assetSelectorSectionTitle = style({
+ padding: "8px 5px",
+});
+
+export const assetSelectorGroup = style({
+ display: "flex",
+ flexDirection: "column",
+ gap: "8px",
+});
+
+export const assetSelectorRow = style({
+ alignItems: "center",
+ background: vars.color.tokenSelectBackground,
+ border: "1px solid transparent",
+ borderRadius: vars.borderRadius.baseContract.xl,
+ boxSizing: "border-box",
+ color: vars.color.text,
+ cursor: "pointer",
+ display: "flex",
+ font: "inherit",
+ gap: "10px",
+ minHeight: "64px",
+ minWidth: 0,
+ padding: "12px",
+ textAlign: "left",
+ width: "100%",
+ ":hover": {
+ background: vars.color.tokenSelectHoverBackground,
+ },
+});
+
+export const assetSelectorRowSelected = style({
+ background: vars.color.tokenSelectHoverBackground,
+});
+
+export const assetSelectorMarketRow = style({
+ paddingLeft: "48px",
+});
+
+export const assetSelectorChevron = style({
+ alignItems: "center",
+ display: "flex",
+ flexShrink: 0,
+ transition: "transform 150ms ease",
+});
+
+export const assetSelectorChevronExpanded = style({
+ transform: "rotate(180deg)",
+});
+
+export const assetSelectorText = style({
+ display: "flex",
+ flex: 1,
+ flexDirection: "column",
+ gap: "6px",
+ minWidth: 0,
+});
+
+export const assetSelectorLabel = style([
+ atoms({
+ color: "tokenSelect",
+ fontWeight: "tokenSelect",
+ }),
+ {
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+ },
+]);
+
+export const assetSelectorMeta = style({
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+});
+
+export const assetSelectorRate = style({
+ alignItems: "flex-end",
+ display: "flex",
+ flexDirection: "column",
+ flexShrink: 0,
+ gap: "6px",
+ minWidth: "72px",
+});
+
+export const assetSelectorEmpty = style([
+ atoms({
+ borderRadius: "xl",
+ px: "3",
+ py: "4",
+ }),
+ {
+ background: vars.color.tokenSelectBackground,
+ textAlign: "center",
+ },
+]);
+
+export const detailCard = style([
+ atoms({
+ background: "stakeSectionBackground",
+ borderRadius: "xl",
+ display: "flex",
+ flexDirection: "column",
+ gap: "1",
+ px: "4",
+ py: "4",
+ }),
+]);
+
+globalStyle(`${detailCard} > *:last-child`, {
+ borderBottom: 0,
+});
+
+export const infoNote = style([
+ atoms({
+ background: "stakeSectionBackground",
+ borderColor: "backgroundMuted",
+ borderRadius: "base",
+ px: "3",
+ py: "3",
+ }),
+ {
+ borderStyle: "solid",
+ borderWidth: "1px",
+ },
+]);
+
+export const infoNoteError = style({
+ borderColor: vars.color.textDanger,
+});
+
+export const detailsHeader = style({
+ alignItems: "center",
+ display: "flex",
+ gap: "12px",
+ minWidth: 0,
+});
+
+export const metricGrid = style({
+ display: "grid",
+ gap: "8px",
+ gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
+});
+
+export const metricCard = style([
+ atoms({
+ background: "stakeSectionBackground",
+ borderRadius: "base",
+ px: "3",
+ py: "3",
+ }),
+]);
+
+export const badge = style([
+ atoms({
+ borderRadius: "base",
+ px: "2",
+ }),
+ {
+ background: `color-mix(in srgb, ${vars.color.text} 8%, transparent)`,
+ },
+]);
+
+export const errorText = style({
+ color: vars.color.textDanger,
+});
+
+export const executionStep = style({
+ minHeight: 52,
+});
+
+export const executionError = style([infoNote, infoNoteError]);
+
+export const flowBackButton = style({
+ alignItems: "center",
+ alignSelf: "flex-start",
+ background: "transparent",
+ border: 0,
+ cursor: "pointer",
+ display: "flex",
+ justifyContent: "flex-start",
+ padding: 0,
+});
+
+export const ltvGauge = style([
+ atoms({
+ display: "flex",
+ flexDirection: "column",
+ gap: "2",
+ }),
+]);
+
+export const ltvGaugeTrack = style({
+ background: `linear-gradient(90deg, #25a56a 0%, #f6b500 52%, ${vars.color.textDanger} 100%)`,
+ borderRadius: vars.borderRadius.baseContract.full,
+ height: "10px",
+ overflow: "hidden",
+ position: "relative",
+});
+
+export const ltvGaugeMarker = style({
+ background: vars.color.text,
+ border: `2px solid ${vars.color.background}`,
+ borderRadius: vars.borderRadius.baseContract.full,
+ height: "14px",
+ position: "absolute",
+ top: "50%",
+ transform: "translate(-50%, -50%)",
+ width: "14px",
+});
+
+export const ltvGaugeThreshold = style({
+ background: vars.color.background,
+ height: "100%",
+ position: "absolute",
+ top: 0,
+ transform: "translateX(-50%)",
+ width: "2px",
+});
+
+export const ltvGaugeLabels = style({
+ alignItems: "center",
+ display: "flex",
+ justifyContent: "space-between",
+});
+
+export const healthValue = style({
+ color: "#25a56a",
+});
+
+export const healthValueWarning = style({
+ color: "#f6b500",
+});
+
+export const healthValueDanger = style({
+ color: vars.color.textDanger,
+});
+
+export const collateralList = style([
+ atoms({
+ display: "flex",
+ flexDirection: "column",
+ gap: "2",
+ }),
+]);
+
+export const collateralListButton = style({
+ alignItems: "center",
+ background: "transparent",
+ border: 0,
+ color: vars.color.text,
+ cursor: "pointer",
+ display: "flex",
+ font: "inherit",
+ gap: "8px",
+ padding: 0,
+ textAlign: "left",
+ width: "100%",
+});
+
+export const collateralRow = style([
+ atoms({
+ background: "stakeSectionBackground",
+ borderRadius: "base",
+ px: "3",
+ py: "3",
+ }),
+ {
+ alignItems: "center",
+ display: "grid",
+ gap: "12px",
+ gridTemplateColumns: "minmax(0, 1fr) minmax(0, auto) auto",
+ },
+]);
+
+export const switchButton = style({
+ alignItems: "center",
+ background: vars.color.backgroundMuted,
+ border: 0,
+ borderRadius: vars.borderRadius.baseContract.full,
+ cursor: "pointer",
+ display: "inline-flex",
+ height: "20px",
+ padding: "2px",
+ width: "36px",
+});
+
+export const switchButtonChecked = style({
+ background: "#25a56a",
+});
+
+export const switchThumb = style({
+ background: vars.color.text,
+ borderRadius: vars.borderRadius.baseContract.full,
+ height: "16px",
+ transform: "translateX(0)",
+ transition: "transform 150ms ease",
+ width: "16px",
+});
+
+export const switchThumbChecked = style({
+ transform: "translateX(16px)",
+});
+
+export const formCard = style([
+ atoms({
+ background: "stakeSectionBackground",
+ borderRadius: "xl",
+ display: "flex",
+ flexDirection: "column",
+ gap: "3",
+ px: "4",
+ py: "4",
+ }),
+]);
+
+export const checkboxRow = style({
+ alignItems: "center",
+ display: "flex",
+ gap: "10px",
+ justifyContent: "space-between",
+});
+
+export const checkbox = style({
+ height: "18px",
+ width: "18px",
+});
+
+export const actionCard = style([
+ atoms({
+ background: "stakeSectionBackground",
+ borderRadius: "xl",
+ display: "flex",
+ gap: "3",
+ px: "4",
+ py: "4",
+ }),
+ {
+ alignItems: "center",
+ justifyContent: "space-between",
+ },
+]);
diff --git a/packages/widget/src/pages-dashboard/borrow/use-borrow-dashboard.ts b/packages/widget/src/pages-dashboard/borrow/use-borrow-dashboard.ts
new file mode 100644
index 00000000..0d817ee3
--- /dev/null
+++ b/packages/widget/src/pages-dashboard/borrow/use-borrow-dashboard.ts
@@ -0,0 +1,74 @@
+import { useAtom } from "@effect/atom-react";
+import type BigNumber from "bignumber.js";
+import {
+ BorrowDashboardKey,
+ borrowActionFormAtom,
+ borrowDashboardAtom,
+} from "../../borrow";
+import { useTokenBalancesScan } from "../../hooks/api/use-token-balances-scan";
+import { useBorrowConnectedWalletBridge } from "./connected-wallet";
+
+export const useBorrowDashboard = () => {
+ const walletBridge = useBorrowConnectedWalletBridge();
+ const wallet = walletBridge.wallet;
+ const tokenBalances = useTokenBalancesScan();
+ const [view, dispatchFormAction] = useAtom(
+ borrowDashboardAtom(
+ new BorrowDashboardKey({
+ network: wallet.network,
+ scopeId: `${wallet.currentAccount.address}:${wallet.network}`,
+ tokenBalances: tokenBalances.data ?? [],
+ walletAddress: wallet.currentAccount.address,
+ })
+ )
+ );
+ const [, dispatchActionForm] = useAtom(borrowActionFormAtom);
+
+ return {
+ borrowAmount: view.borrowAmount,
+ collateralAmount: view.collateralAmount,
+ integrationsResult: view.integrationsResult,
+ isActionReady: view.isActionReady,
+ markets: view.markets,
+ marketsResult: view.marketsResult,
+ preparedReviewState: view.preparedReviewState,
+ projection: view.projection,
+ selectedCollateralBalance: view.selectedCollateralBalance,
+ selectedCollateralToken: view.selectedCollateralToken,
+ selectedCollateralTokenAddress: view.selectedCollateralTokenAddress,
+ selectedIntegration: view.selectedIntegration,
+ selectedMarket: view.selectedMarket,
+ selectedMarketId: view.selectedMarketId,
+ setBorrowAmount: (amount: BigNumber | number | string) =>
+ dispatchFormAction({
+ amount,
+ type: "borrowAmount/set",
+ }),
+ setCollateralAmount: (amount: BigNumber | number | string) =>
+ dispatchFormAction({
+ amount,
+ type: "collateralAmount/set",
+ }),
+ setSelectedCollateralTokenAddress: (tokenAddress: string | null) =>
+ dispatchFormAction({
+ tokenAddress,
+ type: "collateralToken/select",
+ }),
+ setSelectedMarketId: (marketId: string) =>
+ dispatchFormAction({
+ marketId,
+ type: "market/select",
+ }),
+ stageReviewState: () => {
+ if (view.preparedReviewState) {
+ dispatchActionForm({
+ reviewState: view.preparedReviewState,
+ type: "prepareReview",
+ });
+ }
+ },
+ tokenBalances,
+ validation: view.validation,
+ walletBalances: view.walletBalances,
+ };
+};
diff --git a/packages/widget/src/pages-dashboard/borrow/use-borrow-execution.ts b/packages/widget/src/pages-dashboard/borrow/use-borrow-execution.ts
new file mode 100644
index 00000000..13e15bc7
--- /dev/null
+++ b/packages/widget/src/pages-dashboard/borrow/use-borrow-execution.ts
@@ -0,0 +1,99 @@
+import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
+import { Cause, Option } from "effect";
+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
+import { useMemo } from "react";
+import type { Action } from "../../borrow";
+import {
+ BorrowExecutionKey,
+ type BorrowExecutionMachineState,
+ type BorrowExecutionPhase,
+ type BorrowExecutionResult,
+ type BorrowExecutionStep,
+ type BorrowSubmittedTransaction,
+ type BorrowTransactionExecutionError,
+ borrowExecutionAtom,
+ getBorrowExecutionAction,
+ getBorrowExecutionPhase,
+ getBorrowExecutionResult,
+ getBorrowExecutionSteps,
+ getBorrowExecutionSubmissions,
+ isBorrowExecutionDone,
+ isBorrowTransactionExecutionError,
+} from "../../borrow";
+
+type BorrowExecutionState = {
+ readonly action: Action | null;
+ readonly completionResult: BorrowExecutionResult | null;
+ readonly currentTransaction: Action["transactions"][number] | null;
+ readonly currentTransactionIndex: number | null;
+ readonly error: BorrowTransactionExecutionError | null;
+ readonly isDone: boolean;
+ readonly isRunning: boolean;
+ readonly phase: BorrowExecutionPhase;
+ readonly result: AsyncResult.AsyncResult<
+ BorrowExecutionMachineState,
+ unknown
+ >;
+ readonly retry: () => void;
+ readonly steps: ReadonlyArray;
+ readonly submissions: ReadonlyArray;
+};
+
+const getExecutionError = (
+ result: AsyncResult.AsyncResult
+) => {
+ if (!AsyncResult.isFailure(result)) {
+ return null;
+ }
+
+ const error = Cause.findErrorOption(result.cause);
+
+ if (Option.isNone(error)) {
+ return null;
+ }
+
+ return isBorrowTransactionExecutionError(error.value) ? error.value : null;
+};
+
+export const useBorrowExecution = ({
+ action,
+}: {
+ readonly action: Action;
+}): BorrowExecutionState => {
+ const executionAtom = useMemo(
+ () =>
+ borrowExecutionAtom(
+ new BorrowExecutionKey({
+ action,
+ })
+ ),
+ [action]
+ );
+ const result = useAtomValue(executionAtom);
+ const retry = useAtomRefresh(executionAtom);
+ const stateOption = AsyncResult.value(result);
+ const state = Option.isSome(stateOption) ? stateOption.value : null;
+ const error = AsyncResult.isWaiting(result)
+ ? null
+ : getExecutionError(result);
+ const phase = error?.phase ?? getBorrowExecutionPhase(state);
+ const isDone = isBorrowExecutionDone(state);
+
+ return {
+ action: getBorrowExecutionAction(state),
+ completionResult: state && isDone ? getBorrowExecutionResult(state) : null,
+ currentTransaction: state?.transactions[state.currentTxIndex] ?? null,
+ currentTransactionIndex: state?.currentTxIndex ?? null,
+ error,
+ isDone,
+ isRunning: AsyncResult.isWaiting(result) || (!error && !isDone),
+ phase,
+ result,
+ retry,
+ steps: getBorrowExecutionSteps({
+ error,
+ phase,
+ }),
+ submissions: getBorrowExecutionSubmissions(state),
+ };
+};
diff --git a/packages/widget/src/pages-dashboard/borrow/use-borrow-feature-enabled.ts b/packages/widget/src/pages-dashboard/borrow/use-borrow-feature-enabled.ts
new file mode 100644
index 00000000..2f2a6c98
--- /dev/null
+++ b/packages/widget/src/pages-dashboard/borrow/use-borrow-feature-enabled.ts
@@ -0,0 +1,14 @@
+import { useSettings } from "../../providers/settings";
+import type { SettingsContextType } from "../../providers/settings/types";
+
+export const isBorrowFeatureEnabled = ({
+ borrowEnabled,
+ dashboardVariant,
+ yieldGrouping,
+}: Pick<
+ SettingsContextType,
+ "borrowEnabled" | "dashboardVariant" | "yieldGrouping"
+>) => borrowEnabled && !!dashboardVariant && yieldGrouping === "category";
+
+export const useBorrowFeatureEnabled = () =>
+ isBorrowFeatureEnabled(useSettings());
diff --git a/packages/widget/src/pages-dashboard/borrow/use-borrow-positions.ts b/packages/widget/src/pages-dashboard/borrow/use-borrow-positions.ts
new file mode 100644
index 00000000..993cf17b
--- /dev/null
+++ b/packages/widget/src/pages-dashboard/borrow/use-borrow-positions.ts
@@ -0,0 +1,55 @@
+import { useAtomValue } from "@effect/atom-react";
+import {
+ BorrowPositionKey,
+ BorrowPositionsKey,
+ borrowPositionAtom,
+ borrowPositionsAtom,
+} from "../../borrow";
+import {
+ useBorrowConnectedWalletBridge,
+ useBorrowWalletBridge,
+} from "./connected-wallet";
+
+type BorrowPositionsOptions = {
+ readonly enabled?: boolean;
+};
+
+export const useBorrowPositions = ({
+ enabled = true,
+}: BorrowPositionsOptions = {}) => {
+ const walletBridge = useBorrowWalletBridge();
+ const connectedWallet =
+ enabled && walletBridge.status === "connected" ? walletBridge.wallet : null;
+ const positionsResult = useAtomValue(
+ borrowPositionsAtom(
+ new BorrowPositionsKey({
+ address: connectedWallet?.currentAccount.address ?? null,
+ network: connectedWallet?.network ?? null,
+ })
+ )
+ );
+
+ return {
+ positionsResult,
+ walletBridge,
+ };
+};
+
+export const useBorrowPosition = (marketId: string | null | undefined) => {
+ const walletBridge = useBorrowConnectedWalletBridge();
+ const wallet = walletBridge.wallet;
+ const positionResult = useAtomValue(
+ borrowPositionAtom(
+ new BorrowPositionKey({
+ address: wallet.currentAccount.address,
+ marketId: marketId ?? null,
+ network: wallet.network,
+ })
+ )
+ );
+
+ return {
+ positionResult,
+ walletBridge,
+ };
+};
diff --git a/packages/widget/src/pages-dashboard/common/components/back-button.tsx b/packages/widget/src/pages-dashboard/common/components/back-button.tsx
index 55bf273d..c3d28121 100644
--- a/packages/widget/src/pages-dashboard/common/components/back-button.tsx
+++ b/packages/widget/src/pages-dashboard/common/components/back-button.tsx
@@ -15,7 +15,14 @@ const useBackButton = () => {
return useContext(BackButtonContext);
};
-export const BackButton = () => {
+type BackButtonProps = {
+ readonly "aria-label"?: string;
+ readonly "data-rk"?: string;
+ readonly "data-testid"?: string;
+ readonly onClick?: () => void;
+};
+
+export const BackButton = ({ onClick, ...rest }: BackButtonProps) => {
const { onLeftIconPress } = useHeader();
const showBack = useBackButton();
@@ -24,10 +31,12 @@ export const BackButton = () => {
return (
diff --git a/packages/widget/src/pages-dashboard/common/components/tabs/index.tsx b/packages/widget/src/pages-dashboard/common/components/tabs/index.tsx
index e542b4c2..896686b5 100644
--- a/packages/widget/src/pages-dashboard/common/components/tabs/index.tsx
+++ b/packages/widget/src/pages-dashboard/common/components/tabs/index.tsx
@@ -9,6 +9,7 @@ import { useEarnPageContext } from "../../../../pages/details/earn-page/state/ea
import { useSKLocation } from "../../../../providers/location";
import { useSettings } from "../../../../providers/settings";
import { combineRecipeWithVariant } from "../../../../utils/styles";
+import { isBorrowFeatureEnabled } from "../../../borrow/use-borrow-feature-enabled";
import {
divider,
tabsContainer,
@@ -17,10 +18,11 @@ import {
} from "./styles.css";
import { Tab } from "./tab";
-type RouteTab = "earn" | "manage" | "activity";
+type RouteTab = "earn" | "borrow" | "manage" | "activity";
const TABS_MAP = {
earn: "/",
+ borrow: "/borrow",
manage: "/manage",
activity: "/activity",
};
@@ -56,6 +58,7 @@ export const Tabs = () => {
const selectedTab = Match.value(current.pathname).pipe(
Match.when(startsWith("/activity"), () => "activity"),
+ Match.when(startsWith("/borrow"), () => "borrow"),
Match.whenOr(
startsWith("/manage"),
startsWith("/positions"),
@@ -64,8 +67,17 @@ export const Tabs = () => {
Match.orElse(() => "earn")
);
- const { variant, yieldGrouping } = useSettings();
+ const { borrowEnabled, dashboardVariant, variant, yieldGrouping } =
+ useSettings();
const dashboardYieldCategoryGroupingEnabled = yieldGrouping === "category";
+ const borrowFeatureEnabled = isBorrowFeatureEnabled({
+ borrowEnabled,
+ dashboardVariant,
+ yieldGrouping,
+ });
+ const shouldShowTabsGroupDivider =
+ dashboardYieldCategoryGroupingEnabled &&
+ (availableDashboardYieldCategories.length > 0 || borrowFeatureEnabled);
return (
@@ -93,8 +105,15 @@ export const Tabs = () => {
/>
)}
- {dashboardYieldCategoryGroupingEnabled &&
- availableDashboardYieldCategories.length > 0 ? (
+ {borrowFeatureEnabled ? (
+ onRouteTabPress("borrow")}
+ variant="borrow"
+ />
+ ) : null}
+
+ {shouldShowTabsGroupDivider ? (
) : null}
diff --git a/packages/widget/src/pages-dashboard/common/components/tabs/tab.tsx b/packages/widget/src/pages-dashboard/common/components/tabs/tab.tsx
index 37063772..a29258e3 100644
--- a/packages/widget/src/pages-dashboard/common/components/tabs/tab.tsx
+++ b/packages/widget/src/pages-dashboard/common/components/tabs/tab.tsx
@@ -10,7 +10,7 @@ import { tab, tabBorder, tabContainer, tabText } from "./styles.css";
type Props = {
isSelected: boolean;
onTabPress: () => void;
- variant: "earn" | "stake" | "defi" | "rwa" | "manage" | "activity";
+ variant: "earn" | "stake" | "defi" | "rwa" | "borrow" | "manage" | "activity";
};
export const Tab = ({ isSelected, variant, onTabPress }: Props) => {
diff --git a/packages/widget/src/pages-dashboard/overview/earn-details/components/details-section.tsx b/packages/widget/src/pages-dashboard/overview/earn-details/components/details-section.tsx
index edd86178..597c902b 100644
--- a/packages/widget/src/pages-dashboard/overview/earn-details/components/details-section.tsx
+++ b/packages/widget/src/pages-dashboard/overview/earn-details/components/details-section.tsx
@@ -43,9 +43,19 @@ export const DetailsSection = ({
export const DetailRow = ({ label, value }: EarnDetailRow) => (
- {label}
+
+ {label}
+
{typeof value === "string" ? (
-
+
{value}
) : (
diff --git a/packages/widget/src/pages-dashboard/overview/earn-details/components/provider-selection-card.tsx b/packages/widget/src/pages-dashboard/overview/earn-details/components/provider-selection-card.tsx
index dc54b599..2273e3a6 100644
--- a/packages/widget/src/pages-dashboard/overview/earn-details/components/provider-selection-card.tsx
+++ b/packages/widget/src/pages-dashboard/overview/earn-details/components/provider-selection-card.tsx
@@ -7,12 +7,12 @@ import { XIcon } from "../../../../components/atoms/icons/x-icon";
import { Image } from "../../../../components/atoms/image";
import { Text } from "../../../../components/atoms/typography/text";
import { SelectValidator } from "../../../../components/molecules/select-validator";
+import type { Validator } from "../../../../domain/types/validators";
import {
isYieldActionArgRequired,
isYieldValidatorSelectionRequired,
type Yield,
} from "../../../../domain/types/yields";
-import type { ValidatorDto } from "../../../../generated/api/yield";
import { useSelectValidator } from "../../../../pages/details/earn-page/components/select-validator-section/use-select-validator";
import { useEarnPageContext } from "../../../../pages/details/earn-page/state/earn-page-context";
import {
@@ -32,13 +32,13 @@ type ProviderDetailsItem = NonNullable<
type ProviderCardItem = {
key: string;
- commission: ProviderDetailsItem["commission"] | ValidatorDto["commission"];
+ commission: ProviderDetailsItem["commission"] | Validator["commission"];
logo: string | undefined;
name: string;
preferred: boolean | undefined;
- stakedBalance: ProviderDetailsItem["stakedBalance"] | ValidatorDto["tvlRaw"];
- status: ProviderDetailsItem["status"] | ValidatorDto["status"];
- validator: ValidatorDto | undefined;
+ stakedBalance: ProviderDetailsItem["stakedBalance"] | Validator["tvlRaw"];
+ status: ProviderDetailsItem["status"] | Validator["status"];
+ validator: Validator | undefined;
website: string | undefined;
};
@@ -89,7 +89,7 @@ export const ProviderSelectionCard = () => {
tokenSymbol={yieldDto.token.symbol}
/>
}
- selectedValidators={new Set(selectedValidatorsArr.map((v) => v.address))}
+ selectedValidators={new Set(selectedValidatorsArr.map((v) => v.key))}
multiSelect={multiSelect}
selectedStake={yieldDto}
onItemClick={onItemClick}
@@ -115,7 +115,7 @@ const ProviderCardsTrigger = ({
}: {
items: ProviderCardItem[];
multiSelect: boolean;
- onRemoveValidator: (item: ValidatorDto) => void;
+ onRemoveValidator: (item: Validator) => void;
tokenSymbol: string;
}) => {
const { t } = useTranslation();
@@ -227,7 +227,7 @@ const getProviderCardItems = ({
yieldDto,
}: {
providerDetailsArr: ProviderDetailsItem[];
- selectedValidatorsArr: ValidatorDto[];
+ selectedValidatorsArr: Validator[];
yieldDto: Yield;
}): ProviderCardItem[] => {
if (selectedValidatorsArr.length) {
@@ -236,7 +236,7 @@ const getProviderCardItems = ({
const name = validator.name ?? validator.address;
return {
- key: validator.address,
+ key: validator.key,
commission: providerDetails?.commission ?? validator.commission,
logo: providerDetails?.logo ?? validator.logoURI,
name: providerDetails?.name ?? name,
diff --git a/packages/widget/src/pages-dashboard/overview/earn-details/styles.css.ts b/packages/widget/src/pages-dashboard/overview/earn-details/styles.css.ts
index 531c281f..64c8778b 100644
--- a/packages/widget/src/pages-dashboard/overview/earn-details/styles.css.ts
+++ b/packages/widget/src/pages-dashboard/overview/earn-details/styles.css.ts
@@ -361,17 +361,21 @@ export const axisLabel = style({
export const detailRow = style([
atoms({
- alignItems: "center",
display: "flex",
justifyContent: "space-between",
gap: "4",
py: "2",
}),
{
+ alignItems: "baseline",
borderBottom: `1px solid ${vars.color.backgroundMuted}`,
},
]);
+globalStyle(`${detailRow}:last-child`, {
+ borderBottom: "none",
+});
+
export const addressBox = style([
atoms({
background: "stakeSectionBackground",
@@ -396,7 +400,12 @@ export const addressValue = style([
}),
]);
+export const detailRowLabel = style({
+ lineHeight: "20px",
+});
+
export const valueText = style({
+ lineHeight: "20px",
minWidth: 0,
overflow: "hidden",
textAlign: "right",
diff --git a/packages/widget/src/pages-dashboard/overview/positions/components/positions-list-item.tsx b/packages/widget/src/pages-dashboard/overview/positions/components/positions-list-item.tsx
index 1dcaa2da..b747042f 100644
--- a/packages/widget/src/pages-dashboard/overview/positions/components/positions-list-item.tsx
+++ b/packages/widget/src/pages-dashboard/overview/positions/components/positions-list-item.tsx
@@ -1,6 +1,7 @@
import { List, Maybe } from "purify-ts";
import { memo } from "react";
import { useTranslation } from "react-i18next";
+import type { Position as BorrowPosition } from "../../../../borrow";
import { Box } from "../../../../components/atoms/box";
import { ContentLoaderSquare } from "../../../../components/atoms/content-loader";
import { SKLink } from "../../../../components/atoms/link";
@@ -17,230 +18,333 @@ import {
positionName,
rewardRateText,
} from "../../../../pages/details/positions-page/components/styles.css";
-import type { usePositions } from "../../../../pages/details/positions-page/hooks/use-positions";
+import { formatNumber } from "../../../../utils";
+import { formatCompactUsd } from "../../../../utils/formatters";
+import { borrowTokenToTokenDto } from "../../../borrow/position-details-model";
+import type { UnifiedPositionItem } from "../hooks/use-grouped-positions";
import { usePositionListItem } from "../hooks/use-position-list-item";
import { listItemContainer, viaText } from "../styles.css";
-export const PositionsListItem = memo(
- ({
- item,
- }: {
- item: ReturnType["positionsData"]["data"][number];
- }) => {
- const { t } = useTranslation();
-
- const {
- integrationData,
- providersDetails,
- inactiveValidator,
- rewardRateAverage,
- totalAmountFormatted,
- totalAmountPriceFormatted,
- } = usePositionListItem(item);
-
- return (
-
-
- {integrationData.mapOrDefault(
- (d) => (
-
+const BorrowPositionsListItem = ({
+ position,
+}: {
+ readonly position: BorrowPosition;
+}) => {
+ const { t } = useTranslation();
+ const meta = position.getMeta();
+ const currentLtv = position.getCurrentLtv();
+ const headerToken = borrowTokenToTokenDto({
+ network: position.market.network,
+ token: position.debtBalance
+ ? position.market.loanToken
+ : (position.market.collateralTokens[0]?.token ??
+ position.market.loanToken),
+ });
+ const balanceText = position.debtBalance
+ ? `${formatNumber(position.debtBalance.balance, 6)} ${
+ position.debtBalance.tokenSymbol
+ }`
+ : formatCompactUsd(position.getTotalSuppliedUsd().toString());
+ const subValue = position.debtBalance
+ ? `${formatPercent(currentLtv)} ${t(
+ "dashboard.borrow.position_details.ltv"
+ )} · ${formatCompactUsd(
+ position.debtBalance.balanceUsd.toString()
+ )} ${t("dashboard.borrow.position_details.debt").toLowerCase()}`
+ : t("dashboard.borrow.position_details.supplied");
+
+ return (
+
+
+
+
+
+
+
+
+
+ {meta.name}
+
+
+ {t("dashboard.details.tabs.borrow")}
+
+
+
+
+
+ {t("positions.via", {
+ providerName: position.integration.name,
+ count: 1,
+ })}
+
+
+
+
+
+
+ {formatPercent(position.getNetApy())}
+
+
+
+ {balanceText}
+
+ {subValue}
+
+
+
+
+
+
+
+ );
+};
+
+const formatPercent = (value: number | null | undefined) =>
+ value == null || !Number.isFinite(value)
+ ? "-"
+ : `${formatNumber(value * 100, 2)}%`;
+
+const EarnPositionsListItem = ({
+ item,
+}: {
+ item: Extract["position"];
+}) => {
+ const { t } = useTranslation();
+
+ const {
+ integrationData,
+ providersDetails,
+ inactiveValidator,
+ rewardRateAverage,
+ totalAmountFormatted,
+ totalAmountPriceFormatted,
+ } = usePositionListItem(item);
+
+ return (
+
+
+ {integrationData.mapOrDefault(
+ (d) => (
+
+
+ {/* Yield */}
- {/* Yield */}
-
- {item.token.mapOrDefault(
- (val) => (
-
- ),
-
-
-
- )}
-
-
-
- {d.metadata.name}
-
- {item.yieldLabelDto
- .map((label) => (
-
- | undefined
- )}
- >
-
-
- {t(
- `position_details.labels.${label.type as PositionDetailsLabelType}.label`
- )}
-
-
-
- ))
- .extractNullable()}
+ {item.token.mapOrDefault(
+ (val) => (
+
+ ),
+
+
+
+ )}
- {(item.actionRequired ||
- item.hasPendingClaimRewards ||
- !!inactiveValidator) && (
-
-
- {t(
- item.actionRequired
- ? "positions.action_required"
- : inactiveValidator
- ? inactiveValidator === "jailed"
- ? "details.validators_jailed"
- : "details.validators_inactive"
- : "positions.claim_rewards"
- )}
-
-
- )}
-
+
+
+ {d.metadata.name}
- {providersDetails
- .chain((val) =>
- List.head(val).map((p) => (
-
- {t("positions.via", {
- providerName: p.name ?? p.address,
- count: Math.max(val.length - 1, 1),
+ {item.yieldLabelDto
+ .map((label) => (
+ | undefined
+ )}
+ >
+
- ))
- )
+ >
+
+ {t(
+ `position_details.labels.${label.type as PositionDetailsLabelType}.label`
+ )}
+
+
+
+ ))
.extractNullable()}
-
-
-
- {/* Reward rate + staked */}
-
- {rewardRateAverage
- .map((v) => {v})
- .extractNullable()}
- {Maybe.fromRecord({
- amount: totalAmountFormatted,
- token: item.token,
- })
- .map((val) => (
+ {(item.actionRequired ||
+ item.hasPendingClaimRewards ||
+ !!inactiveValidator) && (
-
- {val.amount} {val.token.symbol}
+
+ {t(
+ item.actionRequired
+ ? "positions.action_required"
+ : inactiveValidator
+ ? inactiveValidator === "jailed"
+ ? "details.validators_jailed"
+ : "details.validators_inactive"
+ : "positions.claim_rewards"
+ )}
-
- {totalAmountPriceFormatted
- .map((price) => (
-
- ≈ ${price}
-
- ))
- .extractNullable()}
- ))
- .orDefault(-)}
+ )}
+
+
+ {providersDetails
+ .chain((val) =>
+ List.head(val).map((p) => (
+
+ {t("positions.via", {
+ providerName: p.name ?? p.address,
+ count: Math.max(val.length - 1, 1),
+ })}
+
+ ))
+ )
+ .extractNullable()}
- {item.pointsRewardTokenBalances.length > 0 && (
-
- {item.pointsRewardTokenBalances.map((val, i) => (
+ {/* Reward rate + staked */}
+
+ {rewardRateAverage
+ .map((v) => {v})
+ .extractNullable()}
+
+ {Maybe.fromRecord({
+ amount: totalAmountFormatted,
+ token: item.token,
+ })
+ .map((val) => (
-
-
-
- {val.amount}
+
+ {val.amount} {val.token.symbol}
+
+ {totalAmountPriceFormatted
+ .map((price) => (
+
+ ≈ ${price}
+
+ ))
+ .extractNullable()}
- ))}
-
- )}
-
- ),
-
- )}
-
-
- );
- }
+ ))
+ .orDefault(-)}
+
+
+
+ {item.pointsRewardTokenBalances.length > 0 && (
+
+ {item.pointsRewardTokenBalances.map((val, i) => (
+
+
+
+
+ {val.amount}
+
+
+ ))}
+
+ )}
+
+ ),
+
+ )}
+
+
+ );
+};
+
+export const PositionsListItem = memo(
+ ({ item }: { item: UnifiedPositionItem }) =>
+ item.kind === "borrow" ? (
+
+ ) : (
+
+ )
);
diff --git a/packages/widget/src/pages-dashboard/overview/positions/components/positions-section-header.tsx b/packages/widget/src/pages-dashboard/overview/positions/components/positions-section-header.tsx
index af2a8aa8..377861b4 100644
--- a/packages/widget/src/pages-dashboard/overview/positions/components/positions-section-header.tsx
+++ b/packages/widget/src/pages-dashboard/overview/positions/components/positions-section-header.tsx
@@ -8,7 +8,7 @@ export const PositionsSectionHeader = ({
category,
count,
}: {
- category: DashboardYieldCategory;
+ category: DashboardYieldCategory | "borrow";
count: number;
}) => {
const { t } = useTranslation();
diff --git a/packages/widget/src/pages-dashboard/overview/positions/hooks/use-grouped-positions.ts b/packages/widget/src/pages-dashboard/overview/positions/hooks/use-grouped-positions.ts
index bf4fce8f..b52bfb5e 100644
--- a/packages/widget/src/pages-dashboard/overview/positions/hooks/use-grouped-positions.ts
+++ b/packages/widget/src/pages-dashboard/overview/positions/hooks/use-grouped-positions.ts
@@ -1,4 +1,5 @@
import { useQueries } from "@tanstack/react-query";
+import type { Position as BorrowPosition } from "../../../../borrow";
import {
type DashboardYieldCategory,
getDashboardYieldCategory,
@@ -14,10 +15,18 @@ type PositionItem = ReturnType<
typeof usePositions
>["positionsData"]["data"][number];
+export type UnifiedPositionItem =
+ | { readonly kind: "borrow"; readonly position: BorrowPosition }
+ | { readonly kind: "earn"; readonly position: PositionItem };
+
type PositionsListRow =
| { kind: "chain-modal" }
- | { kind: "section"; category: DashboardYieldCategory; count: number }
- | { kind: "position"; item: PositionItem };
+ | {
+ kind: "section";
+ category: DashboardYieldCategory | "borrow";
+ count: number;
+ }
+ | { kind: "position"; item: UnifiedPositionItem };
const staleTime = 1000 * 60 * 2;
@@ -26,9 +35,13 @@ const staleTime = 1000 * 60 * 2;
* mirroring how the top navigation tabs are grouped. Positions whose category
* cannot (yet) be resolved are kept ungrouped at the end so nothing is hidden.
*/
-export const useGroupedPositions = (
- positions: PositionItem[]
-): PositionsListRow[] => {
+export const useGroupedPositions = ({
+ borrowPositions,
+ earnPositions,
+}: {
+ readonly borrowPositions: BorrowPosition[];
+ readonly earnPositions: PositionItem[];
+}): PositionsListRow[] => {
const { isLedgerLive } = useSKWallet();
const apiClient = useApiClient();
const queryClient = useSKQueryClient();
@@ -36,7 +49,7 @@ export const useGroupedPositions = (
const dashboardYieldCategoryGroupingEnabled = yieldGrouping === "category";
const integrationIds = dashboardYieldCategoryGroupingEnabled
- ? [...new Set(positions.map((p) => p.integrationId))]
+ ? [...new Set(earnPositions.map((p) => p.integrationId))]
: [];
const categoryQueries = useQueries({
@@ -52,7 +65,14 @@ export const useGroupedPositions = (
if (!dashboardYieldCategoryGroupingEnabled) {
return [
{ kind: "chain-modal" },
- ...positions.map((item) => ({ kind: "position" as const, item })),
+ ...earnPositions.map((position) => ({
+ kind: "position" as const,
+ item: { kind: "earn" as const, position },
+ })),
+ ...borrowPositions.map((position) => ({
+ kind: "position" as const,
+ item: { kind: "borrow" as const, position },
+ })),
];
}
@@ -68,7 +88,7 @@ export const useGroupedPositions = (
const grouped = new Map();
const ungrouped: PositionItem[] = [];
- for (const item of positions) {
+ for (const item of earnPositions) {
const category = categoryByIntegrationId.get(item.integrationId);
if (category) {
@@ -87,10 +107,34 @@ export const useGroupedPositions = (
if (!items?.length) continue;
rows.push({ kind: "section", category, count: items.length });
- for (const item of items) rows.push({ kind: "position", item });
+ for (const item of items) {
+ rows.push({
+ kind: "position",
+ item: { kind: "earn", position: item },
+ });
+ }
}
- for (const item of ungrouped) rows.push({ kind: "position", item });
+ for (const item of ungrouped) {
+ rows.push({
+ kind: "position",
+ item: { kind: "earn", position: item },
+ });
+ }
+
+ if (borrowPositions.length > 0) {
+ rows.push({
+ category: "borrow",
+ count: borrowPositions.length,
+ kind: "section",
+ });
+ for (const position of borrowPositions) {
+ rows.push({
+ kind: "position",
+ item: { kind: "borrow", position },
+ });
+ }
+ }
return rows;
};
diff --git a/packages/widget/src/pages-dashboard/overview/positions/model.ts b/packages/widget/src/pages-dashboard/overview/positions/model.ts
new file mode 100644
index 00000000..205df5e6
--- /dev/null
+++ b/packages/widget/src/pages-dashboard/overview/positions/model.ts
@@ -0,0 +1,69 @@
+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
+import type { Position as BorrowPosition } from "../../../borrow";
+
+type UnifiedManagePositionsStateInput = {
+ readonly borrowPositionsResult: AsyncResult.AsyncResult<
+ ReadonlyArray,
+ unknown
+ >;
+ readonly borrowWalletIsConnected: boolean;
+ readonly earnIsError: boolean;
+ readonly earnIsFetching: boolean;
+ readonly earnIsLoading: boolean;
+ readonly earnPositionsCount: number;
+ readonly isConnected: boolean;
+ readonly isConnecting: boolean;
+ readonly showEarnPositions: boolean;
+};
+
+type UnifiedManagePositionsState = {
+ readonly hasOnlyErrors: boolean;
+ readonly hasPartialError: boolean;
+ readonly isAnyPositionsLoading: boolean;
+ readonly showConnectWallet: boolean;
+ readonly showEmptyPositions: boolean;
+ readonly showPositionsList: boolean;
+ readonly totalPositionsCount: number;
+};
+
+export const getUnifiedManagePositionsState = ({
+ borrowPositionsResult,
+ borrowWalletIsConnected,
+ earnIsError,
+ earnIsFetching,
+ earnIsLoading,
+ earnPositionsCount,
+ isConnected,
+ isConnecting,
+ showEarnPositions,
+}: UnifiedManagePositionsStateInput): UnifiedManagePositionsState => {
+ const borrowPositions = AsyncResult.getOrElse(
+ borrowPositionsResult,
+ () => []
+ );
+ const borrowPositionsCount = borrowPositions.length;
+ const borrowIsLoading =
+ borrowWalletIsConnected &&
+ (AsyncResult.isInitial(borrowPositionsResult) ||
+ AsyncResult.isWaiting(borrowPositionsResult));
+ const borrowIsError = AsyncResult.isFailure(borrowPositionsResult);
+ const totalPositionsCount = earnPositionsCount + borrowPositionsCount;
+ const isAnyPositionsLoading =
+ (earnIsLoading && earnIsFetching) || borrowIsLoading;
+ const hasOnlyErrors =
+ earnIsError && borrowIsError && totalPositionsCount === 0;
+ const hasPartialError =
+ totalPositionsCount > 0 && (earnIsError || borrowIsError);
+ const showPositionsList = showEarnPositions || borrowPositionsCount > 0;
+
+ return {
+ hasOnlyErrors,
+ hasPartialError,
+ isAnyPositionsLoading,
+ showConnectWallet: !isConnected && !isConnecting,
+ showEmptyPositions:
+ isConnected && !isAnyPositionsLoading && totalPositionsCount === 0,
+ showPositionsList,
+ totalPositionsCount,
+ };
+};
diff --git a/packages/widget/src/pages-dashboard/overview/positions/positions.page.tsx b/packages/widget/src/pages-dashboard/overview/positions/positions.page.tsx
index f89560ff..c10c1bfa 100644
--- a/packages/widget/src/pages-dashboard/overview/positions/positions.page.tsx
+++ b/packages/widget/src/pages-dashboard/overview/positions/positions.page.tsx
@@ -1,3 +1,4 @@
+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Box } from "../../../components/atoms/box";
@@ -10,28 +11,53 @@ import { usePositions } from "../../../pages/details/positions-page/hooks/use-po
import { useSettings } from "../../../providers/settings";
import { useSKWallet } from "../../../providers/sk-wallet";
import { combineRecipeWithVariant } from "../../../utils/styles";
+import { useBorrowPositions } from "../../borrow/use-borrow-positions";
import { PositionsListItem } from "./components/positions-list-item";
import { PositionsSectionHeader } from "./components/positions-section-header";
import { useGroupedPositions } from "./hooks/use-grouped-positions";
+import { getUnifiedManagePositionsState } from "./model";
import { container, positionsTitle } from "./styles.css";
export const PositionsPage = () => {
useTrackPage("positions");
const { positionsData, showPositions } = usePositions();
-
- const listData = useGroupedPositions(positionsData.data);
-
+ const settings = useSettings();
+ const borrowManageEnabled =
+ settings.borrowEnabled && !!settings.dashboardVariant;
+ const borrowPositions = useBorrowPositions({ enabled: borrowManageEnabled });
+ const borrowPositionItems = AsyncResult.getOrElse(
+ borrowPositions.positionsResult,
+ () => []
+ );
const { isConnected, isConnecting } = useSKWallet();
+ const manageState = getUnifiedManagePositionsState({
+ borrowPositionsResult: borrowPositions.positionsResult,
+ borrowWalletIsConnected:
+ borrowManageEnabled &&
+ borrowPositions.walletBridge.status === "connected",
+ earnIsError: positionsData.isError,
+ earnIsFetching: positionsData.isFetching,
+ earnIsLoading: positionsData.isLoading,
+ earnPositionsCount: positionsData.data.length,
+ isConnected,
+ isConnecting,
+ showEarnPositions: showPositions,
+ });
+
+ const listData = useGroupedPositions({
+ borrowPositions: borrowPositionItems,
+ earnPositions: positionsData.data,
+ });
const { t } = useTranslation();
- const { variant } = useSettings();
+ const { variant } = settings;
const content = useMemo(() => {
- if (positionsData.isLoading && positionsData.isFetching && isConnected) {
+ if (manageState.isAnyPositionsLoading && isConnected) {
return ;
}
- if (!isConnected && !isConnecting) {
+ if (manageState.showConnectWallet) {
return (
{
);
}
- if (positionsData.isError && !positionsData.data.length) {
+ if (manageState.hasOnlyErrors) {
return ;
}
return null;
- }, [
- isConnected,
- isConnecting,
- positionsData.data.length,
- positionsData.isError,
- positionsData.isFetching,
- positionsData.isLoading,
- t,
- ]);
+ }, [isConnected, manageState, t]);
return (
{content}
- {showPositions && (
+ {manageState.showPositionsList && (
<>
{
{t("dashboard.details.my_positions")}
- {!!positionsData.data.length && (
+ {!!manageState.totalPositionsCount && (
{t("dashboard.details.positions_active", {
- count: positionsData.data.length,
+ count: manageState.totalPositionsCount,
})}
)}
+ {manageState.hasPartialError && (
+
+
+ {t("dashboard.details.positions_partial_error")}
+
+
+ )}
+
60}
data={listData}
@@ -112,7 +138,7 @@ export const PositionsPage = () => {
}
/>
- {isConnected && !positionsData.data.length && (
+ {manageState.showEmptyPositions && (
diff --git a/packages/widget/src/pages-dashboard/overview/summary/index.tsx b/packages/widget/src/pages-dashboard/overview/summary/index.tsx
index b6dafa71..dc65fb16 100644
--- a/packages/widget/src/pages-dashboard/overview/summary/index.tsx
+++ b/packages/widget/src/pages-dashboard/overview/summary/index.tsx
@@ -1,3 +1,5 @@
+import BigNumber from "bignumber.js";
+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
import { useTranslation } from "react-i18next";
import { Box } from "../../../components/atoms/box";
import { SummaryItem } from "../../../components/molecules/summary-item";
@@ -5,10 +7,81 @@ import { summaryContainer } from "../../../components/molecules/summary-item/ind
import { useSummary } from "../../../hooks/use-summary";
import { useSettings } from "../../../providers/settings";
import { combineRecipeWithVariant } from "../../../utils/styles";
+import { useBorrowFeatureEnabled } from "../../borrow/use-borrow-feature-enabled";
+import { useBorrowPositions } from "../../borrow/use-borrow-positions";
export const Summary = () => {
const { allPositionsQuery, averageApyQuery, availableBalanceSumQuery } =
useSummary();
+ const borrowFeatureEnabled = useBorrowFeatureEnabled();
+ const borrowPositions = useBorrowPositions({ enabled: borrowFeatureEnabled });
+ const borrowPositionItems = AsyncResult.getOrElse(
+ borrowPositions.positionsResult,
+ () => []
+ );
+ const borrowPositionsAreLoading =
+ borrowFeatureEnabled &&
+ borrowPositions.walletBridge.status === "connected" &&
+ (AsyncResult.isInitial(borrowPositions.positionsResult) ||
+ AsyncResult.isWaiting(borrowPositions.positionsResult));
+ const hasBorrowPositions =
+ borrowFeatureEnabled && borrowPositionItems.length > 0;
+ const borrowTotalSupplied = borrowPositionItems.reduce(
+ (acc, position) => acc.plus(position.getTotalSuppliedUsd()),
+ new BigNumber(0)
+ );
+ const borrowNetWorth = borrowPositionItems.reduce(
+ (acc, position) => acc.plus(position.getNetWorthUsd()),
+ new BigNumber(0)
+ );
+ const borrowApySummary = borrowPositionItems.reduce(
+ (acc, position) => {
+ const netWorth = position.getNetWorthUsd();
+
+ if (netWorth <= 0) {
+ return acc;
+ }
+
+ return {
+ totalValue: acc.totalValue.plus(netWorth),
+ weightedApy: acc.weightedApy.plus(
+ position.getNetApy() * 100 * netWorth
+ ),
+ };
+ },
+ {
+ totalValue: new BigNumber(0),
+ weightedApy: new BigNumber(0),
+ }
+ );
+ const totalPositionsValue = hasBorrowPositions
+ ? (allPositionsQuery.data?.allPositionsSum ?? new BigNumber(0)).plus(
+ borrowTotalSupplied
+ )
+ : allPositionsQuery.data?.allPositionsSum;
+ const averageApyValue = (() => {
+ if (!hasBorrowPositions) {
+ return averageApyQuery.data;
+ }
+
+ const earnPositionsValue =
+ allPositionsQuery.data?.allPositionsSum ?? new BigNumber(0);
+ const totalValue = earnPositionsValue.plus(borrowApySummary.totalValue);
+
+ if (!totalValue.gt(0)) {
+ return new BigNumber(0);
+ }
+
+ const earnWeightedApy =
+ averageApyQuery.data?.times(earnPositionsValue) ?? new BigNumber(0);
+
+ return earnWeightedApy.plus(borrowApySummary.weightedApy).div(totalValue);
+ })();
+ const availableOrNetWorthValue = hasBorrowPositions
+ ? (allPositionsQuery.data?.allPositionsSum ?? new BigNumber(0)).plus(
+ borrowNetWorth
+ )
+ : availableBalanceSumQuery.data;
const { t } = useTranslation();
@@ -20,23 +93,35 @@ export const Summary = () => {
>
);
diff --git a/packages/widget/src/pages-dashboard/position-details/components/position-details-stake-actions.tsx b/packages/widget/src/pages-dashboard/position-details/components/position-details-stake-actions.tsx
index 61f3134c..43f5c074 100644
--- a/packages/widget/src/pages-dashboard/position-details/components/position-details-stake-actions.tsx
+++ b/packages/widget/src/pages-dashboard/position-details/components/position-details-stake-actions.tsx
@@ -1,18 +1,30 @@
-import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Navigate } from "react-router";
import { Box } from "../../../components/atoms/box";
+import { selectTokenButton } from "../../../components/atoms/button/styles.css";
+import { ContentLoaderSquare } from "../../../components/atoms/content-loader";
+import { Dropdown } from "../../../components/atoms/dropdown";
+import { MaxButton } from "../../../components/atoms/max-button";
+import { NumberInput } from "../../../components/atoms/number-input";
import { Spinner } from "../../../components/atoms/spinner";
+import { TokenIcon } from "../../../components/atoms/token-icon";
import { Text } from "../../../components/atoms/typography/text";
+import * as AmountToggle from "../../../components/molecules/amount-toggle";
import { KycGateCard } from "../../../components/molecules/kyc-gate-card";
+import type { TronResourceType } from "../../../domain/types/tron";
+import { getYieldActionArg } from "../../../domain/types/yields";
import { useUnstakeOrPendingActionParams } from "../../../hooks/navigation/use-unstake-or-pending-action-params";
+import { MetaInfo } from "../../../pages/components/meta-info";
import { PageCtaButton } from "../../../pages/components/page-cta";
-import { ExtraArgsSelection } from "../../../pages/details/earn-page/components/extra-args-selection";
-import { Footer } from "../../../pages/details/earn-page/components/footer";
-import { SelectTokenSection } from "../../../pages/details/earn-page/components/select-token-section";
-import { useEarnPageContext } from "../../../pages/details/earn-page/state/earn-page-context";
-import { useEarnPageDispatch } from "../../../pages/details/earn-page/state/earn-page-state-context";
-import { usePositionDetails } from "../../../pages/position-details/hooks/use-position-details";
+import {
+ minMaxContainer,
+ priceTxt,
+ selectTokenBalance,
+ selectTokenSection,
+} from "../../../pages/details/earn-page/components/select-token-section/styles.css";
+import { useSettings } from "../../../providers/settings";
+import { combineRecipeWithVariant } from "../../../utils/styles";
+import { usePositionDetailsStake } from "../hooks/use-position-details-stake";
import { PositionDetailsActionTabs } from "./position-details-action-tabs";
import {
positionDetailsActionsHasContent,
@@ -20,47 +32,270 @@ import {
} from "./position-details-actions";
import { container } from "./styles.css";
-const StakeKycGateSection = () => {
- const { kycGate, kycGateIsChecking, kycProviderName, onKycStatusRefresh } =
- useEarnPageContext();
+type PositionDetailsStakeState = ReturnType;
- if (kycGate.state === "pass" && !kycGateIsChecking) return null;
+const StakeKycGateSection = ({
+ stake,
+}: {
+ stake: PositionDetailsStakeState;
+}) => {
+ if (stake.kycGate.state === "pass" && !stake.kycGateIsChecking) return null;
return (
);
};
-const PositionDetailsStakeStateInitializer = ({
- positionDetails,
+const FixedToken = ({ stake }: { stake: PositionDetailsStakeState }) => {
+ const { variant } = useSettings();
+
+ return stake.selectedToken
+ .map((token) => {
+ return (
+
+
+ {token.symbol}
+
+ );
+ })
+ .extractNullable();
+};
+
+const PositionDetailsStakeTokenSection = ({
+ stake,
}: {
- positionDetails: ReturnType;
+ stake: PositionDetailsStakeState;
}) => {
- const dispatch = useEarnPageDispatch();
- const positionYield = positionDetails.integrationData.extractNullable();
+ const { t } = useTranslation();
+ const { variant } = useSettings();
- useEffect(() => {
- if (!positionYield) {
- return;
- }
+ const isLoading = stake.appLoading;
+ const {
+ submitted,
+ errors: {
+ stakeAmountGreaterThanAvailableAmount,
+ stakeAmountGreaterThanMax,
+ stakeAmountLessThanMin,
+ stakeAmountIsZero,
+ },
+ } = stake.validation;
+ const errorInput =
+ (submitted && stakeAmountIsZero) ||
+ stakeAmountGreaterThanAvailableAmount ||
+ stakeAmountGreaterThanMax ||
+ stakeAmountLessThanMin;
+ const errorBalance = stakeAmountGreaterThanAvailableAmount;
+ const min = stake.stakeMinAmount
+ .map((value) => `${t("shared.min")} ${value} ${stake.symbol}`)
+ .extractNullable();
+ const max = stake.stakeMaxAmount
+ .map((value) => `${t("shared.max")} ${value} ${stake.symbol}`)
+ .extractNullable();
+ const minMax = min || max;
- dispatch({ type: "positionDetails/stake/initialize", data: positionYield });
- }, [dispatch, positionYield]);
+ return isLoading ? (
+
+
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
- return null;
+ {minMax ? (
+
+
+ {min && max ? `${min} / ${max}` : (min ?? max)}
+
+
+ ) : null}
+
+
+
+
+ {stake.formattedPrice}
+
+
+
+
+
+
+ {stake.selectedTokenAvailableAmount
+ .map((value) => (
+
+
+ {({ state }) => (
+
+ {state === "full"
+ ? value.fullFormattedAmount
+ : value.shortFormattedAmount}
+ {value.symbol} {t("shared.available")}
+
+ )}
+
+
+ ))
+ .extractNullable()}
+
+
+
+ {!stake.isStakeTokenSameAsGasToken && (
+
+ )}
+
+
+
+ );
+};
+
+const PositionDetailsStakeFooter = ({
+ stake,
+}: {
+ stake: PositionDetailsStakeState;
+}) => (
+
+);
+
+const PositionDetailsStakeExtraArgs = ({
+ stake,
+}: {
+ stake: PositionDetailsStakeState;
+}) => {
+ const { t } = useTranslation();
+
+ return stake.selectedStake
+ .chainNullable((selectedStake) =>
+ getYieldActionArg(selectedStake, "enter", "tronResource")
+ )
+ .map((tronResources) => {
+ const options = (tronResources.options ?? []).map((value) => ({
+ label: value,
+ value: value as TronResourceType,
+ }));
+ const selectedOption = stake.tronResource
+ .map((value) => ({ value, label: value }))
+ .extract();
+ const isError =
+ stake.validation.submitted && stake.validation.errors.tronResource;
+
+ return (
+
+
+
+ {t("details.tron_resources.label")}
+
+
+
+ stake.onTronResourceSelect(value)}
+ selectedOption={selectedOption}
+ placeholder={t("details.tron_resources.placeholder")}
+ isError={isError}
+ />
+
+ );
+ })
+ .extractNullable();
};
export const PositionDetailsStakeActions = () => {
- const positionDetails = usePositionDetails();
+ const stake = usePositionDetailsStake();
+ const { positionDetails } = stake;
const { plain } = useUnstakeOrPendingActionParams();
- const { cta } = useEarnPageContext();
const { t } = useTranslation();
if (positionDetails.isLoading) {
@@ -117,20 +352,16 @@ export const PositionDetailsStakeActions = () => {
canUnstake={canUnstake}
/>
-
-
-
+
-
+
-
+
-
+
-
+
);
};
diff --git a/packages/widget/src/pages-dashboard/position-details/hooks/use-position-details-stake.ts b/packages/widget/src/pages-dashboard/position-details/hooks/use-position-details-stake.ts
new file mode 100644
index 00000000..a2478468
--- /dev/null
+++ b/packages/widget/src/pages-dashboard/position-details/hooks/use-position-details-stake.ts
@@ -0,0 +1,440 @@
+import { useConnectModal } from "@stakekit/rainbowkit";
+import { useMutation } from "@tanstack/react-query";
+import BigNumber from "bignumber.js";
+import { List, Maybe } from "purify-ts";
+import { useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import type { NumberInputProps } from "../../../components/atoms/number-input";
+import {
+ equalTokens,
+ getTokenPriceInUSD,
+ stakeTokenSameAsGasToken,
+} from "../../../domain";
+import { getKycProviderName } from "../../../domain/types/kyc";
+import type {
+ BalanceDataKey,
+ PositionsData,
+} from "../../../domain/types/positions";
+import { getInitSelectedValidators } from "../../../domain/types/stake";
+import type { TronResourceType } from "../../../domain/types/tron";
+import type { Validator, ValidatorKey } from "../../../domain/types/validators";
+import {
+ getYieldActionArg,
+ isYieldValidatorSelectionRequired,
+ type Yield,
+} from "../../../domain/types/yields";
+import { useTokenBalancesScan } from "../../../hooks/api/use-token-balances-scan";
+import { useTokensPrices } from "../../../hooks/api/use-tokens-prices";
+import { useYieldKycGate } from "../../../hooks/api/use-yield-kyc-gate";
+import { useYieldValidators } from "../../../hooks/api/use-yield-validators";
+import { useNavigateWithScrollToTop } from "../../../hooks/navigation/use-navigate-with-scroll-to-top";
+import {
+ getPositionDetailsStakeReviewPath,
+ usePositionDetailsStakeMatch,
+} from "../../../hooks/navigation/use-position-details-stake-match";
+import { useTrackEvent } from "../../../hooks/tracking/use-track-event";
+import { useAddLedgerAccount } from "../../../hooks/use-add-ledger-account";
+import { useEstimatedRewards } from "../../../hooks/use-estimated-rewards";
+import { useMaxMinYieldAmount } from "../../../hooks/use-max-min-yield-amount";
+import { useSavedRef } from "../../../hooks/use-saved-ref";
+import { useYieldType } from "../../../hooks/use-yield-type";
+import type { PageCta } from "../../../pages/components/page-cta";
+import { useAmountValidation } from "../../../pages/details/earn-page/state/use-amount-validation";
+import { useStakeEnterRequestDto } from "../../../pages/details/earn-page/state/use-stake-enter-request-dto";
+import { usePositionDetails } from "../../../pages/position-details/hooks/use-position-details";
+import { useSetEnterStakeRequest } from "../../../providers/enter-stake-store";
+import { useSettings } from "../../../providers/settings";
+import { useSKWallet } from "../../../providers/sk-wallet";
+import { defaultFormattedNumber, formatNumber } from "../../../utils";
+import { usePositionDetailsStakeMachine } from "../state/stake-machine";
+
+const resolveProviderYieldId = (selectedStake: Maybe) =>
+ selectedStake
+ .chainNullable((stake) => getYieldActionArg(stake, "enter", "providerId"))
+ .filter((arg) => !!arg.required && !!arg.options?.length)
+ .chain((arg) => List.head(arg.options ?? []));
+
+const resolveTronResource = (selectedStake: Maybe) =>
+ selectedStake
+ .chainNullable((stake) => getYieldActionArg(stake, "enter", "tronResource"))
+ .filter((arg) => !!arg.required)
+ .map(() => "ENERGY" as TronResourceType);
+
+export const usePositionDetailsStake = () => {
+ const { t } = useTranslation();
+ const positionDetails = usePositionDetails();
+ const positionDetailsStakeMatch = usePositionDetailsStakeMatch();
+ const integrationId = positionDetailsStakeMatch?.params.integrationId ?? "";
+ const balanceId = positionDetailsStakeMatch?.params.balanceId ?? "";
+ const { intent, dispatch } = usePositionDetailsStakeMachine({
+ integrationId,
+ balanceId,
+ });
+
+ const selectedStake = positionDetails.integrationData;
+ const selectedToken = selectedStake.map((stake) => stake.token);
+ const selectedProviderYieldId = resolveProviderYieldId(selectedStake);
+ const tronResource = Maybe.fromNullable(intent.tronResource).altLazy(() =>
+ resolveTronResource(selectedStake)
+ );
+
+ const tokenBalancesScan = useTokenBalancesScan();
+ const availableAmount = useMemo(
+ () =>
+ selectedToken
+ .chain((token) =>
+ Maybe.fromNullable(
+ tokenBalancesScan.data?.find((balance) =>
+ equalTokens(balance.token, token)
+ )
+ )
+ )
+ .map((balance) => new BigNumber(balance.amount)),
+ [selectedToken, tokenBalancesScan.data]
+ );
+
+ const positionsData = useMemo(
+ () =>
+ selectedStake
+ .map((stake) => {
+ const balances = [
+ ...positionDetails.positionBalancesByType
+ .map((byType) => [...byType.values()].flat())
+ .orDefault([]),
+ ];
+
+ return new Map([
+ [
+ stake.id,
+ {
+ yieldId: stake.id,
+ rewardRate: stake.rewardRate,
+ balanceData: new Map([
+ [
+ "default" as BalanceDataKey,
+ { type: "default" as const, balances },
+ ],
+ ]),
+ },
+ ],
+ ]) as PositionsData;
+ })
+ .orDefault(new Map() as PositionsData),
+ [positionDetails.positionBalancesByType, selectedStake]
+ );
+
+ const {
+ maxIntegrationAmount,
+ minIntegrationAmount,
+ minEnterOrExitAmount,
+ maxEnterOrExitAmount,
+ isForceMax,
+ } = useMaxMinYieldAmount({
+ type: "enter",
+ yieldOpportunity: selectedStake,
+ availableAmount,
+ positionsData,
+ });
+
+ const rawStakeAmount = new BigNumber(intent.stakeAmount);
+ const stakeAmount =
+ intent.useMaxAmount || !rawStakeAmount.isZero()
+ ? rawStakeAmount
+ : minEnterOrExitAmount;
+
+ const selectedTokenAvailableAmount = useMemo(
+ () =>
+ availableAmount.map((amount) => ({
+ symbol: selectedToken.mapOrDefault((token) => token.symbol, ""),
+ shortFormattedAmount: defaultFormattedNumber(amount),
+ fullFormattedAmount: formatNumber(amount),
+ amount,
+ })),
+ [availableAmount, selectedToken]
+ );
+
+ const validatorsRequired = selectedStake
+ .map(isYieldValidatorSelectionRequired)
+ .orDefault(false);
+ const yieldValidators = useYieldValidators({
+ enabled: validatorsRequired,
+ yieldId:
+ selectedStake.map((stake) => stake.id).extractNullable() ?? undefined,
+ network:
+ selectedStake.map((stake) => stake.token.network).extractNullable() ??
+ undefined,
+ });
+ const selectedValidators = useMemo(() => {
+ if (!validatorsRequired) {
+ return new Map();
+ }
+
+ const validators = yieldValidators.data ?? [];
+ return getInitSelectedValidators({
+ initQueryParams: Maybe.empty(),
+ validators,
+ });
+ }, [validatorsRequired, yieldValidators.data]);
+
+ const estimatedRewards = useEstimatedRewards({
+ selectedStake,
+ stakeAmount,
+ selectedValidators,
+ selectedProviderYieldId,
+ });
+
+ const pricesState = useTokensPrices({
+ token: selectedToken,
+ yieldDto: selectedStake,
+ });
+
+ const formattedPrice = useMemo(
+ () =>
+ Maybe.fromRecord({
+ prices: Maybe.fromNullable(pricesState.data),
+ selectedStake,
+ selectedToken,
+ }).mapOrDefault(
+ (value) =>
+ `$${defaultFormattedNumber(
+ getTokenPriceInUSD({
+ baseToken: value.selectedStake.token,
+ amount: stakeAmount,
+ token: value.selectedToken,
+ prices: value.prices,
+ pricePerShare: null,
+ })
+ )}`,
+ ""
+ ),
+ [pricesState.data, selectedStake, selectedToken, stakeAmount]
+ );
+
+ const stakeEnterRequestDto = useStakeEnterRequestDto({
+ selectedProviderYieldId,
+ selectedStake,
+ selectedToken,
+ selectedValidators,
+ stakeAmount,
+ tronResource,
+ useMaxAmount: intent.useMaxAmount,
+ });
+ const yieldKycGate = useYieldKycGate({ yieldDto: selectedStake });
+ const kycGateIsBlocking = yieldKycGate.isGateBlocking;
+ const kycProviderName = selectedStake
+ .map(getKycProviderName)
+ .extractNullable();
+ const onKycStatusRefresh = () => yieldKycGate.refetch();
+ const { openConnectModal } = useConnectModal();
+ const navigate = useNavigateWithScrollToTop();
+ const setEnterStakeRequest = useSetEnterStakeRequest();
+ const { isConnected, isLedgerLiveAccountPlaceholder, chain } = useSKWallet();
+
+ const {
+ stakeAmountGreaterThanAvailableAmount,
+ stakeAmountGreaterThanMax,
+ stakeAmountLessThanMin,
+ stakeAmountIsZero,
+ } = useAmountValidation({
+ availableAmount,
+ stakeAmount,
+ maxEnterOrExitAmount,
+ minEnterOrExitAmount,
+ });
+
+ const onClickHandler = useMutation({
+ mutationFn: async () => {
+ if (validation.hasErrors) return;
+ if (stakeEnterRequestDto.isNothing()) return;
+
+ if (!isConnected) return openConnectModal?.();
+ if (kycGateIsBlocking) return;
+
+ Maybe.fromRecord({
+ selectedToken,
+ stakeEnterRequestDto,
+ }).ifJust((value) => {
+ setEnterStakeRequest(
+ Maybe.of({
+ actionDto: Maybe.empty(),
+ addresses: value.stakeEnterRequestDto.addresses,
+ requestDto: value.stakeEnterRequestDto.dto,
+ selectedToken: value.selectedToken,
+ gasFeeToken: value.stakeEnterRequestDto.gasFeeToken,
+ selectedStake: value.stakeEnterRequestDto.selectedStake,
+ selectedValidators: value.stakeEnterRequestDto.selectedValidators,
+ })
+ );
+ navigate(
+ getPositionDetailsStakeReviewPath({ balanceId, integrationId }) ??
+ "/review"
+ );
+ });
+ },
+ });
+
+ const validation = useMemo(() => {
+ const errors = {
+ tronResource: false,
+ stakeAmountGreaterThanAvailableAmount,
+ stakeAmountGreaterThanMax,
+ stakeAmountLessThanMin,
+ stakeAmountIsZero,
+ };
+
+ if (
+ isConnected &&
+ selectedStake
+ .chainNullable((stake) =>
+ getYieldActionArg(stake, "enter", "tronResource")
+ )
+ .filter((arg) => !!arg.required)
+ .isJust() &&
+ tronResource.isNothing()
+ ) {
+ errors.tronResource = true;
+ }
+
+ return {
+ submitted: onClickHandler.status !== "idle",
+ hasErrors: Object.values(errors).some(Boolean),
+ errors,
+ };
+ }, [
+ isConnected,
+ onClickHandler.status,
+ selectedStake,
+ stakeAmountGreaterThanAvailableAmount,
+ stakeAmountGreaterThanMax,
+ stakeAmountIsZero,
+ stakeAmountLessThanMin,
+ tronResource,
+ ]);
+
+ const trackEvent = useTrackEvent();
+ const onMaxClick = () => {
+ trackEvent("positionDetailsPageMaxClicked", {
+ yieldId: selectedStake.map((stake) => stake.id).extractNullable(),
+ });
+ dispatch({
+ type: "stakeAmount/max",
+ amount: maxEnterOrExitAmount.toString(10),
+ });
+ };
+ const onStakeAmountChange: NumberInputProps["onChange"] = (amount) =>
+ dispatch({ type: "stakeAmount/change", amount: amount.toString(10) });
+ const onTronResourceSelect = (value: TronResourceType) =>
+ dispatch({ type: "tronResource/select", tronResource: value });
+ const onClickRef = useSavedRef(onClickHandler.mutate);
+
+ const addLedgerAccount = useAddLedgerAccount();
+ const connectClickRef = useSavedRef(() => {
+ if (isLedgerLiveAccountPlaceholder && chain) {
+ trackEvent("addLedgerAccountClicked");
+ return addLedgerAccount.mutate(chain);
+ }
+
+ trackEvent("connectWalletClicked");
+ openConnectModal?.();
+ });
+
+ const { externalProviders } = useSettings();
+ const isFetching =
+ positionDetails.isLoading ||
+ tokenBalancesScan.isLoading ||
+ yieldValidators.isLoading ||
+ pricesState.isLoading;
+ const buttonCTAText = useYieldType(selectedStake).mapOrDefault(
+ (yieldType) => yieldType.cta,
+ ""
+ );
+ const buttonDisabled =
+ isConnected &&
+ (isFetching || stakeEnterRequestDto.isNothing() || kycGateIsBlocking);
+ const appLoading = positionDetails.isLoading || selectedStake.isNothing();
+ const cta = useMemo(
+ () =>
+ isConnected && !isLedgerLiveAccountPlaceholder
+ ? {
+ disabled: buttonDisabled,
+ isLoading: !buttonCTAText || isFetching || yieldKycGate.isLoading,
+ onClick: () => onClickRef.current(),
+ label: buttonCTAText,
+ }
+ : externalProviders
+ ? null
+ : {
+ disabled: appLoading,
+ isLoading: appLoading,
+ label: t(
+ isLedgerLiveAccountPlaceholder
+ ? "init.ledger_add_account"
+ : "init.connect_wallet"
+ ),
+ onClick: () => connectClickRef.current(),
+ },
+ [
+ appLoading,
+ buttonCTAText,
+ buttonDisabled,
+ connectClickRef,
+ externalProviders,
+ isConnected,
+ isFetching,
+ isLedgerLiveAccountPlaceholder,
+ onClickRef,
+ t,
+ yieldKycGate.isLoading,
+ ]
+ );
+
+ const stakeMaxAmount = selectedStake
+ .filter(() => maxIntegrationAmount.isJust() && !isForceMax)
+ .map(() => maxEnterOrExitAmount.toNumber());
+ const stakeMinAmount = selectedStake
+ .filter(() => minIntegrationAmount.isJust() && !isForceMax)
+ .map(() => minEnterOrExitAmount.toNumber())
+ .filter((value) => new BigNumber(value).isGreaterThan(0));
+ const isStakeTokenSameAsGasToken = Maybe.fromRecord({
+ selectedStake,
+ selectedToken,
+ }).mapOrDefault(
+ (value) =>
+ stakeTokenSameAsGasToken({
+ stakeToken: value.selectedToken,
+ yieldDto: value.selectedStake,
+ }),
+ false
+ );
+
+ return {
+ appLoading,
+ cta,
+ estimatedRewards,
+ footerIsLoading: isFetching,
+ formattedPrice,
+ isFetching,
+ isStakeTokenSameAsGasToken,
+ kycGate: yieldKycGate.gate,
+ kycGateIsChecking:
+ yieldKycGate.isLoading ||
+ yieldKycGate.isFetching ||
+ yieldKycGate.isRefetching,
+ kycProviderName,
+ onKycStatusRefresh,
+ onMaxClick,
+ onStakeAmountChange,
+ onTronResourceSelect,
+ positionDetails,
+ selectedStake,
+ selectedToken,
+ selectedTokenAvailableAmount,
+ selectedValidators,
+ stakeAmount,
+ stakeMaxAmount,
+ stakeMinAmount,
+ symbol: selectedToken.mapOrDefault((token) => token.symbol, ""),
+ tronResource,
+ validation,
+ };
+};
diff --git a/packages/widget/src/pages-dashboard/position-details/position-details-model.tsx b/packages/widget/src/pages-dashboard/position-details/position-details-model.tsx
index 8895d8ee..c43879ac 100644
--- a/packages/widget/src/pages-dashboard/position-details/position-details-model.tsx
+++ b/packages/widget/src/pages-dashboard/position-details/position-details-model.tsx
@@ -86,7 +86,7 @@ type DashboardPositionDetailsModel = {
statusSummary: DashboardPositionStatusSummary;
};
-export type DashboardPositionPendingAction = {
+type DashboardPositionPendingAction = {
amount: BigNumber | null;
formattedAmount: string;
pendingActionDto: YieldPendingActionDto;
diff --git a/packages/widget/src/pages-dashboard/position-details/state/stake-machine.ts b/packages/widget/src/pages-dashboard/position-details/state/stake-machine.ts
new file mode 100644
index 00000000..3d607567
--- /dev/null
+++ b/packages/widget/src/pages-dashboard/position-details/state/stake-machine.ts
@@ -0,0 +1,88 @@
+import { useAtom } from "@effect/atom-react";
+import { Data } from "effect";
+import * as Atom from "effect/unstable/reactivity/Atom";
+import type { TronResourceType } from "../../../domain/types/tron";
+
+type PositionDetailsStakeEntryParams = {
+ integrationId: string;
+ balanceId: string;
+};
+
+class PositionDetailsStakeEntryKey extends Data.Class {}
+
+type PositionDetailsStakeIntent = {
+ stakeAmount: string;
+ tronResource: TronResourceType | null;
+ useMaxAmount: boolean;
+};
+
+type PositionDetailsStakeAction =
+ | {
+ type: "stakeAmount/change";
+ amount: string;
+ }
+ | {
+ type: "stakeAmount/max";
+ amount: string;
+ }
+ | {
+ type: "tronResource/select";
+ tronResource: TronResourceType;
+ };
+
+const makeDefaultIntent = (): PositionDetailsStakeIntent => ({
+ stakeAmount: "0",
+ tronResource: null,
+ useMaxAmount: false,
+});
+
+const positionDetailsStakeAtom = Atom.family(
+ (_entry: PositionDetailsStakeEntryKey) => {
+ const intentAtom = Atom.make(
+ makeDefaultIntent()
+ );
+
+ return Atom.writable<
+ PositionDetailsStakeIntent,
+ PositionDetailsStakeAction
+ >(
+ (ctx) => ctx.get(intentAtom),
+ (ctx, action) => {
+ const intent = ctx.get(intentAtom);
+
+ switch (action.type) {
+ case "stakeAmount/change":
+ ctx.set(intentAtom, {
+ ...intent,
+ stakeAmount: action.amount,
+ useMaxAmount: false,
+ });
+ return;
+ case "stakeAmount/max":
+ ctx.set(intentAtom, {
+ ...intent,
+ stakeAmount: action.amount,
+ useMaxAmount: true,
+ });
+ return;
+ case "tronResource/select":
+ ctx.set(intentAtom, {
+ ...intent,
+ tronResource: action.tronResource,
+ });
+ return;
+ }
+ }
+ );
+ }
+);
+
+export const usePositionDetailsStakeMachine = (
+ entry: PositionDetailsStakeEntryParams
+) => {
+ const [intent, dispatch] = useAtom(
+ positionDetailsStakeAtom(new PositionDetailsStakeEntryKey(entry))
+ );
+
+ return { intent, dispatch };
+};
diff --git a/packages/widget/src/pages/complete/hooks/use-activity-complete.hook.ts b/packages/widget/src/pages/complete/hooks/use-activity-complete.hook.ts
index e1152e51..19837dd7 100644
--- a/packages/widget/src/pages/complete/hooks/use-activity-complete.hook.ts
+++ b/packages/widget/src/pages/complete/hooks/use-activity-complete.hook.ts
@@ -1,4 +1,3 @@
-import { useSelector } from "@xstate/store/react";
import { Maybe } from "purify-ts";
import { useMemo } from "react";
import { getActionInputToken } from "../../../domain/types/action";
@@ -6,18 +5,17 @@ import type { TokenDto } from "../../../domain/types/tokens";
import { useTrackPage } from "../../../hooks/tracking/use-track-page";
import { useProvidersDetails } from "../../../hooks/use-provider-details";
import { useYieldType } from "../../../hooks/use-yield-type";
-import { useActivityContext } from "../../../providers/activity-provider";
+import {
+ useActivitySelectedAction,
+ useActivitySelectedValidators,
+ useActivitySelectedYield,
+} from "../../../providers/activity-provider";
import { defaultFormattedNumber } from "../../../utils";
export const useActivityComplete = () => {
useTrackPage("activityComplete");
- const activityContext = useActivityContext();
-
- const selectedAction = useSelector(
- activityContext,
- (state) => state.context.selectedAction
- ).unsafeCoerce();
+ const selectedAction = useActivitySelectedAction().unsafeCoerce();
const amount = useMemo(
() =>
@@ -27,15 +25,8 @@ export const useActivityComplete = () => {
[selectedAction]
);
- const selectedYield = useSelector(
- activityContext,
- (state) => state.context.selectedYield
- );
-
- const selectedValidators = useSelector(
- activityContext,
- (state) => state.context.selectedValidators
- );
+ const selectedYield = useActivitySelectedYield();
+ const selectedValidators = useActivitySelectedValidators();
const yieldType = useYieldType(selectedYield).map((v) => v.type);
diff --git a/packages/widget/src/pages/complete/pages/pending-complete.page.tsx b/packages/widget/src/pages/complete/pages/pending-complete.page.tsx
index fcd9bea7..6a375b53 100644
--- a/packages/widget/src/pages/complete/pages/pending-complete.page.tsx
+++ b/packages/widget/src/pages/complete/pages/pending-complete.page.tsx
@@ -1,4 +1,3 @@
-import { useSelector } from "@xstate/store/react";
import BigNumber from "bignumber.js";
import { Maybe } from "purify-ts";
import { useMemo } from "react";
@@ -7,7 +6,7 @@ import { useTrackPage } from "../../../hooks/tracking/use-track-page";
import { usePositionBalances } from "../../../hooks/use-position-balances";
import { useProvidersDetails } from "../../../hooks/use-provider-details";
import { useYieldType } from "../../../hooks/use-yield-type";
-import { usePendingActionStore } from "../../../providers/pending-action-store";
+import { usePendingActionRequest } from "../../../providers/pending-action-store";
import { defaultFormattedNumber } from "../../../utils";
import { CompletePage } from "./common.page";
@@ -19,10 +18,7 @@ export const PendingCompletePage = () => {
integrationId: plain.integrationId,
});
- const pendingRequest = useSelector(
- usePendingActionStore(),
- (state) => state.context.data
- ).unsafeCoerce();
+ const pendingRequest = usePendingActionRequest().unsafeCoerce();
const integrationData = useMemo(
() => Maybe.of(pendingRequest.integrationData),
diff --git a/packages/widget/src/pages/complete/pages/stake-complete.page.tsx b/packages/widget/src/pages/complete/pages/stake-complete.page.tsx
index 65aa69f5..8701eafa 100644
--- a/packages/widget/src/pages/complete/pages/stake-complete.page.tsx
+++ b/packages/widget/src/pages/complete/pages/stake-complete.page.tsx
@@ -1,21 +1,17 @@
-import { useSelector } from "@xstate/store/react";
import BigNumber from "bignumber.js";
import { Maybe } from "purify-ts";
import { useMemo } from "react";
import { useTrackPage } from "../../../hooks/tracking/use-track-page";
import { useProvidersDetails } from "../../../hooks/use-provider-details";
import { useYieldType } from "../../../hooks/use-yield-type";
-import { useEnterStakeStore } from "../../../providers/enter-stake-store";
+import { useEnterStakeRequest } from "../../../providers/enter-stake-store";
import { defaultFormattedNumber } from "../../../utils";
import { CompletePage } from "./common.page";
export const StakeCompletePage = () => {
useTrackPage("stakeComplete");
- const enterRequest = useSelector(
- useEnterStakeStore(),
- (state) => state.context.data
- ).unsafeCoerce();
+ const enterRequest = useEnterStakeRequest().unsafeCoerce();
const selectedStake = useMemo(
() => Maybe.of(enterRequest.selectedStake),
diff --git a/packages/widget/src/pages/complete/pages/unstake-complete.page.tsx b/packages/widget/src/pages/complete/pages/unstake-complete.page.tsx
index b0d61797..5f5a16e0 100644
--- a/packages/widget/src/pages/complete/pages/unstake-complete.page.tsx
+++ b/packages/widget/src/pages/complete/pages/unstake-complete.page.tsx
@@ -1,4 +1,3 @@
-import { useSelector } from "@xstate/store/react";
import { Maybe } from "purify-ts";
import { useMemo } from "react";
import { useUnstakeOrPendingActionParams } from "../../../hooks/navigation/use-unstake-or-pending-action-params";
@@ -6,7 +5,7 @@ import { useTrackPage } from "../../../hooks/tracking/use-track-page";
import { usePositionBalances } from "../../../hooks/use-position-balances";
import { useProvidersDetails } from "../../../hooks/use-provider-details";
import { useYieldType } from "../../../hooks/use-yield-type";
-import { useExitStakeStore } from "../../../providers/exit-stake-store";
+import { useExitStakeRequest } from "../../../providers/exit-stake-store";
import { defaultFormattedNumber } from "../../../utils";
import { CompletePage } from "./common.page";
@@ -17,10 +16,7 @@ export const UnstakeCompletePage = () => {
integrationId: plain.integrationId,
});
- const exitRequest = useSelector(
- useExitStakeStore(),
- (state) => state.context.data
- ).unsafeCoerce();
+ const exitRequest = useExitStakeRequest().unsafeCoerce();
const integrationData = useMemo(
() => Maybe.of(exitRequest.integrationData),
diff --git a/packages/widget/src/pages/components/meta-info/index.tsx b/packages/widget/src/pages/components/meta-info/index.tsx
index 9578fe2a..3cbe5786 100644
--- a/packages/widget/src/pages/components/meta-info/index.tsx
+++ b/packages/widget/src/pages/components/meta-info/index.tsx
@@ -9,8 +9,8 @@ import { InfoIcon } from "../../../components/atoms/icons/info";
import type { TextVariants } from "../../../components/atoms/typography/styles.css";
import { Text } from "../../../components/atoms/typography/text";
import type { TokenDto } from "../../../domain/types/tokens";
+import type { Validator, ValidatorKey } from "../../../domain/types/validators";
import type { Yield } from "../../../domain/types/yields";
-import type { ValidatorDto } from "../../../generated/api/yield";
import { useYieldMetaInfo } from "../../../hooks/use-yield-meta-info";
import { dotContainer, dotText } from "./styles.css";
@@ -19,7 +19,7 @@ type MetaInfoTextSize = NonNullable["size"]>;
type Props = {
isLoading?: boolean;
selectedStake: Maybe;
- selectedValidators: Map;
+ selectedValidators: Map;
selectedToken: Maybe;
textSize?: MetaInfoTextSize;
};
diff --git a/packages/widget/src/pages/details/activity-page/components/action-list-item/index.tsx b/packages/widget/src/pages/details/activity-page/components/action-list-item/index.tsx
index ab86f595..5ea935b2 100644
--- a/packages/widget/src/pages/details/activity-page/components/action-list-item/index.tsx
+++ b/packages/widget/src/pages/details/activity-page/components/action-list-item/index.tsx
@@ -1,7 +1,6 @@
import { List } from "purify-ts";
import { useTranslation } from "react-i18next";
import { Box } from "../../../../../components/atoms/box";
-import { ContentLoaderSquare } from "../../../../../components/atoms/content-loader";
import { ListItem } from "../../../../../components/atoms/list/list-item";
import { Text } from "../../../../../components/atoms/typography/text";
import { listItemContainer } from "../../../positions-page/style.css";
@@ -28,7 +27,7 @@ export const ActionListItem = ({
}) => {
const { t } = useTranslation();
const {
- integrationData,
+ canOpenDetails,
providersDetails,
iconType,
title,
@@ -40,103 +39,113 @@ export const ActionListItem = ({
timestampRelative,
showFailedBadge,
badgeLabel,
+ showUnavailableYieldDetails,
+ unavailableYieldLabel,
} = useActionListItem(action);
+ const providerLabel = providersDetails
+ .chain((val) =>
+ List.head(val).map((p) =>
+ t("positions.via", {
+ providerName: p.name ?? p.address,
+ count: Math.max(val.length - 1, 1),
+ })
+ )
+ )
+ .extractNullable();
+
return (
- {integrationData.mapOrDefault(
- () => (
- onActionSelect(action)} className={listItem}>
-
-
-
+ onActionSelect(action) : undefined}
+ className={listItem}
+ variant={{ hover: canOpenDetails ? "enabled" : "disabled" }}
+ >
+
+
+
-
-
-