Investor Vesting Panel Development
Task: investor bought tokens in private round, has vesting contract on blockchain, needs convenient interface to see unlocked amount, locked amount, claim available tokens. Sounds simple—until you face dozens of contracts on multiple networks, multi-sig investor wallets, and wallet-agnostic requirements.
Panel Architecture
What Should Display
Minimum for each investor:
- Total allocation — total tokens allocated
- Vested — unlocked by now
- Released — already claimed
- Releasable — can claim right now
- Locked — still under vesting
- Vesting schedule — unlock timeline (visual)
- Next unlock — when next release and how much
Read from Contracts
If using OpenZeppelin VestingWallet:
import { createPublicClient, http, parseAbi } from "viem";
const VESTING_ABI = parseAbi([
"function beneficiary() view returns (address)",
"function start() view returns (uint64)",
"function duration() view returns (uint64)",
"function released(address token) view returns (uint256)",
"function releasable(address token) view returns (uint256)",
"function vestedAmount(address token, uint64 timestamp) view returns (uint256)",
]);
async function getVestingData(
vestingAddress: `0x${string}`,
tokenAddress: `0x${string}`,
client: PublicClient
) {
const [start, duration, released, releasable] = await client.multicall({
contracts: [
{ address: vestingAddress, abi: VESTING_ABI, functionName: "start" },
{ address: vestingAddress, abi: VESTING_ABI, functionName: "duration" },
{
address: vestingAddress,
abi: VESTING_ABI,
functionName: "released",
args: [tokenAddress],
},
{
address: vestingAddress,
abi: VESTING_ABI,
functionName: "releasable",
args: [tokenAddress],
},
],
});
// Total allocation = contract balance + already released
const balance = await client.readContract({
address: tokenAddress,
abi: parseAbi(["function balanceOf(address) view returns (uint256)"]),
functionName: "balanceOf",
args: [vestingAddress],
});
const totalAllocation = balance.result! + released.result!;
return {
start: Number(start.result),
duration: Number(duration.result),
released: released.result!,
releasable: releasable.result!,
totalAllocation,
locked: totalAllocation - released.result! - releasable.result!,
};
}
multicall is mandatory—batch requests. One node call instead of four-five sequential—critical for performance with multiple contracts.
Frontend: Vesting Chart Component
Visualization helps investor understand when and how much they'll receive:
import { LineChart, Line, XAxis, YAxis, Tooltip, ReferenceLine } from "recharts";
import { formatUnits } from "viem";
function VestingChart({ start, cliffDuration, vestingDuration, totalAllocation, decimals }) {
const cliffEnd = start + cliffDuration;
const vestingEnd = cliffEnd + vestingDuration;
const now = Date.now() / 1000;
// Generate chart points
const dataPoints = [];
const step = vestingDuration / 30; // 30 points
for (let t = start; t <= vestingEnd; t += step) {
let vested = 0;
if (t >= cliffEnd) {
const elapsed = Math.min(t - cliffEnd, vestingDuration);
vested = Number(formatUnits(
BigInt(Math.floor(Number(totalAllocation) * elapsed / vestingDuration)),
decimals
));
}
dataPoints.push({
date: new Date(t * 1000).toLocaleDateString("en-US", { month: "short", year: "2-digit" }),
vested,
});
}
return (
<LineChart width={600} height={300} data={dataPoints}>
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={(v) => `${(v / 1000).toFixed(0)}k`} />
<Tooltip
formatter={(value) => [`${Number(value).toLocaleString()} tokens`, "Vested"]}
/>
<ReferenceLine
x={new Date(now * 1000).toLocaleDateString("en-US", { month: "short", year: "2-digit" })}
stroke="#f59e0b"
label={{ value: "Now", position: "top" }}
/>
{cliffDuration > 0 && (
<ReferenceLine
x={new Date(cliffEnd * 1000).toLocaleDateString("en-US", { month: "short", year: "2-digit" })}
stroke="#6366f1"
strokeDasharray="4 4"
label={{ value: "Cliff", position: "top" }}
/>
)}
<Line type="monotone" dataKey="vested" stroke="#10b981" strokeWidth={2} dot={false} />
</LineChart>
);
}
Claim Transaction
Claim button must handle all states:
import { useWriteContract, useWaitForTransactionReceipt } from "wagmi";
function ClaimButton({ vestingAddress, tokenAddress, releasable, decimals }) {
const { writeContract, data: txHash, isPending, error } = useWriteContract();
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
hash: txHash,
});
const handleClaim = () => {
writeContract({
address: vestingAddress,
abi: VESTING_ABI,
functionName: "release",
args: [tokenAddress],
});
};
const formattedReleasable = Number(formatUnits(releasable, decimals)).toLocaleString();
if (releasable === 0n) {
return <Button disabled>Nothing to claim</Button>;
}
return (
<div>
<Button
onClick={handleClaim}
disabled={isPending || isConfirming}
>
{isPending ? "Confirm in wallet..." :
isConfirming ? "Confirming..." :
`Claim ${formattedReleasable} tokens`}
</Button>
{isSuccess && (
<p className="text-green-600">
Success! {" "}
<a href={`https://etherscan.io/tx/${txHash}`} target="_blank">
Transaction
</a>
</p>
)}
{error && <p className="text-red-600">{error.shortMessage}</p>}
</div>
);
}
Multi-Wallet and Multi-Chain Support
Investors use different wallets (MetaMask, WalletConnect, Coinbase, Ledger). wagmi v2 with ConnectKit/RainbowKit handles this.
For multi-chain deployment, investor sees all vesting contracts in one place:
const NETWORKS = [
{ chainId: 1, name: "Ethereum", client: mainnetClient },
{ chainId: 42161, name: "Arbitrum", client: arbitrumClient },
];
async function getAllVestings(investorAddress: string) {
const results = await Promise.all(
NETWORKS.map(async (network) => {
const vestingAddress = VESTING_CONTRACTS[network.chainId]?.[investorAddress];
if (!vestingAddress) return null;
const data = await getVestingData(vestingAddress, TOKEN_ADDRESS, network.client);
return { ...data, network: network.name, chainId: network.chainId, vestingAddress };
})
);
return results.filter(Boolean);
}
Investor-Specific Features
Email/Telegram notifications on unlocks: 7 days before cliff, 24 hours before each significant unlock. Requires off-chain service monitoring blockchain and sending notifications.
CSV export for tax reporting: history of all claim transactions with dates and amounts. From ERC20Transfer or TokensReleased events via getLogs or indexer (The Graph).
Whitelist check: if token can't be sold until date (additional lock-up beyond vesting), this may not be in vesting contract—may be in token itself. Panel should display this.
Authentication
For vesting panel, wallet auth (Sign-In with Ethereum, EIP-4361) is sufficient and preferred—no passwords, no user databases.
import { SiweMessage } from "siwe";
async function signIn(address: string, chainId: number) {
const nonce = await fetch("/api/nonce").then((r) => r.text());
const message = new SiweMessage({
domain: window.location.host,
address,
statement: "Sign in to view your vesting schedule",
uri: window.location.origin,
version: "1",
chainId,
nonce,
});
const signature = await walletClient.signMessage({
message: message.prepareMessage(),
});
await fetch("/api/verify", {
method: "POST",
body: JSON.stringify({ message, signature }),
});
}
Stack
| Component | Technology |
|---|---|
| Frontend | Next.js 14 + TypeScript |
| Web3 | wagmi v2 + viem + RainbowKit |
| Data reading | viem multicall + The Graph (optional) |
| Charts | Recharts or Victory |
| Auth | SIWE (EIP-4361) |
| Notifications | cron-service + SendGrid / Telegram Bot API |
MVP timeline (read-only dashboard + claim): 2–3 weeks. Full panel with notifications, multi-chain, CSV export: 4–6 weeks.







