Skip to content

Complete Integration Example

This guide provides a complete, working reference implementation for integrating Morpho rewards (both Merkl and Legacy URD) into your application. We'll walk through the Morpho + Merkl Integration Demo repository, which demonstrates best practices for production-ready rewards integration.

Repository Overview

Tech Stack

  • Next.js 15 - React framework
  • TypeScript - Type safety
  • Morpho API - GraphQL for vault/market data
  • Merkl API - REST API for Merkl rewards
  • Morpho Rewards API - REST API for URD rewards

Project Structure

src/
├── app/              # Next.js app router
├── components/       # React components
│   ├── VaultMetricsPanel.tsx      # Display vault APY + rewards
│   ├── UserRewardsPanel.tsx       # User claimable rewards
│   ├── VaultRewardsDisplay.tsx    # Rewards breakdown
│   └── ClaimImplementationPanel.tsx # Claim code examples
├── hooks/            # React hooks
│   ├── useVaultData.ts            # Fetch vault data
│   └── useClipboard.ts            # Utility hook
├── lib/              # Core logic
│   ├── api.ts                     # API clients
│   ├── claiming.ts                # Claim functions
│   ├── simulation.ts              # Transaction simulation
│   └── helpers/
│       └── rewards.ts             # Reward calculations
└── types/            # TypeScript types
    └── index.ts

Key Concepts Demonstrated

1. Combining Morpho and Merkl Data

The demo shows how to fetch and combine data from multiple sources to provide a complete picture:

File: src/lib/api.ts
export async function getVaultData(vaultAddress: string, chainId: number) {
  // Fetch from Morpho API
  const morphoData = await fetchMorphoVault(vaultAddress, chainId);
 
  // Extract rewards information
  const rewards = morphoData.vault.state.rewards.map((r) => ({
    token: r.asset.symbol,
    apr: parseFloat(r.supplyApr),
    yearlySupplyTokens: r.yearlySupplyTokens,
  }));
 
  // Calculate combined APY
  const baseApy = parseFloat(morphoData.vault.state.apy);
  const rewardsApr = rewards.reduce((sum, r) => sum + r.apr, 0);
 
  return {
    vault: morphoData.vault,
    baseApy,
    rewardsApr,
    totalApy: baseApy + rewardsApr,
    rewards,
  };
}

2. Fetching User Rewards from Both Systems

File: src/lib/helpers/rewards.ts
export async function getUserRewards(userAddress: string, chainId: number) {
  const [merklRewards, urdRewards] = await Promise.all([
    fetchMerklUserRewards(userAddress, chainId),
    fetchURDUserRewards(userAddress),
  ]);
 
  return {
    merkl: merklRewards,
    urd: urdRewards,
    combined: combineRewardsByToken(merklRewards, urdRewards),
  };
}
 
function combineRewardsByToken(merkl: Reward[], urd: Reward[]) {
  const combined = new Map<string, Reward>();
 
  for (const reward of [...merkl, ...urd]) {
    if (combined.has(reward.token)) {
      const existing = combined.get(reward.token)!;
      existing.amount += reward.amount;
    } else {
      combined.set(reward.token, { ...reward });
    }
  }
 
  return Array.from(combined.values());
}

3. Implementing Claims

File: src/lib/claiming.ts

The demo includes claim implementations for both systems:

Merkl Claim:
export async function claimMerklRewards(
  userAddress: string,
  chainId: number,
  walletClient: WalletClient
) {
  // 1. Fetch claim data
  const claimData = await fetch(
    `https://api.merkl.xyz/v4/claim?user=${userAddress}&chainId=${chainId}`
  ).then((r) => r.json());
 
  // 2. Execute claim transaction
  const hash = await walletClient.writeContract({
    address: MERKL_DISTRIBUTOR_ADDRESS,
    abi: MERKL_ABI,
    functionName: "claim",
    args: [
      claimData.user,
      claimData.tokens,
      claimData.amounts,
      claimData.proofs,
    ],
  });
 
  // 3. Wait for confirmation
  return await walletClient.waitForTransactionReceipt({ hash });
}
URD Claim:
export async function claimURDRewards(
  userAddress: string,
  walletClient: WalletClient
) {
  // 1. Fetch all distributions
  const distributions = await fetch(
    `https://rewards.morpho.org/v1/users/${userAddress}/distributions`
  ).then((r) => r.json());
 
  const results = [];
 
  // 2. Claim each distribution
  for (const dist of distributions) {
    // Check if already claimed
    const claimed = await checkClaimedAmount(
      dist.distributor,
      userAddress,
      dist.asset.address
    );
 
    if (BigInt(claimed) >= BigInt(dist.claimable)) {
      continue; // Nothing to claim
    }
 
    // Execute claim using pre-formatted tx_data
    const hash = await walletClient.sendTransaction({
      to: dist.distributor,
      data: dist.tx_data,
    });
 
    const receipt = await walletClient.waitForTransactionReceipt({ hash });
    results.push({ distribution: dist, receipt });
  }
 
  return results;
}

4. React Components for Display

File: src/components/VaultMetricsPanel.tsx
export function VaultMetricsPanel({ vaultAddress, chainId }) {
  const { data, loading, error } = useVaultData(vaultAddress, chainId);
 
  if (loading) return <Skeleton />;
  if (error) return <ErrorDisplay error={error} />;
 
  return (
    <div className="vault-metrics">
      {/* Vault Header */}
      <div className="header">
        <h2>{data.vault.name}</h2>
        <p>{data.vault.symbol}</p>
      </div>
 
      {/* APY Display */}
      <div className="apy-section">
        <div className="total-apy">
          <span className="label">Total APY</span>
          <span className="value">{data.totalApy.toFixed(2)}%</span>
        </div>
 
        <div className="breakdown">
          <div>Base APY: {data.baseApy.toFixed(2)}%</div>
          {data.rewardsApr > 0 && (
            <div className="rewards">
              Rewards: +{data.rewardsApr.toFixed(2)}%
            </div>
          )}
        </div>
      </div>
 
      {/* Rewards List */}
      {data.rewards.length > 0 && (
        <VaultRewardsDisplay rewards={data.rewards} />
      )}
    </div>
  );
}
File: src/components/UserRewardsPanel.tsx
export function UserRewardsPanel({ userAddress, chainId }) {
  const [rewards, setRewards] = useState(null);
  const [claiming, setClaiming] = useState(false);
 
  useEffect(() => {
    getUserRewards(userAddress, chainId).then(setRewards);
  }, [userAddress, chainId]);
 
  const handleClaimAll = async () => {
    setClaiming(true);
    try {
      // Claim from both systems
      await Promise.all([
        claimMerklRewards(userAddress, chainId, walletClient),
        claimURDRewards(userAddress, walletClient),
      ]);
 
      alert("Rewards claimed successfully!");
      // Refresh rewards data
      getUserRewards(userAddress, chainId).then(setRewards);
    } catch (error) {
      console.error("Claim failed:", error);
      alert("Claim failed. See console for details.");
    } finally {
      setClaiming(false);
    }
  };
 
  return (
    <div className="user-rewards">
      <h3>Your Claimable Rewards</h3>
 
      {rewards?.combined.map((reward) => (
        <div key={reward.token} className="reward-row">
          <span>{reward.token}</span>
          <span>{formatAmount(reward.amount)} {reward.token}</span>
        </div>
      ))}
 
      <button onClick={handleClaimAll} disabled={claiming}>
        {claiming ? "Claiming..." : "Claim All Rewards"}
      </button>
 
      {/* Show source breakdown */}
      <details>
        <summary>View breakdown</summary>
        <div>Merkl: {rewards?.merkl.length || 0} rewards</div>
        <div>URD: {rewards?.urd.length || 0} rewards</div>
      </details>
    </div>
  );
}

Running the Demo

Prerequisites

  • Node.js 18+ and yarn
  • A wallet with Morpho positions (for testing user rewards)

Quick Start

# Clone the repository
git clone https://github.com/morpho-org/merkl-morpho-recipe.git
cd merkl-morpho-recipe
 
# Install dependencies
yarn install
 
# Run the development server
yarn dev
 
# Open http://localhost:3000

Explore via Scripts

The demo includes standalone scripts to understand the data flow:

# See vault APY + rewards calculation
yarn demo:yield
 
# Check user claimable rewards
yarn demo:rewards
 
# Complete integration flow
yarn demo:full
Example Output:
$ yarn demo:yield
 
=== Vault Metrics ===
Vault: Steakhouse USDC
Base APY: 8.45%
Rewards APR: 2.13%
Total APY: 10.58%
 
Reward Programs:
  - MORPHO: 1.85% APR
  - STEAK: 0.28% APR

Key Takeaways from the Demo

1. Error Handling

The demo implements graceful error handling:

try {
  const data = await fetchVaultData(address, chain);
  return data;
} catch (error) {
  console.error("Failed to fetch vault data:", error);
  return null; // Fallback to null, don't crash the app
}

2. Type Safety

Full TypeScript coverage ensures correctness:

export interface VaultReward {
  token: string;
  apr: number;
  yearlySupplyTokens: string;
  tokenAddress: string;
}
 
export interface UserClaimableReward {
  token: string;
  amount: bigint;
  source: "merkl" | "urd";
}

3. Caching Strategy

const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
 
const cache = new Map<string, { data: any; timestamp: number }>();
 
export function getCached<T>(
  key: string,
  fetcher: () => Promise<T>
): Promise<T> {
  const cached = cache.get(key);
 
  if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
    return Promise.resolve(cached.data);
  }
 
  return fetcher().then((data) => {
    cache.set(key, { data, timestamp: Date.now() });
    return data;
  });
}

4. Simulation Before Execution

// File: src/lib/simulation.ts
export async function simulateClaimTransaction(
  distributorAddress: string,
  txData: string,
  userAddress: string
) {
  const publicClient = createPublicClient({
    chain: mainnet,
    transport: http(),
  });
 
  try {
    await publicClient.call({
      to: distributorAddress,
      data: txData,
      from: userAddress,
    });
 
    return { success: true };
  } catch (error) {
    return { success: false, error };
  }
}

Adapting the Demo for Production

When building on this demo for production, consider:

Security

  • Never expose private keys: Use secure wallet connection libraries (RainbowKit, Wagmi, etc.)
  • Validate all inputs: Sanitize user addresses, vault addresses, chain IDs
  • Handle transaction failures: Implement retry logic with exponential backoff
  • Rate limiting: Respect API rate limits and implement client-side throttling

Performance

  • Lazy load components: Use React.lazy for routes
  • Optimize bundle size: Code split by route and feature
  • Implement pagination: For users with many rewards
  • Use a state management solution: Redux, Zustand, or React Query for complex state

User Experience

  • Transaction notifications: Toast notifications for claim status
  • Transaction history: Show past claims
  • Gas estimation: Display estimated gas costs before claiming
  • Batch claiming: Allow users to claim multiple rewards in one transaction (using Bundlers)

Monitoring

  • Error tracking: Sentry, Bugsnag, or similar
  • Analytics: Track claim success rates, popular vaults, etc.
  • Logging: Structured logging for debugging

Next Steps from the Demo

After exploring the demo:

  1. Fork and modify: Adapt it to your use case
  2. Add wallet connection: Integrate RainbowKit or ConnectKit
  3. Customize UI: Apply your brand's design system
  4. Deploy: Vercel, Netlify, or your preferred platform

Resources

Tutorials