Skip to main content
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 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: Encode the vault deposit

The yield vault implements ERC-4626, a standard interface for yield-bearing vaults. Use TRAILS_ROUTER_PLACEHOLDER_AMOUNT so Trails fills in the exact routed amount at execution time:
import { encodeFunctionData } from "viem";
import { TRAILS_ROUTER_PLACEHOLDER_AMOUNT } from "0xtrails";

const USDC = "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359";
const MORPHO_VAULT = "0x781FB7F6d845E3bE129289833b04d43Aa8558c42";

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

function encodeDeposit(receiver: string) {
  return encodeFunctionData({
    abi: VAULT_ABI,
    functionName: "deposit",
    args: [TRAILS_ROUTER_PLACEHOLDER_AMOUNT, receiver],
  });
}

Step 3: Quote via Trails

Before moving funds, fetch a quote. This returns the intent object that drives the deposit, including fee breakdowns you can surface in your UI if needed:
const TRAILS_API = "https://trails-api.sequence.app/rpc/Trails";

async function quoteDeposit(userAddress: string, amountUsdc: string) {
  const response = await fetch(`${TRAILS_API}/QuoteIntent`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-Access-Key": process.env.TRAILS_API_KEY,
    },
    body: JSON.stringify({
      ownerAddress: userAddress,
      originChainId: 137,
      originTokenAddress: USDC,
      destinationChainId: 137,
      destinationTokenAddress: USDC,
      destinationToAddress: MORPHO_VAULT,
      destinationTokenAmount: amountUsdc,
      destinationCalldata: encodeDeposit(userAddress),
      tradeType: "EXACT_OUTPUT",
    }),
  });

  const { intent } = await response.json();
  return intent;
}

Step 4: Get user authorization

The user authorizes the transfer with a single tap. Internally this is a gasless EIP-2612 permit signature, with no approval transaction and no gas fee the user needs to manage. From the user’s perspective it’s indistinguishable from confirming any other in-app action:
import { useWalletClient, usePublicClient } from "wagmi";

async function signPermit(
  walletClient: ReturnType<typeof useWalletClient>["data"],
  publicClient: ReturnType<typeof usePublicClient>,
  userAddress: `0x${string}`,
  spender: `0x${string}`,
  amount: bigint
) {
  const nonce = await publicClient.readContract({
    address: USDC,
    abi: [
      {
        name: "nonces",
        type: "function",
        inputs: [{ name: "owner", type: "address" }],
        outputs: [{ name: "", type: "uint256" }],
        stateMutability: "view",
      },
    ] as const,
    functionName: "nonces",
    args: [userAddress],
  });

  const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600);

  const signature = await walletClient.signTypedData({
    domain: { name: "USD Coin", version: "2", chainId: 137, verifyingContract: USDC },
    types: {
      Permit: [
        { name: "owner", type: "address" },
        { name: "spender", type: "address" },
        { name: "value", type: "uint256" },
        { name: "nonce", type: "uint256" },
        { name: "deadline", type: "uint256" },
      ],
    },
    primaryType: "Permit",
    message: { owner: userAddress, spender, value: amount, nonce, deadline },
  });

  return { signature, deadline };
}

Step 5: Commit and execute

Once the user authorizes, commit and execute the deposit. Trails handles routing and settlement:
async function commitAndExecute(
  intent: object,
  signature: string,
  deadline: bigint
) {
  const { intentId } = await fetch(`${TRAILS_API}/CommitIntent`, {
    method: "POST",
    headers: { "Content-Type": "application/json", "X-Access-Key": process.env.TRAILS_API_KEY },
    body: JSON.stringify({ intent }),
  }).then((r) => r.json());

  await fetch(`${TRAILS_API}/ExecuteIntent`, {
    method: "POST",
    headers: { "Content-Type": "application/json", "X-Access-Key": process.env.TRAILS_API_KEY },
    body: JSON.stringify({
      intentId,
      depositSignature: { signature, deadline: deadline.toString() },
    }),
  });

  return intentId;
}

async function waitForReceipt(intentId: string) {
  const { receipt } = await fetch(`${TRAILS_API}/WaitIntentReceipt`, {
    method: "POST",
    headers: { "Content-Type": "application/json", "X-Access-Key": process.env.TRAILS_API_KEY },
    body: JSON.stringify({ intentId }),
  }).then((r) => r.json());

  return receipt;
}

Putting it together

A “Move to Savings” button from your app’s perspective. The entire signing flow runs silently in under a second:
import { useAccount, useWalletClient, usePublicClient } from "wagmi";
import { parseUnits } from "viem";

export function MoveToSavingsButton() {
  const { address } = useAccount();
  const { data: walletClient } = useWalletClient();
  const publicClient = usePublicClient();

  async function handleDeposit() {
    const amount = parseUnits("100", 6); // $100.00

    const intent = await quoteDeposit(address!, amount.toString());
    const spender = intent.depositTransaction.toAddress as `0x${string}`;

    const { signature, deadline } = await signPermit(
      walletClient!, publicClient!, address! as `0x${string}`, spender, amount
    );

    const intentId = await commitAndExecute(intent, signature, deadline);
    await waitForReceipt(intentId);
    // Update your UI: show new savings balance, yield rate, etc.
  }

  return <button onClick={handleDeposit}>Move $100 to Savings</button>;
}

Withdraw

Users can withdraw their full balance (principal + accrued yield) at any time. Read the vault share balance and redeem it for the underlying funds:
import { useWriteContract, useReadContract, useAccount } from "wagmi";

const VAULT_REDEEM_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" }],
  },
  {
    name: "balanceOf",
    type: "function",
    stateMutability: "view",
    inputs: [{ name: "account", type: "address" }],
    outputs: [{ name: "", type: "uint256" }],
  },
] as const;

export function WithdrawButton() {
  const { address } = useAccount();
  const { writeContract } = useWriteContract();

  const { data: shares } = useReadContract({
    address: MORPHO_VAULT,
    abi: VAULT_REDEEM_ABI,
    functionName: "balanceOf",
    args: [address!],
  });

  return (
    <button
      onClick={() =>
        writeContract({
          address: MORPHO_VAULT,
          abi: VAULT_REDEEM_ABI,
          functionName: "redeem",
          args: [shares!, address!, address!],
        })
      }
    >
      Withdraw all
    </button>
  );
}

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.