Skip to main content

What we’re building

A Next.js app that connects Dynamic’s embedded wallets to MoneyGram Ramps, letting users convert USDC to cash at any MoneyGram location worldwide. The integration covers three chains — Base, Ethereum, and Solana — using a single email-based authentication flow.

How it works

MoneyGram Ramps is a full-screen iframe that handles the entire offramp flow internally: country selection, amount entry, live quotes, KYC, fraud disclosure, and transaction confirmation. Your app’s only responsibilities are:
  1. Respond to RAMPS_CONFIG — send wallet address, chain, and API key when the widget loads
  2. Respond to RAMPS_CHECK_BALANCE — fetch the user’s on-chain USDC balance and return it
  3. Respond to RAMPS_SIGN_TRANSACTION — sign and broadcast the USDC transfer when the user confirms
Communication is entirely via window.postMessage. The MoneyGram REST API is called from inside the iframe — you never call it directly from your app. Authentication uses Dynamic’s email OTP flow. On successful verification, Dynamic automatically provisions embedded wallets for both EVM and Solana, so users have a wallet address on every supported chain without installing any extensions.

Building the application

Project setup

Scaffold a Next.js app and follow the JavaScript quickstart. This example uses the headless @dynamic-labs-sdk/client (not the React SDK) with manual OTP handling.
Dashboard: Enable EVM and Solana embedded wallets under Chains & Networks. Enable Email OTP under Sign-in Methods. Under Security → Allowed Origins, add your local origin (for example http://localhost:3000).

Install dependencies

npm install @dynamic-labs-sdk/client @dynamic-labs-sdk/evm @dynamic-labs-sdk/evm-waas @dynamic-labs-sdk/solana @dynamic-labs-sdk/solana-waas viem @solana/web3.js @solana/spl-token zod @t3-oss/env-nextjs

Configure environment variables

.env.local
# Dynamic dashboard → Developer Settings → SDK & API Keys
NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID=your_environment_id

# MoneyGram Ramps API key — sandbox prefix: ramps_pk_sbox_
# Your origin must be allowlisted by MoneyGram
NEXT_PUBLIC_MG_RAMP_KEY=ramps_pk_sbox_your_key

# Solana RPC (devnet for sandbox, mainnet for production)
NEXT_PUBLIC_SOLANA_RPC_URL=https://api.devnet.solana.com

# USDC mint address for the Solana environment you're targeting
NEXT_PUBLIC_SOLANA_USDC_MINT=4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU
Validate all variables at startup so missing keys surface immediately:
lib/env.ts
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";

export const env = createEnv({
  client: {
    NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID: z.string().min(1),
    NEXT_PUBLIC_MG_RAMP_KEY: z.string().min(1),
    NEXT_PUBLIC_SOLANA_RPC_URL: z.string().url(),
    NEXT_PUBLIC_SOLANA_USDC_MINT: z.string().min(1),
  },
  runtimeEnv: {
    NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID: process.env.NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID,
    NEXT_PUBLIC_MG_RAMP_KEY: process.env.NEXT_PUBLIC_MG_RAMP_KEY,
    NEXT_PUBLIC_SOLANA_RPC_URL: process.env.NEXT_PUBLIC_SOLANA_RPC_URL,
    NEXT_PUBLIC_SOLANA_USDC_MINT: process.env.NEXT_PUBLIC_SOLANA_USDC_MINT,
  },
});

Initialize Dynamic

Create the Dynamic client with EVM and Solana WaaS extensions. The autoInitialize: false flag lets you defer initialization until the page is ready. A guard prevents double-initialization on re-renders.
lib/dynamic.ts
import { createDynamicClient, initializeClient, type DynamicClient } from "@dynamic-labs-sdk/client";
import { addWaasEvmExtension } from "@dynamic-labs-sdk/evm/waas";
import { addWaasSolanaExtension } from "@dynamic-labs-sdk/solana/waas";
import { env } from "./env";

export const dynamicClient: DynamicClient = createDynamicClient({
  environmentId: env.NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID,
  autoInitialize: false,
  metadata: { name: "MoneyGram Ramp Demo" },
});

let initialized = false;

export async function initDynamic(): Promise<void> {
  if (initialized) return;
  initialized = true;
  addWaasEvmExtension(dynamicClient);
  addWaasSolanaExtension(dynamicClient);
  await initializeClient(dynamicClient);
}

Configure chains

Define a MgChain union type that maps directly to the chain identifiers MoneyGram expects. Each entry includes the USDC contract address (or SPL mint) and the chain-specific config needed for balance reads and transaction signing.
lib/chains.ts
import { baseSepolia, sepolia } from "viem/chains";
import type { Chain } from "viem";
import { env } from "./env";

// MoneyGram's chain identifiers — use "base" for Base, NOT "ethereum"
export type MgChain = "base" | "ethereum" | "solana";

interface EvmChainConfig {
  type: "evm";
  name: string;
  mgChain: MgChain;
  networkId: number;
  viemChain: Chain;
  usdcAddress: `0x${string}`;
}

interface SolanaChainConfig {
  type: "solana";
  name: string;
  mgChain: MgChain;
  rpcUrl: string;
  usdcMint: string;
}

export type ChainConfig = EvmChainConfig | SolanaChainConfig;

export const CHAINS: Record<MgChain, ChainConfig> = {
  base: {
    type: "evm",
    name: "Base Sepolia",
    mgChain: "base",
    networkId: 84532,
    viemChain: baseSepolia,
    usdcAddress: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
  },
  ethereum: {
    type: "evm",
    name: "Eth Sepolia",
    mgChain: "ethereum",
    networkId: 11155111,
    viemChain: sepolia,
    usdcAddress: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238",
  },
  solana: {
    type: "solana",
    name: "Solana Devnet",
    mgChain: "solana",
    rpcUrl: env.NEXT_PUBLIC_SOLANA_RPC_URL,
    usdcMint: env.NEXT_PUBLIC_SOLANA_USDC_MINT,
  },
};
Always use chain: 'base' for Base transactions. Using chain: 'ethereum' for Base will cause a 502 error from the MoneyGram backend — they are treated as separate chains.

Authenticate and create wallets

The auth flow is two steps: send an email OTP, then verify it. On success, Dynamic provisions embedded wallets for EVM and Solana automatically. Subscribe to tokenChanged and walletAccountsChanged events to keep local state in sync.
components/ramp-app.tsx
import {
  isSignedIn,
  sendEmailOTP,
  verifyOTP,
  getWalletAccounts,
  onEvent,
  type OTPVerification,
} from "@dynamic-labs-sdk/client";
import { createWaasWalletAccounts } from "@dynamic-labs-sdk/client/waas";
import { initDynamic, dynamicClient } from "@/lib/dynamic";

// On mount: initialize SDK, restore session if already signed in
useEffect(() => {
  let unsubToken: (() => void) | undefined;
  let unsubWallets: (() => void) | undefined;

  initDynamic().then(async () => {
    if (isSignedIn()) {
      const existing = getWalletAccounts();
      if (existing.length === 0) {
        // First visit after sign-in — wallets may not exist yet
        await createWaasWalletAccounts({ chains: ["EVM", "SOL"] }, dynamicClient);
      }
      setSignedIn(true);
      setWalletAccounts(getWalletAccounts());
    }

    unsubToken = onEvent(
      { event: "tokenChanged", listener: ({ token }) => {
        setSignedIn(!!token);
        if (!token) setWalletAccounts([]);
      }},
      dynamicClient,
    );

    unsubWallets = onEvent(
      { event: "walletAccountsChanged", listener: ({ walletAccounts }) =>
        setWalletAccounts(walletAccounts) },
      dynamicClient,
    );
  });

  return () => { unsubToken?.(); unsubWallets?.(); };
}, []);

// Step 1: request OTP
const handleSendOtp = async () => {
  const verification = await sendEmailOTP({ email });
  setOtpVerification(verification);
};

// Step 2: verify OTP and provision wallets
const handleVerifyOtp = async () => {
  await verifyOTP({ otpVerification, verificationToken: otp });
  if (getWalletAccounts().length === 0) {
    await createWaasWalletAccounts({ chains: ["EVM", "SOL"] }, dynamicClient);
  }
};
createWaasWalletAccounts provisions non-custodial embedded wallets for both EVM and Solana in a single call. The walletAccountsChanged event fires when wallets are ready, updating the UI automatically.

Fetch USDC balance

Before opening the widget, and again after a successful transaction, fetch the user’s on-chain USDC balance. The implementation branches on chain type:
  • EVM: calls balanceOf(address) on the USDC contract via viem’s readContract
  • Solana: resolves the Associated Token Account (ATA) for the user’s address and queries its token balance
lib/balance.ts
import { createPublicClient, formatUnits, http, parseAbi } from "viem";
import { Connection, PublicKey } from "@solana/web3.js";
import { getAssociatedTokenAddress } from "@solana/spl-token";
import { CHAINS, type MgChain } from "./chains";

const erc20BalanceAbi = parseAbi([
  "function balanceOf(address) view returns (uint256)",
]);

export async function fetchUsdcBalance(
  chain: MgChain,
  address: string,
): Promise<number> {
  if (!address) return 0;
  const config = CHAINS[chain];

  try {
    if (config.type === "evm") {
      const client = createPublicClient({ chain: config.viemChain, transport: http() });
      const raw = await client.readContract({
        address: config.usdcAddress,
        abi: erc20BalanceAbi,
        functionName: "balanceOf",
        args: [address as `0x${string}`],
      });
      return parseFloat(formatUnits(raw, 6));
    }

    if (config.type === "solana") {
      const connection = new Connection(config.rpcUrl, "confirmed");
      const ata = await getAssociatedTokenAddress(
        new PublicKey(config.usdcMint),
        new PublicKey(address),
      );
      const info = await connection.getTokenAccountBalance(ata);
      return parseFloat(info.value.uiAmountString ?? "0");
    }
  } catch {
    // ATA may not exist yet — return 0
  }

  return 0;
}
USDC uses 6 decimal places on both EVM and Solana. formatUnits(raw, 6) on EVM and uiAmountString on Solana both return human-readable values.

Handle the postMessage protocol

The widget communicates with your app via window.postMessage. Set up a single message event listener when the widget opens. Always validate event.origin before acting on a message, and always pass WIDGET_ORIGIN as the target origin when posting back — never '*'. The full message sequence is:
Widget                          Your app
  │                                │
  │──── RAMPS_READY ──────────────▶│  Widget loaded
  │◀─── RAMPS_CONFIG ──────────────│  Send wallet + API config
  │                                │
  │──── RAMPS_CHECK_BALANCE ──────▶│  Widget needs current balance
  │◀─── RAMPS_BALANCE_RESULT ──────│  Return balance + wallet address
  │                                │
  │  [user selects country,        │
  │   enters amount, KYC,          │
  │   fraud disclosure]            │
  │                                │
  │──── RAMPS_SIGN_TRANSACTION ───▶│  User confirmed — sign & broadcast
  │◀─── RAMPS_SIGN_SUCCESS ────────│  (or RAMPS_SIGN_ERROR on failure)
  │                                │
  │──── RAMPS_TRANSACTION_COMPLETE▶│  Done
  │──── RAMPS_CLOSE ──────────────▶│  User dismissed widget
components/cash-pickup-widget.tsx
const WIDGET_ORIGIN = "https://d3em1tdv304u3f.cloudfront.net";
const API_BASE_URL = "https://zq4rdvdd9j.execute-api.us-east-2.amazonaws.com";

useEffect(() => {
  if (!open) return;

  function post(type: string, payload?: unknown) {
    iframeRef.current?.contentWindow?.postMessage(
      payload ? { type, payload } : { type },
      WIDGET_ORIGIN,
    );
  }

  async function handleMessage(event: MessageEvent) {
    if (event.origin !== WIDGET_ORIGIN) return;  // always validate origin

    const { type, payload } = (event.data ?? {}) as {
      type: string;
      payload: Record<string, unknown>;
    };

    switch (type) {
      case "RAMPS_READY": {
        // Respond immediately with wallet config — this starts the flow
        const chain = selectedChainRef.current;
        const address = getAddressForChain(chain, walletAccountsRef.current);
        post("RAMPS_CONFIG", {
          apiKey: env.NEXT_PUBLIC_MG_RAMP_KEY,
          wallet: { address, chain, asset: "USDC", walletType: "non-custodial" },
          devConfig: { mockMode: false, apiBaseUrl: API_BASE_URL, apiVersion: "v2" },
          theme: "dark",
        });
        break;
      }

      case "RAMPS_CHECK_BALANCE": {
        const chain = (payload?.chain as MgChain) ?? selectedChainRef.current;
        const address = getAddressForChain(chain, walletAccountsRef.current);
        const requestedAmount = (payload?.amount as number) ?? 0;
        const balance = await fetchUsdcBalance(chain, address);
        post("RAMPS_BALANCE_RESULT", {
          walletAddress: address,  // required — widget uses this for KYC pre-fill
          balance,
          asset: "USDC",
          sufficient: balance >= requestedAmount,
        });
        break;
      }

      case "RAMPS_SIGN_TRANSACTION": {
        const chain = (payload?.chain as MgChain) ?? selectedChainRef.current;
        const to = payload?.to as string;
        const amount = parseFloat(payload?.amount as string);
        try {
          const hash = await sendUsdc({ to, amount: String(amount), chain, walletAccounts: walletAccountsRef.current });
          pendingAmountRef.current = amount;
          post("RAMPS_SIGN_SUCCESS", { txHash: hash });
        } catch (err) {
          post("RAMPS_SIGN_ERROR", { error: err instanceof Error ? err.message : "Transaction failed" });
        }
        break;
      }

      case "RAMPS_TRANSACTION_COMPLETE":
        onSuccessRef.current?.(pendingAmountRef.current);
        onCloseRef.current();
        break;

      case "RAMPS_OPEN_URL":
        if (payload?.url && typeof payload.url === "string") {
          window.open(payload.url, "_blank", "noopener,noreferrer");
        }
        break;

      case "RAMPS_CLOSE":
        onCloseRef.current();
        break;
    }
  }

  window.addEventListener("message", handleMessage);
  return () => window.removeEventListener("message", handleMessage);
}, [open]);
Why refs instead of state in event handlers?
The message listener is registered once when open becomes true. If you close over state directly, the handler captures stale values. The example syncs selectedChain, walletAccounts, onClose, and onSuccess into refs so the event handler always reads the current values without needing to re-register.
const selectedChainRef = useRef(selectedChain);
const walletAccountsRef = useRef(walletAccounts);

useEffect(() => { selectedChainRef.current = selectedChain; }, [selectedChain]);
useEffect(() => { walletAccountsRef.current = walletAccounts; }, [walletAccounts]);
RAMPS_CONFIG is the only message needed to start the widget flow. Do not send RAMPS_INIT or RAMPS_OPEN — these are not valid message types and will leave the widget frozen.
Always include walletAddress in RAMPS_BALANCE_RESULT. The widget uses it to look up an existing MoneyGram profile and pre-fill the KYC form for returning users.

Sign and broadcast the USDC transfer

When the widget fires RAMPS_SIGN_TRANSACTION, it provides the exact recipient address and amount. Your app signs and broadcasts the transfer, then responds with the transaction hash. The signing path differs by chain type: EVM (Base and Ethereum) Use Dynamic’s createWalletClientForWalletAccount to get a viem wallet client for the user’s embedded wallet, then encode an ERC-20 transfer call and send it:
lib/send-usdc.ts (EVM path)
import { encodeFunctionData, erc20Abi, parseUnits } from "viem";
import { createWalletClientForWalletAccount } from "@dynamic-labs-sdk/evm/viem";
import { isEvmWalletAccount } from "@dynamic-labs-sdk/evm";

if (config.type === "evm") {
  const evmWallet = walletAccounts.find(isEvmWalletAccount);
  if (!evmWallet) throw new Error("No EVM wallet found.");

  const data = encodeFunctionData({
    abi: erc20Abi,
    functionName: "transfer",
    args: [to as `0x${string}`, parseUnits(amount, 6)],  // 6 decimals for USDC
  });

  const walletClient = await createWalletClientForWalletAccount({ walletAccount: evmWallet });
  return walletClient.sendTransaction({
    to: config.usdcAddress,   // the USDC contract, not the recipient
    data,
    value: BigInt(0),
    chain: config.viemChain,
  });
}
The transaction is sent to the USDC contract address with the transfer(to, amount) calldata. value must be BigInt(0) — no ETH is transferred. Solana Solana token transfers require Associated Token Accounts (ATAs) for both the sender and recipient. If the recipient’s ATA doesn’t exist, create it as part of the same transaction:
lib/send-usdc.ts (Solana path)
import { Connection, PublicKey, TransactionMessage, VersionedTransaction } from "@solana/web3.js";
import {
  createAssociatedTokenAccountInstruction,
  createTransferCheckedInstruction,
  getAssociatedTokenAddress,
} from "@solana/spl-token";
import { isSolanaWalletAccount, signAndSendTransaction } from "@dynamic-labs-sdk/solana";

const solanaWallet = walletAccounts.find(isSolanaWalletAccount);
const connection = new Connection(config.rpcUrl, "confirmed");
const fromPubkey = new PublicKey(solanaWallet.address);
const toPubkey = new PublicKey(to);
const mintPubkey = new PublicKey(config.usdcMint);

// Derive ATAs for both parties
const senderATA = await getAssociatedTokenAddress(mintPubkey, fromPubkey);
const recipientATA = await getAssociatedTokenAddress(mintPubkey, toPubkey);
const tokenAmount = BigInt(Math.floor(parseFloat(amount) * 1_000_000));

const instructions = [];

// Create recipient ATA if it doesn't exist yet
const recipientInfo = await connection.getAccountInfo(recipientATA);
if (!recipientInfo) {
  instructions.push(
    createAssociatedTokenAccountInstruction(fromPubkey, recipientATA, toPubkey, mintPubkey),
  );
}

// Add the SPL token transfer instruction
instructions.push(
  createTransferCheckedInstruction(
    senderATA,    // source
    mintPubkey,   // mint (required by TransferChecked)
    recipientATA, // destination
    fromPubkey,   // owner of source ATA
    tokenAmount,
    6,            // USDC decimals
  ),
);

// Build and sign a v0 versioned transaction
const { blockhash } = await connection.getLatestBlockhash("finalized");
const message = new TransactionMessage({
  payerKey: fromPubkey,
  recentBlockhash: blockhash,
  instructions,
}).compileToV0Message();

const tx = new VersionedTransaction(message);
const { signature } = await signAndSendTransaction({ transaction: tx, walletAccount: solanaWallet });
return signature;
createTransferCheckedInstruction is used instead of createTransferInstruction because it includes the mint address and decimal count, making it safer against mint substitution attacks.

Common mistakes

MistakeEffectFix
Sending RAMPS_INIT or RAMPS_OPENWidget stays frozenSend RAMPS_CONFIG instead
chain: 'ethereum' for Base502 on POST /v2/transactionsUse chain: 'base'
Omitting walletAddress in RAMPS_BALANCE_RESULTKYC form is blank for returning usersAlways include walletAddress
postMessage(msg, '*')Security warning; rejected in productionAlways use WIDGET_ORIGIN as target
Not validating event.originSecurity vulnerabilityCheck event.origin === WIDGET_ORIGIN before processing
Calling the MoneyGram REST API directly from the browserCORS error on all endpointsThe widget calls the API internally — you only need postMessage
mockMode: trueWidget uses fake data, never reaches a real transactionSet mockMode: false for live sandbox testing

Additional resources