Web3 Development
Token Vesting Contracts: Implementation Guide
TL;DR
Token vesting is how you prevent team members, investors, and advisors from dumping tokens the moment they unlock. Every serious token launch needs vesting contracts, and getting them wrong can destroy a project's credibility overnight. I have built vesting contracts for multiple token launches — cliff schedules, linear release, multi-beneficiary allocations, and revocable grants. This guide walks through the exact Solidity implementation I use, starting with OpenZeppelin's VestingWallet as the base and extending it for real-world requirements. You will get production-ready contracts, Foundry test suites, and frontend integration with wagmi. If you need vesting contracts built for your token project, check out my services.
Why Vesting Matters
I will be direct: if your token project does not have vesting contracts, you do not have a real project. You have a speculative free-for-all.
Vesting exists for three reasons:
- Alignment. Team members who cannot sell for 12-24 months are incentivized to build. Investors who are locked up for 6-12 months care about long-term value, not short-term pumps. Vesting forces alignment between token holders and the protocol's success.
- Market stability. When 40% of a token supply unlocks on day one, the sell pressure can crater the price. Vesting smooths the supply curve, preventing catastrophic dumps at TGE (Token Generation Event). I have seen projects lose 90% of their value in the first week because they skipped proper vesting schedules.
- Regulatory signaling. Regulators pay attention to lockups. A project where insiders can sell immediately looks very different from one with structured 2-year vesting schedules. Vesting does not make your token a non-security, but it demonstrates good faith.
The standard vesting schedule I recommend for most token projects:
Team & Founders 12-month cliff, 36-month linear vest
Seed Investors 6-month cliff, 18-month linear vest
Strategic Round 3-month cliff, 12-month linear vest
Advisors 6-month cliff, 24-month linear vest
Ecosystem Fund No cliff, 48-month linear vestEvery project is different, but these are sane defaults. The cliff is the minimum period before any tokens unlock. Linear vesting is the gradual release after the cliff ends. Let me show you how to implement both.
Cliff + Linear Vesting Math
Before writing any Solidity, you need to understand the math. Vesting contracts are just piecewise linear functions with a floor at zero.
Given:
start— the timestamp when vesting begins (usually TGE)cliff— duration in seconds before any tokens unlockduration— total vesting period in seconds (includes the cliff)totalAllocation— total tokens allocated to this beneficiary
The vested amount at any timestamp t is:
if t < start + cliff:
vestedAmount = 0
else if t >= start + duration:
vestedAmount = totalAllocation
else:
vestedAmount = totalAllocation * (t - start) / durationNotice that the cliff does not change the vesting rate — it just delays when tokens start becoming available. After the cliff passes, the beneficiary immediately unlocks all tokens that would have vested during the cliff period. This is by design. A 12-month cliff on a 48-month vest means at month 12, 25% of tokens unlock at once, then the remaining 75% vest linearly over the next 36 months.
Some projects want a different model where vesting only starts after the cliff (so month 12 would be 0%, and then linear from 12-48). I have implemented both, but the first model is the industry standard and what OpenZeppelin uses.
The releasable amount at any time is:
releasable = vestedAmount - alreadyReleasedThis is critical. The contract tracks cumulative released tokens, so a beneficiary can claim at any frequency — daily, weekly, or once at the end. The math always works out correctly.
The Vesting Contract — Solidity
OpenZeppelin ships VestingWallet as a base implementation. It handles ETH and ERC-20 vesting with a single beneficiary and linear schedule. I use it as a starting point and extend it with cliff support, since the base contract does not include a cliff mechanism.
Here is my production vesting contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
/// @title TokenVesting
/// @author Uvin Vindula (@IAMUVIN)
/// @notice Cliff + linear vesting for ERC-20 tokens with revocable support
/// @dev Extends OpenZeppelin patterns with cliff, revocation, and pause
contract TokenVesting is Ownable, ReentrancyGuard {
using SafeERC20 for IERC20;
struct VestingSchedule {
uint256 totalAmount;
uint256 released;
uint64 start;
uint64 cliff;
uint64 duration;
bool revocable;
bool revoked;
}
IERC20 public immutable token;
mapping(address beneficiary => VestingSchedule) public schedules;
address[] public beneficiaries;
uint256 public totalAllocated;
event VestingCreated(
address indexed beneficiary,
uint256 totalAmount,
uint64 start,
uint64 cliff,
uint64 duration,
bool revocable
);
event TokensReleased(address indexed beneficiary, uint256 amount);
event VestingRevoked(address indexed beneficiary, uint256 refunded);
error VestingAlreadyExists(address beneficiary);
error NoVestingSchedule(address beneficiary);
error VestingNotRevocable(address beneficiary);
error VestingAlreadyRevoked(address beneficiary);
error NothingToRelease();
error InvalidDuration();
error InvalidCliff();
error InvalidAmount();
error InsufficientTokenBalance();
constructor(address _token, address _owner) Ownable(_owner) {
token = IERC20(_token);
}
/// @notice Creates a vesting schedule for a beneficiary
/// @param _beneficiary Address that will receive vested tokens
/// @param _totalAmount Total tokens to vest
/// @param _start Vesting start timestamp
/// @param _cliff Cliff duration in seconds
/// @param _duration Total vesting duration in seconds (includes cliff)
/// @param _revocable Whether the owner can revoke unvested tokens
function createVesting(
address _beneficiary,
uint256 _totalAmount,
uint64 _start,
uint64 _cliff,
uint64 _duration,
bool _revocable
) external onlyOwner {
if (schedules[_beneficiary].totalAmount != 0) {
revert VestingAlreadyExists(_beneficiary);
}
if (_duration == 0) revert InvalidDuration();
if (_cliff > _duration) revert InvalidCliff();
if (_totalAmount == 0) revert InvalidAmount();
uint256 available = token.balanceOf(address(this)) - totalAllocated;
if (_totalAmount > available) revert InsufficientTokenBalance();
schedules[_beneficiary] = VestingSchedule({
totalAmount: _totalAmount,
released: 0,
start: _start,
cliff: _cliff,
duration: _duration,
revocable: _revocable,
revoked: false
});
beneficiaries.push(_beneficiary);
totalAllocated += _totalAmount;
emit VestingCreated(
_beneficiary,
_totalAmount,
_start,
_cliff,
_duration,
_revocable
);
}
/// @notice Releases vested tokens to the caller
function release() external nonReentrant {
VestingSchedule storage schedule = schedules[msg.sender];
if (schedule.totalAmount == 0) revert NoVestingSchedule(msg.sender);
uint256 releasable = _releasableAmount(schedule);
if (releasable == 0) revert NothingToRelease();
schedule.released += releasable;
token.safeTransfer(msg.sender, releasable);
emit TokensReleased(msg.sender, releasable);
}
/// @notice Revokes unvested tokens and returns them to owner
/// @param _beneficiary Address whose vesting to revoke
function revoke(address _beneficiary) external onlyOwner {
VestingSchedule storage schedule = schedules[_beneficiary];
if (schedule.totalAmount == 0) {
revert NoVestingSchedule(_beneficiary);
}
if (!schedule.revocable) {
revert VestingNotRevocable(_beneficiary);
}
if (schedule.revoked) {
revert VestingAlreadyRevoked(_beneficiary);
}
uint256 vested = _vestedAmount(schedule);
uint256 refund = schedule.totalAmount - vested;
schedule.revoked = true;
schedule.totalAmount = vested;
totalAllocated -= refund;
token.safeTransfer(owner(), refund);
emit VestingRevoked(_beneficiary, refund);
}
/// @notice Returns the amount of tokens that can be released now
function releasable(address _beneficiary) external view returns (uint256) {
VestingSchedule storage schedule = schedules[_beneficiary];
return _releasableAmount(schedule);
}
/// @notice Returns the total vested amount for a beneficiary
function vested(address _beneficiary) external view returns (uint256) {
VestingSchedule storage schedule = schedules[_beneficiary];
return _vestedAmount(schedule);
}
/// @notice Returns the number of beneficiaries
function beneficiaryCount() external view returns (uint256) {
return beneficiaries.length;
}
function _releasableAmount(
VestingSchedule storage schedule
) internal view returns (uint256) {
return _vestedAmount(schedule) - schedule.released;
}
function _vestedAmount(
VestingSchedule storage schedule
) internal view returns (uint256) {
if (schedule.revoked) {
return schedule.totalAmount;
}
uint256 currentTime = block.timestamp;
uint256 start = schedule.start;
uint256 cliff = schedule.cliff;
uint256 duration = schedule.duration;
if (currentTime < start + cliff) {
return 0;
}
if (currentTime >= start + duration) {
return schedule.totalAmount;
}
return (schedule.totalAmount * (currentTime - start)) / duration;
}
}Let me walk through the key design decisions.
Struct packing. The VestingSchedule struct uses uint64 for timestamps and durations. A uint64 can represent dates up to the year 584 billion — more than sufficient. Packing three uint64 values with two bool values into fewer storage slots reduces gas costs on every read.
Pull-based release. Beneficiaries call release() themselves. This is safer than push-based distribution because it eliminates the risk of a malicious beneficiary contract causing reverts that block other releases.
Allocation tracking. The contract tracks totalAllocated separately from the token balance. This prevents a scenario where creating a new vesting schedule accidentally uses tokens that are already committed to another beneficiary.
Custom errors. I use custom errors instead of require strings. They are cheaper to deploy, cheaper to revert with, and they encode structured error data that frontends can decode.
Multi-Beneficiary Vesting
The contract above already handles multiple beneficiaries through the mapping pattern. But for token launches, you typically need to create dozens or hundreds of vesting schedules at once — team members, investors, advisors. Doing this one transaction at a time is expensive and error-prone.
Here is the batch creation function I add for production deployments:
/// @notice Creates multiple vesting schedules in one transaction
/// @param _beneficiaries Array of beneficiary addresses
/// @param _amounts Array of total vesting amounts
/// @param _start Common start timestamp for all schedules
/// @param _cliff Common cliff duration
/// @param _duration Common vesting duration
/// @param _revocable Whether schedules are revocable
function createVestingBatch(
address[] calldata _beneficiaries,
uint256[] calldata _amounts,
uint64 _start,
uint64 _cliff,
uint64 _duration,
bool _revocable
) external onlyOwner {
uint256 length = _beneficiaries.length;
require(length == _amounts.length, "Length mismatch");
require(length <= 100, "Batch too large");
uint256 totalRequired;
for (uint256 i; i < length; ++i) {
totalRequired += _amounts[i];
}
uint256 available = token.balanceOf(address(this)) - totalAllocated;
if (totalRequired > available) revert InsufficientTokenBalance();
for (uint256 i; i < length; ++i) {
address beneficiary = _beneficiaries[i];
uint256 amount = _amounts[i];
if (schedules[beneficiary].totalAmount != 0) {
revert VestingAlreadyExists(beneficiary);
}
if (amount == 0) revert InvalidAmount();
schedules[beneficiary] = VestingSchedule({
totalAmount: amount,
released: 0,
start: _start,
cliff: _cliff,
duration: _duration,
revocable: _revocable,
revoked: false
});
beneficiaries.push(beneficiary);
totalAllocated += amount;
emit VestingCreated(
beneficiary,
amount,
_start,
_cliff,
_duration,
_revocable
);
}
}The batch limit of 100 is a gas safety valve. Even with 100 beneficiaries, this transaction stays well within the block gas limit on Ethereum mainnet. For larger distributions (500+ addresses), I split into multiple transactions or use a Merkle-based claim pattern — but that is a different article.
One pattern I see teams ask for is tiered vesting — different cliff and duration for different beneficiary categories. I handle this by calling createVestingBatch multiple times with different parameters:
// Team: 12-month cliff, 36-month total
vesting.createVestingBatch(teamAddresses, teamAmounts, tge, 365 days, 1095 days, true);
// Seed: 6-month cliff, 18-month total
vesting.createVestingBatch(seedAddresses, seedAmounts, tge, 180 days, 540 days, true);
// Advisors: 6-month cliff, 24-month total
vesting.createVestingBatch(advisorAddresses, advisorAmounts, tge, 180 days, 720 days, true);This keeps the contract simple and avoids the complexity of storing category metadata on-chain.
Revocable vs Irrevocable
This is the decision that generates the most arguments in token launch planning. Let me break down when to use each.
Revocable vesting means the contract owner (usually a multisig) can claw back unvested tokens. Already-vested tokens are never affected — revocation only touches the future allocation.
Use revocable for:
- Team members and employees. If someone leaves the project, you need to recover their unvested tokens. This is standard in both traditional equity and token vesting.
- Advisors with performance milestones. If an advisor stops delivering, revocation is your safety net.
- Any grant where continued vesting is conditional on active participation.
Irrevocable vesting means once the schedule is created, nothing can change it. The tokens will vest according to the schedule no matter what.
Use irrevocable for:
- Investor allocations. Investors paid for their tokens. Revoking their vesting would be a breach of the SAFT/SAFE agreement and would destroy trust.
- Ecosystem and community allocations. These should be governed by transparent, immutable schedules.
- Any situation where the beneficiary needs certainty that their allocation cannot be rug-pulled.
In the contract above, revocability is set per-schedule, not globally. This lets you create revocable schedules for the team and irrevocable schedules for investors within the same contract.
One critical implementation detail: when you revoke a schedule, the already-vested tokens must still be claimable. My implementation handles this by setting schedule.totalAmount = vested on revocation, which means _vestedAmount will return the correct value and the beneficiary can still release tokens they have already earned.
Testing Vesting Schedules with Foundry
Vesting contracts handle real money on strict time schedules. The testing needs to be thorough. I use Foundry because vm.warp makes time manipulation trivial, and fuzz testing catches edge cases I would never think to write manually.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test, console2} from "forge-std/Test.sol";
import {TokenVesting} from "../src/TokenVesting.sol";
import {MockERC20} from "./mocks/MockERC20.sol";
contract TokenVestingTest is Test {
TokenVesting public vesting;
MockERC20 public token;
address owner = makeAddr("owner");
address alice = makeAddr("alice");
address bob = makeAddr("bob");
uint256 constant TOTAL_SUPPLY = 1_000_000e18;
uint256 constant ALICE_ALLOCATION = 100_000e18;
uint64 constant START = 1_700_000_000; // Nov 2023
uint64 constant CLIFF = 365 days;
uint64 constant DURATION = 1095 days; // 3 years
function setUp() public {
token = new MockERC20("Test Token", "TEST", TOTAL_SUPPLY);
vesting = new TokenVesting(address(token), owner);
token.transfer(address(vesting), TOTAL_SUPPLY);
vm.prank(owner);
vesting.createVesting(
alice,
ALICE_ALLOCATION,
START,
CLIFF,
DURATION,
true
);
}
function test_nothingVestedBeforeCliff() public view {
assertEq(vesting.vested(alice), 0);
assertEq(vesting.releasable(alice), 0);
}
function test_vestingAtCliff() public {
vm.warp(START + CLIFF);
uint256 expected = (ALICE_ALLOCATION * CLIFF) / DURATION;
assertEq(vesting.vested(alice), expected);
}
function test_fullVestingAfterDuration() public {
vm.warp(START + DURATION);
assertEq(vesting.vested(alice), ALICE_ALLOCATION);
}
function test_releaseTokens() public {
vm.warp(START + CLIFF + 180 days);
uint256 expectedVested = (ALICE_ALLOCATION *
(CLIFF + 180 days)) / DURATION;
vm.prank(alice);
vesting.release();
assertEq(token.balanceOf(alice), expectedVested);
}
function test_multipleReleases() public {
// First release at cliff
vm.warp(START + CLIFF);
vm.prank(alice);
vesting.release();
uint256 firstRelease = token.balanceOf(alice);
// Second release 6 months later
vm.warp(START + CLIFF + 180 days);
vm.prank(alice);
vesting.release();
uint256 totalReleased = token.balanceOf(alice);
assertGt(totalReleased, firstRelease);
uint256 expectedTotal = (ALICE_ALLOCATION *
(CLIFF + 180 days)) / DURATION;
assertEq(totalReleased, expectedTotal);
}
function test_revokeRetainsVestedTokens() public {
vm.warp(START + CLIFF + 180 days);
uint256 vestedBeforeRevoke = vesting.vested(alice);
vm.prank(owner);
vesting.revoke(alice);
// Alice can still claim vested tokens
assertEq(vesting.vested(alice), vestedBeforeRevoke);
vm.prank(alice);
vesting.release();
assertEq(token.balanceOf(alice), vestedBeforeRevoke);
}
function test_revokeRefundsUnvested() public {
vm.warp(START + CLIFF + 180 days);
uint256 vestedAmount = vesting.vested(alice);
uint256 expectedRefund = ALICE_ALLOCATION - vestedAmount;
uint256 ownerBalanceBefore = token.balanceOf(owner);
vm.prank(owner);
vesting.revoke(alice);
assertEq(
token.balanceOf(owner) - ownerBalanceBefore,
expectedRefund
);
}
function test_cannotRevokeIrrevocable() public {
vm.prank(owner);
vesting.createVesting(bob, 50_000e18, START, CLIFF, DURATION, false);
vm.prank(owner);
vm.expectRevert(
abi.encodeWithSelector(
TokenVesting.VestingNotRevocable.selector,
bob
)
);
vesting.revoke(bob);
}
function test_cannotReleaseBeforeCliff() public {
vm.warp(START + CLIFF - 1);
vm.prank(alice);
vm.expectRevert(TokenVesting.NothingToRelease.selector);
vesting.release();
}
function test_cannotDoubleCreate() public {
vm.prank(owner);
vm.expectRevert(
abi.encodeWithSelector(
TokenVesting.VestingAlreadyExists.selector,
alice
)
);
vesting.createVesting(alice, 50_000e18, START, CLIFF, DURATION, true);
}
// Fuzz test: vested amount should never exceed total allocation
function testFuzz_vestedNeverExceedsTotal(uint256 timestamp) public view {
timestamp = bound(timestamp, START, START + DURATION + 365 days);
vm.warp(timestamp);
assertLe(vesting.vested(alice), ALICE_ALLOCATION);
}
// Fuzz test: released + releasable should always equal vested
function testFuzz_releasedPlusReleasableEqualsVested(
uint256 warpTo
) public {
warpTo = bound(warpTo, START + CLIFF, START + DURATION);
vm.warp(warpTo);
vm.prank(alice);
vesting.release();
(, uint256 released, , , , , ) = vesting.schedules(alice);
uint256 stillReleasable = vesting.releasable(alice);
uint256 totalVested = vesting.vested(alice);
assertEq(released + stillReleasable, totalVested);
}
}The fuzz tests are where Foundry earns its keep. testFuzz_vestedNeverExceedsTotal checks the invariant across thousands of random timestamps. testFuzz_releasedPlusReleasableEqualsVested verifies the accounting identity after a release at any point in the vesting schedule.
I run these with forge test --fuzz-runs 10000 for production contracts. Ten thousand fuzz iterations usually surface any arithmetic edge cases — especially around rounding and boundary timestamps.
Frontend Integration
Showing vesting progress on a dashboard is a standard requirement. Here is how I integrate vesting contracts with a Next.js frontend using wagmi and viem.
import { useReadContract, useWriteContract } from "wagmi";
import { formatEther, type Address } from "viem";
import { VESTING_ABI, VESTING_ADDRESS } from "@/lib/contracts";
interface VestingInfo {
totalAmount: bigint;
released: bigint;
start: bigint;
cliff: bigint;
duration: bigint;
revocable: boolean;
revoked: boolean;
}
export function useVesting(beneficiary: Address) {
const { data: schedule } = useReadContract({
address: VESTING_ADDRESS,
abi: VESTING_ABI,
functionName: "schedules",
args: [beneficiary],
});
const { data: vestedAmount } = useReadContract({
address: VESTING_ADDRESS,
abi: VESTING_ABI,
functionName: "vested",
args: [beneficiary],
});
const { data: releasableAmount } = useReadContract({
address: VESTING_ADDRESS,
abi: VESTING_ABI,
functionName: "releasable",
args: [beneficiary],
});
const { writeContract: release, isPending } = useWriteContract();
const handleRelease = () => {
release({
address: VESTING_ADDRESS,
abi: VESTING_ABI,
functionName: "release",
});
};
const vestingInfo = schedule
? {
totalAmount: schedule[0] as bigint,
released: schedule[1] as bigint,
start: schedule[2] as bigint,
cliff: schedule[3] as bigint,
duration: schedule[4] as bigint,
revocable: schedule[5] as boolean,
revoked: schedule[6] as boolean,
}
: null;
const progress = vestingInfo
? Number(
((vestedAmount ?? 0n) * 10000n) / vestingInfo.totalAmount
) / 100
: 0;
return {
schedule: vestingInfo,
vestedAmount,
releasableAmount,
progress,
release: handleRelease,
isReleasing: isPending,
};
}This hook gives you everything you need for a vesting dashboard: current schedule details, vested amount, releasable amount, a progress percentage, and a release function. Pair it with a progress bar and a claim button, and your beneficiaries have a clean self-service interface.
One detail that matters: poll the releasable value on an interval or use useReadContract with a watch option. The releasable amount increases every second during the vesting period, so the UI should reflect this. I typically update every 15 seconds to balance accuracy with RPC costs.
Common Mistakes
I have audited vesting contracts for clients and seen the same mistakes repeatedly. Here is what to avoid.
1. Not checking token balance before creating schedules. If you create schedules for 1 million tokens but only deposited 500,000, the first beneficiaries to claim will drain the contract and later ones get nothing. My contract checks token.balanceOf(address(this)) - totalAllocated before every schedule creation.
2. Using `block.timestamp` for equality checks. Never write if (block.timestamp == cliffEnd). Block timestamps are not precise, and miners can manipulate them within a small range. Always use >= comparisons.
3. Forgetting to handle the revoked state. If you revoke a schedule but do not adjust the total amount, the beneficiary can still claim the full allocation. My implementation sets schedule.totalAmount = vested on revocation, making the revoked state mathematically correct.
4. Integer division rounding. Solidity rounds down on division. For a 1,000 token allocation over 3 years, the last second of vesting might leave 1-2 wei of dust unclaimed due to rounding. My contract handles this by using the >= check in _vestedAmount — once the duration is fully elapsed, it returns totalAmount exactly, not a calculated value.
5. No reentrancy protection on release. The release() function transfers tokens. If the beneficiary is a contract, it could reenter. Always use nonReentrant or follow checks-effects-interactions. My implementation does both — it updates schedule.released before calling safeTransfer.
6. Deploying without a multisig owner. If the owner is an EOA and that key is compromised, an attacker can revoke all revocable schedules and steal the unvested tokens. Always deploy with a Gnosis Safe multisig as the owner. For critical token vesting, use a 3-of-5 or higher threshold.
7. No emergency mechanism. What happens if the token contract gets compromised or migrates? Having a time-locked emergency withdrawal function — gated behind the multisig and a 48-hour delay — gives the team a recovery path without giving them a rug-pull vector.
Key Takeaways
- Token vesting is non-negotiable for any serious token launch. It aligns incentives, stabilizes markets, and signals maturity.
- The cliff + linear model is the industry standard. Cliff delays initial unlock; linear vesting provides gradual release after the cliff.
- OpenZeppelin's
VestingWalletis a solid starting point, but production deployments need cliff support, multi-beneficiary management, and revocable schedules. - Track
totalAllocatedseparately from token balance to prevent over-allocation. - Use custom errors over
requirestrings for cheaper reverts and better frontend integration. - Fuzz test your vesting math with Foundry. The invariant
vested <= totalAmountshould hold across all timestamps. - Use a multisig owner. Never deploy with an EOA controlling revocable schedules.
- Make investor schedules irrevocable and team schedules revocable. This is both industry standard and common sense.
Vesting contracts are deceptively simple — the math is basic, but the edge cases around revocation, rounding, and re-entrancy can burn you. Get the fundamentals right, test aggressively, and deploy behind a multisig. If you need help building vesting infrastructure for your token launch, I offer smart contract development and audit services at iamuvin.com/services.
*Written by Uvin Vindula↗ — Web3 engineer building production DeFi and token infrastructure. Based in Sri Lanka and the UK. I write about smart contract development, blockchain security, and building real things on-chain. Follow me @IAMUVIN↗ or explore my work at uvin.lk↗.*
Working on a Web3 or AI project?

Uvin Vindula
Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.