Skip to main content
Skip table of contents

6. Send Transfer

Finally, to complete the cycle we are going to add an action to send tokens via MetaMask.

We’ll also need to take care of validation - making sure the user has enough balance to cover the transfer and associated fees.

1. Understand the RPC Request

MetaMask will sign our transaction and send it via the eth_sendTransaction RPC method. We’ve included the information here to help us understand the makeup of the transaction, including gas fees.

Parameters

  1. Object - The transaction object

  • from: DATA, 20 Bytes - The address the transaction is sent from.

  • to: DATA, 20 Bytes - (optional when creating new contract) The address the transaction is directed to.

  • gas: QUANTITY - (optional, default: 90000) Integer of the gas provided for the transaction execution. It will return unused gas.

  • gasPrice: QUANTITY - (optional, default: To-Be-Determined) Integer of the gasPrice used for each paid gas.

  • value: QUANTITY - (optional) Integer of the value sent with this transaction.

  • input: DATA - The compiled code of a contract OR the hash of the invoked method signature and encoded parameters.

  • nonce: QUANTITY - (optional) Integer of a nonce. This allows to overwrite your own pending transactions that use the same nonce.

Request Format

CODE
params: [
  {
    from: "0xb60e8dd61c5d32be8058bb8eb970870f07233155",
    to: "0xd46e8dd67c5d32be8058bb8eb970870f07244567",
    gas: "0x76c0", // 30400
    gasPrice: "0x9184e72a000", // 10000000000000
    value: "0x9184e72a", // 2441406250
    input:
      "0xd46e8dd67c5d32be8d46e8dd67c5d32be8058bb8eb970870f072445675058bb8eb970870f072445675",
  },
]

Returns

DATA, 32 Bytes - the transaction hash, or the zero hash if the transaction is not yet available.

Use eth_getTransactionReceipt to get the contract address, after the transaction was proposed in a block, when you created a contract.

Example Response

CODE
{
"id":1,
"jsonrpc": "2.0",
"result": "0xe670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d1527331"
}

2. Send Button & Modal

In this section, we will:

  1. Add a Send button to app after user connects wallet

  2. New modal accepting To address and Value

2.1) SendButton Component

New component …/src/components/SendButton.tsx

A simple component to display the Send button & open the modal. Only displays if a wallet is connected.

TYPESCRIPT
"use client";

import { useState } from "react";
import { useAccount } from "wagmi";
import { SendModal } from "./SendModal";

export function SendButton() {
  const { isConnected } = useAccount();
  const [isModalOpen, setIsModalOpen] = useState(false);

  if (!isConnected) return null;

  return (
    <>
      <button
        onClick={() => setIsModalOpen(true)}
        className="flex items-center gap-2 px-4 py-2 rounded-full bg-foreground text-background font-medium text-sm hover:bg-zinc-800 dark:hover:bg-zinc-200 transition-colors"
      >
        <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
        </svg>
        Send
      </button>
      <SendModal 
        isOpen={isModalOpen} 
        onClose={() => setIsModalOpen(false)} 
      />
    </>
  );
}

2.2) SendModal Component

New component …/src/components/SendModal

For now, it includes the fields but only logs the results. Close modal by pressing cancel or clicking outside.

TYPESCRIPT
"use client";

import { useState } from "react";

interface SendModalProps {
  isOpen: boolean;
  onClose: () => void;
}

export function SendModal({ isOpen, onClose }: SendModalProps) {
  const [recipient, setRecipient] = useState("");
  const [amount, setAmount] = useState("");

  if (!isOpen) return null;

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    // TODO: Implement actual sending
    console.log("Send:", { recipient, amount });
  };

  const handleClose = () => {
    setRecipient("");
    setAmount("");
    onClose();
  };

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center">
      <div 
        className="absolute inset-0 bg-black/50" 
        onClick={handleClose}
      />
      <div className="relative bg-white dark:bg-zinc-900 rounded-2xl p-6 w-full max-w-md mx-4 shadow-xl">
        <div className="flex items-center justify-between mb-6">
          <h3 className="text-xl font-bold text-zinc-900 dark:text-zinc-100">
            Send Base Sepolia (Testnet) ETH
          </h3>
          <button
            onClick={handleClose}
            className="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 transition-colors"
          >
            <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
            </svg>
          </button>
        </div>

        <form onSubmit={handleSubmit} className="space-y-4">
          <div>
            <label 
              htmlFor="recipient" 
              className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2"
            >
              Recipient Address
            </label>
            <input
              id="recipient"
              type="text"
              value={recipient}
              onChange={(e) => setRecipient(e.target.value)}
              placeholder="0x..."
              className="w-full px-4 py-3 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-zinc-100 font-mono text-sm"
            />
          </div>

          <div>
            <label 
              htmlFor="amount" 
              className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2"
            >
              Amount (ETH)
            </label>
            <input
              id="amount"
              type="text"
              value={amount}
              onChange={(e) => setAmount(e.target.value)}
              placeholder="0.0"
              className="w-full px-4 py-3 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-zinc-100 text-sm"
            />
          </div>

          <div className="flex gap-3 pt-4">
            <button
              type="button"
              onClick={handleClose}
              className="flex-1 px-4 py-3 rounded-full border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 font-medium hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
            >
              Cancel
            </button>
            <button
              type="submit"
              className="flex-1 px-4 py-3 rounded-full bg-foreground text-background font-medium hover:bg-zinc-800 dark:hover:bg-zinc-200 transition-colors"
            >
              Send
            </button>
          </div>
        </form>
      </div>
    </div>
  );
}

2.3) Add Button to Page

Update …/src/app/page.tsx to add the Send Button to the header

TYPESCRIPT
import { ConnectButton } from "@/components/ConnectButton";
import { WalletBalance } from "@/components/WalletBalance";
import { TransactionHistory } from "@/components/TransactionHistory";
import { SendButton } from "@/components/SendButton";

export default function Home() {
  return (
    <main className="min-h-screen bg-zinc-50 dark:bg-black">
      <header className="flex items-center justify-end gap-4 p-4">
        <WalletBalance />
        <SendButton />
        <ConnectButton />
      </header>
      <div className="flex justify-center py-8">
        <TransactionHistory />
      </div>
    </main>
  );
}

3. Validation

Before we add the Send functionality we should validate that the user has enough funds.

Max Transfer = User Balance - TransactionFee

We’ll need to refresh the user’s wallet balance when they open the model, and then set the TransactionFee. To remove the guesswork, it would be helpful to add a Max button.

3.1) Estimate Transaction Fee

We will use the RPC Method eth_estimateGas to get the gas fee for the transaction. This could vary, depending on whether the recipient is a contract vs a natural wallet address.

To further complicate things, gas has its own conversion rate into ETH - so we need to get the conversion rate as well. Get the Gas Price via eth_gasPrice

Transaction Fee = Gas Estimate * Gas PRice

3.2) Refresh Wallet Balance

We’ll also update the SendModal component to reload the user’s available balance in case it has changed. We’ll show this in the modal to help the user.

3.3) Validate transaction + Max button

Let’s bring it all together in SendModal.tsx

If Available Balance < Amount + Transaction Fee show a validation error.

We’ve also added a Max button to remove guesswork finding the upper limit.

TYPESCRIPT
"use client";

import { useState, useEffect } from "react";
import { useAccount } from "wagmi";

const RPC_URL = process.env.NEXT_PUBLIC_ONFINALITY_RPC_URL!;

interface SendModalProps {
  isOpen: boolean;
  onClose: () => void;
}

export function SendModal({ isOpen, onClose }: SendModalProps) {
  const { address } = useAccount();
  const [recipient, setRecipient] = useState("");
  const [amount, setAmount] = useState("");
  const [balance, setBalance] = useState<string | null>(null);
  const [gasPrice, setGasPrice] = useState<bigint | null>(null);
  const [estimatedGas, setEstimatedGas] = useState<bigint | null>(null);
  const [estimatingFee, setEstimatingFee] = useState(false);
  const [feeError, setFeeError] = useState<string | null>(null);

  const fetchBalance = async () => {
    if (!address) return;
    
    try {
      const response = await fetch(RPC_URL, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          jsonrpc: "2.0",
          method: "eth_getBalance",
          params: [address, "latest"],
          id: 1,
        }),
      });
      const data = await response.json();
      if (data.result) {
        setBalance(data.result);
      }
    } catch (err) {
      console.error("Failed to fetch balance:", err);
    }
  };

  const fetchGasPrice = async () => {
    try {
      const response = await fetch(RPC_URL, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          jsonrpc: "2.0",
          method: "eth_gasPrice",
          params: [],
          id: 1,
        }),
      });
      const data = await response.json();
      if (data.result) {
        setGasPrice(BigInt(data.result));
      }
    } catch (err) {
      console.error("Failed to fetch gas price:", err);
    }
  };

  const estimateGas = async () => {
    if (!address || !recipient || !amount) return;
    
    const isValidAddress = /^0x[a-fA-F0-9]{40}$/.test(recipient);
    const parsedAmount = parseFloat(amount);
    
    if (!isValidAddress || isNaN(parsedAmount) || parsedAmount <= 0) {
      setEstimatedGas(null);
      return;
    }

    setEstimatingFee(true);
    setFeeError(null);

    try {
      const valueInWei = BigInt(Math.floor(parsedAmount * 1e18));
      const response = await fetch(RPC_URL, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          jsonrpc: "2.0",
          method: "eth_estimateGas",
          params: [{
            from: address,
            to: recipient,
            value: "0x" + valueInWei.toString(16),
          }],
          id: 1,
        }),
      });
      const data = await response.json();
      if (data.result) {
        setEstimatedGas(BigInt(data.result));
      } else if (data.error) {
        setFeeError(data.error.message);
        setEstimatedGas(null);
      }
    } catch (err) {
      console.error("Failed to estimate gas:", err);
      setFeeError("Failed to estimate fee");
    } finally {
      setEstimatingFee(false);
    }
  };

  useEffect(() => {
    if (isOpen && address) {
      fetchBalance();
      fetchGasPrice();
    }
  }, [isOpen, address]);

  useEffect(() => {
    const debounceTimer = setTimeout(() => {
      if (recipient && amount) {
        estimateGas();
      }
    }, 500);

    return () => clearTimeout(debounceTimer);
  }, [recipient, amount, address]);

  const handleClose = () => {
    setRecipient("");
    setAmount("");
    setBalance(null);
    setGasPrice(null);
    setEstimatedGas(null);
    setFeeError(null);
    onClose();
  };

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    // TODO: Implement actual sending
    console.log("Send:", { recipient, amount, estimatedFee: estimatedFee?.toString() });
  };

  const formatEth = (wei: bigint) => {
    const eth = Number(wei) / 1e18;
    if (eth < 0.000001) {
      return eth.toExponential(2);
    }
    return eth.toFixed(6);
  };

  const balanceInWei = balance ? BigInt(balance) : null;
  const estimatedFee = gasPrice && estimatedGas ? gasPrice * estimatedGas : null;
  const amountInWei = amount && !isNaN(parseFloat(amount)) ? BigInt(Math.floor(parseFloat(amount) * 1e18)) : null;
  const totalCost = amountInWei && estimatedFee ? amountInWei + estimatedFee : null;
  const hasInsufficientFunds = balanceInWei && totalCost ? totalCost > balanceInWei : false;

  if (!isOpen) return null;

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center">
      <div 
        className="absolute inset-0 bg-black/50" 
        onClick={handleClose}
      />
      <div className="relative bg-white dark:bg-zinc-900 rounded-2xl p-6 w-full max-w-md mx-4 shadow-xl">
        <div className="flex items-center justify-between mb-6">
          <h3 className="text-xl font-bold text-zinc-900 dark:text-zinc-100">
            Send Base Sepolia (Testnet) ETH
          </h3>
          <button
            onClick={handleClose}
            className="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 transition-colors"
          >
            <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
            </svg>
          </button>
        </div>

        {balanceInWei !== null && (
          <div className="mb-4 p-3 rounded-xl bg-zinc-100 dark:bg-zinc-800">
            <p className="text-sm text-zinc-600 dark:text-zinc-400">Available Balance</p>
            <p className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
              {formatEth(balanceInWei)} ETH
            </p>
          </div>
        )}

        <form onSubmit={handleSubmit} className="space-y-4">
          <div>
            <label 
              htmlFor="recipient" 
              className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2"
            >
              Recipient Address
            </label>
            <input
              id="recipient"
              type="text"
              value={recipient}
              onChange={(e) => setRecipient(e.target.value)}
              placeholder="0x..."
              className="w-full px-4 py-3 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-zinc-100 font-mono text-sm"
            />
          </div>

          <div>
            <div className="flex items-center justify-between mb-2">
              <label 
                htmlFor="amount" 
                className="text-sm font-medium text-zinc-700 dark:text-zinc-300"
              >
                Amount (ETH)
              </label>
              {balanceInWei && gasPrice && (
                <button
                  type="button"
                  onClick={() => {
                    const gasUnits = estimatedGas ?? BigInt(21000);
                    const maxFee = gasPrice * gasUnits;
                    const maxAmount = balanceInWei - maxFee;
                    if (maxAmount > 0) {
                      const ethAmount = Number(maxAmount) / 1e18;
                      setAmount(ethAmount.toString());
                    }
                  }}
                  className="text-xs font-medium text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
                >
                  Max
                </button>
              )}
            </div>
            <input
              id="amount"
              type="text"
              value={amount}
              onChange={(e) => setAmount(e.target.value)}
              placeholder="0.0"
              className="w-full px-4 py-3 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-zinc-100 text-sm"
            />
          </div>

          {(estimatedFee || estimatingFee || feeError) && (
            <div className="p-3 rounded-xl bg-zinc-50 dark:bg-zinc-800/50 space-y-2">
              {estimatingFee ? (
                <p className="text-sm text-zinc-500">Estimating fee...</p>
              ) : feeError ? (
                <p className="text-sm text-red-500">Insufficient funds</p>
              ) : estimatedFee && (
                <>
                  <div className="flex justify-between text-sm">
                    <span className="text-zinc-600 dark:text-zinc-400">Estimated Fee</span>
                    <span className="text-zinc-900 dark:text-zinc-100">{formatEth(estimatedFee)} ETH</span>
                  </div>
                  {totalCost && (
                    <div className="flex justify-between text-sm font-medium border-t border-zinc-200 dark:border-zinc-700 pt-2">
                      <span className="text-zinc-600 dark:text-zinc-400">Total</span>
                      <span className={hasInsufficientFunds ? "text-red-500" : "text-zinc-900 dark:text-zinc-100"}>
                        {formatEth(totalCost)} ETH
                      </span>
                    </div>
                  )}
                  {hasInsufficientFunds && (
                    <p className="text-sm text-red-500">Insufficient funds</p>
                  )}
                </>
              )}
            </div>
          )}

          <div className="flex gap-3 pt-4">
            <button
              type="button"
              onClick={handleClose}
              className="flex-1 px-4 py-3 rounded-full border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 font-medium hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
            >
              Cancel
            </button>
            <button
              type="submit"
              disabled={!estimatedFee || hasInsufficientFunds || estimatingFee}
              className="flex-1 px-4 py-3 rounded-full bg-foreground text-background font-medium hover:bg-zinc-800 dark:hover:bg-zinc-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
            >
              Send
            </button>
          </div>
        </form>
      </div>
    </div>
  );
}

Newly displayed Available Balance & Max button. Max has been pressed.

The Total including Fee is shown

An error is shown, and Send button disabled, if user has insufficient funds for the Amount + Fee

4. Send Transaction

We’ve made it to the final step of this tutorial! Now we’ll prepare the transaction, submit it to MetaMask for signing, and display the result.

Prepare transaction and press Send

Confirm transaction in MetaMask

Success!

Thanks for following along!

Next (Optional): Further Challenges

JavaScript errors detected

Please note, these errors can depend on your browser setup.

If this problem persists, please contact our support.