diff --git a/apps/indexer/eslint.config.mjs b/apps/indexer/eslint.config.mjs index 7db0f0f..042a019 100644 --- a/apps/indexer/eslint.config.mjs +++ b/apps/indexer/eslint.config.mjs @@ -20,6 +20,7 @@ export default [ ], }, ], + '@typescript-eslint/no-unused-vars': 'warn', }, languageOptions: { parser: await import('jsonc-eslint-parser'), diff --git a/apps/indexer/package.json b/apps/indexer/package.json index 58fd034..7924c09 100644 --- a/apps/indexer/package.json +++ b/apps/indexer/package.json @@ -160,6 +160,7 @@ "pg": "8.16.3", "pino": "9.11.0", "pino-pretty": "13.1.1", + "viem": "2.38.6", "zod": "4.1.11" }, "devDependencies": { diff --git a/apps/indexer/src/app/app.ts b/apps/indexer/src/app/app.ts index 1f2b3d7..3fa6bcb 100644 --- a/apps/indexer/src/app/app.ts +++ b/apps/indexer/src/app/app.ts @@ -28,4 +28,11 @@ export async function app(fastify: FastifyInstance, opts: AppOptions) { indexPattern: /^routes\.js$/, options: { ...opts }, }); + + fastify.addHook('onError', async (request, _reply, error) => { + if (error?.statusCode === 500) { + // push to Sentry, Prometheus, etc. + request.log.error({ err: error }, 'unhandled error'); + } + }); } diff --git a/apps/indexer/src/app/plugins/bullmq.ts b/apps/indexer/src/app/plugins/bullmq.ts index 6d22d90..f6f1196 100644 --- a/apps/indexer/src/app/plugins/bullmq.ts +++ b/apps/indexer/src/app/plugins/bullmq.ts @@ -6,18 +6,21 @@ import fp from 'fastify-plugin'; import { ENV } from '../../env'; import { ingestQueue } from '../../workers/queues'; -export default fp(async function (fastify: FastifyInstance) { - if (ENV.isProd && !ENV.FLAGS.includes('ui')) { - // only show UI in non-prod environments - return; - } - const serverAdapter = new FastifyAdapter(); - serverAdapter.setBasePath('/ui'); +export default fp( + async function (fastify: FastifyInstance) { + if (ENV.isProd && !ENV.FLAGS.includes('ui')) { + // only show UI in non-prod environments + return; + } + const serverAdapter = new FastifyAdapter(); + serverAdapter.setBasePath('/ui'); - createBullBoard({ - queues: [ingestQueue].map((q) => new BullMQAdapter(q)), - serverAdapter, - }); + createBullBoard({ + queues: [ingestQueue].map((q) => new BullMQAdapter(q)), + serverAdapter, + }); - fastify.register(serverAdapter.registerPlugin(), { prefix: '/ui' }); -}); + fastify.register(serverAdapter.registerPlugin(), { prefix: '/ui' }); + }, + { name: 'bullmq-ui' }, +); diff --git a/apps/indexer/src/app/plugins/cache.ts b/apps/indexer/src/app/plugins/cache.ts new file mode 100644 index 0000000..cf37b30 --- /dev/null +++ b/apps/indexer/src/app/plugins/cache.ts @@ -0,0 +1,277 @@ +import type { + FastifyInstance, + FastifyPluginAsync, + FastifyReply, + FastifyRequest, +} from 'fastify'; +import fp from 'fastify-plugin'; +import { ENV } from '../../env'; +import { encode } from '../../libs/encode'; +import { createRedisConnection } from '../../libs/utils/redis'; + +const cacheRedisConnection = createRedisConnection( + ENV.REDIS_URL, + ENV.REDIS_CLUSTER_MODE, + { + keyPrefix: 'indexer-cache:', + }, +); + +type RedisClient = ReturnType; + +export type RouteCacheOptions = { + /** Enable/disable cache for this route (default: true if cache is set) */ + enabled?: boolean; + /** TTL for this route in seconds (default: plugin defaultTtlSeconds or 60) */ + ttlSeconds?: number; + /** + * Serve stale responses for this many extra seconds while + * a background refresh runs (stale-while-revalidate window). + */ + staleTtlSeconds?: number; + /** + * Enable/disable background revalidation (default: true if staleTtlSeconds set). + */ + backgroundRevalidate?: boolean; + /** Build a custom cache key based on request */ + key?: (req: FastifyRequest) => string; +}; + +export type RedisCachePluginOptions = { + /** Existing Redis client instance (if you already manage it elsewhere) */ + redisClient?: RedisClient; + /** Or pass Redis URL, e.g. redis://localhost:6379 */ + redisUrl?: string; + /** Default TTL in seconds for all cached routes */ + defaultTtlSeconds?: number; + /** Default stale TTL in seconds for all cached routes */ + defaultStaleTtlSeconds?: number; + /** Prefix for all cache keys */ + keyPrefix?: string; +}; + +type CacheEntry = { + payload: unknown; + headers?: Record; + statusCode: number; + storedAt: number; // ms since epoch + ttlSeconds: number; +}; + +declare module 'fastify' { + interface FastifyInstance { + cacheRedis: RedisClient; + } + + interface FastifyRequest { + __cacheKey?: string; + __cacheHit?: boolean; + } + + // interface RouteShorthandOptions { + // /** + // * If present, enables response caching for this route (default false). + // * - `true` -> use defaults + // * - `false` -> no cache + // * - object -> fine-grained control + // */ + // cache?: RouteCacheOptions | boolean; + // } + + interface FastifyContextConfig { + /** + * If present, enables response caching for this route (default false). + * - `true` -> use defaults + * - `false` -> no cache + * - object -> fine-grained control + */ + cache?: RouteCacheOptions | boolean; + } +} + +const redisCachePlugin: FastifyPluginAsync = async ( + fastify: FastifyInstance, + opts: RedisCachePluginOptions, +) => { + const redis = opts.redisClient ?? cacheRedisConnection; + + const defaultTtl = opts.defaultTtlSeconds ?? 60; + const defaultStaleTtl = opts.defaultStaleTtlSeconds ?? 600; // 10 minutes + const keyPrefix = opts.keyPrefix ?? 'route-cache'; + + // @ts-expect-error declare decorator + fastify.decorate('cacheRedis', redis); + + const getRouteCacheConfig = ( + req: FastifyRequest, + ): RouteCacheOptions | null => { + const rawCfg = req.routeOptions.config.cache; + if (!rawCfg) return null; + + if (typeof rawCfg === 'boolean') { + if (!rawCfg) return null; + return { enabled: true }; + } + + if (rawCfg.enabled === false) return null; + + return { enabled: true, ...rawCfg }; + }; + + // 1) Try to serve from cache + fastify.addHook( + 'preHandler', + async (req: FastifyRequest, reply: FastifyReply) => { + const cfg = getRouteCacheConfig(req); + + if (!cfg) return; + + // Internal revalidation request: do not serve from cache + if (req.headers['x-cache-revalidate'] === '1') { + return; + } + + const ttl = cfg.ttlSeconds ?? defaultTtl; + const staleTtl = cfg.staleTtlSeconds ?? defaultStaleTtl; + const routeUrl = + req.routeOptions.url ?? req.raw.url?.split('?')[0] ?? 'unknown'; + + const key = + keyPrefix + + ':' + + (cfg.key?.(req) ?? + encode.sha256( + `${routeUrl}:${req.raw.method}:` + + `${JSON.stringify(req.query ?? {})}:${JSON.stringify(req.body ?? {})}`, + )); + + req.__cacheKey = key; + + const cached = await redis.get(key); + if (!cached) return; + + const entry: CacheEntry = JSON.parse(cached); + const ageSec = (Date.now() - entry.storedAt) / 1000; + + const isFresh = ageSec <= entry.ttlSeconds; + const isWithinStale = + !isFresh && staleTtl > 0 && ageSec <= entry.ttlSeconds + staleTtl; + + if (!isFresh && !isWithinStale) { + // Hard expired: ignore cache + return; + } + + req.__cacheHit = true; + + if (entry.headers) { + for (const [hKey, hVal] of Object.entries(entry.headers)) { + // do not override critical hop-by-hop headers if you don't want to + if (hKey.toLowerCase() === 'content-length') continue; + if (hKey.toLowerCase() === 'x-cache') continue; + reply.header(hKey, hVal); + } + } + + reply.header('x-cache', isFresh ? 'HIT' : 'HIT-STALE'); + reply.code(entry.statusCode); + reply.send(entry.payload); + + // Background revalidation for stale entries + if (isWithinStale && (cfg.backgroundRevalidate ?? true)) { + const lockKey = `${key}:revalidate-lock`; + const lockTtl = Math.max(5, Math.floor(ttl / 2)); // seconds + + // Try to acquire revalidation lock + try { + const lockResult = await redis.setnx(lockKey, '1'); + if (lockResult === 1) { + await redis.expire(lockKey, lockTtl); + // Fire-and-forget background refresh + (async () => { + try { + await fastify.inject({ + // @ts-expect-error bad type + method: req.raw.method, + url: req.raw.url ?? routeUrl, + // @ts-expect-error bad type + payload: req.body, + // @ts-expect-error bad type + query: req.query, + headers: { + ...req.headers, + 'x-cache-revalidate': '1', + }, + }); + } finally { + // Let the lock expire naturally; optional explicit delete: + // await redis.del(lockKey); + } + })().catch((err) => { + fastify.log.error({ err }, 'cache revalidation failed'); + }); + } + } catch (err) { + fastify.log.error({ err }, 'failed to acquire revalidate lock'); + } + } + }, + ); + + // 2) Store response into cache + fastify.addHook( + 'onSend', + async (req: FastifyRequest, reply: FastifyReply, payload) => { + const rawCfg = req.routeOptions.config.cache; + if (!rawCfg) return payload; + + const cfg: RouteCacheOptions = + typeof rawCfg === 'boolean' ? { enabled: rawCfg } : rawCfg; + + if (cfg.enabled === false) return payload; + + if (req.__cacheHit) { + return payload; + } + + const key = req.__cacheKey; + + if (!key) return payload; + + // By default, don't cache server error responses (5xx) + if (reply.statusCode >= 500) return payload; + + const ttl = cfg.ttlSeconds ?? defaultTtl; + + const headers = reply.getHeaders() as Record; + for (const h of Object.keys(headers)) { + if (h.toLowerCase() === 'x-cache') { + delete headers[h]; + } + } + + const entry: CacheEntry = { + payload, + headers, + statusCode: reply.statusCode, + storedAt: Date.now(), + ttlSeconds: ttl, + }; + + const expireSeconds = ttl + (cfg.staleTtlSeconds ?? defaultStaleTtl); + + await redis.setex(key, expireSeconds, JSON.stringify(entry)); + + // For "real" client requests (not internal revalidation), set MISS header + if (req.headers['x-cache-revalidate'] !== '1') { + reply.header('x-cache', 'MISS'); + } + + return payload; + }, + ); +}; + +export default fp(redisCachePlugin, { + name: 'cache-plugin', +}); diff --git a/apps/indexer/src/app/plugins/sensible.ts b/apps/indexer/src/app/plugins/sensible.ts index 7ad73ad..21e0385 100644 --- a/apps/indexer/src/app/plugins/sensible.ts +++ b/apps/indexer/src/app/plugins/sensible.ts @@ -1,12 +1,15 @@ +import sensible from '@fastify/sensible'; import { FastifyInstance } from 'fastify'; import fp from 'fastify-plugin'; -import sensible from '@fastify/sensible'; /** * This plugins adds some utilities to handle http errors * * @see https://github.com/fastify/fastify-sensible */ -export default fp(async function (fastify: FastifyInstance) { - fastify.register(sensible); -}); +export default fp( + async function (fastify: FastifyInstance) { + fastify.register(sensible); + }, + { name: 'sensible' }, +); diff --git a/apps/indexer/src/app/routes/_chain/routes.ts b/apps/indexer/src/app/routes/_chain/routes.ts index b942330..e144543 100644 --- a/apps/indexer/src/app/routes/_chain/routes.ts +++ b/apps/indexer/src/app/routes/_chain/routes.ts @@ -1,15 +1,75 @@ +import { areAddressesEqual, Decimal } from '@sovryn/slayer-shared'; import { and, asc, eq, gte, inArray } from 'drizzle-orm'; -import { FastifyInstance } from 'fastify'; +import { FastifyInstance, FastifyRequest } from 'fastify'; import { ZodTypeProvider } from 'fastify-type-provider-zod'; -import gql from 'graphql-tag'; import z from 'zod'; -import { chains } from '../../../configs/chains'; import { client } from '../../../database/client'; import { tTokens } from '../../../database/schema'; -import { tTokensSelectors } from '../../../database/selectors'; -import { queryFromSubgraph } from '../../../libs/loaders/subgraph'; +import { TTokenSelected, tTokensSelectors } from '../../../database/selectors'; +import { + fetchPoolList, + fetchPoolReserves, + selectPoolById, +} from '../../../libs/loaders/money-market'; import { paginationResponse, paginationSchema } from '../../../libs/pagination'; +interface ReserveDataHumanized { + originalId: number; + id: string; + underlyingAsset: string; + + token: TTokenSelected; + + name: string; + symbol: string; + decimals: number; + baseLTVasCollateral: string; + reserveLiquidationThreshold: string; + reserveLiquidationBonus: string; + reserveFactor: string; + usageAsCollateralEnabled: boolean; + borrowingEnabled: boolean; + isActive: boolean; + isFrozen: boolean; + liquidityIndex: string; + variableBorrowIndex: string; + liquidityRate: string; + variableBorrowRate: string; + lastUpdateTimestamp: number; + aTokenAddress: string; + variableDebtTokenAddress: string; + interestRateStrategyAddress: string; + availableLiquidity: string; + totalScaledVariableDebt: string; + priceInMarketReferenceCurrency: string; + priceOracle: string; + variableRateSlope1: string; + variableRateSlope2: string; + baseVariableBorrowRate: string; + optimalUsageRatio: string; + // v3 only + isPaused: boolean; + isSiloedBorrowing: boolean; + accruedToTreasury: string; + unbacked: string; + isolationModeTotalDebt: string; + flashLoanEnabled: boolean; + debtCeiling: string; + debtCeilingDecimals: number; + borrowCap: string; + supplyCap: string; + borrowableInIsolation: boolean; + virtualAccActive: boolean; + virtualUnderlyingBalance: string; +} + +interface PoolBaseCurrencyHumanized { + marketReferenceCurrencyDecimals: number; + marketReferenceCurrencyPriceInUsd: string; + networkBaseTokenPriceInUsd: string; + networkBaseTokenPriceDecimals: number; +} + export default async function (fastify: FastifyInstance) { fastify.get('/', async (req) => { return { data: req.chain }; @@ -21,6 +81,13 @@ export default async function (fastify: FastifyInstance) { schema: { querystring: paginationSchema, }, + config: { + cache: { + key: (req) => `chain:${req.chain.chainId}:tokens`, + ttlSeconds: 30, + enabled: true, + }, + }, }, async (req) => { const items = await client.query.tTokens.findMany({ @@ -40,62 +107,28 @@ export default async function (fastify: FastifyInstance) { fastify.withTypeProvider().get( '/money-market', { - schema: { - querystring: paginationSchema, + config: { + cache: true, }, }, async (req, reply) => { - if (req.chain.key !== 'bob-sepolia') { + try { + const data = await fetchPoolList(req.chain.chainId); + return { data }; + } catch (err) { + fastify.log.error( + { err, chainId: req.chain.chainId }, + `error: fetchMoneyMarketByChain`, + ); return reply.notFound( - 'Money Market data is only available for BOB Sepolia', + 'Money Market data is not available for this chain', ); } - - const chain = chains.get('bob-sepolia'); - const { cursor, limit } = req.query; - - const data = await queryFromSubgraph<{ - pools: Array<{ - id: string; - pool: string; - }>; - }>( - chain.aaveSubgraphUrl, - gql` - query ($first: Int!, $cursor: String) { - pools(first: $first, where: { id_gt: $cursor }) { - id - pool - addressProviderId - poolCollateralManager - poolImpl - poolDataProviderImpl - poolConfigurator - proxyPriceProvider - lastUpdateTimestamp - bridgeProtocolFee - flashloanPremiumTotal - flashloanPremiumToProtocol - } - } - `, - { - first: limit, - cursor: cursor ?? '', - }, - ); - - const items = data.pools.map((p) => ({ - pool: p.pool, - addressProvider: p.id, - })); - - return paginationResponse(items, limit, 'addressProvider'); }, ); fastify.withTypeProvider().get( - '/money-market/:pool', + '/money-market/:pool/reserves', { schema: { querystring: paginationSchema, @@ -103,65 +136,20 @@ export default async function (fastify: FastifyInstance) { pool: z.string(), }), }, + config: { + cache: true, + }, }, - async (req, reply) => { - if (req.chain.key !== 'bob-sepolia') { - return reply.notFound( - 'Money Market data is only available for BOB Sepolia', - ); - } + async (req: FastifyRequest<{ Params: { pool: string } }>, reply) => { + const pools = await fetchPoolList(req.chain.chainId); + const pool = selectPoolById(req.params.pool, pools); - const chain = chains.get('bob-sepolia'); - const { pool } = req.params; - const { cursor, limit } = req.query; - - const data = await queryFromSubgraph<{ - reserves: Array<{ - id: string; - totalLiquidity: string; - underlyingAsset: string; - usageAsCollateralEnabled: boolean; - borrowingEnabled: boolean; - pool: { - id: string; - pool: string; - }; - }>; - }>( - chain.aaveSubgraphUrl, - gql` - query ($pool: String!, $first: Int!, $cursor: String) { - reserves( - first: $first - where: { pool_: { id: $pool }, id_gt: $cursor } - ) { - underlyingAsset - pool { - id - pool - } - symbol - name - decimals - usageAsCollateralEnabled - borrowingEnabled - totalLiquidity - totalATokenSupply - totalLiquidityAsCollateral - availableLiquidity - totalSupplies - liquidityRate - } - } - `, - { - pool, - first: limit, - cursor: cursor ?? '', - }, - ); + if (!pool) { + return reply.notFound('Pool not found'); + } - const items = data.reserves; + const { 0: reservesRaw, 1: poolBaseCurrencyRaw } = + await fetchPoolReserves(req.chain.chainId, pool); const tokens = await client.query.tTokens.findMany({ columns: tTokensSelectors.columns, @@ -169,19 +157,109 @@ export default async function (fastify: FastifyInstance) { eq(tTokens.chainId, req.chain.chainId), inArray( tTokens.address, - items.map((i) => i.underlyingAsset), + reservesRaw.map((i) => i.underlyingAsset.toLowerCase()), ), ), }); - const merged = items - .map((item) => ({ - ...item, - token: tokens.find((t) => t.address === item.underlyingAsset), - })) - .filter((i) => i.token); + const reservesData: Partial[] = reservesRaw.map( + (reserveRaw, index) => { + // const virtualUnderlyingBalance = + // reserveRaw.virtualUnderlyingBalance.toString(); + // const { virtualAccActive } = reserveRaw; + return { + originalId: index, + id: `${req.chain.chainId}-${reserveRaw.underlyingAsset}-${pool.address}`.toLowerCase(), + // underlyingAsset: reserveRaw.underlyingAsset.toLowerCase(), + + token: tokens.find((t) => + areAddressesEqual(t.address, reserveRaw.underlyingAsset), + ), + pool, + + // name: reserveRaw.name, + // symbol: ammSymbolMap[reserveRaw.underlyingAsset.toLowerCase()] + // ? ammSymbolMap[reserveRaw.underlyingAsset.toLowerCase()] + // : reserveRaw.symbol, + // decimals: reserveRaw.decimals.toNumber(), + baseLTVasCollateral: reserveRaw.baseLTVasCollateral.toString(), + reserveLiquidationThreshold: + reserveRaw.reserveLiquidationThreshold.toString(), + reserveLiquidationBonus: + reserveRaw.reserveLiquidationBonus.toString(), + reserveFactor: reserveRaw.reserveFactor.toString(), + usageAsCollateralEnabled: reserveRaw.usageAsCollateralEnabled, + borrowingEnabled: reserveRaw.borrowingEnabled, + isActive: reserveRaw.isActive, + isFrozen: reserveRaw.isFrozen, + liquidityIndex: reserveRaw.liquidityIndex.toString(), + variableBorrowIndex: reserveRaw.variableBorrowIndex.toString(), + liquidityRate: reserveRaw.liquidityRate.toString(), + variableBorrowRate: reserveRaw.variableBorrowRate.toString(), + lastUpdateTimestamp: reserveRaw.lastUpdateTimestamp, + aTokenAddress: reserveRaw.aTokenAddress.toString(), + variableDebtTokenAddress: + reserveRaw.variableDebtTokenAddress.toString(), + interestRateStrategyAddress: + reserveRaw.interestRateStrategyAddress.toString(), + availableLiquidity: Decimal.from( + reserveRaw.availableLiquidity, + reserveRaw.decimals.toNumber(), + ).toString(), + // availableLiquidity: reserveRaw.availableLiquidity.toString(), + totalScaledVariableDebt: + reserveRaw.totalScaledVariableDebt.toString(), + priceInMarketReferenceCurrency: + reserveRaw.priceInMarketReferenceCurrency.toString(), + // priceOracle: reserveRaw.priceOracle, + variableRateSlope1: reserveRaw.variableRateSlope1.toString(), + variableRateSlope2: reserveRaw.variableRateSlope2.toString(), + // baseVariableBorrowRate: + // reserveRaw.baseVariableBorrowRate.toString(), + // optimalUsageRatio: reserveRaw.optimalUsageRatio.toString(), + // new fields + // isPaused: reserveRaw.isPaused, + // debtCeiling: reserveRaw.debtCeiling.toString(), + // borrowCap: reserveRaw.borrowCap.toString(), + // supplyCap: reserveRaw.supplyCap.toString(), + // borrowableInIsolation: reserveRaw.borrowableInIsolation, + // accruedToTreasury: reserveRaw.accruedToTreasury.toString(), + // unbacked: reserveRaw.unbacked.toString(), + // isolationModeTotalDebt: + // reserveRaw.isolationModeTotalDebt.toString(), + // debtCeilingDecimals: reserveRaw.debtCeilingDecimals.toNumber(), + // isSiloedBorrowing: reserveRaw.isSiloedBorrowing, + // flashLoanEnabled: reserveRaw.flashLoanEnabled, + // virtualAccActive, + // virtualUnderlyingBalance, + }; + }, + ); + + const baseCurrencyData: PoolBaseCurrencyHumanized = { + // this is to get the decimals from the unit so 1e18 = string length of 19 - 1 to get the number of 0 + marketReferenceCurrencyDecimals: + poolBaseCurrencyRaw.marketReferenceCurrencyUnit.toString().length - 1, + marketReferenceCurrencyPriceInUsd: + poolBaseCurrencyRaw.marketReferenceCurrencyPriceInUsd.toString(), + networkBaseTokenPriceInUsd: + poolBaseCurrencyRaw.networkBaseTokenPriceInUsd.toString(), + networkBaseTokenPriceDecimals: + poolBaseCurrencyRaw.networkBaseTokenPriceDecimals, + }; + + return { data: { reservesData, baseCurrencyData } }; - return paginationResponse(merged, limit, 'id'); + // return { + // data: items + // .map((item) => ({ + // ...item, + // token: tokens.find((t) => t.address === item.underlyingAsset), + // })) + // .filter((i) => i.token), + // nextCursor: null, + // count: items.length, + // }; }, ); } diff --git a/apps/indexer/src/configs/chains.ts b/apps/indexer/src/configs/chains.ts index 92d8171..b3b76dc 100644 --- a/apps/indexer/src/configs/chains.ts +++ b/apps/indexer/src/configs/chains.ts @@ -1,8 +1,18 @@ +import { + createPublicClient, + http, + PublicClient, + Transport, + Chain as ViemChain, +} from 'viem'; +import { bobSepolia, rootstock, rootstockTestnet } from 'viem/chains'; type ChainConfig = { key: string; chainId: number; name: string; + rpc: PublicClient; + // Aave subgraph URL aaveSubgraphUrl?: string; aavePriceFeedUrl?: string; @@ -11,18 +21,30 @@ type ChainConfig = { const items = [ { key: 'rootstock', - chainId: 30, + chainId: rootstock.id, name: 'Rootstock', + rpc: createPublicClient({ + chain: rootstock, + transport: http(rootstock.rpcUrls.default.http[0]), + }), }, { key: 'rsk-testnet', - chainId: 31, + chainId: rootstockTestnet.id, name: 'Rootstock Testnet', + rpc: createPublicClient({ + chain: rootstockTestnet, + transport: http(rootstockTestnet.rpcUrls.default.http[0]), + }), }, { key: 'bob-sepolia', - chainId: 808813, + chainId: bobSepolia.id, name: 'BOB Sepolia', + rpc: createPublicClient({ + chain: bobSepolia, + transport: http(bobSepolia.rpcUrls.default.http[0]), + }) as PublicClient, aaveSubgraphUrl: 'https://bob-mm.test.sovryn.app/subgraphs/name/DistributedCollective/sov-protocol-subgraphs', aavePriceFeedUrl: 'https://bob-mm-cache.test.sovryn.app/data/rates-history', diff --git a/apps/indexer/src/configs/redis.ts b/apps/indexer/src/configs/redis.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/indexer/src/database/schema.ts b/apps/indexer/src/database/schema.ts index 472337d..0c66f3e 100644 --- a/apps/indexer/src/database/schema.ts +++ b/apps/indexer/src/database/schema.ts @@ -40,10 +40,13 @@ export const tTokens = pgTable( export type TToken = typeof tTokens.$inferSelect; export type TNewToken = typeof tTokens.$inferInsert; -export enum TIngestionSourceMode { - backfill = 'backfill', - live = 'live', -} +export const TIngestionSourceMode = { + backfill: 'backfill', + live: 'live', +} as const; + +export type TIngestionSourceMode = + (typeof TIngestionSourceMode)[keyof typeof TIngestionSourceMode]; export const tIngestionSources = pgTable('ingestion_sources', { id: serial('id'), @@ -71,3 +74,18 @@ export const tIngestionSources = pgTable('ingestion_sources', { export type TIngestionSource = typeof tIngestionSources.$inferSelect; export type TNewIngestionSource = typeof tIngestionSources.$inferInsert; + +export const tPools = pgTable('pools', { + id: serial('id'), + chainId: integer('chain_id').notNull(), + // pool address + address: char('address', { length: 42 }).notNull().primaryKey(), + + wethGateway: char('weth_gateway', { length: 42 }).notNull(), + uiPoolDataProvider: char('ui_pool_data_provider', { length: 42 }).notNull(), + poolAddressesProvider: char('address_provider', { length: 42 }).notNull(), + variableDebtEth: char('variable_debt_eth', { length: 42 }).notNull(), + weth: char('weth', { length: 42 }).notNull(), + treasury: char('treasury', { length: 42 }).notNull(), + ...timestamps, +}); diff --git a/apps/indexer/src/database/selectors.ts b/apps/indexer/src/database/selectors.ts index 566ce69..53e81e2 100644 --- a/apps/indexer/src/database/selectors.ts +++ b/apps/indexer/src/database/selectors.ts @@ -1,4 +1,4 @@ -import { tTokens } from './schema'; +import { TToken, tTokens } from './schema'; export const tTokensSelectors = { columns: { @@ -16,3 +16,8 @@ export const tTokensSelectors = { logoUrl: tTokens.logoUrl, }, } as const; + +export type TTokenSelected = Pick< + TToken, + keyof typeof tTokensSelectors.columns +>; diff --git a/apps/indexer/src/libs/loaders/money-market.ts b/apps/indexer/src/libs/loaders/money-market.ts new file mode 100644 index 0000000..ffbc952 --- /dev/null +++ b/apps/indexer/src/libs/loaders/money-market.ts @@ -0,0 +1,409 @@ +import { Address } from 'viem'; +import { ChainId, chains, ChainSelector } from '../../configs/chains'; +import { BASE_DEFINITIONS_URL } from '../../configs/constants'; + +const uiPoolDataProviderAbi = [ + { + inputs: [ + { + internalType: 'contract IChainlinkAggregator', + name: '_networkBaseTokenPriceInUsdProxyAggregator', + type: 'address', + }, + { + internalType: 'contract IChainlinkAggregator', + name: '_marketReferenceCurrencyPriceInUsdProxyAggregator', + type: 'address', + }, + ], + stateMutability: 'nonpayable', + type: 'constructor', + }, + { + inputs: [], + name: 'ETH_CURRENCY_UNIT', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'contract ILendingPoolAddressesProvider', + name: 'provider', + type: 'address', + }, + ], + name: 'getReservesData', + outputs: [ + { + components: [ + { + internalType: 'address', + name: 'underlyingAsset', + type: 'address', + }, + { + internalType: 'string', + name: 'name', + type: 'string', + }, + { + internalType: 'string', + name: 'symbol', + type: 'string', + }, + { + internalType: 'uint256', + name: 'decimals', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'baseLTVasCollateral', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'reserveLiquidationThreshold', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'reserveLiquidationBonus', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'reserveFactor', + type: 'uint256', + }, + { + internalType: 'bool', + name: 'usageAsCollateralEnabled', + type: 'bool', + }, + { + internalType: 'bool', + name: 'borrowingEnabled', + type: 'bool', + }, + { + internalType: 'bool', + name: 'stableBorrowRateEnabled', + type: 'bool', + }, + { + internalType: 'bool', + name: 'isActive', + type: 'bool', + }, + { + internalType: 'bool', + name: 'isFrozen', + type: 'bool', + }, + { + internalType: 'uint128', + name: 'liquidityIndex', + type: 'uint128', + }, + { + internalType: 'uint128', + name: 'variableBorrowIndex', + type: 'uint128', + }, + { + internalType: 'uint128', + name: 'liquidityRate', + type: 'uint128', + }, + { + internalType: 'uint128', + name: 'variableBorrowRate', + type: 'uint128', + }, + { + internalType: 'uint128', + name: 'stableBorrowRate', + type: 'uint128', + }, + { + internalType: 'uint40', + name: 'lastUpdateTimestamp', + type: 'uint40', + }, + { + internalType: 'address', + name: 'aTokenAddress', + type: 'address', + }, + { + internalType: 'address', + name: 'stableDebtTokenAddress', + type: 'address', + }, + { + internalType: 'address', + name: 'variableDebtTokenAddress', + type: 'address', + }, + { + internalType: 'address', + name: 'interestRateStrategyAddress', + type: 'address', + }, + { + internalType: 'uint256', + name: 'availableLiquidity', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'totalPrincipalStableDebt', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'averageStableRate', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'stableDebtLastUpdateTimestamp', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'totalScaledVariableDebt', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'priceInMarketReferenceCurrency', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'variableRateSlope1', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'variableRateSlope2', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'stableRateSlope1', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'stableRateSlope2', + type: 'uint256', + }, + ], + internalType: 'struct IUiPoolDataProvider.AggregatedReserveData[]', + name: '', + type: 'tuple[]', + }, + { + components: [ + { + internalType: 'uint256', + name: 'marketReferenceCurrencyUnit', + type: 'uint256', + }, + { + internalType: 'int256', + name: 'marketReferenceCurrencyPriceInUsd', + type: 'int256', + }, + { + internalType: 'int256', + name: 'networkBaseTokenPriceInUsd', + type: 'int256', + }, + { + internalType: 'uint8', + name: 'networkBaseTokenPriceDecimals', + type: 'uint8', + }, + ], + internalType: 'struct IUiPoolDataProvider.BaseCurrencyInfo', + name: '', + type: 'tuple', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'contract ILendingPoolAddressesProvider', + name: 'provider', + type: 'address', + }, + ], + name: 'getReservesList', + outputs: [ + { + internalType: 'address[]', + name: '', + type: 'address[]', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'contract ILendingPoolAddressesProvider', + name: 'provider', + type: 'address', + }, + { + internalType: 'address', + name: 'user', + type: 'address', + }, + ], + name: 'getUserReservesData', + outputs: [ + { + components: [ + { + internalType: 'address', + name: 'underlyingAsset', + type: 'address', + }, + { + internalType: 'uint256', + name: 'scaledATokenBalance', + type: 'uint256', + }, + { + internalType: 'bool', + name: 'usageAsCollateralEnabledOnUser', + type: 'bool', + }, + { + internalType: 'uint256', + name: 'stableBorrowRate', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'scaledVariableDebt', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'principalStableDebt', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'stableBorrowLastUpdateTimestamp', + type: 'uint256', + }, + ], + internalType: 'struct IUiPoolDataProvider.UserReserveData[]', + name: '', + type: 'tuple[]', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'marketReferenceCurrencyPriceInUsdProxyAggregator', + outputs: [ + { + internalType: 'contract IChainlinkAggregator', + name: '', + type: 'address', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'networkBaseTokenPriceInUsdProxyAggregator', + outputs: [ + { + internalType: 'contract IChainlinkAggregator', + name: '', + type: 'address', + }, + ], + stateMutability: 'view', + type: 'function', + }, +] as const; + +type PoolDefinition = { + id: string | 'default'; + name: string; + logoURI: string; + address: Address; + wethGateway: Address; + uiPoolDataProvider: Address; + poolAddressesProvider: Address; + variableDebtEth: Address; + weth: Address; + treasury: Address; + subgraphURI: string; + priceFeedURI: string; +}; + +export async function fetchPoolList(chainId: ChainId) { + const url = `${BASE_DEFINITIONS_URL}/chains/${chainId}/money-market.json`; + + const response = await fetch(url); + + if (!response.ok) { + throw new Error( + `Failed to fetch pools for chainId ${chainId}: ${response.statusText}`, + ); + } + + const data = (await response.json()) as { items: PoolDefinition[] }; + + return data.items; +} + +export function selectPoolById( + id: PoolDefinition['id'], + pools: PoolDefinition[], +) { + return pools.find((pool) => pool.id === id); +} + +export async function fetchPoolReserves( + chainId: ChainSelector, + pool: PoolDefinition, +) { + const chain = chains.get(chainId); + if (!chain) { + throw new Error(`Unsupported chain: ${chainId}`); + } + + return chain.rpc.readContract({ + address: pool.uiPoolDataProvider, + abi: uiPoolDataProviderAbi, + functionName: 'getReservesData', + args: [pool.poolAddressesProvider], + }); +} diff --git a/apps/indexer/src/libs/shims.ts b/apps/indexer/src/libs/shims.ts new file mode 100644 index 0000000..44ad6b8 --- /dev/null +++ b/apps/indexer/src/libs/shims.ts @@ -0,0 +1,14 @@ +declare global { + interface BigInt { + toJSON(): string; + toNumber(): number; + } +} + +BigInt.prototype.toJSON = function () { + return this.toString(); +}; + +BigInt.prototype.toNumber = function () { + return Number(this); +}; diff --git a/apps/indexer/src/libs/utils/redis.ts b/apps/indexer/src/libs/utils/redis.ts new file mode 100644 index 0000000..9e39ca2 --- /dev/null +++ b/apps/indexer/src/libs/utils/redis.ts @@ -0,0 +1,33 @@ +import IORedis, { RedisOptions } from 'ioredis'; + +export function createRedisConnection( + REDIS_URL: string, + CLUSTER_MODE = false, + opts: RedisOptions = {}, +) { + const u = new URL(REDIS_URL); + + // Common options (apply to every node connection) + const redisOptions: RedisOptions = { + // ACL (Redis 6+). If you don't use ACLs, omit username. + username: u.username || undefined, + password: u.password || undefined, + // TLS for rediss:// + tls: u.protocol === 'rediss:' ? {} : undefined, + // Often desirable in server apps (avoid request-level timeouts) + maxRetriesPerRequest: null, + ...opts, + }; + + if (CLUSTER_MODE) { + const seed = [{ host: u.hostname, port: Number(u.port || 6379) }]; + + return new IORedis.Cluster(seed, { + dnsLookup: (addr, cb) => cb(null, addr), + redisOptions, + }); + } + + // For single-instance (cluster mode disabled), this would be: + return new IORedis(REDIS_URL, redisOptions); +} diff --git a/apps/indexer/src/main.ts b/apps/indexer/src/main.ts index 634c4d1..7436e22 100644 --- a/apps/indexer/src/main.ts +++ b/apps/indexer/src/main.ts @@ -1,5 +1,6 @@ // env must be imported first import { ENV } from './env'; +import './libs/shims'; // other imports import cors from '@fastify/cors'; import { migrate } from 'drizzle-orm/node-postgres/migrator'; diff --git a/apps/indexer/src/workers/queues.ts b/apps/indexer/src/workers/queues.ts index a93c599..c9cb95a 100644 --- a/apps/indexer/src/workers/queues.ts +++ b/apps/indexer/src/workers/queues.ts @@ -1,8 +1,8 @@ import { Queue } from 'bullmq'; import { IngestWorkerType } from './ingest/types'; -import { INGEST_QUEUE_NAME, redisConnection } from './shared'; +import { INGEST_QUEUE_NAME, queueRedisConnection } from './shared'; export const ingestQueue = new Queue(INGEST_QUEUE_NAME, { - connection: redisConnection, + connection: queueRedisConnection, prefix: '{slayer:ingest}', }); diff --git a/apps/indexer/src/workers/shared.ts b/apps/indexer/src/workers/shared.ts index 2918457..bea4a6b 100644 --- a/apps/indexer/src/workers/shared.ts +++ b/apps/indexer/src/workers/shared.ts @@ -1,33 +1,9 @@ -import IORedis, { RedisOptions } from 'ioredis'; import { ENV } from '../env'; +import { createRedisConnection } from '../libs/utils/redis'; -export const redisConnection = createRedis(ENV.REDIS_URL); +export const queueRedisConnection = createRedisConnection( + ENV.REDIS_URL, + ENV.REDIS_CLUSTER_MODE, +); export const INGEST_QUEUE_NAME = 'ingest'; - -function createRedis(REDIS_URL: string) { - const u = new URL(REDIS_URL); - - // Common options (apply to every node connection) - const redisOptions: RedisOptions = { - // ACL (Redis 6+). If you don't use ACLs, omit username. - username: u.username || undefined, - password: u.password || undefined, - // TLS for rediss:// - tls: u.protocol === 'rediss:' ? {} : undefined, - // Often desirable in server apps (avoid request-level timeouts) - maxRetriesPerRequest: null, - }; - - if (ENV.REDIS_CLUSTER_MODE) { - const seed = [{ host: u.hostname, port: Number(u.port || 6379) }]; - - return new IORedis.Cluster(seed, { - dnsLookup: (addr, cb) => cb(null, addr), - redisOptions, - }); - } - - // For single-instance (cluster mode disabled), this would be: - return new IORedis(REDIS_URL, redisOptions); -} diff --git a/apps/indexer/src/workers/spawner.ts b/apps/indexer/src/workers/spawner.ts index fa2618d..f5cc544 100644 --- a/apps/indexer/src/workers/spawner.ts +++ b/apps/indexer/src/workers/spawner.ts @@ -5,7 +5,7 @@ import { logger } from '../libs/logger'; import { onShutdown } from '../libs/shutdown'; import { onReady } from '../libs/startup'; import fn from './ingest/processor'; -import { INGEST_QUEUE_NAME, redisConnection } from './shared'; +import { INGEST_QUEUE_NAME, queueRedisConnection } from './shared'; if (!ENV.READ_ONLY_MODE) { const log = logger.child({ module: 'worker-spawner' }); @@ -14,7 +14,7 @@ if (!ENV.READ_ONLY_MODE) { INGEST_QUEUE_NAME, ENV.isDev ? fn : path.resolve(__dirname, `ingest/processor.js`), { - connection: redisConnection, + connection: queueRedisConnection, prefix: '{slayer:ingest}', useWorkerThreads: true, removeOnComplete: { diff --git a/apps/web-app/package.json b/apps/web-app/package.json index 2a43926..abd5855 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", @@ -36,6 +37,7 @@ "i18next-browser-languagedetector": "8.2.0", "i18next-http-backend": "3.0.2", "lucide-react": "0.544.0", + "mobx": "^6.15.0", "next-themes": "0.4.6", "react": "19.1.1", "react-dom": "19.1.1", diff --git a/apps/web-app/src/components/demo.FormComponents.tsx b/apps/web-app/src/components/FormComponents.tsx similarity index 54% rename from apps/web-app/src/components/demo.FormComponents.tsx rename to apps/web-app/src/components/FormComponents.tsx index 28ed214..2893b72 100644 --- a/apps/web-app/src/components/demo.FormComponents.tsx +++ b/apps/web-app/src/components/FormComponents.tsx @@ -1,21 +1,34 @@ 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 type { GetBalanceData } from 'wagmi/query'; +import { Field, FieldDescription, FieldError, FieldLabel } from './ui/field'; export function SubscribeButton({ label }: { label: string }) { const form = useFormContext(); return ( - state.isSubmitting}> - {(isSubmitting) => ( - )} @@ -31,12 +44,12 @@ function ErrorMessages({ return ( <> {errors.map((error) => ( -
{typeof error === 'string' ? error : error.message} -
+ ))} ); @@ -45,44 +58,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 +114,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, + balance, +}: { + label: string; + placeholder?: string; + description?: string; + balance?: GetBalanceData; +}) { + 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} + + {balance && ( + + (Balance:{' '} + {Decimal.from(balance.value, balance.decimals).toFormatted()}{' '} + {balance.symbol}) + + )} + + 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..9671b5f 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 @@ -6,69 +6,37 @@ import { TableHeader, TableRow, } from '@/components/ui/table/table'; -import React, { useEffect, useState, type FC } from 'react'; +import React, { 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 { 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[]; }; export const AssetsTable: FC = ({ assets }) => { - const [sortedAssets, setSortedAssets] = - useState(assets); - useEffect(() => { - setSortedAssets(assets); - }, [assets]); + // const [sortedAssets, setSortedAssets] = + // useState(assets); + // useEffect(() => { + // 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} -

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

+ {asset.token.symbol} +

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

- -

-
- -
-

{0}%

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

+ +

+
+ +
+

{0}%

+
+
+ +
+ + +
+
+
+ + {index !== assets.length - 1 && ( + + + + )} +
+ ))} + {assets.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..cd760b4 --- /dev/null +++ b/apps/web-app/src/components/MoneyMarket/components/BorrowDialog/BorrowDialog.tsx @@ -0,0 +1,127 @@ +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 ( +
+ e.preventDefault()} + > + + 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/components/LendAssetsList/LendAssetsList.tsx b/apps/web-app/src/components/MoneyMarket/components/LendAssetsList/LendAssetsList.tsx index 456dc9a..571e884 100644 --- a/apps/web-app/src/components/MoneyMarket/components/LendAssetsList/LendAssetsList.tsx +++ b/apps/web-app/src/components/MoneyMarket/components/LendAssetsList/LendAssetsList.tsx @@ -1,12 +1,12 @@ import { Accordion } from '@/components/ui/accordion'; import { Checkbox } from '@/components/ui/checkbox'; import { Label } from '@/components/ui/label'; +import type { MoneyMarketPoolReserve } from '@sovryn/slayer-sdk'; import { useCallback, useMemo, useState, type FC } from 'react'; -import type { LendAsset } from './LendAssetsList.types'; import { AssetsTable } from './components/AssetsTable/AssetsTable'; type LendPAssetsListProps = { - lendAssets: LendAsset[]; + lendAssets: MoneyMarketPoolReserve[]; loading?: boolean; }; @@ -20,12 +20,7 @@ export const LendAssetsList: FC = ({ lendAssets }) => { ); const filteredAssets = useMemo( - () => - showZeroBalances - ? lendAssets - : lendAssets.filter( - ({ balance }) => parseFloat(balance.replace(/,/g, '')) > 0, - ), + () => lendAssets, [lendAssets, showZeroBalances], ); diff --git a/apps/web-app/src/components/MoneyMarket/components/LendAssetsList/components/AssetsTable/AssetsTable.tsx b/apps/web-app/src/components/MoneyMarket/components/LendAssetsList/components/AssetsTable/AssetsTable.tsx index ef2f1ce..e4e9f0b 100644 --- a/apps/web-app/src/components/MoneyMarket/components/LendAssetsList/components/AssetsTable/AssetsTable.tsx +++ b/apps/web-app/src/components/MoneyMarket/components/LendAssetsList/components/AssetsTable/AssetsTable.tsx @@ -6,62 +6,61 @@ import { TableHeader, TableRow, } from '@/components/ui/table/table'; -import { Fragment, useCallback, useEffect, useState, type FC } from 'react'; +import { Fragment, type FC } from 'react'; -import iconSort from '@/assets/lend/icon-sort.svg'; +import { lendRequestStore } from '@/components/MoneyMarket/stores/lend-request.store'; import { AmountRenderer } from '@/components/ui/amount-renderer'; import { Button } from '@/components/ui/button'; import { InfoButton } from '@/components/ui/info-button'; -import { - OrderColumn, - OrderType, - type OrderSorting, -} from '@/components/ui/table/table.types'; +import type { MoneyMarketPoolReserve } from '@sovryn/slayer-sdk'; import { Check, X } from 'lucide-react'; -import type { LendAsset } from '../../LendAssetsList.types'; type AssetsTableProps = { - assets: LendAsset[]; + assets: MoneyMarketPoolReserve[]; }; export const AssetsTable: FC = ({ assets }) => { - const [sortDirection, setSortDirection] = useState( - OrderType.ASC, - ); - const [sortedAssets, setSortedAssets] = useState(assets); - useEffect(() => { - setSortedAssets(assets); - }, [assets]); + // const [sortDirection, setSortDirection] = useState( + // OrderType.ASC, + // ); + // const [sortedAssets, setSortedAssets] = + // useState(assets); + // useEffect(() => { + // setSortedAssets(assets); + // }, [assets]); - const sortAssets = useCallback( - (column: keyof LendAsset) => { - const newSortDirection = - sortDirection === OrderType.ASC ? OrderType.DESC : OrderType.ASC; - setSortDirection(newSortDirection); + // const sortAssets = useCallback( + // (column: keyof MoneyMarketPoolReserve) => { + // const newSortDirection = + // sortDirection === OrderType.ASC ? OrderType.DESC : OrderType.ASC; + // setSortDirection(newSortDirection); - const sorted = [...sortedAssets].sort((a, b) => { - if (column === OrderColumn.SYMBOL) { - return newSortDirection === OrderType.ASC - ? a[column].localeCompare(b[column]) - : b[column].localeCompare(a[column]); - } else if (column === OrderColumn.BALANCE) { - const balanceA = parseFloat(a.balance.replace(/,/g, '')); - const balanceB = parseFloat(b.balance.replace(/,/g, '')); - return newSortDirection === OrderType.ASC - ? balanceA - balanceB - : balanceB - balanceA; - } else if (column === OrderColumn.APY) { - const apyA = parseFloat(a.apy.replace('%', '')); - const apyB = parseFloat(b.apy.replace('%', '')); - return newSortDirection === OrderType.ASC ? apyA - apyB : apyB - apyA; - } - return 0; - }); + // // const sorted = [...sortedAssets].sort((a, b) => { + // // if (column === OrderColumn.SYMBOL) { + // // return newSortDirection === OrderType.ASC + // // ? a[column].localeCompare(b[column]) + // // : b[column].localeCompare(a[column]); + // // } else if (column === OrderColumn.BALANCE) { + // // const balanceA = parseFloat(a.balance.replace(/,/g, '')); + // // const balanceB = parseFloat(b.balance.replace(/,/g, '')); + // // return newSortDirection === OrderType.ASC + // // ? balanceA - balanceB + // // : balanceB - balanceA; + // // } else if (column === OrderColumn.APY) { + // // const apyA = parseFloat(a.apy.replace('%', '')); + // // const apyB = parseFloat(b.apy.replace('%', '')); + // // return newSortDirection === OrderType.ASC ? apyA - apyB : apyB - apyA; + // // } + // // return 0; + // // }); - setSortedAssets(sorted); - }, - [sortDirection], - ); + // // setSortedAssets(sorted); + // }, + // [sortDirection], + // ); + + const handleLending = (reserve: MoneyMarketPoolReserve) => + lendRequestStore.getState().setReserve(reserve); return ( @@ -70,7 +69,7 @@ export const AssetsTable: FC = ({ assets }) => {
Asset - {assets.some((asset) => asset.isSortable) && ( + {/* {assets.some((asset) => asset.isSortable) && ( - )} + )} */}
Wallet balance - {assets.some((asset) => asset.isSortable) && ( + {/* {assets.some((asset) => asset.isSortable) && ( - )} + )} */}
@@ -103,7 +102,7 @@ export const AssetsTable: FC = ({ assets }) => { APY - {assets.some((asset) => asset.isSortable) && ( + {/* {assets.some((asset) => asset.isSortable) && ( - )} + )} */} @@ -124,32 +123,34 @@ export const AssetsTable: FC = ({ assets }) => { - {sortedAssets.map((asset, index) => ( - + {assets.map((asset, index) => ( +
{asset.symbol}
-

{asset.symbol}

+

+ {asset.token.symbol} +

- +
- +
- {asset.canBeCollateral ? ( + {asset.usageAsCollateralEnabled ? ( ) : ( @@ -158,7 +159,10 @@ export const AssetsTable: FC = ({ assets }) => {
- + + + + + + + + ); +}; + +export const LendDialog = () => { + const isOpen = useStoreWithEqualityFn( + lendRequestStore, + (state) => state.reserve !== null, + ); + + const handleClose = (open: boolean) => { + if (!open) { + lendRequestStore.getState().reset(); + } + }; + + return ( + + {isOpen && } + + ); +}; diff --git a/apps/web-app/src/components/MoneyMarket/components/LendPositionsList/LendPositionsList.tsx b/apps/web-app/src/components/MoneyMarket/components/LendPositionsList/LendPositionsList.tsx index 17b9659..bfc8f32 100644 --- a/apps/web-app/src/components/MoneyMarket/components/LendPositionsList/LendPositionsList.tsx +++ b/apps/web-app/src/components/MoneyMarket/components/LendPositionsList/LendPositionsList.tsx @@ -1,15 +1,15 @@ import { Accordion } from '@/components/ui/accordion'; -import type { MoneyMarketPoolReserve } from '@sovryn/slayer-sdk'; import { useState, type FC } from 'react'; import { AmountRenderer } from '../../../ui/amount-renderer'; import { PoolPositionStat } from '../PoolPositionStat/PoolPositionStat'; import { AssetsTable } from './components/AssetsTable/AssetsTable'; +import type { LendPosition } from './LendPositionsList.types'; type LendPositionsListProps = { supplyBalance: number; supplyWeightedApy: number; collateralBalance: number; - lendPositions: MoneyMarketPoolReserve[]; + lendPositions: LendPosition[]; loading?: boolean; }; 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/MoneyMarket/stores/lend-request.store.ts b/apps/web-app/src/components/MoneyMarket/stores/lend-request.store.ts new file mode 100644 index 0000000..60e2c6b --- /dev/null +++ b/apps/web-app/src/components/MoneyMarket/stores/lend-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 LendRequestStore = State & Actions; + +export const lendRequestStore = 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..27ac6d8 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, @@ -106,6 +106,7 @@ export function useInternalTxHandler( pendingTxs, ); } else if (error) { + console.log('Send transaction error:', error); const msg = handleErrorMessage(error); setItemError(currentTx.id, msg); props.onError?.(currentTx, msg, error); @@ -200,6 +201,7 @@ export function useInternalTxHandler( config.getClient(), modifiedData, ); + sendTransaction(prepared); } else { throw new Error('Unknown transaction request type'); @@ -230,9 +232,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 ( +