0xtrails
Version:
SDK for Trails
418 lines (376 loc) • 10.3 kB
text/typescript
import { useMemo, useState, useEffect } from "react"
import type { Address, PublicClient } from "viem"
import { logger } from "./logger.js"
type AccrualPosition = any
const MORPHO_API_ENDPOINT = "https://api.morpho.org/graphql"
// Supported vault configurations
export interface VaultConfig {
address: Address
name: string
chainId: number
asset: {
symbol: string
name: string
address: Address
decimals: number
logoUrl?: string
}
}
// Chain ID to chain name mapping for URLs
const CHAIN_ID_TO_NAME: Record<number, string> = {
1: "ethereum",
8453: "base",
42161: "arbitrum",
137: "polygon",
10: "optimism",
100: "gnosis",
}
// Pool data interface (same as pools.ts)
export interface Pool {
id: string
name: string
protocol: string
chainId: number
apy: number
tvl: number
token: {
symbol: string
name: string
address: string
decimals: number
logoUrl?: string
}
depositAddress: string
isActive: boolean
poolUrl?: string
protocolUrl?: string
wrappedTokenGatewayAddress?: string
}
/**
* Fetch all vaults from Morpho GraphQL API
*/
async function fetchAllVaults(): Promise<any[]> {
const vaultsQuery = {
query: `
query Vaults {
vaults(
first: 1000,
where: {
chainId_in: [1, 8453, 42161, 137, 10, 100]
},
orderBy: TotalAssetsUsd,
orderDirection: Desc
) {
items {
address
symbol
name
whitelisted
asset {
id
address
decimals
logoURI
}
chain {
id
network
}
state {
totalAssets
totalAssetsUsd
totalSupply
apy
netApy
netApyWithoutRewards
rewards {
asset {
address
chain {
id
}
}
supplyApr
yearlySupplyTokens
}
allocation {
supplyAssets
supplyAssetsUsd
market {
uniqueKey
state {
rewards {
asset {
address
chain {
id
}
}
supplyApr
borrowApr
}
}
}
}
}
}
}
}
`,
}
try {
const response = await fetch(MORPHO_API_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(vaultsQuery),
})
const vaultsData = (await response.json()) as any
return vaultsData.data?.vaults?.items || []
} catch (apiError) {
logger.console.error("Failed to fetch vaults from GraphQL API:", apiError)
return []
}
}
/**
* Fetch vault data from Morpho GraphQL API
*/
export async function fetchVaultData(
vaultAddress: Address,
chainId: number,
): Promise<any | null> {
const vaultQuery = {
query: `
query VaultByAddress($address: String!, $chainId: Int) {
vaultByAddress(address: $address, chainId: $chainId) {
address
id
state {
totalAssets
totalSupply
rewards {
asset {
address
name
symbol
chain {
id
}
}
amountPerSuppliedToken
supplyApr
}
allocation {
market {
id
uniqueKey
state {
rewards {
supplyApr
amountPerSuppliedToken
asset {
address
symbol
chain {
id
}
}
}
}
}
supplyAssetsUsd
}
}
chain {
id
}
}
}
`,
variables: {
address: vaultAddress.toLowerCase(),
chainId: chainId,
},
}
try {
const response = await fetch(MORPHO_API_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(vaultQuery),
})
const vaultData = (await response.json()) as any
return vaultData.data?.vaultByAddress || null
} catch (apiError) {
logger.console.error(
"Failed to fetch vault data from GraphQL API:",
apiError,
)
return null
}
}
/**
* Calculate vault APY from SDK data
*/
export function calculateVaultApy(vault: any): number {
try {
if (vault.totalAssets === 0n) {
return 0
}
// Convert allocations Map to array and calculate weighted APY
const allocationsArray = Array.from(vault.allocations.values())
const totalWeightedApy = allocationsArray.reduce(
(total: bigint, allocation: any) => {
const position: AccrualPosition = allocation.position
const market = position.market
if (market && position.supplyShares > 0n) {
// Get current supply assets and market APY
const supplyAssets = position.supplyAssets
const marketSupplyApy = BigInt(market.supplyApy || 0)
// Calculate weighted APY for this allocation
return total + marketSupplyApy * supplyAssets
}
return total
},
0n,
)
// Calculate base APY (before fees)
const baseApyBigInt = totalWeightedApy / vault.totalAssets
const baseApy = Number(baseApyBigInt) / 1e18
// Apply vault fee (fee is in WAD format, 1e18 = 100%)
const vaultFeeRate = Number(vault.fee) / 1e18
return baseApy * (1 - vaultFeeRate)
} catch (calculationError) {
logger.console.error("Failed to calculate vault APY:", calculationError)
return 0
}
}
/**
* Calculate rewards APR from API data
*/
export function calculateRewardsApr(vaultData: any): number {
if (!vaultData?.state?.rewards) {
return 0
}
return vaultData.state.rewards.reduce((total: number, reward: any) => {
return total + (reward.supplyApr || 0)
}, 0)
}
/**
* Transform API vault data to Pool interface
*/
function transformVaultToPool(vaultData: any): Pool | null {
try {
if (!vaultData || !vaultData.address || !vaultData.chain?.id) {
return null
}
const chainId = vaultData.chain.id
const asset = vaultData.asset
const state = vaultData.state
// Use the actual APY from the API
const apy = state?.apy || state?.netApy || 0
// Calculate TVL from totalAssetsUsd if available, otherwise from totalAssets
const tvl = state?.totalAssetsUsd
? Number(state.totalAssetsUsd)
: state?.totalAssets
? Number(state.totalAssets) / 10 ** (asset?.decimals || 18)
: 0
// Filter out vaults with TVL < 1M OR APY > 20%
const tvlInMillions = tvl / 1_000_000
const apyPercentage = apy * 100
if (tvlInMillions < 1 || apyPercentage > 20) {
// logger.console.log(
// `Filtering out vault ${vaultData.address}: TVL=${tvlInMillions.toFixed(2)}M, APY=${apyPercentage.toFixed(2)}%`,
// )
return null
}
return {
id: `${vaultData.address}-${chainId}`,
name: vaultData.name || `${vaultData.symbol || "Unknown"} Vault`,
protocol: "Morpho",
chainId: chainId,
apy: apy * 100, // Convert to percentage
tvl: tvl,
token: {
symbol: vaultData.symbol || asset?.symbol || "UNKNOWN",
name: vaultData.name || asset?.name || "Unknown Token",
address: asset?.address || vaultData.address,
decimals: asset?.decimals || 18,
logoUrl: asset?.logoURI || undefined,
},
depositAddress: vaultData.address,
isActive: vaultData.whitelisted !== false, // Consider whitelisted vaults as active
protocolUrl: "https://app.morpho.org/",
poolUrl: `https://app.morpho.org/${CHAIN_ID_TO_NAME[chainId]}/vault/${vaultData.address}`,
}
} catch (error) {
logger.console.error(`Failed to transform vault data:`, error)
return null
}
}
/**
* Hook to fetch Morpho vaults
*/
export function useMorphoVaults() {
const [vaults, setVaults] = useState<Pool[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
async function fetchVaults() {
try {
setLoading(true)
setError(null)
const vaultsData = await fetchAllVaults()
const transformedVaults = vaultsData
.map(transformVaultToPool)
.filter((vault): vault is Pool => vault !== null)
setVaults(transformedVaults)
} catch (err) {
setError(
err instanceof Error ? err : new Error("Failed to fetch vaults"),
)
} finally {
setLoading(false)
}
}
fetchVaults()
}, [])
// Sort by APY descending
const sortedVaults = useMemo(() => {
return vaults.sort((a: Pool, b: Pool) => b.apy - a.apy)
}, [vaults])
return {
data: sortedVaults,
loading,
error,
}
}
/**
* Get all vaults for a specific chain
*/
export async function getVaultsForChain(
chainId: number,
_publicClient: PublicClient,
): Promise<Pool[]> {
const vaultsData = await fetchAllVaults()
const chainVaults = vaultsData.filter((vault) => vault.chain?.id === chainId)
const transformedVaults = chainVaults
.map(transformVaultToPool)
.filter((vault): vault is Pool => vault !== null)
return transformedVaults
}
/**
* Get all vaults across all chains
*/
export async function getAllVaults(
_publicClient: PublicClient,
): Promise<Pool[]> {
const vaultsData = await fetchAllVaults()
const transformedVaults = vaultsData
.map(transformVaultToPool)
.filter((vault): vault is Pool => vault !== null)
return transformedVaults
}
export default useMorphoVaults