Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.polygon.technology/llms.txt

Use this file to discover all available pages before exploring further.

This guide shows how to build a yield account feature for a neobank or fintech app. Your users deposit funds, which are allocated to a Morpho lending vault via Trails. The blockchain layer is fully abstracted: your users see a savings balance and an interest rate, nothing else.

How it works

User taps "Move to Savings"
    → Your backend quotes the deposit route via Trails
        → User authorizes with a single tap (gasless, no fees to manage)
            → Trails routes funds into the yield vault
                → Yield accrues; user withdraws any time
Users never interact with a blockchain directly. Wallet creation, gas, and transaction signing all happen in the background via an embedded wallet tied to their account.

Prerequisites

  • A Trails API key
  • A wagmi-compatible embedded wallet provider (Polygon Wallet, Privy, Dynamic, or similar), which powers the invisible signing layer your users never see

Install

pnpm install 0xtrails viem wagmi @tanstack/react-query @privy-io/react-auth

Addresses

Address
USDC (Polygon)0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359
Morpho Compound USDC vault0x781FB7F6d845E3bE129289833b04d43Aa8558c42

Step 1: Set up the embedded wallet

Each user gets an embedded wallet created automatically at sign-up, with no crypto onboarding and no seed phrases. This wallet is the signing key for their yield account; users never see or interact with it directly.
import { PrivyProvider } from "@privy-io/react-auth";
import { WagmiProvider } from "wagmi";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { polygon } from "viem/chains";

const queryClient = new QueryClient();

export const App = () => (
  <PrivyProvider
    appId={process.env.NEXT_PUBLIC_PRIVY_APP_ID}
    config={{
      defaultChain: polygon,
      supportedChains: [polygon],
      embeddedWallets: {
        createOnLogin: "users-without-wallets", // automatic, no user action required
      },
    }}
  >
    <QueryClientProvider client={queryClient}>
      <WagmiProvider config={wagmiConfig}>
        <YourApp />
      </WagmiProvider>
    </QueryClientProvider>
  </PrivyProvider>
);
Users log in with email, phone, or SSO via your standard auth flow. Privy silently provisions the wallet in the background:
import { usePrivy } from "@privy-io/react-auth";

const LoginButton = () => {
  const { login, logout, authenticated } = usePrivy();

  return authenticated ? (
    <button onClick={logout}>Sign out</button>
  ) : (
    <button onClick={login}>Sign in</button>
  );
};

Step 2: Discover the vault market

Composable actions use market IDs instead of hand-encoded vault calldata. Fetch the available vaults, then pass the selected market’s id to the deposit action:
import { useEarnMarkets } from "0xtrails";

function useMorphoUsdcVault() {
  const { data: markets, isLoading, error } = useEarnMarkets({
    chain: "polygon",
    type: "vault",
    provider: "morpho",
    search: "USDC",
  });

  return {
    market: markets?.[0],
    isLoading,
    error,
  };
}

Step 3: Quote and execute the deposit

Use useQuote with the deposit action. Trails quotes the route, handles any bridge or swap, and executes the vault deposit from the destination intent wallet:
import { useQuote, deposit } from "0xtrails";
import { useAccount } from "wagmi";

const MORPHO_USDC_MARKET_ID = "MARKET_ID_FROM_USE_EARN_MARKETS";

export function MoveToSavingsButton() {
  const { address } = useAccount();

  if (!address) return <button disabled>Sign in first</button>;

  return <MoveToSavingsForm walletAddress={address} />;
}

function MoveToSavingsForm({
  walletAddress,
}: {
  walletAddress: `0x${string}`;
}) {
  const { send, isLoadingQuote, quoteError } = useQuote({
    walletAddress,
    from: { chain: "polygon", token: "USDC", amount: "100" },
    to: { chain: "polygon", token: "USDC" },
    actions: [
      deposit({
        marketId: MORPHO_USDC_MARKET_ID,
        amount: "100",
        receiverAddress: walletAddress,
      }),
    ],
    onStatusUpdate: (states) => {
      console.log("Savings deposit status:", states);
    },
  });

  if (isLoadingQuote) return <button disabled>Preparing...</button>;
  if (quoteError) return <p>{quoteError.message}</p>;

  return <button disabled={!send} onClick={() => send?.()}>Move $100 to Savings</button>;
}
From the user’s perspective this is still one confirmation. From your app’s perspective, onStatusUpdate gives you progress events for the route and destination action so you can update the savings balance after settlement.

Withdraw

Users can withdraw their full balance (principal + accrued yield) at any time. Use useEarnBalances to read the user’s position, then build a plain ERC-4626 redeem transaction with viem:
import { encodeFunctionData } from "viem";
import { useEarnBalances } from "0xtrails";
import { useAccount, useWalletClient } from "wagmi";

const MORPHO_USDC_MARKET_ID = "MARKET_ID_FROM_USE_EARN_MARKETS";
const MORPHO_VAULT = "0x...";

const VAULT_ABI = [
  {
    type: "function",
    name: "redeem",
    stateMutability: "nonpayable",
    inputs: [
      { name: "shares", type: "uint256" },
      { name: "receiver", type: "address" },
      { name: "owner", type: "address" },
    ],
    outputs: [{ name: "assets", type: "uint256" }],
  },
] as const;

function hasBalance(balance: { amount?: string; amountRaw?: string }) {
  if (balance.amountRaw) return BigInt(balance.amountRaw) > 0n;
  return Number(balance.amount ?? "0") > 0;
}

export function WithdrawButton() {
  const { address } = useAccount();
  const { data: walletClient } = useWalletClient();

  const { data: positions, isLoading, error } = useEarnBalances({
    walletAddress: address ?? "",
    chain: "polygon",
    marketId: MORPHO_USDC_MARKET_ID,
    enabled: Boolean(address),
  });

  const position = positions?.[0];
  const displayBalance =
    position?.outputTokenBalance ??
    position?.balances.find((item) => hasBalance(item));
  const shareBalance = position?.balances.find((item) =>
    item.shareToken?.address?.toLowerCase() === MORPHO_VAULT.toLowerCase() ||
    item.token.address?.toLowerCase() === MORPHO_VAULT.toLowerCase()
  );

  async function withdrawAllShares() {
    if (!address || !walletClient || !shareBalance?.amountRaw) return;

    const data = encodeFunctionData({
      abi: VAULT_ABI,
      functionName: "redeem",
      args: [BigInt(shareBalance.amountRaw), address, address],
    });

    await walletClient.sendTransaction({
      account: address,
      to: MORPHO_VAULT,
      data,
    });
  }

  if (!address) return <button disabled>Sign in first</button>;
  if (isLoading) return <button disabled>Loading balance...</button>;
  if (error) return <p>{error.message}</p>;
  if (!displayBalance || !hasBalance(displayBalance)) {
    return <button disabled>No balance</button>;
  }

  return (
    <div>
      <p>
        Balance: {displayBalance.amount} {displayBalance.token.symbol}
      </p>
      <button disabled={!walletClient || !shareBalance} onClick={withdrawAllShares}>
        Withdraw all
      </button>
    </div>
  );
}
useEarnBalances returns the normalized earn position for the wallet, including the market yieldId, the display balance, and protocol-specific share/output token balances. Until the SDK exposes a higher-level withdraw helper, you can encode the vault’s ERC-4626 redeem call directly and submit it with the user’s wallet.

Next steps

Trails API Reference

Full reference for QuoteIntent, CommitIntent, ExecuteIntent, and monitoring.

Wallet Compatibility

Full list of supported embedded wallet providers including Privy, Dynamic, and WalletConnect.

Smart Sessions

Automate recurring deposits: schedule weekly transfers to savings without per-transaction prompts.

Treasury Wallet

Sweep funds from user accounts into a central treasury wallet on a schedule.