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
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
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
{
"id":1,
"jsonrpc": "2.0",
"result": "0xe670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d1527331"
}
2. Send Button & Modal
In this section, we will:
Add a
Sendbutton to app after user connects walletNew modal accepting
Toaddress andValue


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