> ## Documentation Index
> Fetch the complete documentation index at: https://www.dynamic.xyz/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# USDC to cash offramp with MoneyGram and Dynamic

> Learn how to integrate MoneyGram Ramps with Dynamic's JS SDK embedded wallets to let users convert USDC to cash across Base, Ethereum, and Solana.

## 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 JS SDK. On successful sign-in, 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](/javascript/reference/quickstart). This example uses `@dynamic-labs-sdk/client` with EVM and Solana extensions for signing, and `@dynamic-labs-sdk/react-hooks` for reactive auth state (`DynamicProvider`, `useUser`, `useGetWalletAccounts`).

<Info>
  **Dashboard:** Enable **EVM** and **Solana** embedded wallets under **Chains & Networks**. Enable **Email OTP** and **Google** under **Sign-in Methods**. Under **Security → Allowed Origins**, add your local origin (for example `http://localhost:3000`).
</Info>

### Install dependencies

<CodeGroup>
  ```bash npm theme={"system"}
  npm install @dynamic-labs-sdk/client @dynamic-labs-sdk/evm @dynamic-labs-sdk/solana @dynamic-labs-sdk/react-hooks viem @solana/web3.js @solana/spl-token zod @t3-oss/env-nextjs
  ```

  ```bash pnpm theme={"system"}
  pnpm add @dynamic-labs-sdk/client @dynamic-labs-sdk/evm @dynamic-labs-sdk/solana @dynamic-labs-sdk/react-hooks viem @solana/web3.js @solana/spl-token zod @t3-oss/env-nextjs
  ```
</CodeGroup>

### Configure environment variables

```env .env.local theme={"system"}
# 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:

```typescript lib/env.ts theme={"system"}
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 extensions. Both extensions must be registered immediately after `createDynamicClient()`. The client auto-initializes — no explicit `initializeClient()` call is needed.

```typescript lib/dynamic.ts theme={"system"}
import { createDynamicClient } from "@dynamic-labs-sdk/client";
import { addEvmExtension } from "@dynamic-labs-sdk/evm";
import { addSolanaExtension } from "@dynamic-labs-sdk/solana";
import { env } from "./env";

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

// Register both extensions — takes NO arguments
addEvmExtension();
addSolanaExtension();
```

### Configure wallet context

`Providers` wraps the app in `DynamicProvider` from `@dynamic-labs-sdk/react-hooks`. Components read auth state directly via `useUser()` and `useGetWalletAccounts()` — no custom context or `useState` for auth state needed:

```typescript lib/providers.tsx theme={"system"}
"use client";

import { DynamicProvider } from "@dynamic-labs-sdk/react-hooks";
import { dynamicClient } from "@/lib/dynamic";

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <DynamicProvider client={dynamicClient}>
      {children}
    </DynamicProvider>
  );
}
```

In components that need auth state, import the hooks directly:

```typescript components/ramp-app.tsx theme={"system"}
import { useUser, useGetWalletAccounts } from "@dynamic-labs-sdk/react-hooks";

export function RampApp() {
  const { data: user } = useUser();
  const signedIn = user !== null;
  const { data: walletAccounts = [] } = useGetWalletAccounts();
  // ...
}
```

### 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.

```typescript lib/chains.ts theme={"system"}
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,
  },
};
```

<Warning>
  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.
</Warning>

### Authenticate and create wallets

Use the `useWallet()` context in your component to check sign-in state. For the auth form, call `sendEmailOTP` and `verifyOTP` directly:

```typescript components/ramp-app.tsx theme={"system"}
import {
  sendEmailOTP,
  verifyOTP,
  getWalletAccounts,
  type OTPVerification,
} from "@dynamic-labs-sdk/client";
import { createWaasWalletAccounts } from "@dynamic-labs-sdk/client/waas";
import { dynamicClient } from "@/lib/dynamic";
import { useUser, useGetWalletAccounts } from "@dynamic-labs-sdk/react-hooks";

const { data: user } = useUser();
const signedIn = user !== null;
const { data: walletAccounts = [] } = useGetWalletAccounts();

// 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. Because the component is inside `DynamicProvider`, `useGetWalletAccounts()` re-renders automatically when wallets are ready — no manual event subscription needed.

### 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

```typescript lib/balance.ts theme={"system"}
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
```

```typescript components/cash-pickup-widget.tsx theme={"system"}
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.

```typescript theme={"system"}
const selectedChainRef = useRef(selectedChain);
const walletAccountsRef = useRef(walletAccounts);

useEffect(() => { selectedChainRef.current = selectedChain; }, [selectedChain]);
useEffect(() => { walletAccountsRef.current = walletAccounts; }, [walletAccounts]);
```

<Note>
  `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.
</Note>

<Note>
  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.
</Note>

### 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:

```typescript lib/send-usdc.ts (EVM path) theme={"system"}
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:

```typescript lib/send-usdc.ts (Solana path) theme={"system"}
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

| Mistake                                                  | Effect                                                  | Fix                                                               |
| -------------------------------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------- |
| Sending `RAMPS_INIT` or `RAMPS_OPEN`                     | Widget stays frozen                                     | Send `RAMPS_CONFIG` instead                                       |
| `chain: 'ethereum'` for Base                             | `502` on `POST /v2/transactions`                        | Use `chain: 'base'`                                               |
| Omitting `walletAddress` in `RAMPS_BALANCE_RESULT`       | KYC form is blank for returning users                   | Always include `walletAddress`                                    |
| `postMessage(msg, '*')`                                  | Security warning; rejected in production                | Always use `WIDGET_ORIGIN` as target                              |
| Not validating `event.origin`                            | Security vulnerability                                  | Check `event.origin === WIDGET_ORIGIN` before processing          |
| Calling the MoneyGram REST API directly from the browser | CORS error on all endpoints                             | The widget calls the API internally — you only need `postMessage` |
| `mockMode: true`                                         | Widget uses fake data, never reaches a real transaction | Set `mockMode: false` for live sandbox testing                    |
| Using `autoInitialize: false` + `initializeClient()`     | Pattern removed in v0.24                                | Remove `autoInitialize` option; client auto-initializes           |
| Using `addWaasEvmExtension` / `addWaasSolanaExtension`   | Replaced in v0.24                                       | Use `addEvmExtension()` / `addSolanaExtension()` (no arguments)   |

## Additional resources

* [Dynamic JavaScript SDK](/javascript/reference/quickstart)
* [Dynamic Embedded Wallets](/overview/wallets/embedded-wallets/mpc/overview)
* [MoneyGram Ramps Partner Integration Guide](https://www.moneygram.com)
* [viem documentation](https://viem.sh)
* [Solana SPL Token documentation](https://spl.solana.com/token)
