Skip to main content

Overview

One of the biggest barriers to mainstream crypto adoption is the complexity of wallet addresses and the requirement that both parties must have wallets. This recipe shows you how to build a user-friendly USDC transfer system where users can send stablecoins to friends using familiar identifiers like email addresses or phone numbers—even if the recipient hasn’t signed up yet. Using Dynamic’s pregeneration feature, you can create wallets for recipients automatically when they’re sent funds. When recipients later sign up with that email or phone number, they’ll automatically receive their wallet with the funds already in it.

What We’re Building

A React Native application that allows users to:
  • Send USDC to friends by entering their email address or phone number
  • Automatically create wallets for recipients who don’t have one yet (pregeneration)
  • Transfer USDC seamlessly without exposing wallet addresses to end users
  • Enable recipients to claim their wallet and funds when they sign up later

Architecture

The solution uses a secure backend-to-backend pattern:
  1. Backend API Endpoint: A secure backend endpoint queries Dynamic’s API with filters to find users by their email/phone verified credentials. The Dynamic API token is never exposed to the client.
  2. Frontend Lookup: The React Native app calls your backend API endpoint (not Dynamic’s API directly)
  3. Wallet Resolution: The backend extracts wallet addresses from the found user’s embedded wallets
  4. Transfer Component: A React Native component that looks up recipients via your backend API and executes USDC transfers

Prerequisites

  • Sender must be a Dynamic user with a wallet linked (either embedded or external)
  • Recipient can be anyone with an email address or phone number (they don’t need to have signed up yet)
  • Backend server with Dynamic API token (the token must never be exposed to the client)
  • Embedded wallets enabled in your Dynamic environment for pregeneration support
The backend API setup is the same for React and React Native. See the React guide for the backend endpoint code (Steps 1-3).

Create User Lookup Hook (React Query)

Create a custom hook that uses React Query to first try lookup, then fall back to pregeneration. Make sure your app is wrapped in a QueryClientProvider.
// hooks/useRecipientLookup.ts
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";

type IdentifierType = "email" | "phone";

interface RecipientResult {
  walletAddress: string;
  email?: string;
  phone?: string;
  userId?: string;
  isPregenerated: boolean;
  found: boolean;
}

interface ResolveInput {
  identifier: string;
  identifierType: IdentifierType;
}

const ensureResponse = async (response: Response) => {
  if (response.ok) return response.json();
  const errorData = await response.json().catch(() => ({}));
  const err = new Error(errorData.error || response.statusText);
  (err as any).status = response.status;
  throw err;
};

export const useRecipientLookup = (apiBaseUrl: string) => {
  const [recipient, setRecipient] = useState<RecipientResult | null>(null);

  const lookupMutation = useMutation({
    mutationFn: async ({ identifier, identifierType }: ResolveInput) => {
      const searchParam =
        identifierType === "email"
          ? `email=${encodeURIComponent(identifier)}`
          : `phone=${encodeURIComponent(identifier)}`;
      const res = await fetch(`${apiBaseUrl}/api/users/lookup?${searchParam}`);
      const data = await ensureResponse(res);
      return { ...data, found: true };
    },
  });

  const pregenerateMutation = useMutation({
    mutationFn: async ({ identifier, identifierType }: ResolveInput) => {
      const res = await fetch(`${apiBaseUrl}/api/users/pregen`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(
          identifierType === "email"
            ? { email: identifier }
            : { phone: identifier }
        ),
      });
      const data = await ensureResponse(res);
      return { ...data, found: true };
    },
  });

  const resolveRecipient = async (payload: ResolveInput) => {
    setRecipient(null);
    try {
      const data = await lookupMutation.mutateAsync(payload);
      setRecipient(data);
      return data;
    } catch (err: any) {
      if (err?.status === 404) {
        const data = await pregenerateMutation.mutateAsync(payload);
        setRecipient(data);
        return data;
      }
      throw err;
    }
  };

  return {
    resolveRecipient,
    recipient,
    isLoading: lookupMutation.isPending || pregenerateMutation.isPending,
    error:
      (lookupMutation.error as Error | undefined)?.message ||
      (pregenerateMutation.error as Error | undefined)?.message ||
      null,
    clearRecipient: () => setRecipient(null),
  };
};

Create Transfer Component

Now create the main transfer component that allows users to send USDC by email or phone:
// components/SendUSDCByIdentifier.tsx
import { useState } from "react";
import { View, Text, TextInput, TouchableOpacity, Alert, StyleSheet } from "react-native";
import { dynamicClient } from "../lib/dynamicClient";
import { parseUnits, erc20Abi } from "viem";
import { useRecipientLookup } from "../hooks/useRecipientLookup";

const SendUSDCByIdentifier = ({ apiBaseUrl }: { apiBaseUrl: string }) => {
  const {
    resolveRecipient,
    recipient,
    isLoading: isLookingUp,
    error: lookupError,
    clearRecipient,
  } = useRecipientLookup(apiBaseUrl);

  const [identifier, setIdentifier] = useState("");
  const [identifierType, setIdentifierType] = useState<"email" | "phone">(
    "email"
  );
  const [amount, setAmount] = useState("");
  const [isSending, setIsSending] = useState(false);
  const [txHash, setTxHash] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);

  const handleLookup = async () => {
    if (!identifier.trim()) {
      setError("Please enter an email or phone number");
      return;
    }

    setError(null);
    setTxHash(null);

    if (identifierType === "email") {
      const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
      if (!emailRegex.test(identifier)) {
        setError("Please enter a valid email address");
        return;
      }
    } else {
      const phoneRegex = /^\+?[1-9]\d{1,14}$/;
      if (!phoneRegex.test(identifier.replace(/\s/g, ""))) {
        setError("Please enter a valid phone number");
        return;
      }
    }

    try {
      await resolveRecipient({ identifier, identifierType });
    } catch (err: any) {
      setError(err.message || "Unable to find or create wallet for this user");
    }
  };

  const handleSend = async () => {
    const primaryWallet = dynamicClient.wallets.primary;

    if (!primaryWallet || primaryWallet.chain !== "EVM") {
      setError("Wallet not connected or not EVM compatible");
      return;
    }

    if (!recipient || !recipient.found) {
      setError("Please find a recipient first");
      return;
    }

    if (!amount || parseFloat(amount) <= 0) {
      setError("Please enter a valid amount");
      return;
    }

    setIsSending(true);
    setError(null);

    try {
      // Create wallet client using viem extension
      const walletClient = dynamicClient.viem.createWalletClient({
        wallet: primaryWallet,
      });

      // Create public client for waiting for receipt
      const publicClient = dynamicClient.viem.createPublicClient({
        chain: primaryWallet.chain, // Use the wallet's chain
      });

      // USDC contract address (replace with your network's USDC address)
      // This example uses Ethereum mainnet - adjust for your network
      const usdcAddress = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";

      // Convert amount to USDC units (6 decimals)
      const amountInUnits = parseUnits(amount, 6);

      // Send USDC transfer using writeContract
      const hash = await walletClient.writeContract({
        address: usdcAddress as `0x${string}`,
        abi: erc20Abi,
        functionName: "transfer",
        args: [recipient.walletAddress as `0x${string}`, amountInUnits],
      });

      setTxHash(hash);

      // Wait for transaction receipt
      const receipt = await publicClient.waitForTransactionReceipt({ hash });
      console.log("USDC transfer successful:", receipt);

      Alert.alert(
        "Success",
        `USDC transfer successful!\nTransaction Hash: ${hash}`
      );

      // Reset form
      setIdentifier("");
      setAmount("");
      clearRecipient();
    } catch (err: any) {
      console.error("Failed to send USDC:", err);
      if (err.message?.includes("insufficient funds")) {
        setError("Insufficient USDC balance");
      } else if (err.message?.includes("user rejected") || err.message?.includes("User rejected")) {
        setError("Transaction was cancelled");
      } else {
        setError(err.message || "Failed to send USDC");
      }
      Alert.alert("Error", err.message || "Failed to send USDC");
    } finally {
      setIsSending(false);
    }
  };

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Send USDC</Text>

      <View style={styles.section}>
        <Text style={styles.label}>Send to:</Text>
        <View style={styles.row}>
          <View style={styles.selectContainer}>
            <TouchableOpacity
              style={[
                styles.selectButton,
                identifierType === "email" && styles.selectButtonActive,
              ]}
              onPress={() => {
                setIdentifierType("email");
                setIdentifier("");
                clearRecipient();
              }}
            >
              <Text
                style={[
                  styles.selectButtonText,
                  identifierType === "email" && styles.selectButtonTextActive,
                ]}
              >
                Email
              </Text>
            </TouchableOpacity>
            <TouchableOpacity
              style={[
                styles.selectButton,
                identifierType === "phone" && styles.selectButtonActive,
              ]}
              onPress={() => {
                setIdentifierType("phone");
                setIdentifier("");
                clearRecipient();
              }}
            >
              <Text
                style={[
                  styles.selectButtonText,
                  identifierType === "phone" && styles.selectButtonTextActive,
                ]}
              >
                Phone
              </Text>
            </TouchableOpacity>
          </View>
        </View>
        <TextInput
          value={identifier}
          onChangeText={(text) => {
            setIdentifier(text);
            clearRecipient();
          }}
          placeholder={
            identifierType === "email"
              ? "user@example.com"
              : "+1234567890"
          }
          keyboardType={identifierType === "email" ? "email-address" : "phone-pad"}
          style={styles.input}
        />
        <TouchableOpacity
          onPress={handleLookup}
          disabled={isLookingUp || !identifier.trim()}
          style={[
            styles.button,
            styles.lookupButton,
            (isLookingUp || !identifier.trim()) && styles.buttonDisabled,
          ]}
        >
          <Text style={styles.buttonText}>
            {isLookingUp ? "Looking up..." : "Find"}
          </Text>
        </TouchableOpacity>

        {recipient && recipient.found && (
          <View
            style={[
              styles.successBox,
              recipient.isPregenerated && { backgroundColor: "#fff4e6" },
            ]}
          >
            <Text style={styles.successText}>
              {recipient.isPregenerated
                ? "✓ Wallet created for:"
                : "✓ User found:"}{" "}
              {recipient.email || recipient.phone}
            </Text>
            <Text style={styles.walletText}>
              Wallet: {recipient.walletAddress.substring(0, 8)}...
              {recipient.walletAddress.substring(
                recipient.walletAddress.length - 6
              )}
            </Text>
            {recipient.isPregenerated && (
              <Text
                style={{
                  fontSize: 11,
                  color: "#856404",
                  fontStyle: "italic",
                  marginTop: 4,
                }}
              >
                They'll receive this wallet when they sign up with this{" "}
                {recipient.email ? "email" : "phone number"}
              </Text>
            )}
          </View>
        )}
      </View>

      {recipient && recipient.found && (
        <>
          <View style={styles.section}>
            <Text style={styles.label}>Amount (USDC):</Text>
            <TextInput
              value={amount}
              onChangeText={setAmount}
              placeholder="0.00"
              keyboardType="decimal-pad"
              style={styles.input}
            />
          </View>

          <TouchableOpacity
            onPress={handleSend}
            disabled={isSending || !amount || parseFloat(amount) <= 0}
            style={[
              styles.button,
              styles.sendButton,
              (isSending || !amount || parseFloat(amount) <= 0) &&
                styles.buttonDisabled,
            ]}
          >
            <Text style={styles.buttonText}>
              {isSending ? "Sending..." : `Send ${amount || "0"} USDC`}
            </Text>
          </TouchableOpacity>
        </>
      )}

      {(error || lookupError) && (
        <View style={styles.errorBox}>
          <Text style={styles.errorText}>{error || lookupError}</Text>
        </View>
      )}

      {txHash && (
        <View style={styles.successBox}>
          <Text style={styles.successText}>✓ Transaction sent!</Text>
          <Text style={styles.txHashText}>
            Hash: {txHash.substring(0, 16)}...
            {txHash.substring(txHash.length - 8)}
          </Text>
        </View>
      )}
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 20,
    maxWidth: 500,
    alignSelf: "center",
    width: "100%",
  },
  title: {
    fontSize: 24,
    fontWeight: "bold",
    marginBottom: 20,
  },
  section: {
    marginBottom: 20,
  },
  label: {
    fontSize: 16,
    marginBottom: 8,
    fontWeight: "500",
  },
  row: {
    flexDirection: "row",
    marginBottom: 10,
  },
  selectContainer: {
    flexDirection: "row",
    marginBottom: 10,
    borderRadius: 8,
    overflow: "hidden",
    borderWidth: 1,
    borderColor: "#ddd",
  },
  selectButton: {
    flex: 1,
    padding: 8,
    backgroundColor: "#f5f5f5",
  },
  selectButtonActive: {
    backgroundColor: "#4779FE",
  },
  selectButtonText: {
    textAlign: "center",
    color: "#333",
  },
  selectButtonTextActive: {
    color: "#fff",
    fontWeight: "bold",
  },
  input: {
    borderWidth: 1,
    borderColor: "#ddd",
    borderRadius: 8,
    padding: 12,
    fontSize: 16,
    marginBottom: 10,
  },
  button: {
    padding: 12,
    borderRadius: 8,
    alignItems: "center",
  },
  lookupButton: {
    backgroundColor: "#666",
  },
  sendButton: {
    backgroundColor: "#4779FE",
    marginTop: 10,
  },
  buttonDisabled: {
    opacity: 0.5,
  },
  buttonText: {
    color: "#fff",
    fontSize: 16,
    fontWeight: "600",
  },
  successBox: {
    padding: 12,
    backgroundColor: "#f0f9ff",
    borderRadius: 8,
    marginTop: 10,
  },
  successText: {
    fontWeight: "bold",
    marginBottom: 4,
  },
  walletText: {
    fontSize: 12,
    color: "#666",
  },
  txHashText: {
    fontSize: 12,
    marginTop: 4,
  },
  errorBox: {
    marginTop: 16,
    padding: 12,
    backgroundColor: "#fee",
    borderRadius: 8,
  },
  errorText: {
    color: "#c00",
  },
});

export default SendUSDCByIdentifier;
Note: Make sure your dynamicClient is set up with the ViemExtension:
// lib/dynamicClient.ts
import { createClient } from '@dynamic-labs/sdk-react-native';
import { ViemExtension } from '@dynamic-labs/viem-extension';

export const dynamicClient = createClient({
  environmentId: 'YOUR_ENVIRONMENT_ID',
}).extend(ViemExtension());

How Recipients Claim Their Wallets

When you send USDC to a friend who doesn’t have a wallet yet:
  1. Pregeneration: A wallet is automatically created for them with the email/phone you provided
  2. Funds Arrive: The USDC is sent to that wallet address immediately
  3. Sign Up: When your friend signs up with Dynamic using the same email or phone number
  4. Automatic Claim: They automatically receive their pregenerated wallet with the funds already in it
No additional steps are required - the wallet claiming happens automatically during authentication. For more details, see the pregeneration guide.

Important Considerations

Security

  1. API Token Protection: Critical: Never expose your Dynamic API token in client-side code. All Dynamic API calls must happen on your backend server. The frontend should only call your backend API endpoints.
  2. Rate Limiting: Implement rate limiting on lookup endpoints to prevent abuse
  3. Authentication: Consider requiring authentication for lookup endpoints to prevent unauthorized access
  4. Validation: Always validate email/phone formats and wallet addresses on both frontend and backend
  5. Backend Security: Keep your backend API token secure using environment variables and never commit it to version control

Performance

  1. Rate limits (Dynamic): /environments/:environmentId/users follows developer limits of 1500 requests/min per IP and 3000 requests/min per environment; /environments/:environmentId/users/embeddedWallets (pregeneration) is limited to 300 requests/min per IP. See rate limits.
  2. Caching: Cache user lookups to reduce API calls and stay under limits
  3. Filtering: The API supports filtering - check the API reference for available filter parameters

User Experience

  1. Error Handling: Provide clear error messages for common issues
  2. Loading States: Show loading indicators during lookup and transfer
  3. Confirmation: Always show the recipient’s identifier before sending
  4. Transaction Status: Display transaction hashes and links to block explorers