From 46019ddddbe357fde8bf8ecc1ffb4da80d7b88fe Mon Sep 17 00:00:00 2001 From: Rytis Grincevicius Date: Wed, 19 Nov 2025 11:45:34 +0200 Subject: [PATCH 1/7] borrowing modal and transactions --- apps/web-app/package.json | 1 + ....FormComponents.tsx => FormComponents.tsx} | 138 +++++++-- .../components/AssetsTable/AssetsTable.tsx | 253 ++++++++--------- .../components/BorrowDialog/BorrowDialog.tsx | 126 +++++++++ .../stores/borrow-request.store.ts | 26 ++ .../TransactionDialog/TransactionDialog.tsx | 15 +- .../hooks/use-internal-tx-handler.ts | 7 +- apps/web-app/src/components/ui/field.tsx | 246 +++++++++++++++++ apps/web-app/src/components/ui/separator.tsx | 28 ++ ...mo.form-context.ts => app-form-context.ts} | 0 .../src/hooks/{demo.form.ts => app-form.ts} | 6 +- apps/web-app/src/lib/transactions/index.ts | 17 +- apps/web-app/src/lib/transactions/store.ts | 15 +- apps/web-app/src/lib/validations.ts | 31 +++ apps/web-app/src/routes/demo.form.address.tsx | 2 +- apps/web-app/src/routes/demo.form.simple.tsx | 2 +- apps/web-app/src/routes/money-market.tsx | 64 +++-- nx.json | 8 +- packages/sdk/package.json | 2 + .../sdk/src/managers/money-market.manager.ts | 248 +++++++++-------- packages/sdk/src/managers/tokens.manager.ts | 8 +- packages/sdk/src/types.ts | 11 +- pnpm-lock.yaml | 261 ++++++++++++++++++ 23 files changed, 1179 insertions(+), 336 deletions(-) rename apps/web-app/src/components/{demo.FormComponents.tsx => FormComponents.tsx} (57%) create mode 100644 apps/web-app/src/components/MoneyMarket/components/BorrowDialog/BorrowDialog.tsx create mode 100644 apps/web-app/src/components/MoneyMarket/stores/borrow-request.store.ts create mode 100644 apps/web-app/src/components/ui/field.tsx create mode 100644 apps/web-app/src/components/ui/separator.tsx rename apps/web-app/src/hooks/{demo.form-context.ts => app-form-context.ts} (100%) rename apps/web-app/src/hooks/{demo.form.ts => app-form.ts} (70%) create mode 100644 apps/web-app/src/lib/validations.ts diff --git a/apps/web-app/package.json b/apps/web-app/package.json index 2a43926..f90adcd 100644 --- a/apps/web-app/package.json +++ b/apps/web-app/package.json @@ -12,6 +12,7 @@ "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-select": "2.2.6", + "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", diff --git a/apps/web-app/src/components/demo.FormComponents.tsx b/apps/web-app/src/components/FormComponents.tsx similarity index 57% rename from apps/web-app/src/components/demo.FormComponents.tsx rename to apps/web-app/src/components/FormComponents.tsx index 28ed214..0d2579f 100644 --- a/apps/web-app/src/components/demo.FormComponents.tsx +++ b/apps/web-app/src/components/FormComponents.tsx @@ -1,21 +1,33 @@ import { useStore } from '@tanstack/react-form'; -import { useFieldContext, useFormContext } from '../hooks/demo.form-context'; +import { useFieldContext, useFormContext } from '../hooks/app-form-context'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Textarea as ShadcnTextarea } from '@/components/ui/textarea'; import * as ShadcnSelect from '@/components/ui/select'; import { Slider as ShadcnSlider } from '@/components/ui/slider'; import { Switch as ShadcnSwitch } from '@/components/ui/switch'; -import { Label } from '@/components/ui/label'; +import { Textarea as ShadcnTextarea } from '@/components/ui/textarea'; +import { Decimal } from '@sovryn/slayer-shared'; +import { Loader2Icon } from 'lucide-react'; +import { useState } from 'react'; +import { Field, FieldDescription, FieldError, FieldLabel } from './ui/field'; export function SubscribeButton({ label }: { label: string }) { const form = useFormContext(); return ( - state.isSubmitting}> - {(isSubmitting) => ( - )} @@ -31,12 +43,12 @@ function ErrorMessages({ return ( <> {errors.map((error) => ( -
{typeof error === 'string' ? error : error.message} -
+ ))} ); @@ -45,44 +57,45 @@ function ErrorMessages({ export function TextField({ label, placeholder, + description, }: { label: string; placeholder?: string; + description?: string; }) { const field = useFieldContext(); const errors = useStore(field.store, (state) => state.meta.errors); return ( -
- + + {label} field.handleChange(e.target.value)} /> + {description && {description}} {field.state.meta.isTouched && } -
+ ); } export function TextArea({ label, rows = 3, + description, }: { label: string; rows?: number; + description?: string; }) { const field = useFieldContext(); const errors = useStore(field.store, (state) => state.meta.errors); return ( -
- + + {label} field.handleChange(e.target.value)} /> + {description && {description}} {field.state.meta.isTouched && } -
+ ); } @@ -99,16 +113,19 @@ export function Select({ label, values, placeholder, + description, }: { label: string; values: Array<{ label: string; value: string }>; placeholder?: string; + description?: string; }) { const field = useFieldContext(); const errors = useStore(field.store, (state) => state.meta.errors); return ( -
+ + {label} + {description && {description}} {field.state.meta.isTouched && } -
+ ); } -export function Slider({ label }: { label: string }) { +export function Slider({ + label, + description, +}: { + label: string; + description?: string; +}) { const field = useFieldContext(); const errors = useStore(field.store, (state) => state.meta.errors); return ( -
- + + {label} field.handleChange(value[0])} /> + {description && {description}} {field.state.meta.isTouched && } -
+ ); } -export function Switch({ label }: { label: string }) { +export function Switch({ + label, + description, +}: { + label: string; + description?: string; +}) { const field = useFieldContext(); const errors = useStore(field.store, (state) => state.meta.errors); return ( -
+
field.handleChange(checked)} /> - + {label}
+ {description && {description}} + {field.state.meta.isTouched && } +
+ ); +} + +const tryDecimalValue = (input: string): string => { + try { + if (input) { + const decimalValue = Decimal.from(input); + return decimalValue.toString(); + } + return ''; + } catch { + return ''; + } +}; + +export function AmountField({ + label, + placeholder, + description, +}: { + label: string; + placeholder?: string; + description?: string; +}) { + const field = useFieldContext(); + const errors = useStore(field.store, (state) => state.meta.errors); + + const [renderedValue, setRenderedValue] = useState( + tryDecimalValue(field.state.value), + ); + + const handleChange = (input: string) => { + setRenderedValue(input); + field.handleChange(tryDecimalValue(input)); + }; + + return ( + + {label} + handleChange(e.target.value)} + /> + {description && {description}} {field.state.meta.isTouched && } -
+ ); } diff --git a/apps/web-app/src/components/MoneyMarket/components/BorrowAssetsList/components/AssetsTable/AssetsTable.tsx b/apps/web-app/src/components/MoneyMarket/components/BorrowAssetsList/components/AssetsTable/AssetsTable.tsx index 4678d52..829315a 100644 --- a/apps/web-app/src/components/MoneyMarket/components/BorrowAssetsList/components/AssetsTable/AssetsTable.tsx +++ b/apps/web-app/src/components/MoneyMarket/components/BorrowAssetsList/components/AssetsTable/AssetsTable.tsx @@ -8,14 +8,12 @@ import { } from '@/components/ui/table/table'; import React, { useEffect, useState, type FC } from 'react'; +import { borrowRequestStore } from '@/components/MoneyMarket/stores/borrow-request.store'; import { AmountRenderer } from '@/components/ui/amount-renderer'; import { Button } from '@/components/ui/button'; +import { Dialog } from '@/components/ui/dialog'; import { InfoButton } from '@/components/ui/info-button'; -import { sdk } from '@/lib/sdk'; -import { useSlayerTx } from '@/lib/transactions'; -import { type MoneyMarketPoolReserve, type Token } from '@sovryn/slayer-sdk'; -import { Decimal } from '@sovryn/slayer-shared'; -import { useAccount, useWriteContract } from 'wagmi'; +import { type MoneyMarketPoolReserve } from '@sovryn/slayer-sdk'; type AssetsTableProps = { assets: MoneyMarketPoolReserve[]; @@ -28,47 +26,18 @@ export const AssetsTable: FC = ({ assets }) => { setSortedAssets(assets); }, [assets]); - const { address } = useAccount(); - - const { writeContractAsync } = useWriteContract(); - - const { begin } = useSlayerTx(); - - const handleBorrow = async (token: Token) => { - begin(async () => { - const s = await sdk.moneyMarket.borrow(token, Decimal.from(1), 1, { - account: address!, - }); - console.log('Transaction Request:', s); - return s; - }); - - // const msg = await sdk.moneyMarket.borrow( - // token, - // Decimal.from(1), - // BorrowRateMode.stable, - // { - // account: address!, - // }, - // ); - // console.log('Transaction Request:', msg); - - // if (msg.length) { - // // const data = await writeContractAsync(msg[0]); - // // console.log('Transaction Response:', data); - // } - // const d = await signMessageAsync(msg); - // console.warn('Signature:', { data, d }); - }; + const handleBorrow = (reserve: MoneyMarketPoolReserve) => + borrowRequestStore.getState().setReserve(reserve); return ( - - - - -
- Asset - {/* {assets.some((asset) => asset.isSortable) && ( + <> +
+ + + +
+ Asset + {/* {assets.some((asset) => asset.isSortable) && ( )} */} -
-
- -
-
- Available -
- {/* {assets.some((asset) => asset.isSortable) && ( + + +
+
+ Available + +
+ {/* {assets.some((asset) => asset.isSortable) && ( )} */} -
-
- -
-
- APY -
- {/* {assets.some((asset) => asset.isSortable) && ( + + +
+
+ APY + +
+ {/* {assets.some((asset) => asset.isSortable) && ( )} */} -
-
- - - - - {sortedAssets.map((asset, index) => ( - - - -
- {asset.token.symbol} -
-

- {asset.token.symbol} -

+
+ + + + + + {sortedAssets.map((asset, index) => ( + + + +
+ {asset.token.symbol} +
+

+ {asset.token.symbol} +

+
-
-
- - - - {asset.token.symbol} - -

- -

-
- -
-

{0}%

-
-
- -
- - -
+
+ + + + {asset.token.symbol} + +

+ +

+
+ +
+

{0}%

+
+
+ +
+ + +
+
+
+ + {index !== sortedAssets.length - 1 && ( + + + + )} +
+ ))} + {sortedAssets.length === 0 && ( + + + No assets found. - - {index !== sortedAssets.length - 1 && ( - - - - )} - - ))} - {sortedAssets.length === 0 && ( - - - No assets found. - - - )} -
-
+ )} + + + + ); }; diff --git a/apps/web-app/src/components/MoneyMarket/components/BorrowDialog/BorrowDialog.tsx b/apps/web-app/src/components/MoneyMarket/components/BorrowDialog/BorrowDialog.tsx new file mode 100644 index 0000000..9abef83 --- /dev/null +++ b/apps/web-app/src/components/MoneyMarket/components/BorrowDialog/BorrowDialog.tsx @@ -0,0 +1,126 @@ +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { useAppForm } from '@/hooks/app-form'; +import { sdk } from '@/lib/sdk'; +import { useSlayerTx } from '@/lib/transactions'; +import { validateDecimal } from '@/lib/validations'; +import { BORROW_RATE_MODES } from '@sovryn/slayer-sdk'; +import { useAccount } from 'wagmi'; +import z from 'zod'; +import { useStore } from 'zustand'; +import { useStoreWithEqualityFn } from 'zustand/traditional'; +import { borrowRequestStore } from '../../stores/borrow-request.store'; + +const schema = z.object({ + amount: validateDecimal({ min: 1n }), +}); + +const BorrowDialogForm = () => { + const reserve = useStore(borrowRequestStore, (state) => state.reserve!); + + const { begin } = useSlayerTx({ + onClosed: (ok: boolean) => { + console.log('borrow tx modal closed, success:', ok); + if (ok) { + // close borrowing dialog if tx was successful + borrowRequestStore.getState().reset(); + } + }, + }); + const { address } = useAccount(); + + const form = useAppForm({ + defaultValues: { + amount: '', + }, + validators: { + onMount: schema, + onBlur: schema, + }, + onSubmit: ({ value }) => { + begin(() => + sdk.moneyMarket.borrow( + reserve, + value.amount, + BORROW_RATE_MODES.variable, + { + account: address!, + }, + ), + ); + }, + onSubmitInvalid(props) { + console.log('Borrow request submission invalid:', props); + }, + onSubmitMeta() { + console.log('Borrow request submission meta:', form); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }; + + const handleEscapes = (e: Event) => { + borrowRequestStore.getState().reset(); + e.preventDefault(); + }; + + return ( +
+ + + Borrow Asset + + Borrowing functionality is under development. + + + + {(field) => } + + + + + + + + + + +
+ ); +}; + +export const BorrowDialog = () => { + const isOpen = useStoreWithEqualityFn( + borrowRequestStore, + (state) => state.reserve !== null, + ); + + const handleClose = (open: boolean) => { + if (!open) { + borrowRequestStore.getState().reset(); + } + }; + + return ( + + {isOpen && } + + ); +}; diff --git a/apps/web-app/src/components/MoneyMarket/stores/borrow-request.store.ts b/apps/web-app/src/components/MoneyMarket/stores/borrow-request.store.ts new file mode 100644 index 0000000..daa5d47 --- /dev/null +++ b/apps/web-app/src/components/MoneyMarket/stores/borrow-request.store.ts @@ -0,0 +1,26 @@ +import type { MoneyMarketPoolReserve } from '@sovryn/slayer-sdk'; +import { createStore } from 'zustand'; +import { combine } from 'zustand/middleware'; + +type State = { + reserve: MoneyMarketPoolReserve | null; +}; + +type Actions = { + setReserve: (reserve: MoneyMarketPoolReserve) => void; + reset: () => void; +}; + +type BorrowRequestStore = State & Actions; + +export const borrowRequestStore = createStore( + combine( + { + reserve: null as MoneyMarketPoolReserve | null, + }, + (set) => ({ + setReserve: (reserve: MoneyMarketPoolReserve) => set({ reserve }), + reset: () => set({ reserve: null }), + }), + ), +); diff --git a/apps/web-app/src/components/TransactionDialog/TransactionDialog.tsx b/apps/web-app/src/components/TransactionDialog/TransactionDialog.tsx index 19d08cb..ecc3ba5 100644 --- a/apps/web-app/src/components/TransactionDialog/TransactionDialog.tsx +++ b/apps/web-app/src/components/TransactionDialog/TransactionDialog.tsx @@ -15,19 +15,26 @@ import { TxList } from './TxList'; export const TransactionDialogProvider = () => { const { t } = useTranslation('tx'); - const [isOpen, isReady] = useStoreWithEqualityFn( + const [isOpen, isReady, isClosing] = useStoreWithEqualityFn( txStore, - (state) => [state.isFetching || state.isReady, state.isReady] as const, + (state) => + [ + (state.isFetching || state.isReady) && !state.isClosing, + state.isReady, + state.isClosing, + ] as const, ); const onClose = (open: boolean) => { - if (!open) { + if (!open && !isClosing) { + txStore.getState().handlers.onClosed?.(txStore.getState().isCompleted); txStore.getState().reset(); } }; const handleEscapes = (e: Event) => { - if (!isReady) { + if (!isReady && !isClosing) { + txStore.getState().handlers.onClosed?.(false); txStore.getState().reset(); return; } diff --git a/apps/web-app/src/components/TransactionDialog/hooks/use-internal-tx-handler.ts b/apps/web-app/src/components/TransactionDialog/hooks/use-internal-tx-handler.ts index 1b61753..9604e3a 100644 --- a/apps/web-app/src/components/TransactionDialog/hooks/use-internal-tx-handler.ts +++ b/apps/web-app/src/components/TransactionDialog/hooks/use-internal-tx-handler.ts @@ -8,7 +8,7 @@ import { isTransactionRequest, isTypedDataRequest, } from '@sovryn/slayer-sdk'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { prepareTransactionRequest } from 'viem/actions'; import { useConfig, @@ -230,9 +230,14 @@ export function useInternalTxHandler( (pendingTxHash && isReceiptPending) || currentTx?.state === TRANSACTION_STATE.pending; + const marketAsCompleted$ = useRef(false); + useEffect(() => { + if (marketAsCompleted$.current) return; const count = txStore.getState().items.length; if (!isPending && !currentTx && count > 0) { + marketAsCompleted$.current = true; + txStore.getState().setIsCompleted(true); props.onCompleted?.(count); handlers.onCompleted?.(count); } diff --git a/apps/web-app/src/components/ui/field.tsx b/apps/web-app/src/components/ui/field.tsx new file mode 100644 index 0000000..db0dc12 --- /dev/null +++ b/apps/web-app/src/components/ui/field.tsx @@ -0,0 +1,246 @@ +import { useMemo } from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" +import { Separator } from "@/components/ui/separator" + +function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { + return ( +
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className + )} + {...props} + /> + ) +} + +function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + + ) +} + +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
[data-slot=field-group]]:gap-4", + className + )} + {...props} + /> + ) +} + +const fieldVariants = cva( + "group/field flex w-full gap-3 data-[invalid=true]:text-destructive", + { + variants: { + orientation: { + vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"], + horizontal: [ + "flex-row items-center", + "[&>[data-slot=field-label]]:flex-auto", + "has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + ], + responsive: [ + "flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto", + "@md/field-group:[&>[data-slot=field-label]]:flex-auto", + "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + ], + }, + }, + defaultVariants: { + orientation: "vertical", + }, + } +) + +function Field({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function FieldLabel({ + className, + ...props +}: React.ComponentProps) { + return ( +