Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.dynamic.xyz/docs/llms.txt

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

What We’re Building

A Next.js app that pairs Dynamic’s embedded wallets with Iron Finance’s payment rails so a user can:
  • Complete one-time KYC, register their embedded wallet, and add a SEPA bank account
  • Onramp: get a live EUR → USDC quote, execute it, and receive a virtual IBAN (vIBAN) to send a SEPA transfer to. USDC is delivered to the embedded wallet automatically.
  • Offramp: get a live USDC → EUR quote, execute it, and receive a deposit address. Sending USDC to it triggers a SEPA payout to the registered IBAN.
  • View prior onramps and offramps with live status

How It Works

Iron exposes a quote + execute model around its /api/autoramps endpoint:
  1. Quote (GET /api/autoramps/quote) returns a signed, time-limited rate for the requested direction, amount, and rails.
  2. Execute (POST /api/autoramps) creates an autoramp from that quote. The response includes deposit_rails — a vIBAN (for onramps) or a deposit crypto address (for offramps) the user sends funds to.
  3. When the deposit arrives, Iron converts and settles automatically to the destination registered on the autoramp (the user’s embedded wallet for onramps, their IBAN for offramps).
Before a user can ramp, they complete a six-step onboarding:
  1. Create an Iron customer
  2. Complete KYC identity verification (hosted link)
  3. Sign any required compliance documents
  4. Register the Dynamic embedded wallet (signed proof of ownership — Travel Rule)
  5. Add a SEPA bank account
  6. Done
3. Deposits trigger automatic conversion — Each autoramp address/vIBAN is persistent and reusable. Iron handles the conversion and settlement automatically on every deposit. All Iron API calls are proxied through your own Next.js API routes. Iron credentials never touch the client.

Building the Application

Project setup

Scaffold a Next.js app and follow the JavaScript quickstart. This guide uses the headless @dynamic-labs-sdk/client (not the React SDK) with the EVM extension.
Dashboard: Under Chains & Networks, enable Ethereum and the stablecoin networks you use with Iron (for example Ethereum mainnet for USDC). Under Sign-in Methods, enable Email OTP and Google under Social Sign-in. Under Security → Allowed Origins, add the origin where the app runs (for example http://localhost:3000).

Install Dependencies

npm install @dynamic-labs-sdk/client @dynamic-labs-sdk/evm @tanstack/react-query @radix-ui/react-select lucide-react

Configure Environment Variables

.env.local
NEXT_PUBLIC_DYNAMIC_ENV_ID=your-dynamic-environment-id
IRON_API_KEY=your-iron-api-key
IRON_ENVIRONMENT=sandbox
NEXT_PUBLIC_IRON_ENVIRONMENT=sandbox
You can find your Dynamic Environment ID in the Dynamic dashboard under Developer Settings → SDK & API Keys. Contact Iron to obtain your IRON_API_KEY. Set IRON_ENVIRONMENT=sandbox while testing — in sandbox mode, KYC approvals can be simulated without real documents.

Initialize Dynamic

Create the Dynamic client with the EVM extension at src/lib/dynamic.ts:
src/lib/dynamic.ts
import { createDynamicClient } from "@dynamic-labs-sdk/client";
import { addEvmExtension } from "@dynamic-labs-sdk/evm";

export const dynamicClient = createDynamicClient({
  environmentId: process.env.NEXT_PUBLIC_DYNAMIC_ENV_ID!,
  metadata: { name: "Iron Finance Ramp" },
});

addEvmExtension();
Extensions must be registered immediately after createDynamicClient(). The client auto-initializes on import — no explicit initializeClient() call is needed.

Configure Wallet Context (Providers)

Create a WalletContext that tracks the current EVM account and exposes auth/wallet helpers to all components:
src/lib/providers.tsx
"use client";

import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react";
import {
  getWalletAccounts, onEvent, isSignedIn, logout,
  detectOAuthRedirect, completeSocialAuthentication,
} from "@dynamic-labs-sdk/client";
import { createWaasWalletAccounts } from "@dynamic-labs-sdk/client/waas";
import { isEvmWalletAccount, type EvmWalletAccount } from "@dynamic-labs-sdk/evm";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { dynamicClient } from "./dynamic";

interface WalletContextValue {
  evmAccount: EvmWalletAccount | null;
  loggedIn: boolean;
  ensureEvmWallet: () => Promise<void>;
  disconnect: () => Promise<void>;
}

const WalletContext = createContext<WalletContextValue>({
  evmAccount: null,
  loggedIn: false,
  ensureEvmWallet: async () => {},
  disconnect: async () => {},
});

export function useWallet() { return useContext(WalletContext); }

const queryClient = new QueryClient({
  defaultOptions: { queries: { staleTime: 1000 * 60 * 5, refetchOnWindowFocus: false } },
});

export default function Providers({ children }: { children: ReactNode }) {
  const [evmAccount, setEvmAccount] = useState<EvmWalletAccount | null>(null);
  const [loggedIn, setLoggedIn] = useState(false);

  const refresh = useCallback(() => {
    const accounts = getWalletAccounts(dynamicClient);
    setEvmAccount(accounts.find(isEvmWalletAccount) ?? null);
    setLoggedIn(isSignedIn(dynamicClient));
  }, []);

  const disconnect = useCallback(async () => {
    await logout(dynamicClient);
    setEvmAccount(null);
    setLoggedIn(false);
  }, []);

  const ensureEvmWallet = useCallback(async () => {
    try {
      const accounts = getWalletAccounts(dynamicClient);
      if (!accounts.some(isEvmWalletAccount) && isSignedIn(dynamicClient)) {
        await createWaasWalletAccounts({ chains: ["EVM"] }, dynamicClient);
      }
    } catch {}
    refresh();
  }, [refresh]);

  useEffect(() => {
    const handleOAuthRedirect = async () => {
      if (typeof window === "undefined") return;
      try {
        const url = new URL(window.location.href);
        if (await detectOAuthRedirect({ url }, dynamicClient)) {
          await completeSocialAuthentication({ url }, dynamicClient);
          await ensureEvmWallet();
          window.history.replaceState({}, "", window.location.pathname);
          return;
        }
      } catch {}
      refresh();
    };
    handleOAuthRedirect();
    const unsub1 = onEvent({ event: "walletAccountsChanged", listener: () => ensureEvmWallet() }, dynamicClient);
    const unsub2 = onEvent({ event: "logout", listener: () => { setEvmAccount(null); setLoggedIn(false); } }, dynamicClient);
    return () => { unsub1(); unsub2(); };
  }, [refresh, ensureEvmWallet]);

  return (
    <WalletContext.Provider value={{ evmAccount, loggedIn, ensureEvmWallet, disconnect }}>
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </WalletContext.Provider>
  );
}

Create Server-Side Iron API Routes

All calls to Iron are made from Next.js API routes. This keeps your Iron credentials server-side only. For example, src/app/api/iron/customers/route.ts:
src/app/api/iron/customers/route.ts
import { NextRequest, NextResponse } from "next/server";
import { ironClient } from "@/lib/services/iron";

export async function POST(req: NextRequest) {
  const body = await req.json();
  const customer = await ironClient.createCustomer(body);
  return NextResponse.json({ data: customer }, { status: 201 });
}
Create similar route files for:
  • /api/iron/customers/[id]/wallets — register a crypto wallet with an Iron customer (required for Travel Rule compliance)
  • /api/iron/customers/[id]/banks — add a SEPA bank account (fiat address)
  • /api/iron/autoramps — create an onramp or offramp autoramp
  • /api/iron/fiatcurrencies — list supported fiat currencies
For example, src/app/api/iron/customers/[id]/banks/route.ts:
src/app/api/iron/customers/[id]/banks/route.ts
import { NextRequest, NextResponse } from "next/server";
import { ironFetch } from "@/lib/services/iron";

export async function POST(req: NextRequest) {
  const b = await req.json();
  const [given, ...rest] = b.account_holder_name.trim().split(/\s+/);
  const fiatAddress = await ironFetch<{ id: string }>("/api/addresses/fiat", {
    method: "POST",
    idempotent: true,
    body: JSON.stringify({
      customer_id: b.customer_id,
      currency: { code: b.currency },
      bank_details: {
        recipient: {
          type: "Individual",
          given_name: given,
          family_name: rest.join(" ") || given,
        },
        provider_name: b.bank_name,
        provider_country: { code: b.bank_country },
        account_identifier: { type: "SEPA", iban: b.iban },
        address: {
          street: b.street,
          city: b.city,
          state: b.state,
          country: { code: b.country },
          postal_code: b.postal_code,
        },
        is_third_party: false,
      },
      label: b.label,
    }),
  });
  return NextResponse.json({ data: fiatAddress }, { status: 201 });
}
Iron’s onboarding involves multiple steps. Use Dynamic’s updateUser to persist progress in the user’s metadata across sessions so users don’t have to restart if they close the tab.
src/lib/hooks/useKYCMetadata.ts
import { onEvent, updateUser } from "@dynamic-labs-sdk/client";
import { useState, useEffect, useCallback } from "react";
import { dynamicClient } from "@/lib/dynamic";

export type OnboardStep = "customer" | "kyc" | "signings" | "wallet" | "bank" | "complete";

export function useKYCMetadata() {
  const [state, setState] = useState({ customerId: "", step: "customer" as OnboardStep, /* ... */ });

  // Load from dynamicClient.user on mount and whenever user changes
  useEffect(() => {
    const loadFromUser = () => {
      const metadata = dynamicClient.user?.metadata as { iron?: { customerId?: string; onboardingStep?: OnboardStep } } | undefined;
      if (metadata?.iron?.customerId) {
        setState((s) => ({ ...s, customerId: metadata.iron!.customerId!, step: metadata.iron!.onboardingStep ?? "customer" }));
      }
    };
    loadFromUser();
    const unsub = onEvent({ event: "userChanged", listener: () => loadFromUser() }, dynamicClient);
    return () => unsub();
  }, []);

  const updateState = useCallback(async (updates: Partial<typeof state>) => {
    const newState = { ...state, ...updates };
    setState(newState);
    // Persist to Dynamic user metadata
    await updateUser(
      { userFields: { metadata: { iron: { customerId: newState.customerId, onboardingStep: newState.step } } } },
      dynamicClient
    );
  }, [state]);

  return { ...state, updateState };
}

Build the Onboarding Flow

The onboarding page (src/app/onboard/page.tsx) walks users through six steps. Use useWallet() to access the current EVM account for wallet registration and signing. Wallet registration requires signing a proof-of-ownership message:
const { evmAccount } = useWallet();

const handleCreateWallet = async () => {
  if (!evmAccount) throw new Error("No wallet connected.");
  const proofMessage = `I am verifying ownership of ${evmAccount.address} as customer ${customerId}`;
  const signature = await evmAccount.signMessage(proofMessage);

  await fetch("/api/iron/wallets/self-hosted", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ customer_id: customerId, blockchain: "Base", address: evmAccount.address, message: proofMessage, signature }),
  });
};

Build the Ramp Interface

With onboarding complete, the main page lets users create autoramps for either direction. The UI has two tabs: Offramp (crypto → fiat) and Onramp (fiat → crypto). Creating an Offramp Autoramp (Crypto → Fiat)
const handleCreateOfframpAutoramp = async () => {
  const selectedBank = registeredBanks[selectedBankIndex];
  const iban = selectedBank?.iban || selectedBank?.account_identifier?.iban;

  const res = await fetch("/api/offramp", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      action: "execute",
      customer_id: customerId,
      source_currency: selectedToken,     // e.g. "USDC"
      destination_currency: selectedFiat, // e.g. "EUR"
      bank_account_id: iban,
      blockchain: selectedChain,
    }),
  });

  const data = await res.json();
  // data.payment_instructions contains the deposit address
};
Creating an Onramp Autoramp (Fiat → Crypto)
const handleCreateOnrampAutoramp = async () => {
  const res = await fetch("/api/onramp", {
    method: "POST",
    idempotent: true,
    body: JSON.stringify({
      action: "execute",
      customer_id: customerId,
      source_currency: selectedFiat,     // e.g. "EUR"
      destination_currency: selectedToken, // e.g. "USDC"
      wallet_address: evmAccount?.address,
      blockchain: selectedChain,
    }),
  });

  const data = await res.json();
  // data.payment_instructions contains the virtual IBAN
};

Load Registered Wallets and Banks

Transaction History

List prior ramps with GET /api/iron/customers/[id]/autoramps. Each item includes kind, status, the embedded quote, and the deposit_rails so you can render a detail view without re-fetching.

Run the Application

npm run dev
The app is available at http://localhost:3000. Make sure that origin is in Security → Allowed Origins in the Dynamic dashboard.

Conclusion

This integration demonstrates how Dynamic’s embedded wallets combine with Iron’s payment infrastructure to deliver a compliant onramp and offramp experience:
  • Embedded Wallets — Dynamic’s JS SDK handles wallet creation, authentication, and message signing; no seed phrase management for users
  • KYC & Compliance — Iron’s hosted onboarding flow handles identity verification, document signing, and Travel Rule compliance
  • Offramp — Users send USDC to an Iron-managed wallet address and receive EUR via SEPA directly in their bank account
  • Onramp — Users send EUR via SEPA to a virtual IBAN and receive USDC in their Dynamic embedded wallet
  • Persistent Autoramps — Conversion rules are set up once and reused for every subsequent deposit
  • Persistent State — Dynamic user metadata (updateUser) keeps onboarding progress safe across sessions

Additional Resources