Repository: curvefi/curve-frontend
Pull request #803: feat: add on-chain market rate fetching and validation suite
Author: login: OnlyJousting
Created: 2025-03-28T15:22:56Z | Merged: 2025-03-31T15:39:25Z

Lend markets were using API data to display borrow and lending rates causing there to be some confusion after interaction with a position since the borrow/lend rates wouldn't update because of API delays.

I kept the API data as a fallback.

Changes:
- Added on chain rates query to get fresh data when a wallet is connected


Diff:
diff --git a/apps/main/src/lend/components/DetailInfoRate.tsx b/apps/main/src/lend/components/DetailInfoRate.tsx
index 079a9d951a..efdcb8fab9 100644
--- a/apps/main/src/lend/components/DetailInfoRate.tsx
+++ b/apps/main/src/lend/components/DetailInfoRate.tsx
@@ -1,4 +1,4 @@
-import useStore from '@/lend/store/useStore'
+import { useMarketRates } from '@/lend/hooks/useMarketRates'
 import { ChainId, FutureRates } from '@/lend/types/lend.types'
 import DetailInfo from '@ui/DetailInfo'
 import Icon from '@ui/Icon'
@@ -16,23 +16,19 @@ const DetailInfoRate = ({
   isBorrow: boolean
   futureRates?: FutureRates | undefined | null
 }) => {
-  const ratesResp = useStore((state) => state.markets.ratesMapper[rChainId]?.[rOwmId])
+  const { rates, loading, error } = useMarketRates(rChainId, rOwmId)
 
-  const { rates, error } = ratesResp ?? {}
   const futureRate = isBorrow ? futureRates?.borrowApy : futureRates?.lendApr
+  const rate = isBorrow ? rates?.borrowApy : rates?.lendApr
 
   return (
-    <DetailInfo
-      loading={typeof ratesResp === 'undefined'}
-      loadingSkeleton={[100, 20]}
-      label={isBorrow ? t`Borrow APY:` : t`Lend APR:`}
-    >
+    <DetailInfo loading={loading} loadingSkeleton={[100, 20]} label={isBorrow ? t`Borrow APY:` : t`Lend APR:`}>
       <span>
         {error ? (
           '?'
         ) : (
           <strong>
-            {formatNumber(isBorrow ? rates?.borrowApy : rates?.lendApr, {
+            {formatNumber(rate, {
               ...FORMAT_OPTIONS.PERCENT,
               defaultValue: '-',
             })}
diff --git a/apps/main/src/lend/components/SharedCellData/CellBorrowRate.tsx b/apps/main/src/lend/components/SharedCellData/CellBorrowRate.tsx
index 7b9d762991..0ba771a6b3 100644
--- a/apps/main/src/lend/components/SharedCellData/CellBorrowRate.tsx
+++ b/apps/main/src/lend/components/SharedCellData/CellBorrowRate.tsx
@@ -1,3 +1,4 @@
+import { useMarketOnChainRates } from '@/lend/entities/market-onchain-rate'
 import useStore from '@/lend/store/useStore'
 import { ChainId } from '@/lend/types/lend.types'
 import { FORMAT_OPTIONS, formatNumber } from '@ui/utils'
@@ -11,6 +12,8 @@ const CellBorrowRate = ({
   rOwmId: string
   defaultValue?: string
 }) => {
+  // uses onchain data if available
+  const { data: onchainData } = useMarketOnChainRates({ chainId: rChainId, marketId: rOwmId })
   const resp = useStore((state) => state.markets.ratesMapper[rChainId]?.[rOwmId])
 
   const { rates, error } = resp ?? {}
@@ -23,7 +26,7 @@ const CellBorrowRate = ({
     return <>?</>
   }
 
-  return formatNumber(rates?.borrowApy, { ...FORMAT_OPTIONS.PERCENT, defaultValue })
+  return formatNumber(onchainData?.rates?.borrowApy ?? rates?.borrowApy, { ...FORMAT_OPTIONS.PERCENT, defaultValue })
 }
 
 export default CellBorrowRate
diff --git a/apps/main/src/lend/entities/market-onchain-rate.ts b/apps/main/src/lend/entities/market-onchain-rate.ts
new file mode 100644
index 0000000000..221865ff59
--- /dev/null
+++ b/apps/main/src/lend/entities/market-onchain-rate.ts
@@ -0,0 +1,31 @@
+import { lendingJsValidationSuite } from '@/lend/entities/validation/lending-js-validation'
+import { ChainId } from '@/lend/types/lend.types'
+import { FieldsOf } from '@ui-kit/lib'
+import type { ChainQuery } from '@ui-kit/lib/model/query'
+import { queryFactory } from '@ui-kit/lib/model/query'
+import { useApiStore } from '@ui-kit/shared/useApiStore'
+
+type MarketQuery = ChainQuery<ChainId> & { marketId: string }
+type MarketParams = FieldsOf<MarketQuery>
+
+const _fetchOnChainMarketRate = async ({ chainId, marketId }: MarketQuery) => {
+  const api = useApiStore.getState().lending
+  const market = api!.getOneWayMarket(marketId)
+
+  const ratesRes = await market.stats.rates(false, false)
+
+  return { rates: ratesRes }
+}
+
+/**
+ * The purpose of this query is to allow fetching market parameters on chain
+ * in order to display the most current data when a wallet is connected.
+ * The api data can have a few minutes delay.
+ * */
+export const { useQuery: useMarketOnChainRates, invalidate: invalidateMarketOnChainRates } = queryFactory({
+  queryKey: (params: MarketParams) =>
+    ['marketOnchainData', { chainId: params.chainId }, { marketId: params.marketId }] as const,
+  queryFn: _fetchOnChainMarketRate,
+  refetchInterval: '1m',
+  validationSuite: lendingJsValidationSuite,
+})
diff --git a/apps/main/src/lend/entities/validation/lending-js-validation.ts b/apps/main/src/lend/entities/validation/lending-js-validation.ts
new file mode 100644
index 0000000000..c0402e82ea
--- /dev/null
+++ b/apps/main/src/lend/entities/validation/lending-js-validation.ts
@@ -0,0 +1,25 @@
+import { enforce, group, test } from 'vest'
+import type { ChainId } from '@/lend/types/lend.types'
+import type { ChainParams } from '@ui-kit/lib/model/query'
+import { createValidationSuite } from '@ui-kit/lib/validation'
+import { useApiStore } from '@ui-kit/shared/useApiStore'
+
+export const chainValidationGroup = ({ chainId }: ChainParams<ChainId>) =>
+  group('chainValidation', () => {
+    test('chainId', () => {
+      enforce(chainId).message('Chain ID is required').isNotEmpty().message('Invalid chain ID').isValidChainId()
+    })
+  })
+
+export const lendingJsValidationGroup = ({ chainId }: ChainParams<ChainId>) =>
+  group('apiValidation', () => {
+    test('api', () => {
+      const api = useApiStore.getState().lending
+      enforce(api?.chainId).message('Chain ID should be loaded').equals(chainId)
+    })
+  })
+
+export const lendingJsValidationSuite = createValidationSuite((params: ChainParams<ChainId>) => {
+  chainValidationGroup(params)
+  lendingJsValidationGroup(params)
+})
diff --git a/apps/main/src/lend/hooks/useMarketRates.ts b/apps/main/src/lend/hooks/useMarketRates.ts
new file mode 100644
index 0000000000..5a830d87d5
--- /dev/null
+++ b/apps/main/src/lend/hooks/useMarketRates.ts
@@ -0,0 +1,27 @@
+import { useMarketOnChainRates } from '@/lend/entities/market-onchain-rate'
+import useStore from '@/lend/store/useStore'
+import { ChainId } from '@/lend/types/lend.types'
+
+export function useMarketRates(rChainId: ChainId, rOwmId: string) {
+  const { data: onChainData, isLoading: isOnChainLoading } = useMarketOnChainRates({
+    chainId: rChainId,
+    marketId: rOwmId,
+  })
+  const ratesResp = useStore((state) => state.markets.ratesMapper[rChainId]?.[rOwmId])
+
+  if (onChainData) {
+    return {
+      rates: onChainData.rates,
+      loading: isOnChainLoading,
+      error: '',
+    }
+  }
+
+  const { rates, error } = ratesResp ?? {}
+
+  return {
+    rates,
+    loading: typeof ratesResp === 'undefined',
+    error,
+  }
+}
diff --git a/apps/main/src/lend/hooks/useSupplyTotalApr.ts b/apps/main/src/lend/hooks/useSupplyTotalApr.ts
index 08396b76be..cf47498bfb 100644
--- a/apps/main/src/lend/hooks/useSupplyTotalApr.ts
+++ b/apps/main/src/lend/hooks/useSupplyTotalApr.ts
@@ -1,6 +1,7 @@
 import { useMemo } from 'react'
 import { zeroAddress } from 'viem'
 import { useOneWayMarket } from '@/lend/entities/chain'
+import { useMarketOnChainRates } from '@/lend/entities/market-onchain-rate'
 import useStore from '@/lend/store/useStore'
 import { ChainId, MarketRates, RewardOther, MarketRewards } from '@/lend/types/lend.types'
 import { getTotalApr } from '@/lend/utils/utilsRewards'
@@ -10,19 +11,32 @@ function useSupplyTotalApr(rChainId: ChainId, rOwmId: string) {
   const market = useOneWayMarket(rChainId, rOwmId).data
   const marketRewardsResp = useStore((state) => state.markets.rewardsMapper[rChainId]?.[rOwmId])
   const marketRatesResp = useStore((state) => state.markets.ratesMapper[rChainId]?.[rOwmId])
+  const {
+    data: onChainData,
+    isError: onChainError,
+    isSuccess: onChainSuccess,
+  } = useMarketOnChainRates({ chainId: rChainId, marketId: rOwmId })
 
   const { gauge } = market?.addresses ?? {}
   const { error: rewardsError } = marketRewardsResp ?? {}
   const { error: ratesError } = marketRatesResp ?? {}
+  const onChainRatesObj: MarketRates = {
+    rates: onChainData?.rates ?? null,
+    error: onChainError ? 'Error fetching on chain data' : '',
+  }
+
+  const marketRates = onChainRatesObj.rates ? onChainRatesObj : marketRatesResp
+  const isReady = onChainSuccess || (typeof marketRewardsResp !== 'undefined' && typeof marketRatesResp !== 'undefined')
+  const isError = (onChainError && ratesError) || rewardsError
 
   const { totalApr, tooltipValues } = useMemo(
-    () => _getTotalAndTooltip(marketRewardsResp, marketRatesResp),
-    [marketRewardsResp, marketRatesResp],
+    () => _getTotalAndTooltip(marketRewardsResp, marketRates),
+    [marketRewardsResp, marketRates],
   )
 
   return {
-    isReady: typeof marketRewardsResp !== 'undefined' && typeof marketRatesResp !== 'undefined',
-    isError: rewardsError || ratesError,
+    isReady,
+    isError,
     invalidGaugeAddress: typeof gauge !== 'undefined' && gauge === zeroAddress,
     totalApr,
     tooltipValues,
diff --git a/apps/main/src/lend/store/createMarketsSlice.ts b/apps/main/src/lend/store/createMarketsSlice.ts
index aec5b81276..ce52620056 100644
--- a/apps/main/src/lend/store/createMarketsSlice.ts
+++ b/apps/main/src/lend/store/createMarketsSlice.ts
@@ -1,4 +1,5 @@
 import type { GetState, SetState } from 'zustand'
+import { invalidateMarketOnChainRates } from '@/lend/entities/market-onchain-rate'
 import apiLending from '@/lend/lib/apiLending'
 import type { State } from '@/lend/store/useStore'
 import {
@@ -119,6 +120,8 @@ const createMarketsSlice = (set: SetState<State>, get: GetState<State>): Markets
 
     fetchAll: async (api, OneWayMarketTemplate, shouldRefetch) => {
       const { ...sliceState } = get()[sliceKey]
+      const chainId = api.chainId
+      const marketId = OneWayMarketTemplate.id
 
       const keys = [
         'statsParametersMapper',
@@ -132,6 +135,8 @@ const createMarketsSlice = (set: SetState<State>, get: GetState<State>): Markets
         'totalLiquidityMapper',
       ] as const
 
+      // invalidate and refetch onchain data
+      invalidateMarketOnChainRates({ chainId, marketId })
       await Promise.all(keys.map((key) => sliceState.fetchDatas(key, api, [OneWayMarketTemplate], shouldRefetch)))
     },
     fetchVaultPricePerShare: async (chainId, owm, shouldRefetch) => {


Reviews:
user: login: 0xtutti
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T18:03:52Z
user: login: mimaklas
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T22:55:26Z
user: login: Macket
state: DISMISSED
author_association: NONE
submitted_at: 2025-03-29T10:58:38Z
user: login: DanielSchiavini
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-31T08:42:03Z
user: login: 0xtutti
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T08:43:10Z
user: login: DanielSchiavini
state: DISMISSED
author_association: COLLABORATOR
submitted_at: 2025-03-31T12:46:25Z
user: login: DanielSchiavini
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-31T14:32:39Z
user: login: mimaklas
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T15:36:18Z

Review comments:
diff_hunk: @@ -32,10 +35,15 @@ const DetailInfoRate = ({
           '?'
         ) : (
           <strong>
-            {formatNumber(isBorrow ? rates?.borrowApy : rates?.lendApr, {
-              ...FORMAT_OPTIONS.PERCENT,
-              defaultValue: '-',
-            })}
+            {formatNumber(
+              isBorrow
+                ? (onchainData?.rates?.borrowApy ?? rates?.borrowApy)
path: apps/main/src/lend/components/DetailInfoRate.tsx
user: login: DanielSchiavini
body: this logic should be moved to a hook imo
created_at: 2025-03-31T08:38:06Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,32 @@
+import { lendingJsValidationSuite } from '@/lend/entities/validation/lending-js-validation'
+import { ChainId } from '@/lend/types/lend.types'
+import type { ChainParams, ChainQuery } from '@ui-kit/lib/model/query'
+import { queryFactory } from '@ui-kit/lib/model/query'
+import { useApiStore } from '@ui-kit/shared/useApiStore'
+
+type Params = ChainParams<ChainId> & {
+  marketId: string
+}
+
+const _fetchOnChainMarketRate = async ({ chainId, marketId }: ChainQuery<ChainId> & { marketId: string }) => {
+  const api = useApiStore.getState().lending
+  const market = api?.getOneWayMarket(marketId)
+
+  if (!market) return null
path: apps/main/src/lend/entities/market-onchain-rate.ts
user: login: DanielSchiavini
body: I don't think this is reachable, api is validated in the suite and it will throw an error if the marketId is unknown
created_at: 2025-03-31T08:40:20Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,32 @@
+import { lendingJsValidationSuite } from '@/lend/entities/validation/lending-js-validation'
+import { ChainId } from '@/lend/types/lend.types'
+import type { ChainParams, ChainQuery } from '@ui-kit/lib/model/query'
+import { queryFactory } from '@ui-kit/lib/model/query'
+import { useApiStore } from '@ui-kit/shared/useApiStore'
+
+type Params = ChainParams<ChainId> & {
+  marketId: string
+}
path: apps/main/src/lend/entities/market-onchain-rate.ts
user: login: DanielSchiavini
body: There should be a distinction between the query (after validation) and params (before validation)
```suggestion
type MarketQuery = ChainQuery<ChainId> & { marketId: string }
type MarketParams = FieldsOf<MarketQuery>
```
created_at: 2025-03-31T08:41:44Z
author_association: COLLABORATOR
diff_hunk: @@ -16,11 +18,22 @@ const DetailInfoRate = ({
   isBorrow: boolean
   futureRates?: FutureRates | undefined | null
 }) => {
+  // uses onchain data if available
+  const { data: onChainData } = useMarketOnChainRates({ chainId: rChainId, marketId: rOwmId })
   const ratesResp = useStore((state) => state.markets.ratesMapper[rChainId]?.[rOwmId])
 
   const { rates, error } = ratesResp ?? {}
   const futureRate = isBorrow ? futureRates?.borrowApy : futureRates?.lendApr
 
+  const rate = useMemo(() => {
path: apps/main/src/lend/components/DetailInfoRate.tsx
user: login: DanielSchiavini
body: Sorry for the confusion, what I meant here is that we should just call `useSupplyTotalApr` and let the hook decide where the data comes from. Basically, deciding where data comes from does not belong in an UI component but it's business logic
created_at: 2025-03-31T12:46:22Z
author_association: COLLABORATOR

PR comments:
user: login: vercel[bot]
created_at: 2025-03-28T15:22:59Z
body: [vc]: #UbbWTLwrtag89Nr2/C12wpr4RAzuSsnWvz7a6VflWRQ=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJjdXJ2ZS1kYXBwIiwicm9vdERpcmVjdG9yeSI6ImFwcHMvbWFpbiIsImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9jdXJ2ZWZpL2N1cnZlLWRhcHAvRUFYYVFkYVJXcHVvajdkRzh3eE0xdGdIZlp3VyIsInByZXZpZXdVcmwiOiJjdXJ2ZS1kYXBwLWdpdC1mZWF0LWZldGNoLW1hcmtldC1kYXRhLW9uLWNoYWluLXctZmEyNDNkLWN1cnZlZmkudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJjdXJ2ZS1kYXBwLWdpdC1mZWF0LWZldGNoLW1hcmtldC1kYXRhLW9uLWNoYWluLXctZmEyNDNkLWN1cnZlZmkudmVyY2VsLmFwcCJ9fSx7Im5hbWUiOiJjdXJ2ZS1kYXBwLXN0b3J5Ym9vayIsInJvb3REaXJlY3RvcnkiOiJwYWNrYWdlcy9jdXJ2ZS11aS1raXQiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoiY3VydmUtZGFwcC1zdG9yeWJvb2stZ2l0LWZlYXQtZmV0Y2gtbWFya2V0LWRhdGEtMDI1YWM3LWN1cnZlZmkudmVyY2VsLmFwcCJ9LCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vY3VydmVmaS9jdXJ2ZS1kYXBwLXN0b3J5Ym9vay84ckRxc0RIRXFOS1Y2b29tc2RneEhMeGpjU2k5IiwicHJldmlld1VybCI6ImN1cnZlLWRhcHAtc3Rvcnlib29rLWdpdC1mZWF0LWZldGNoLW1hcmtldC1kYXRhLTAyNWFjNy1jdXJ2ZWZpLnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiREVQTE9ZRUQifV19
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **curve-dapp** | ✅ Ready ([Inspect](https://vercel.com/curvefi/curve-dapp/EAXaQdaRWpuoj7dG8wxM1tgHfZwW)) | [Visit Preview](https://curve-dapp-git-feat-fetch-market-data-on-chain-w-fa243d-curvefi.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/curve-dapp-git-feat-fetch-market-data-on-chain-w-fa243d-curvefi.vercel.app?via=pr-comment-feedback-link) | Mar 31, 2025 3:35pm |
| **curve-dapp-storybook** | ✅ Ready ([Inspect](https://vercel.com/curvefi/curve-dapp-storybook/8rDqsDHEqNKV6oomsdgxHLxjcSi9)) | [Visit Preview](https://curve-dapp-storybook-git-feat-fetch-market-data-025ac7-curvefi.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/curve-dapp-storybook-git-feat-fetch-market-data-025ac7-curvefi.vercel.app?via=pr-comment-feedback-link) | Mar 31, 2025 3:35pm |



author_association: NONE
user: login: DanielSchiavini
created_at: 2025-03-31T08:52:45Z
body: My comments are not blocking if someone is waiting for this feature it can be merged
author_association: COLLABORATOR