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} /> - - - + -