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:
- Respond to
RAMPS_CONFIG — send wallet address, chain, and API key when the widget loads
- Respond to
RAMPS_CHECK_BALANCE — fetch the user’s on-chain USDC balance and return it
- 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
# 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:
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.
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);
}
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.
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.
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
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
| 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 |
Additional resources