Разработка vesting-панели для инвесторов

Проектируем и разрабатываем блокчейн-решения полного цикла: от архитектуры смарт-контрактов до запуска DeFi-протоколов, NFT-маркетплейсов и криптобирж. Аудит безопасности, токеномика, интеграция с существующей инфраструктурой.
Показано 1 из 1 услугВсе 1306 услуг
Разработка vesting-панели для инвесторов
Средняя
~3-5 рабочих дней
Часто задаваемые вопросы
Направления блокчейн-разработки
Этапы блокчейн-разработки
Последние работы
  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1221
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1163
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    855
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1056
  • image_logo-advance_0.png
    Разработка логотипа компании B2B Advance
    561
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    828

Разработка vesting-панели для инвесторов

Задача: инвестор купил токены на private round, у него есть vesting контракт в блокчейне, и ему нужен удобный интерфейс чтобы видеть сколько разблокировалось, сколько ещё заблокировано, и забрать доступные токены. Кажется простым — пока не сталкиваешься с десятками контрактов на нескольких сетях, multi-sig кошельками инвесторов и wallet-агностик требованиями.

Архитектура панели

Что должно отображаться

Минимальный набор для каждого инвестора:

  • Total allocation — сколько токенов выделено
  • Vested — сколько разблокировалось к текущему моменту
  • Released — сколько уже забрано
  • Releasable — сколько можно забрать прямо сейчас
  • Locked — ещё под vesting
  • Vesting schedule — график разблокировки (визуально)
  • Next unlock — когда следующий разлок и сколько

Читаем данные из контрактов

Если используется 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],
      },
    ],
  });
  
  // Общий allocation = баланс контракта + уже 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 — обязательно использовать для batch запросов. Один вызов к ноде вместо четырёх-пяти последовательных — критично для производительности при нескольких контрактах.

Frontend: компонент графика vesting

Визуализация schedule помогает инвестору понять когда и сколько он получит:

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;
  
  // Генерируем точки для графика
  const dataPoints = [];
  const step = vestingDuration / 30; // 30 точек на период vesting
  
  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("ru-RU", { 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("ru-RU", { month: "short", year: "2-digit" })}
        stroke="#f59e0b"
        label={{ value: "Сейчас", position: "top" }}
      />
      {cliffDuration > 0 && (
        <ReferenceLine
          x={new Date(cliffEnd * 1000).toLocaleDateString("ru-RU", { 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 транзакция

Кнопка "Claim" должна корректно обрабатывать все состояния:

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>Нечего забирать</Button>;
  }
  
  return (
    <div>
      <Button
        onClick={handleClaim}
        disabled={isPending || isConfirming}
      >
        {isPending ? "Подтвердите в кошельке..." :
         isConfirming ? "Ожидание подтверждения..." :
         `Забрать ${formattedReleasable} токенов`}
      </Button>
      {isSuccess && (
        <p className="text-green-600">
          Успешно! {" "}
          <a href={`https://etherscan.io/tx/${txHash}`} target="_blank" rel="noreferrer">
            Транзакция
          </a>
        </p>
      )}
      {error && <p className="text-red-600">{error.shortMessage}</p>}
    </div>
  );
}

Multi-wallet и multi-chain поддержка

Инвесторы используют разные кошельки (MetaMask, WalletConnect, Coinbase Wallet, Ledger). wagmi v2 с ConnectKit или RainbowKit обрабатывает это.

Если проект задеплоен на нескольких сетях, инвестор должен видеть все свои vesting контракты в одном месте:

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);
}

Инвестор-специфические фичи

Email / Telegram уведомления об разлоках: за 7 дней до cliff, за 24 часа до каждого значимого unlock. Требует off-chain сервис, который мониторит блокчейн и отправляет уведомления.

CSV экспорт для налоговой отчётности: история всех claim транзакций с датами и суммами. Берётся из событий ERC20Transfer или TokensReleased через getLogs или indexer (The Graph).

Whitelist check: если токен нельзя продавать до определённой даты (lock-up дополнительный к vesting), это не всегда отражено в vesting контракте — может быть в самом токене. Панель должна это показывать.

Аутентификация

Для вестинг панели аутентификация через кошелёк (Sign-In with Ethereum, EIP-4361) достаточна и предпочтительна — нет паролей, нет баз пользователей.

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 }),
  });
}

Стек

Компонент Технология
Frontend Next.js 14 + TypeScript
Web3 wagmi v2 + viem + RainbowKit
Чтение данных viem multicall + The Graph (опц.)
Графики Recharts или Victory
Auth SIWE (EIP-4361)
Уведомления cron-сервис + SendGrid / Telegram Bot API

Срок разработки MVP (read-only dashboard + claim): 2–3 недели. Полная панель с уведомлениями, multi-chain, CSV экспортом — 4–6 недель.