A small spec-driven dApp for creating, viewing and claiming ERC-20 vesting schedules: grantors lock tokens behind a cliff and 90-day release intervals in a single unaudited factory contract; beneficiaries watch their vesting progress on a responsive dashboard and claim exactly what has vested — with wallet and network guards, transaction-status recovery, and a generated-artifacts handshake that keeps the frontend and the contracts from ever drifting apart.
Live site: https://token-vesting-lyart.vercel.app — deployed on Vercel, talking to Sepolia.
| Network | Sepolia — chain id 11155111 |
VestingFactory |
0x826420f34B8075610aA942AE9fD11f03Ec197438 |
TestToken |
0xaC6a0FC000d2A3aD4d5dD03811BdF100dc18067e |
| Deploy tx — TestToken | 0xdab11ad3…4c69a5d5 |
| Deploy tx — VestingFactory | 0x36601c0f…e95acf |
| Verification | Etherscan and Sourcify (both contracts, chain 11155111) |
| Live checks | frontend/scripts/live-check.mjs (shell/leaks) + live-e2e.mjs (stub wallet) |
Example schedules on the deployed factory:
| id | Beneficiary | Amount | Origin |
|---|---|---|---|
| 1 | 0x8E691e…27140DB47 (owner) |
100 TEST | Sepolia smoke run (tx) |
| 3 | 0x5b7764…315Ce7B4 (deployer) |
100 TEST | Sepolia smoke run (tx) |
| 6 | 0x8E691e…27140DB47 (owner) |
10 TEST | live-e2e.mjs passing run (tx) |
| 7 | 0x8E691e…27140DB47 (owner) |
10 TEST | live-e2e.mjs re-run after the dependency fix (tx) |
| 8 | 0x8E691e…27140DB47 (owner) |
100 TEST | live-e2e.mjs run verifying the beneficiary read-back step in a real browser (tx) |
| 9 | 0x8E691e…27140DB47 (owner) |
10 TEST | live-e2e.mjs run re-verifying the create flow after the WalletConnect/Reown project id was set (tx) |
Ids 2, 4 and 5 also exist on-chain and are deliberately not deleted: 2 is a
duplicate from a transient parser bug in the first smoke run, and 4 and 5 were
development attempts of live-e2e.mjs. Those three plus the six above account
for all 9 schedules the deployed factory currently holds.
Screenshots are interleaved so the deployed site sits next to the local build for the same viewport — any drift between them is visible at a glance.
Desktop — 1280×800
| Landing — live (deployed) | Landing — local build |
|---|---|
![]() |
![]() |
Mobile — 375×812
| View | Live (deployed) | Local build |
|---|---|---|
| Landing | ![]() |
![]() |
| Dashboard | ![]() |
![]() |
| Create schedule | — | ![]() |
Live end-to-end run — real Sepolia tx, schedule id 9
| Pending tx | Creation record | Beneficiary dashboard |
|---|---|---|
![]() |
![]() |
![]() |
- Atomic schedule creation —
createSchedulepulls the full amount from the grantor in the same transaction that stores the schedule; no schedule can exist unfunded, and fee-on-transfer tokens are rejected by a balance-delta check. - Cliff + 90-day intervals — deterministic floor-division vesting, claimable by anyone but always paid to the beneficiary.
- Dashboard (beneficiary view) — schedules where the connected wallet is the beneficiary (v1 has no grantor dashboard; grantors get a confirmation view after creating a schedule), vesting progress, live claim buttons with pending/confirmed/failed status and pending-tx recovery.
- Test-token faucet — rate-limited (1,000 TEST / 24 h) for testnets.
- Wallet & network guards — wrong-network banner, connect prompts, and a clear notice when the contracts are not deployed on the connected chain.
- Responsive & theme-verified — Plinth design system
(
docs/design/theme.md), automated theme and responsive Playwright checks with committed screenshots. - Generated-artifact handshake — addresses + ABIs are written by
script/Deploy.s.solintofrontend/src/contracts/and committed; the UI never hand-copies an ABI.
-
Months are 30 days. A schedule has a
start, acliff(whole multiples of 3 months), and aduration(whole multiples of 3 months, i.e. multiples of the 90-day interval). -
Before the cliff: nothing is vested. At or after
start + duration: everything is vested. In between, tokens vest once per completed 90-day interval, with floor division (no partial intervals, never rounds up):vested = totalAmount × completedIntervals / totalIntervals completedIntervals = (now − start) / 90 days totalIntervals = duration / 90 days -
Example: 900 TEST over 9 months (cliff 3 months, 3 intervals):
Time after startVested Claimable day 89 (pre-cliff) 0 0 — releaserevertsNothingToRelease()day 90 900 × 1/3 = 300 300 day 180 900 × 2/3 = 600 300 more (600 − 300 already released) day 270 (= duration) 900 the remaining 300 Floor division shows up with indivisible amounts too: 5 wei over 2 intervals vests
floor(5 × 1 / 2) = 2wei after interval 1 and the full 5 after interval 2.
┌──────────────────────────┐ forge script script/Deploy.s.sol ┌──────────────────────────────────┐
│ Foundry contracts │ ───────────────────────────────────► │ frontend/src/contracts/ (GENERA- │
│ src/VestingFactory.sol │ writes, committed on purpose: │ TED, committed): │
│ src/TestToken.sol │ • deployments.json (per chain id) │ • deployments.json │
│ script/Deploy.s.sol │ • abis/*.json │ • abis/{VestingFactory,TestToken}│
└────────────┬─────────────┘ └───────────────┬──────────────────┘
│ deploy (anvil / Sepolia) imported by │
▼ ▼
┌──────────────────────────┐ wagmi + viem (reads batched via Multicall3, ┌──────────────────────────┐
│ EVM node (RPC) │ ◄── writes via wallet, ABI decode, zod ──────► │ frontend (Vite + React) │
│ anvil 31337 / Sepolia │ validated forms, tx-status hooks) │ RainbowKit + wagmi │
└──────────────────────────┘ └──────────────────────────┘
The handshake is one-way: contracts → generated artifacts → frontend. Deploying
regenerates frontend/src/contracts/, which you commit so every environment
(Vercel included) builds against the real addresses.
| Path | What it is |
|---|---|
src/ |
VestingFactory (all schedules, one address) + TestToken (testnet token + faucet) |
test/ |
Unit, fuzz and invariant Foundry tests (+ test/mocks/ adversarial tokens) |
script/Deploy.s.sol |
Deploys both contracts, writes addresses + ABIs into the frontend |
script/smoke.sh |
End-to-end claim flow against a local anvil via cast |
script/coverage-gate.sh |
Fails below 95% line coverage on src/ |
frontend/ |
Vite + React + TypeScript app (Plinth theme, wagmi/RainbowKit) |
frontend/src/contracts/ |
Generated deployments.json + abis/ (committed) |
frontend/scripts/ |
responsive-check.mjs, theme-check.mjs, plus the live-site checks live-check.mjs and live-e2e.mjs |
docs/design/ |
"Plinth" design system (theme.md, theme.png) |
docs/screenshots/ |
Committed screenshots used in this README |
docs/SECURITY-REVIEW.md |
Security review: findings, slither, gas, limitations |
specs/001-token-vesting-app/ |
Spec, plan, research, data model, tasks, quickstart |
# 1. Chain (terminal A) — anvil, chain id 31337
anvil
# 2. Contracts (terminal B) — deploy with anvil's unlocked account #0
forge script script/Deploy.s.sol:Deploy \
--rpc-url http://127.0.0.1:8545 --broadcast --unlocked \
--sender 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
# 3. Frontend (terminal B)
cd frontend
npm ci
cp .env.example .env.local # then set VITE_CHAIN_ID=31337
npm run dev # reads .env.local (VITE_CHAIN_ID=31337)MetaMask setup: add a network with RPC http://127.0.0.1:8545, chain id
31337, currency ETH, and import anvil account #0 (or #1) using its
publicly known dev key printed by anvil. Those keys are for local testing
only — never reuse them on a real network.
# Contracts — unit + fuzz + invariant (fixed seed 0x5eed): 62 test functions.
forge test
FOUNDRY_PROFILE=ci forge test # what CI runs: fuzz 512, invariants 128
# Coverage gate — fails below 95% line coverage on src/
./script/coverage-gate.sh
# Gas snapshot — committed as .gas-snapshot (62 entries: every unit/fuzz test
# plus all 6 invariants individually — count with `grep -c '' .gas-snapshot`,
# NOT `wc -l`: the file has no trailing newline, so wc undercounts by one).
# CI runs the same suite, so an unexpected gas change shows up as a diff here.
forge snapshot
# Frontend — lint, typecheck, 217 tests, build
cd frontend
npm run lint && npm run typecheck && npm test && npm run build
# Browser checks (Playwright) — each script spawns its OWN `vite preview`
# (ports 4173/4174/4175) and serves dist/, so: run `npm run build` first, and
# start NO dev server. The wallet-connect check also clicks every row of the
# RainbowKit modal and asserts the QR view renders instead of crashing.
npm run build
node scripts/responsive-check.mjs
node scripts/theme-check.mjs
node scripts/wallet-connect-check.mjs
# Live checks against the DEPLOYED site (no wallet needed; the e2e run injects
# a stub window.ethereum — the signing key is passed ONLY via the E2E_PK env
# var of that one command, never committed):
node scripts/live-check.mjs https://token-vesting-lyart.vercel.app
E2E_ADDRESS=0x… E2E_RPC=<sepolia-rpc> node scripts/live-e2e.mjs \
https://token-vesting-lyart.vercel.app owner # read-only run
# End-to-end smoke: faucet → approve → create → warp → claim (anvil must be
# running with the contracts deployed, and `node` + `cast` on PATH)
./script/smoke.shMerge gates (also run by CI in .github/workflows/ci.yml):
forge fmt --check && forge build --deny warnings && forge test && ./script/coverage-gate.sh
cd frontend && npm run lint && npm run typecheck && npm test && npm run buildCI runs the three browser checks as well (after the build), and a separate
live-check.yml workflow re-runs
live-check.mjs against the production URL every day — CI proves the repo
builds, the daily run proves the deployed site still serves a working app.
Each CI run also uploads its lcov.info as the coverage-lcov artifact (on
success and on failure, since a failed gate is when you want to read it), and
the v1.0.0 release
carries gas-snapshot.txt and lcov.info as evidence.
| Variable | Purpose |
|---|---|
VITE_CHAIN_ID |
31337 (local anvil) or 11155111 (Sepolia, default) |
VITE_WALLETCONNECT_PROJECT_ID |
WalletConnect Cloud project id — free at https://cloud.reown.com (or cloud.walletconnect.com) |
VITE_RPC_URL |
Optional custom RPC; defaults to viem's public RPC for the chain |
Copy frontend/.env.example to frontend/.env.local.
.env* files are gitignored — only the example template is committed.
Use a Foundry keystore so the private key never touches the repo or shell history:
# one-time: import the key interactively (stored encrypted in ~/.foundry/keystores)
cast wallet import deployer --interactive
# deploy (keystore prompts for the password; never pass a raw key)
forge script script/Deploy.s.sol:Deploy \
--rpc-url "$SEPOLIA_RPC_URL" --account deployer --broadcast
# the script rewrote addresses + ABIs — commit them so the frontend builds
git add frontend/src/contracts && git commit -m "chore: record Sepolia deployment"Never commit or paste raw keys. Keystores only (cast wallet import +
--account), exactly as CI and the security review assume.
- Root directory:
frontend· Framework: Vite · Build command:npm run build· Output directory:dist - Environment variables (production, as configured):
VITE_CHAIN_ID=11155111VITE_RPC_URL=https://ethereum-sepolia-rpc.publicnode.comVITE_WALLETCONNECT_PROJECT_ID= set (the Reown/WalletConnect Cloud project id — a public identifier baked into the JS bundle). Without it the app falls back to a placeholder: injected wallets (MetaMask) still work, but Reown answers the relay with 400/403 and WalletConnect pairing is dead. Create one free at https://cloud.reown.com.- Never put an archive-node API key in
VITE_*vars — they are baked into the public JS bundle.
- Add the Vercel domains to the allowed domains in your WalletConnect/Reown
project settings (Project → Allowed domains) — at minimum
https://token-vesting-lyart.vercel.app— or WalletConnect connections will be refused on that origin. - The project's Root Directory is
frontend, so run the CLI from the repo root, not from insidefrontend/(the latter makes Vercel look forfrontend/frontendand fail with "Root Directory … does not exist"):npx vercel link --yes --project token-vesting && npx vercel --prod --yes.
This project is built with GitHub's Spec Kit workflow: a constitution sets
the engineering rules (TDD, ≥ 95% coverage, merge gates), then each feature goes
/speckit.specify → /speckit.plan → /speckit.tasks → /speckit.implement
(waves of test-first tasks) → /speckit.checklist. The full spec, plan,
research, data model and task list live in
specs/001-token-vesting-app/ — the task file
records every task together with the evidence that closed it: 76 of 77 are
checked off, including the items that were open for the longest (local deploy,
live URL, quickstart run, the release PR). The one still open is T076, the
in-browser verification with a real MetaMask — left unchecked rather than
closed without the evidence.
See docs/SECURITY-REVIEW.md for the full review:
CEI/re-entrancy analysis (including the createSchedule guard added with a
red→green adversarial test), slither results, contract sizes and gas, numeric
bounds, and the secrets/push-readiness scan.
Copied from the security review (§7) — read the full review for context:
- No revocation — a wrong beneficiary address locks the tokens until
start + duration, when only that address can claim. The create form has one beneficiary input validated with viem'sisAddress(mixed-case input with a wrong EIP-55 checksum is rejected; all-lowercase input passes), and a valid submit now stops at a review step that reads the address back in canonical EIP-55 form, warns when it was typed all-lowercase (checksum unverified), and requires an explicit Confirm & create before the wallet is prompted. That makes a wrong address much less likely — it does not make it impossible (revocation is still not a feature): paste, don't type, and read the read-back before confirming. - No admin / no pause — immutable by design; nothing can be frozen later.
- Shared pool — all grantors' funds for the same token live in one contract; per-schedule accounting keeps releases independent.
- Fee-on-transfer and rebasing tokens are rejected at creation.
- Test-token faucet has unlimited supply — testnet only, never mainnet.
- Extreme amounts above the per-duration overflow bound (e.g. above ≈ 2.97 × 10⁵⁷ tokens for a 120-month schedule; higher for shorter durations) can panic mid-schedule — see the overflow section of the review.
- 30-day months, rigid shape — cliffs/durations in whole multiples of 3 months; no arbitrary schedules.
- Permissionless release — anyone may trigger a claim, but it always pays the beneficiary.
- Unaudited — one engineer plus slither and 62 tests is not an audit; not for mainnet with real value.









