James Bachini

Aligning Founders, VC’s and Stakeholders Interests In A Smart Contract

VC Unlock Vesting Schedule Smart Contract

The blockchain sector prides itself on openness and transparency. Except that is when it comes to early stage VC deals and token allocations. In this article we are going to look at an experimental solidity smart contract which lays out terms, vesting schedules and milestone bonuses on-chain.

Team Allocations and VC Unlock Schedules

This is the logic we want to achieve in the smart contract which will also act as the ERC20 governance token for the project.

Team allocation

Team get dynamic 1% of circulating supply or 1.25% if mktcap > 1000 ETH. So at any point the team can call this function which will withdraw up to 1% of the circulating supply.

For example if the circulating supply is 1000 tokens and the team has already taken 5 previously they are now due another 5. As the funds come in to the contract the circulating supply increases meaning the team has more funds to work with.

VC Funding

There are two VC’s with different terms:-

  • VC1 has 1m tokens owed linearly over 1 year with a 2x bonus if token price doubles
  • VC2 has 2m tokens owed linearly over 4 years with a 3x bonus if/when TVL goes over 100 ETH

Code Walk-through

Full code is open source at: https://github.com/jamesbachini/Unlock-Smart-Contract

To start we will import the OpenZeppelin ERC20 token library and create some state variables.

tokenPrice is set initially but will be adjusted later depending on supply and demand.

We then define 3 wallets for the team and VC’s, these are set in the constructor argument which runs once on deployment.

In the constructor argument we also set a deploymentTimestamp which is the number of seconds since 1st January 1970 using block.timestamp which is system variable available within Solidity.

Finally we have 3 state variables to track the amount of funds each party has withdrawn. uint256 variables are set to 0 initially as standard.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.16;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract Unlock is ERC20 {

  uint256 public tokenPrice = 69420; // price in wei for 1e18 tokens
  address public teamWallet;
  address public vc1;
  address public vc2;
  uint256 public deploymentTimestamp;
  uint256 public teamFundsWithdrawn;
  uint256 public vc1FundsWithdrawn;
  uint256 public vc2FundsWithdrawn;

  constructor(address _vc1, address _vc2) ERC20("Unlock Token", "UNLOCK") {
   deploymentTimestamp = block.timestamp;
   teamWallet = msg.sender;
   vc1 = _vc1;
   vc2 = _vc2;
  }

The contract will work by accepting ETH and distributing a token. In practice we could have a more useful protocol for generating value but this will do for now. Token price is adjusted based on if someone is depositing funds or removing them from the contract. A 0.1 ETH deposit moves the price per token 1 wei.

function deposit() external payable {
  require(msg.value > 0, "Send some ETH");
  uint256 amount = msg.value * 1e18 / tokenPrice;
  tokenPrice = 69420 + (address(this).balance / 1e16); // 0.1 ETH moves the price 1 wei
  _mint(msg.sender, amount);
}

function withdraw(uint256 _amount) external {
  require(_amount > 0, "Send some tokens");
  require(balanceOf(msg.sender) >= _amount, "Not enough tokens");
  _burn(msg.sender, _amount);
  uint256 ethAmount = _amount * tokenPrice / 1e18;
  tokenPrice = 69420 + (address(this).balance / 1e16);
  payable(msg.sender).transfer(ethAmount);
}

From here we can calculate TVL and market cap of the contract and token.

function calculateMarketCap() public view returns(uint256) {
  uint256 circulatingSupply = totalSupply();
  return circulatingSupply * tokenPrice / 1e18;
}

function calculateTVL() public view returns(uint256) {
  return address(this).balance;
}

Next we want some public view functions so the team, VC’s and anyone else can view how much they have available to withdraw from the contract. These functions will contain the logic based on the milestone bonuses.

function teamDue() public view returns(uint256) {
  // team get dynamic 1% of circulating supply or 1.25% if mktcap > 1000 ETH 
  uint256 circulatingSupply = totalSupply();
  uint256 divisionFactor = 100;
  uint256 mktCap = calculateMarketCap();
  if (mktCap > 1000 ether) divisionFactor = 80;
  uint256 teamMaxAllowed = circulatingSupply / 100;
  uint256 teamAvailable = teamMaxAllowed - teamFundsWithdrawn;
  return teamAvailable;
}

function vcDue(address vc) public view returns(uint256) {
  // VC1 has 1m tokens owed over 1 year with a 2x bonus if token price doubles 
  // VC2 has 2m tokens owed over 4 years with a 3x bonus if TVL goes over 100 ETH
  uint256 vcAvailable;
  if (vc == vc1) {
    uint256 vcTotal = 1000000 ether;
    uint256 timePassed = block.timestamp - deploymentTimestamp;
    uint256 maxDue = vcTotal * timePassed / 31560000;
    if (tokenPrice > 69420 * 2) maxDue = maxDue * 2;
    vcAvailable = maxDue - vc1FundsWithdrawn;
  } else if (vc == vc2) {
    uint256 vcTotal = 2000000 ether;
    uint256 timePassed = block.timestamp - deploymentTimestamp;
    uint256 maxDue = vcTotal * timePassed / 126200000;
    uint256 tvl = calculateTVL();
    if (tvl > 100 ether) maxDue = maxDue * 3;
    vcAvailable = maxDue - vc2FundsWithdrawn;
  }
  return vcAvailable;
}

Finally we need a couple of functions so that the tokens can be minted. This will use the internal _mint(address,amount) function which is part of the ERC20 token library. Note that tokens are minted based on no collateral added meaning the contract could become undercollateralized, the perils of DeFi.

function teamFunding(uint256 _amount) external payable {
  require(msg.sender == teamWallet, "Only team can withdraw");
  uint256 teamAvailable = teamDue();
  require(_amount <= teamAvailable, "Team too greedy");
  teamFundsWithdrawn += _amount;
  _mint(teamWallet, _amount);
}

function vcVesting(uint256 _amount) external payable {
  uint256 vcAvailable = vcDue(msg.sender);
  if (msg.sender == vc1) {
    vc1FundsWithdrawn += _amount;
  } else if (msg.sender == vc2) {
    vc2FundsWithdrawn += _amount;
    _mint(vc2, _amount);
  } else {
   revert("Not a VC");
  }
  require(_amount <= vcAvailable, "VC too greedy");
  _mint(vc1, _amount);
}

The full code laid out slightly differently is available here: https://github.com/jamesbachini/Unlock-Smart-Contract/blob/main/contracts/Unlock.sol

As always we need to rigorously test our code. For this experiment which does not belong on mainnet some simple unit tests in Hardhat are sufficient.

VC Unlock Smart Contract
const { expect } = require('chai');
const { ethers } = require('hardhat');

describe('Unlock', function () {
  let unlock;

  before(async () => {
    [owner,vc1,vc2,user1,user2] = await ethers.getSigners();
    const ownerBalance = await ethers.provider.getBalance(owner.address);
    console.log(`    Owner: ${owner.address} Balance: ${ethers.utils.formatEther(ownerBalance)} ETH`);
    const UnlockContract = await ethers.getContractFactory('Unlock');
    unlock = await UnlockContract.deploy(vc1.address, vc2.address);
    console.log(`    Unlock deployed to: ${unlock.address}`);
    
    await hre.ethers.provider.send('evm_increaseTime', [7 * 24 * 60 * 60]);
  });

  it('Deposit Funds From Two Users Check Price Increase', async function () {
    await unlock.connect(user1).deposit({value: ethers.utils.parseEther('1')});
    const bal1 = await unlock.balanceOf(user1.address);
    expect(bal1).to.be.gt(0);
    await unlock.connect(user2).deposit({value: ethers.utils.parseEther('1')});
    const bal2 = await unlock.balanceOf(user2.address);
    expect(bal2).to.be.lt(bal1);
  });

  it('Withdraw team funds', async function () {
    const due = await unlock.teamDue();
    expect(due).to.be.gt(0);
    await unlock.connect(owner).teamFunding(due);
    const bal1 = await unlock.balanceOf(owner.address);
    expect(bal1).to.be.gt(0);
  });

  it('Withdraw vc1 funds', async function () {
    const due = await unlock.vcDue(vc1.address);
    expect(due).to.be.gt(0);
    await unlock.connect(vc1).vcVesting(due);
    const bal1 = await unlock.balanceOf(owner.address);
    expect(bal1).to.be.gt(0);
  });

  it('Withdraw vc2 funds', async function () {
    const due = await unlock.vcDue(vc2.address);
    expect(due).to.be.gt(0);
    await unlock.connect(vc2).vcVesting(due);
    const bal1 = await unlock.balanceOf(owner.address);
    expect(bal1).to.be.gt(0);
  });

  it('Check market cap', async function () {
    const mktcap = await unlock.calculateMarketCap();
    expect(mktcap).to.be.gt(0);
  });

  it('Check TVL', async function () {
    const tvl = await unlock.calculateTVL();
    expect(tvl).to.be.gt(0);
  });
  
  it('Withdraw user funds', async function () {
    const bal1 = await unlock.balanceOf(user1.address);
    await unlock.connect(user1).withdraw(bal1);
  });
});

I hope this solidity walk through has been of interest.


Get The Blockchain Sector Newsletter, binge the YouTube channel and connect with me on Twitter

The Blockchain Sector newsletter goes out a few times a month when there is breaking news or interesting developments to discuss. All the content I produce is free, if you’d like to help please share this content on social media.

Thank you.

James Bachini

Disclaimer: Not a financial advisor, not financial advice. The content I create is to document my journey and for educational and entertainment purposes only. It is not under any circumstances investment advice. I am not an investment or trading professional and am learning myself while still making plenty of mistakes along the way. Any code published is experimental and not production ready to be used for financial transactions. Do your own research and do not play with funds you do not want to lose.