diff --git a/.env.local.example b/.env.local.example index ab796d75..c13801f7 100644 --- a/.env.local.example +++ b/.env.local.example @@ -41,6 +41,7 @@ NEXT_PUBLIC_BASE_RPC= NEXT_PUBLIC_POLYGON_RPC= NEXT_PUBLIC_UNICHAIN_RPC= NEXT_PUBLIC_ARBITRUM_RPC= +NEXT_PUBLIC_ETHERLINK_RPC= NEXT_PUBLIC_HYPEREVM_RPC= NEXT_PUBLIC_MONAD_RPC= diff --git a/src/config/appkit.ts b/src/config/appkit.ts index 382e551c..1c697ddf 100644 --- a/src/config/appkit.ts +++ b/src/config/appkit.ts @@ -5,6 +5,7 @@ import { createStorage, type Storage } from 'wagmi'; import localStorage from 'local-storage-fallback'; import { createAppKit } from '@reown/appkit/react'; import type { AppKitNetwork } from '@reown/appkit/networks'; +import { etherlink } from 'viem/chains'; import { arbitrum, base, mainnet, monad, optimism, polygon, unichain } from 'wagmi/chains'; import { SupportedNetworks, getDefaultRPC, hyperEvm } from '@/utils/networks'; @@ -51,6 +52,7 @@ const customBase = withAppKitRpc(base, getDefaultRPC(SupportedNetworks.Base)); const customPolygon = withAppKitRpc(polygon, getDefaultRPC(SupportedNetworks.Polygon)); const customArbitrum = withAppKitRpc(arbitrum, getDefaultRPC(SupportedNetworks.Arbitrum)); const customUnichain = withAppKitRpc(unichain, getDefaultRPC(SupportedNetworks.Unichain)); +const customEtherlink = withAppKitRpc(etherlink, getDefaultRPC(SupportedNetworks.Etherlink)); const customMonad = withAppKitRpc(monad, getDefaultRPC(SupportedNetworks.Monad)); const customHyperEvm = withAppKitRpc(hyperEvm, getDefaultRPC(SupportedNetworks.HyperEVM)); @@ -62,6 +64,7 @@ export const networks = [ customPolygon, customArbitrum, customUnichain, + customEtherlink, customHyperEvm, customMonad, ] as [AppKitNetwork, ...AppKitNetwork[]]; diff --git a/src/constants/public-allocator.ts b/src/constants/public-allocator.ts index 41f7a6c9..02f87411 100644 --- a/src/constants/public-allocator.ts +++ b/src/constants/public-allocator.ts @@ -7,6 +7,7 @@ export const PUBLIC_ALLOCATOR_ADDRESSES: Partial = { 137: 3, // Polygon 130: 4, // Unichain 42161: 6, // Arbitrum + 42793: 7, // Etherlink 999: 5, // HyperEVM 143: 2, // Monad }; diff --git a/src/features/autovault/components/vault-identity.tsx b/src/features/autovault/components/vault-identity.tsx index d2ee4628..cbed0940 100644 --- a/src/features/autovault/components/vault-identity.tsx +++ b/src/features/autovault/components/vault-identity.tsx @@ -7,7 +7,7 @@ import { ExternalLinkIcon } from '@radix-ui/react-icons'; import { TokenIcon } from '@/components/shared/token-icon'; import { TooltipContent } from '@/components/shared/tooltip-content'; import type { VaultCurator } from '@/constants/vaults/known_vaults'; -import { getVaultURL } from '@/utils/external'; +import { getVaultURL, supportsMorphoAppLinks } from '@/utils/external'; import { VaultIcon } from './vault-icon'; type VaultIdentityVariant = 'chip' | 'inline' | 'icon'; @@ -44,6 +44,7 @@ export function VaultIdentity({ showAddressInTooltip = true, }: VaultIdentityProps) { const vaultHref = useMemo(() => getVaultURL(address, chainId), [address, chainId]); + const canLinkToMorpho = useMemo(() => supportsMorphoAppLinks(chainId), [chainId]); const formattedAddress = `${address.slice(0, 6)}...${address.slice(-4)}`; const displayName = vaultName ?? formattedAddress; const curatorLabel = curator === 'unknown' ? 'Curator unknown' : `Curated by ${curator}`; @@ -92,19 +93,20 @@ export function VaultIdentity({ ); })(); - const interactiveContent = showLink ? ( - e.stopPropagation()} - > - {baseContent} - - ) : ( - baseContent - ); + const interactiveContent = + showLink && canLinkToMorpho ? ( + e.stopPropagation()} + > + {baseContent} + + ) : ( + baseContent + ); if (!showTooltip) { return interactiveContent; diff --git a/src/features/market-detail/components/market-header.tsx b/src/features/market-detail/components/market-header.tsx index 870b608e..8de8004f 100644 --- a/src/features/market-detail/components/market-header.tsx +++ b/src/features/market-detail/components/market-header.tsx @@ -34,7 +34,7 @@ import { convertApyToApr } from '@/utils/rateMath'; import { formatReadable } from '@/utils/balance'; import { getIRMTitle } from '@/utils/morpho'; import { getNetworkImg, getNetworkName, type SupportedNetworks } from '@/utils/networks'; -import { getMarketURL } from '@/utils/external'; +import { getMarketURL, supportsMorphoAppLinks } from '@/utils/external'; import type { Market, MarketPosition, WarningWithDetail } from '@/utils/types'; import { WarningCategory } from '@/utils/types'; import { getRiskLevel, countWarningsByLevel, type RiskLevel } from '@/utils/warnings'; @@ -630,12 +630,14 @@ export function MarketHeader({ Accrue Interest )} - window.open(getMarketURL(resolvedMarketId, network), '_blank')} - startContent={} - > - View on Morpho - + {supportsMorphoAppLinks(network) && ( + window.open(getMarketURL(resolvedMarketId, network), '_blank')} + startContent={} + > + View on Morpho + + )} diff --git a/src/features/positions/components/rebalance/rebalance-modal.tsx b/src/features/positions/components/rebalance/rebalance-modal.tsx index 14efccd5..22a78d66 100644 --- a/src/features/positions/components/rebalance/rebalance-modal.tsx +++ b/src/features/positions/components/rebalance/rebalance-modal.tsx @@ -36,6 +36,7 @@ import { FiTrash2 } from 'react-icons/fi'; import { AllocationCell } from '../allocation-cell'; import { FromMarketsTable } from '../from-markets-table'; import { MarketIdentity, MarketIdentityFocus, MarketIdentityMode } from '@/features/markets/components/market-identity'; +import { RiskIndicator } from '@/features/markets/components/risk-indicator'; import { RebalanceActionInput } from './rebalance-action-input'; import { RebalanceCart } from './rebalance-cart'; @@ -143,6 +144,44 @@ function PreviewSection({ title, rows }: { title: string; rows: PreviewRow[] }) ); } +function formatAmountForSmartConstraintLog(value: bigint, decimals: number): { raw: string; formatted: string } { + return { + raw: value.toString(), + formatted: formatUnits(value, decimals), + }; +} + +function getSmartConstraintWarning(plan: SmartRebalancePlan | null): { title: string; detail: string } | null { + const violations = plan?.diagnostics.constraintViolations ?? []; + if (violations.length === 0) return null; + + const reasons = new Set(violations.map((violation) => violation.reason)); + + if (reasons.size === 1 && reasons.has('locked-liquidity')) { + return { + title: 'Some max-allocation limits could not be met with current withdrawable liquidity.', + detail: + 'One or more positions cannot be reduced far enough right now. Raise the cap on the flagged market or wait for more liquidity before retrying.', + }; + } + + if (reasons.size === 1 && reasons.has('selected-capacity')) { + return { + title: 'Your selected max-allocation limits leave too little room for the full balance.', + detail: + plan?.diagnostics.unallocatedAmount && plan.diagnostics.unallocatedAmount > 0n + ? 'Raise one or more caps or add more destination markets. Any excess amount beyond the selected room will remain in the wallet.' + : 'Raise one or more caps or add more destination markets before retrying.', + }; + } + + return { + title: 'Some max-allocation limits could not be fully satisfied.', + detail: + 'One or more positions could not be reduced far enough because of current withdrawable liquidity or selected capacity. Check the console for per-market details.', + }; +} + export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, isRefetching }: RebalanceModalProps) { const [mode, setMode] = useState('smart'); @@ -292,6 +331,34 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, }) .then((plan) => { if (id !== calcIdRef.current) return; + + if (plan && plan.diagnostics.constraintViolations.length > 0) { + console.warn('[smart-rebalance] unmet max-allocation constraints', { + calcId: id, + chainId: groupedPosition.chainId, + loanAssetSymbol: groupedPosition.loanAssetSymbol, + totalPool: formatAmountForSmartConstraintLog(plan.totalPool, groupedPosition.loanAssetDecimals), + totalMoved: formatAmountForSmartConstraintLog(plan.totalMoved, groupedPosition.loanAssetDecimals), + selectedCapacityShortfall: formatAmountForSmartConstraintLog( + plan.diagnostics.selectedCapacityShortfall, + groupedPosition.loanAssetDecimals, + ), + unallocatedAmount: formatAmountForSmartConstraintLog(plan.diagnostics.unallocatedAmount, groupedPosition.loanAssetDecimals), + violations: plan.diagnostics.constraintViolations.map((violation) => ({ + uniqueKey: violation.uniqueKey, + collateralSymbol: violation.collateralSymbol, + maxAllocationPercent: violation.maxAllocationBps / 100, + reason: violation.reason, + currentAmount: formatAmountForSmartConstraintLog(violation.currentAmount, groupedPosition.loanAssetDecimals), + targetAmount: formatAmountForSmartConstraintLog(violation.targetAmount, groupedPosition.loanAssetDecimals), + maxAllowedAmount: formatAmountForSmartConstraintLog(violation.maxAllowedAmount, groupedPosition.loanAssetDecimals), + excessAmount: formatAmountForSmartConstraintLog(violation.excessAmount, groupedPosition.loanAssetDecimals), + maxWithdrawable: formatAmountForSmartConstraintLog(violation.maxWithdrawable, groupedPosition.loanAssetDecimals), + lockedAmount: formatAmountForSmartConstraintLog(violation.lockedAmount, groupedPosition.loanAssetDecimals), + })), + }); + } + setSmartPlan(plan); }) .catch((error: unknown) => { @@ -503,26 +570,7 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, }); }, [currentSupplyByMarket, groupedPosition.loanAssetDecimals, marketByKey, smartPlan, smartSelectedMarketKeys]); - const constraintViolations = useMemo(() => { - if (!smartPlan) return []; - - const deltaByMarket = new Map(smartPlan.deltas.map((delta) => [delta.market.uniqueKey, delta])); - const violations: { uniqueKey: string; maxAllocationBps: number }[] = []; - - for (const [uniqueKey, maxAllocationBps] of Object.entries(debouncedSmartMaxAllocationBps)) { - if (maxAllocationBps >= 10_000) continue; - - const delta = deltaByMarket.get(uniqueKey); - const targetAmount = delta?.targetAmount ?? currentSupplyByMarket.get(uniqueKey) ?? 0n; - const maxAllowedAmount = (smartPlan.totalPool * BigInt(maxAllocationBps)) / 10_000n; - - if (targetAmount > maxAllowedAmount) { - violations.push({ uniqueKey, maxAllocationBps }); - } - } - - return violations; - }, [currentSupplyByMarket, debouncedSmartMaxAllocationBps, smartPlan]); + const smartConstraintWarning = useMemo(() => getSmartConstraintWarning(smartPlan), [smartPlan]); const isSmartWithdrawOnly = useMemo(() => { if (!smartPlan || smartTotalMoved === 0n) return false; @@ -1134,9 +1182,18 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, {!isSmartCalculating && smartPlan && smartTotalMoved > 0n && !isSmartFeeReady && (
Waiting for loan asset USD price to enforce the smart rebalance fee cap.
)} - {!isSmartCalculating && constraintViolations.length > 0 && ( -
- Some max-allocation limits could not be fully satisfied due to current market liquidity/capacity. + {!isSmartCalculating && smartConstraintWarning != null && ( +
+
+ +
+
+
{smartConstraintWarning.title}
+
{smartConstraintWarning.detail}
+
)} diff --git a/src/features/positions/smart-rebalance/engine.ts b/src/features/positions/smart-rebalance/engine.ts index 746dfe9e..5a7479ad 100644 --- a/src/features/positions/smart-rebalance/engine.ts +++ b/src/features/positions/smart-rebalance/engine.ts @@ -1,5 +1,12 @@ import type { Market as BlueMarket } from '@morpho-org/blue-sdk'; -import type { SmartRebalanceConstraintMap, SmartRebalanceDelta, SmartRebalanceEngineInput, SmartRebalanceEngineOutput } from './types'; +import type { + SmartRebalanceConstraintMap, + SmartRebalanceConstraintViolation, + SmartRebalanceConstraintViolationReason, + SmartRebalanceDelta, + SmartRebalanceEngineInput, + SmartRebalanceEngineOutput, +} from './types'; const MAX_CHUNKS = 100n; const APY_SCALE = 1_000_000_000_000n; @@ -79,11 +86,13 @@ type CleanStateResult = { maxAllocationMap: Map; allocations: Map; marketMap: Map; + lockedAmountMap: Map; }; type ChunkAllocationState = { allocations: Map; marketMap: Map; + unallocatedAmount: bigint; }; function cleanStates( @@ -96,6 +105,7 @@ function cleanStates( const maxAllocationMap = new Map(); const allocations = new Map(); const marketMap = new Map(); + const lockedAmountMap = new Map(); let runningPrincipal = principal; @@ -109,6 +119,7 @@ function cleanStates( movableKeys.push(entry.uniqueKey); allocations.set(entry.uniqueKey, lockedAmount); marketMap.set(entry.uniqueKey, marketAfter); + lockedAmountMap.set(entry.uniqueKey, lockedAmount); runningPrincipal += withdrawnAmount; } @@ -118,6 +129,7 @@ function cleanStates( maxAllocationMap, allocations, marketMap, + lockedAmountMap, }; } @@ -197,13 +209,18 @@ function calculateAllocation( marketMap: Map, maxAllocationMap: Map, ): ChunkAllocationState { + let unallocatedAmount = 0n; + for (const chunk of chunks) { let remainingChunk = chunk; if (remainingChunk <= 0n) continue; while (remainingChunk > 0n) { const best = findBestSupplyTarget(remainingChunk, movableKeys, uniqueKeys, allocations, marketMap, maxAllocationMap); - if (!best) break; + if (!best) { + unallocatedAmount += remainingChunk; + break; + } allocations.set(best.uniqueKey, (allocations.get(best.uniqueKey) ?? 0n) + best.amount); marketMap.set(best.uniqueKey, best.marketAfter); @@ -214,6 +231,7 @@ function calculateAllocation( return { allocations, marketMap, + unallocatedAmount, }; } @@ -248,6 +266,86 @@ function sumTotalMoved(deltas: SmartRebalanceDelta[]): bigint { }, 0n); } +function hasExplicitMaxAllocationConstraint( + entries: SmartRebalanceEngineInput['entries'], + constraints: SmartRebalanceConstraintMap | undefined, +): boolean { + return entries.some((entry) => { + const maxAllocationBps = clampBps(constraints?.[entry.uniqueKey]?.maxAllocationBps); + return maxAllocationBps !== undefined && maxAllocationBps < 10_000; + }); +} + +function buildDiagnostics({ + entries, + constraints, + totalPool, + allocations, + maxAllocationMap, + lockedAmountMap, + objectiveGuardTriggered, + unallocatedAmount, +}: { + entries: SmartRebalanceEngineInput['entries']; + constraints: SmartRebalanceConstraintMap | undefined; + totalPool: bigint; + allocations: Map; + maxAllocationMap: Map; + lockedAmountMap: Map; + objectiveGuardTriggered: boolean; + unallocatedAmount: bigint; +}): SmartRebalanceEngineOutput['diagnostics'] { + const hasUnboundedSelectedMarket = entries.some((entry) => maxAllocationMap.get(entry.uniqueKey) === undefined); + const totalSelectedCapacity = hasUnboundedSelectedMarket + ? null + : entries.reduce((sum, entry) => sum + (maxAllocationMap.get(entry.uniqueKey) ?? 0n), 0n); + const selectedCapacityShortfall = + totalSelectedCapacity !== null && totalSelectedCapacity < totalPool ? totalPool - totalSelectedCapacity : 0n; + + const constraintViolations: SmartRebalanceConstraintViolation[] = []; + + for (const entry of entries) { + const maxAllocationBps = clampBps(constraints?.[entry.uniqueKey]?.maxAllocationBps); + if (maxAllocationBps === undefined || maxAllocationBps >= 10_000) continue; + + const maxAllowedAmount = maxAllocationMap.get(entry.uniqueKey); + if (maxAllowedAmount === undefined) continue; + + const targetAmount = allocations.get(entry.uniqueKey) ?? entry.currentSupply; + if (targetAmount <= maxAllowedAmount) continue; + + const lockedAmount = lockedAmountMap.get(entry.uniqueKey) ?? 0n; + const requiredReduction = entry.currentSupply > maxAllowedAmount ? entry.currentSupply - maxAllowedAmount : 0n; + + let reason: SmartRebalanceConstraintViolationReason = 'unknown'; + if (requiredReduction > entry.maxWithdrawable) { + reason = 'locked-liquidity'; + } else if (selectedCapacityShortfall > 0n || unallocatedAmount > 0n) { + reason = 'selected-capacity'; + } + + constraintViolations.push({ + uniqueKey: entry.uniqueKey, + collateralSymbol: entry.market.collateralAsset?.symbol ?? 'N/A', + maxAllocationBps, + currentAmount: entry.currentSupply, + targetAmount, + maxAllowedAmount, + excessAmount: targetAmount - maxAllowedAmount, + maxWithdrawable: entry.maxWithdrawable, + lockedAmount, + reason, + }); + } + + return { + constraintViolations, + objectiveGuardTriggered, + selectedCapacityShortfall, + unallocatedAmount, + }; +} + /** * Pure smart-rebalance optimizer. * @@ -269,6 +367,7 @@ export function planRebalance(input: SmartRebalanceEngineInput): SmartRebalanceE if (totalPool <= 0n) return null; const uniqueKeys = entries.map((entry) => entry.uniqueKey); + const hasExplicitConstraints = hasExplicitMaxAllocationConstraint(entries, constraints); // 1. Simulate extra liquidity and start state: // - attempt best-effort withdrawal from each selected market @@ -293,6 +392,16 @@ export function planRebalance(input: SmartRebalanceEngineInput): SmartRebalanceE currentWeightedApy: objectiveToWeightedApy(currentObjective, totalPool), projectedWeightedApy: objectiveToWeightedApy(projectedObjective, totalPool), totalMoved: sumTotalMoved(deltas), + diagnostics: buildDiagnostics({ + entries, + constraints, + totalPool, + allocations: cleaned.allocations, + maxAllocationMap: cleaned.maxAllocationMap, + lockedAmountMap: cleaned.lockedAmountMap, + objectiveGuardTriggered: false, + unallocatedAmount: 0n, + }), }; } @@ -319,8 +428,9 @@ export function planRebalance(input: SmartRebalanceEngineInput): SmartRebalanceE ); const projectedObjective = computeObjective(uniqueKeys, allocated.allocations, allocated.marketMap); - // Reliability guard: never return a plan that is worse than the current weighted objective. - if (projectedObjective < currentObjective) { + // Reliability guard: for unconstrained auto-optimization, never return a plan that is worse than the current weighted objective. + // Explicit max-allocation caps are user intent and should still produce the best feasible constrained plan even if the weighted rate falls. + if (!hasExplicitConstraints && projectedObjective < currentObjective) { const noOpAllocations = new Map(entries.map((entry) => [entry.uniqueKey, entry.currentSupply])); const noOpMarkets = new Map(entries.map((entry) => [entry.uniqueKey, entry.baselineMarket])); const noOpDeltas = buildDeltas(entries, noOpAllocations, noOpMarkets); @@ -335,6 +445,16 @@ export function planRebalance(input: SmartRebalanceEngineInput): SmartRebalanceE currentWeightedApy: objectiveToWeightedApy(currentObjective, totalPool), projectedWeightedApy: objectiveToWeightedApy(currentObjective, totalPool), totalMoved: 0n, + diagnostics: buildDiagnostics({ + entries, + constraints, + totalPool, + allocations: noOpAllocations, + maxAllocationMap: cleaned.maxAllocationMap, + lockedAmountMap: cleaned.lockedAmountMap, + objectiveGuardTriggered: true, + unallocatedAmount: 0n, + }), }; } @@ -350,6 +470,16 @@ export function planRebalance(input: SmartRebalanceEngineInput): SmartRebalanceE currentWeightedApy: objectiveToWeightedApy(currentObjective, totalPool), projectedWeightedApy: objectiveToWeightedApy(projectedObjective, totalPool), totalMoved, + diagnostics: buildDiagnostics({ + entries, + constraints, + totalPool, + allocations: allocated.allocations, + maxAllocationMap: cleaned.maxAllocationMap, + lockedAmountMap: cleaned.lockedAmountMap, + objectiveGuardTriggered: false, + unallocatedAmount: allocated.unallocatedAmount, + }), }; } diff --git a/src/features/positions/smart-rebalance/types.ts b/src/features/positions/smart-rebalance/types.ts index 4f66780a..29cfbba7 100644 --- a/src/features/positions/smart-rebalance/types.ts +++ b/src/features/positions/smart-rebalance/types.ts @@ -25,6 +25,28 @@ export type SmartRebalanceConstraintMap = Record< } >; +export type SmartRebalanceConstraintViolationReason = 'locked-liquidity' | 'selected-capacity' | 'unknown'; + +export type SmartRebalanceConstraintViolation = { + uniqueKey: string; + collateralSymbol: string; + maxAllocationBps: number; + currentAmount: bigint; + targetAmount: bigint; + maxAllowedAmount: bigint; + excessAmount: bigint; + maxWithdrawable: bigint; + lockedAmount: bigint; + reason: SmartRebalanceConstraintViolationReason; +}; + +export type SmartRebalanceDiagnostics = { + constraintViolations: SmartRebalanceConstraintViolation[]; + objectiveGuardTriggered: boolean; + selectedCapacityShortfall: bigint; + unallocatedAmount: bigint; +}; + export type SmartRebalanceEngineInput = { entries: SmartRebalanceEngineEntry[]; constraints?: SmartRebalanceConstraintMap; @@ -51,4 +73,5 @@ export type SmartRebalanceEngineOutput = { currentWeightedApy: number; projectedWeightedApy: number; totalMoved: bigint; + diagnostics: SmartRebalanceDiagnostics; }; diff --git a/src/imgs/chains/etherlink.svg b/src/imgs/chains/etherlink.svg new file mode 100644 index 00000000..29bb8525 --- /dev/null +++ b/src/imgs/chains/etherlink.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/imgs/tokens/mbasis.png b/src/imgs/tokens/mbasis.png new file mode 100644 index 00000000..e27854ef Binary files /dev/null and b/src/imgs/tokens/mbasis.png differ diff --git a/src/imgs/tokens/mmev.svg b/src/imgs/tokens/mmev.svg new file mode 100644 index 00000000..9793e4b5 --- /dev/null +++ b/src/imgs/tokens/mmev.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/imgs/tokens/mtbill.png b/src/imgs/tokens/mtbill.png new file mode 100644 index 00000000..c7795106 Binary files /dev/null and b/src/imgs/tokens/mtbill.png differ diff --git a/src/imgs/tokens/xu3o8.png b/src/imgs/tokens/xu3o8.png new file mode 100644 index 00000000..3b679c68 Binary files /dev/null and b/src/imgs/tokens/xu3o8.png differ diff --git a/src/store/createWagmiConfig.ts b/src/store/createWagmiConfig.ts index b2948350..578a78e6 100644 --- a/src/store/createWagmiConfig.ts +++ b/src/store/createWagmiConfig.ts @@ -1,4 +1,5 @@ import { createConfig, http } from 'wagmi'; +import { etherlink } from 'viem/chains'; import { arbitrum, base, mainnet, monad, optimism, polygon, unichain } from 'wagmi/chains'; import type { CustomRpcUrls } from '@/stores/useCustomRpc'; import { SupportedNetworks, getDefaultRPC, hyperEvm } from '@/utils/networks'; @@ -15,12 +16,13 @@ export function createWagmiConfig(customRpcUrls: CustomRpcUrls = {}) { const rpcPolygon = customRpcUrls[SupportedNetworks.Polygon] ?? getDefaultRPC(SupportedNetworks.Polygon); const rpcUnichain = customRpcUrls[SupportedNetworks.Unichain] ?? getDefaultRPC(SupportedNetworks.Unichain); const rpcArbitrum = customRpcUrls[SupportedNetworks.Arbitrum] ?? getDefaultRPC(SupportedNetworks.Arbitrum); + const rpcEtherlink = customRpcUrls[SupportedNetworks.Etherlink] ?? getDefaultRPC(SupportedNetworks.Etherlink); const rpcHyperEVM = customRpcUrls[SupportedNetworks.HyperEVM] ?? getDefaultRPC(SupportedNetworks.HyperEVM); const rpcMonad = customRpcUrls[SupportedNetworks.Monad] ?? getDefaultRPC(SupportedNetworks.Monad); return createConfig({ ssr: true, - chains: [mainnet, optimism, base, polygon, unichain, arbitrum, hyperEvm, monad], + chains: [mainnet, optimism, base, polygon, unichain, arbitrum, etherlink, hyperEvm, monad], transports: { [mainnet.id]: http(rpcMainnet), [optimism.id]: http(rpcOptimism), @@ -28,6 +30,7 @@ export function createWagmiConfig(customRpcUrls: CustomRpcUrls = {}) { [polygon.id]: http(rpcPolygon), [unichain.id]: http(rpcUnichain), [arbitrum.id]: http(rpcArbitrum), + [etherlink.id]: http(rpcEtherlink), [hyperEvm.id]: http(rpcHyperEVM), [monad.id]: http(rpcMonad), }, diff --git a/src/types/token.ts b/src/types/token.ts index f2743a98..ed4e2f77 100644 --- a/src/types/token.ts +++ b/src/types/token.ts @@ -24,6 +24,7 @@ export const WETH_BY_CHAIN: Partial> = { [SupportedNetworks.Polygon]: '0x7ceb23fd6bc0add59e62ac25578270cff1b9f619', [SupportedNetworks.Unichain]: '0x4200000000000000000000000000000000000006', [SupportedNetworks.Arbitrum]: '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + [SupportedNetworks.Etherlink]: '0xfc24f770F94edBca6D6f885E12d4317320BcB401', [SupportedNetworks.Monad]: '0xEE8c0E9f1BFFb4Eb878d8f15f368A02a35481242', }; diff --git a/src/utils/external.ts b/src/utils/external.ts index 159d8288..4967b63b 100644 --- a/src/utils/external.ts +++ b/src/utils/external.ts @@ -1,6 +1,6 @@ import { getNetworkName, SupportedNetworks, getExplorerUrl } from './networks'; -const getMorphoNetworkSlug = (chainId: number): string | undefined => { +export const getMorphoNetworkSlug = (chainId: number): string | undefined => { const network = getNetworkName(chainId)?.toLowerCase(); if (chainId === SupportedNetworks.HyperEVM) { return 'hyperevm'; @@ -8,16 +8,25 @@ const getMorphoNetworkSlug = (chainId: number): string | undefined => { if (chainId === SupportedNetworks.Mainnet) { return 'ethereum'; } + if (chainId === SupportedNetworks.Etherlink) { + return undefined; + } return network; }; +export const supportsMorphoAppLinks = (chainId: number): boolean => { + return getMorphoNetworkSlug(chainId) !== undefined; +}; + export const getMarketURL = (id: string, chainId: number): string => { const network = getMorphoNetworkSlug(chainId); + if (!network) return 'https://app.morpho.org'; return `https://app.morpho.org/${network}/market/${id}`; }; export const getVaultURL = (address: string, chainId: number): string => { const network = getMorphoNetworkSlug(chainId); + if (!network) return 'https://app.morpho.org'; return `https://app.morpho.org/${network}/vault/${address}`; }; diff --git a/src/utils/morpho.ts b/src/utils/morpho.ts index 9c042610..2c88fd1e 100644 --- a/src/utils/morpho.ts +++ b/src/utils/morpho.ts @@ -19,6 +19,8 @@ export const getMorphoAddress = (chain: SupportedNetworks) => { return '0x8f5ae9cddb9f68de460c77730b018ae7e04a140a'; case SupportedNetworks.Arbitrum: return '0x6c247b1F6182318877311737BaC0844bAa518F5e'; + case SupportedNetworks.Etherlink: + return '0xbCE7364E63C3B13C73E9977a83c9704E2aCa876e'; case SupportedNetworks.HyperEVM: return '0x68e37dE8d93d3496ae143F2E900490f6280C57cD'; case SupportedNetworks.Monad: @@ -43,6 +45,8 @@ export const getBundlerV2 = (chain: SupportedNetworks) => { return '0x5738366B9348f22607294007e75114922dF2a16A'; // ChainAgnosticBundlerV2 we deployed case SupportedNetworks.Arbitrum: return '0x5738366B9348f22607294007e75114922dF2a16A'; // ChainAgnosticBundlerV2 we deployed + case SupportedNetworks.Etherlink: + return '0x5738366B9348f22607294007e75114922dF2a16A'; case SupportedNetworks.HyperEVM: return '0x5738366B9348f22607294007e75114922dF2a16A'; // ChainAgnosticBundlerV2 we deployed case SupportedNetworks.Monad: @@ -66,6 +70,8 @@ export const getIRMTitle = (address: string) => { return 'Adaptive Curve'; case '0x66f30587fb8d4206918deb78eca7d5ebbafd06da': // on arbitrum return 'Adaptive Curve'; + case '0xc1523be776e66ba07b609b1914d0925278f21fe5': // on etherlink + return 'Adaptive Curve'; case '0xd4a426f010986dcad727e8dd6eed44ca4a9b7483': // on hyperevm return 'Adaptive Curve'; case '0x09475a3d6ea8c314c592b1a3799bde044e2f400f': // on monad @@ -104,6 +110,8 @@ export function getMorphoGenesisDate(chainId: number): Date { return new Date('2025-02-18T02:03:6.000Z'); case SupportedNetworks.Arbitrum: return new Date('2025-01-17T06:04:51.000Z'); + case SupportedNetworks.Etherlink: + return new Date('2025-07-14T20:41:53.000Z'); case SupportedNetworks.HyperEVM: return new Date('2025-04-03T04:52:00.000Z'); case SupportedNetworks.Monad: diff --git a/src/utils/networks.ts b/src/utils/networks.ts index 46b2ca2c..a9e0bb3f 100644 --- a/src/utils/networks.ts +++ b/src/utils/networks.ts @@ -1,5 +1,15 @@ import { type Address, type Chain, defineChain } from 'viem'; -import { arbitrum, base, mainnet, monad, optimism, polygon, unichain, hyperEvm as hyperEvmOld } from 'viem/chains'; +import { + arbitrum, + base, + etherlink as etherlinkChain, + mainnet, + monad, + optimism, + polygon, + unichain, + hyperEvm as hyperEvmOld, +} from 'viem/chains'; import { v2AgentsBase } from './monarch-agent'; import type { AgentMetadata } from './types'; @@ -13,10 +23,10 @@ const _apiKey = process.env.NEXT_PUBLIC_THEGRAPH_API_KEY; * - If NEXT_PUBLIC_RPC_PRIORITY === 'ALCHEMY': Use Alchemy first, fall back to specific RPC * - Otherwise (default): Use specific network RPC first, fall back to Alchemy */ -const getRpcUrl = (specificRpcUrl: string | undefined, alchemySubdomain: string): string => { +const getRpcUrl = (specificRpcUrl: string | undefined, alchemySubdomain?: string): string => { // Sanitize empty strings to undefined for correct fallback behavior const targetRpc = specificRpcUrl || undefined; - const alchemyUrl = alchemyKey ? `https://${alchemySubdomain}.g.alchemy.com/v2/${alchemyKey}` : undefined; + const alchemyUrl = alchemyKey && alchemySubdomain ? `https://${alchemySubdomain}.g.alchemy.com/v2/${alchemyKey}` : undefined; if (rpcPriority === 'ALCHEMY') { // Prioritize Alchemy when explicitly set @@ -34,6 +44,7 @@ export enum SupportedNetworks { Polygon = 137, Unichain = 130, Arbitrum = 42_161, + Etherlink = 42_793, HyperEVM = 999, Monad = 143, } @@ -45,6 +56,7 @@ export const ALL_SUPPORTED_NETWORKS = [ SupportedNetworks.Polygon, SupportedNetworks.Unichain, SupportedNetworks.Arbitrum, + SupportedNetworks.Etherlink, SupportedNetworks.HyperEVM, SupportedNetworks.Monad, ]; @@ -161,6 +173,18 @@ export const networks: NetworkConfig[] = [ explorerUrl: 'https://arbiscan.io', wrappedNativeToken: '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', }, + { + network: SupportedNetworks.Etherlink, + chain: etherlinkChain, + logo: require('../imgs/chains/etherlink.svg') as string, + name: 'Etherlink', + defaultRPC: getRpcUrl(process.env.NEXT_PUBLIC_ETHERLINK_RPC), + blocktime: 4.83, + maxBlockDelay: 10, + explorerUrl: 'https://explorer.etherlink.com', + nativeTokenSymbol: 'XTZ', + wrappedNativeToken: '0xc9B53AB2679f573e480d01e0f49e2B5CFB7a3EAb', + }, { network: SupportedNetworks.HyperEVM, chain: hyperEvm, diff --git a/src/utils/rpc.ts b/src/utils/rpc.ts index 7acd774e..88e0fcf0 100644 --- a/src/utils/rpc.ts +++ b/src/utils/rpc.ts @@ -1,5 +1,5 @@ import { createPublicClient, http, type PublicClient } from 'viem'; -import { arbitrum, base, mainnet, monad, optimism, polygon, unichain } from 'viem/chains'; +import { arbitrum, base, etherlink, mainnet, monad, optimism, polygon, unichain } from 'viem/chains'; import { getDefaultRPC, getViemChain, SupportedNetworks, hyperEvm } from './networks'; // Default clients (cached) @@ -33,6 +33,10 @@ const initializeDefaultClients = () => { chain: arbitrum, transport: http(getDefaultRPC(SupportedNetworks.Arbitrum)), }) as PublicClient, + [SupportedNetworks.Etherlink]: createPublicClient({ + chain: etherlink, + transport: http(getDefaultRPC(SupportedNetworks.Etherlink)), + }) as PublicClient, [SupportedNetworks.HyperEVM]: createPublicClient({ chain: hyperEvm, transport: http(getDefaultRPC(SupportedNetworks.HyperEVM)), diff --git a/src/utils/tokens.ts b/src/utils/tokens.ts index 850da2b8..b406041a 100644 --- a/src/utils/tokens.ts +++ b/src/utils/tokens.ts @@ -1,4 +1,4 @@ -import { type Chain, base, mainnet, polygon, unichain, arbitrum, optimism, monad } from 'viem/chains'; +import { type Chain, arbitrum, base, etherlink, mainnet, monad, optimism, polygon, unichain } from 'viem/chains'; import { getWrappedNativeToken, hyperEvm } from './networks'; export type TokenSource = 'local' | 'external' | 'unknown'; @@ -65,6 +65,7 @@ const supportedTokens = [ chain: arbitrum, address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', }, + { chain: etherlink, address: '0x796Ea11Fa2dD751eD01b53C372fFDB4AAa8f00F9' }, { chain: hyperEvm, address: '0xb88339cb7199b77e23db6e890353e22632ba630f', @@ -270,6 +271,7 @@ const supportedTokens = [ chain: arbitrum, address: '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', }, + { chain: etherlink, address: '0xfc24f770F94edBca6D6f885E12d4317320BcB401' }, // wrapped eth on polygon, defined here as it will not be interpreted as "WETH Contract" // which is determined by isWETH function // This is solely for displaying and linking to eth. @@ -284,6 +286,12 @@ const supportedTokens = [ decimals: 18, networks: [{ chain: polygon, address: '0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270' }], }, + { + symbol: 'WXTZ', + img: undefined, + decimals: 18, + networks: [{ chain: etherlink, address: '0xc9B53AB2679f573e480d01e0f49e2B5CFB7a3EAb' }], + }, { symbol: 'sDAI', img: require('../imgs/tokens/sdai.svg') as string, @@ -357,6 +365,12 @@ const supportedTokens = [ decimals: 6, networks: [{ chain: mainnet, address: '0xCE8e559Ac89c2bDC97Bdb5F58705c54dB9cB77dC' }], }, + { + symbol: 'xU3O8', + img: undefined, + decimals: 18, + networks: [{ chain: etherlink, address: '0x79052Ab3C166D4899a1e0DD033aC3b379AF0B1fD' }], + }, { symbol: 'osETH', img: require('../imgs/tokens/oseth.png') as string, @@ -372,6 +386,7 @@ const supportedTokens = [ { chain: mainnet, address: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599' }, { chain: polygon, address: '0x1bfd67037b42cf73acf2047067bd4f2c47d9bfd6' }, { chain: optimism, address: '0x68f180fcCe6836688e9084f035309E29Bf0A2095' }, + { chain: etherlink, address: '0xbFc94CD2B1E55999Cfc7347a9313e88702B83d0F' }, { chain: unichain, address: '0x927B51f251480a681271180DA4de28D44EC4AfB8', @@ -394,6 +409,42 @@ const supportedTokens = [ ], peg: TokenPeg.BTC, }, + { + symbol: 'mBASIS', + img: require('../imgs/tokens/mbasis.png') as string, + decimals: 18, + networks: [{ chain: etherlink, address: '0x2247B5A46BB79421a314aB0f0b67fFd11dd37Ee4' }], + protocol: { + name: 'Midas', + }, + }, + { + symbol: 'xU3O8', + img: require('../imgs/tokens/xu3o8.png') as string, + decimals: 18, + networks: [{ chain: etherlink, address: '0x79052ab3c166d4899a1e0dd033ac3b379af0b1fd' }], + protocol: { + name: 'Midas', + }, + }, + { + symbol: 'mMEV', + img: require('../imgs/tokens/mmev.svg') as string, + decimals: 18, + networks: [{ chain: etherlink, address: '0x5542F82389b76C23f5848268893234d8A63fd5c8' }], + protocol: { + name: 'Midas', + }, + }, + { + symbol: 'mTBILL', + img: require('../imgs/tokens/mtbill.png') as string, + decimals: 18, + networks: [{ chain: etherlink, address: '0xDD629E5241CbC5919847783e6C96B2De4754e438' }], + protocol: { + name: 'Midas', + }, + }, { symbol: 'tBTC', img: require('../imgs/tokens/tbtc.webp') as string,