On-chain token vesting with cliff/linear release + proportional revenue sharing for OP_20 tokens, deployed on OPNet (Bitcoin L1). No bridges, no L2, no EVM.
Deployed contract (OPNet Testnet):
opt1sqplya7jvr5ryduzsrlezps6gfcfznddmeyjghym6View on OPScan
VestingVault lets a protocol owner lock OP_20 tokens for beneficiaries under a configurable linear vesting schedule with an optional cliff period. Revenue deposited into the vault by anyone is distributed proportionally to all active vested holders based on their currently locked balance.
Revenue distribution uses the Synthetix reward-per-token accumulator pattern — O(1) per claim regardless of the number of beneficiaries.
Owner Beneficiary Anyone (depositor)
│ │ │
├─ increaseAllowance() │ │
├─ addVesting() ───────►│ │
│ (locks tokens) │ increaseAllowance()
│ │ depositRevenue()
│ │ (rewardPerToken accumulates)
│ [cliff passes] │
│ release() │
│ claimRevenue() │
Open website/index.html in your browser with OP Wallet extension installed.
The dashboard lets you interact with a deployed VestingVault contract entirely through your browser — no CLI required.
- OP Wallet browser extension installed and set to OPNet Testnet
- Some testnet BTC for transaction fees (each TX costs ~400 sat)
- Deployed VestingVault contract address
- Deployed VestingToken and RevenueToken addresses
Open website/index.html. The page auto-connects to OP Wallet on load. You'll see:
✓ Wallet connected: opt1p...youraddress
✓ State refreshed
Your wallet address auto-fills in the Your Wallet field.
Fill in the three address fields at the top of the dashboard:
| Field | What to enter |
|---|---|
| Vault Contract Address | Your deployed VestingVault address (hex 0x... or bech32 opt1p...) |
| Vesting Token Address | The OP_20 token being vested |
| Revenue Token Address | The OP_20 token used for revenue distribution |
Click ↻ Refresh to load vault state. The four stat cards will populate.
Skip this step if the vault is already initialized.
Go to Add Vesting (Owner) tab → Initialize Vault section.
The vault addresses auto-fill from your configuration. Click Initialize Vault.
This calls initialize(vestingToken, revenueToken) — a one-time setup required because OPNet currently has a known node bug where constructor calldata is 0 bytes on deploy.
Before adding a vesting schedule, the vault owner must hold the vesting tokens that will be locked.
If you deployed your own VestingToken, use its mint(to, amount) function (via OPScan or a separate script) to mint tokens to your wallet before proceeding.
Tip: amounts are in full token units including decimals. For a token with 18 decimals,
1000000000000000000= 1 token.
Go to Add Vesting (Owner) tab → Add Vesting Entry section.
| Field | Description | Example |
|---|---|---|
| Beneficiary | Address that will receive vested tokens | opt1p... or 0x... |
| Amount | Tokens to lock (full decimals) | 1000000000000000000 |
| Cliff (blocks) | Blocks before any tokens unlock | 10 |
| Duration (blocks) | Total blocks for full vesting | 100 |
Click Add Vesting.
What happens under the hood:
increaseAllowance(vault, amount)— your wallet approves the vault to pull tokensaddVesting(beneficiary, amount, cliff, duration)— vault pulls tokens viatransferFromand records the schedule
The second transaction simulates against confirmed chain state. The dashboard automatically retries until the allowance from step 1 is confirmed on-chain (up to 8 × 5s = 40 seconds). You'll see:
✓ increaseAllowance() TX: abc123... ℹ addVesting(): allowance not yet confirmed, retrying in 5s… (2/8) ✓ addVesting() simulation OK. Sending… ✓ addVesting() TX: def456...
After confirmation, the Vesting Schedule progress bar appears and TOTAL LOCKED updates.
Go to Release & Claim tab.
The dashboard shows:
- YOUR RELEASABLE — tokens available to claim right now
- Vesting Schedule — progress bar with
% vested, start block, duration
Click Release Tokens. The contract transfers your currently releasable tokens to your wallet.
Nothing is released before the cliff passes. After the cliff, tokens unlock linearly each block.
Go to Deposit Revenue tab.
| Field | Description |
|---|---|
| Amount | Revenue tokens to deposit (full decimals) |
Click Deposit Revenue. Same two-step flow as addVesting:
increaseAllowance(vault, amount)on the revenue tokendepositRevenue(amount)— vault pulls and distributes
REVENUE DEPOSITED and PENDING REVENUE update after confirmation.
Go to Release & Claim tab.
PENDING REVENUE shows your accumulated share. Click Claim Revenue.
Your proportional share is calculated as:
pending = lockedBalance × (rewardPerToken − yourDebt) / 1e18
| Card | Description |
|---|---|
| TOTAL LOCKED | All tokens currently locked across all beneficiaries |
| REVENUE DEPOSITED | Cumulative revenue deposited to the vault |
| YOUR RELEASABLE | Tokens you can release right now |
| PENDING REVENUE | Your unclaimed revenue share |
| Error | Cause | Fix |
|---|---|---|
Insufficient balance |
Your wallet has fewer tokens than amount |
Mint tokens first (Step 4) |
no vesting schedule |
Your wallet address has no active vesting | Check beneficiary address matches your wallet |
only owner |
You're not the vault owner | Use the owner wallet |
Stats show 0 after TX |
TX not confirmed yet | Click ↻ Refresh after ~2 minutes |
| Parameter | Description |
|---|---|
amount |
Total tokens to vest |
cliffDuration |
Blocks until any tokens unlock |
vestingDuration |
Total blocks for complete vesting |
startBlock |
Block height at addVesting() call |
Linear formula:
releasable = amount × (currentBlock − startBlock) / vestingDuration
(only after cliff passes, capped at amount)
Example: 1 token, cliff=10 blocks, duration=100 blocks
- Block 10: 0 tokens releasable (cliff not passed)
- Block 20: 0.10 tokens releasable (10%)
- Block 60: 0.60 tokens releasable (60%)
- Block 110+: 1.00 tokens releasable (100%)
rewardPerToken += (depositAmount × 1e18) / totalLocked
pendingRevenue(user) = lockedBalance × (rewardPerToken − rewardDebt[user]) / 1e18
Example: Alice has 7000 locked, Bob has 3000 locked, someone deposits 1000 revenue tokens:
rewardPerToken += 1000 × 1e18 / 10000 = 1e17- Alice earns:
7000 × 1e17 / 1e18 = 700✓ - Bob earns:
3000 × 1e17 / 1e18 = 300✓
After release(), only the remaining locked balance earns future revenue.
| Property | Implementation |
|---|---|
| Reentrancy guard | StoredBoolean in persistent blockchain storage (survives re-instantiation per call) |
| Checks-effects-interactions | State updated before every external Blockchain.call() |
| Only owner can add vesting | onlyOwner() guard on addVesting() |
| Revenue deposits open | Any address may deposit |
| No public mint/withdraw | Only release() + claimRevenue() for beneficiaries |
tx.sender not tx.origin |
Prevents delegation attacks |
| Method | Caller | Description |
|---|---|---|
initialize(vestingToken, revenueToken) |
Owner | One-time setup — sets token addresses post-deploy |
addVesting(beneficiary, amount, cliff, duration) |
Owner | Create vesting schedule, pull tokens via transferFrom |
release() |
Beneficiary | Release linearly vested tokens to caller |
depositRevenue(amount) |
Anyone | Deposit revenue for proportional distribution |
claimRevenue() |
Beneficiary | Claim accumulated revenue share |
| Method | Returns |
|---|---|
releasableAmount(address) |
Tokens available to release now |
vestedBalance(address) |
Total vested so far (including already released) |
pendingRevenue(address) |
Unclaimed revenue (stored + current epoch) |
totalRevenueDeposited() |
Cumulative revenue deposited |
getVestingInfo(address) |
Full schedule: amount, startBlock, cliff, duration, released, releasable |
totalLocked() |
All currently locked tokens |
owner() |
Contract owner address |
vestingToken() |
Token being vested |
revenueToken() |
Token used for revenue |
| Function | Selector |
|---|---|
initialize(address,address) |
0x67758e02 |
addVesting(address,uint256,uint256,uint256) |
0x7361c073 |
release() |
0xca66fa8a |
depositRevenue(uint256) |
0x5868922b |
claimRevenue() |
0xdba5add9 |
releasableAmount(address) |
0x5ac042fa |
vestedBalance(address) |
0xa8a3c859 |
pendingRevenue(address) |
0x23e7044e |
totalRevenueDeposited() |
0x86c091af |
getVestingInfo(address) |
0x2b302f16 |
owner() |
0x3fc2bcdd |
vestingToken() |
0xea9b7f23 |
revenueToken() |
0xa37f8d09 |
totalLocked() |
0x885dc9b0 |
src/
VestingVault.ts # Main contract (AssemblyScript / OPNet btc-runtime)
VestingToken.ts # Example vesting token (OP_20)
RevenueToken.ts # Example revenue token (OP_20)
index.ts # OPNet entry point
events/
VestingEvents.ts # NetEvent definitions
build/
VestingVault.wasm # Compiled binary (~30 KB)
VestingVault.wat # Human-readable WAT
abis/
VestingVault.abi.json # ABI (JSON)
VestingVault.abi.ts # ABI for opnet package
VestingVault.d.ts # TypeScript type definitions
OP20.abi.ts # Standard OP_20 ABI
test/
test-vesting-flow.ts # Full E2E test script
website/
index.html # Live dashboard (OP Wallet integration)
DEPLOY.md # Deployment guide
npm install
# Development build
npm run build
# Production build (optimized, no assertions)
npm run build:releaseOutputs build/VestingVault.wasm.
See DEPLOY.md for the full guide.
Quick summary:
npm run build:release- Open OP Wallet → Switch to OPNet Testnet
- Click Deploy → select
build/VestingVault.wasm - Leave constructor calldata empty (OPNet node bug: calldata is 0 bytes on deploy)
- Confirm the 2 Bitcoin transactions (fund + reveal)
- Note the deployed contract address
- Open
website/index.html, enter the address, click Initialize Vault
| Event | Data |
|---|---|
VestingAdded |
beneficiary, amount, cliffDuration, vestingDuration |
TokensReleased |
beneficiary, amount |
RevenueDeposited |
depositor, amount |
RevenueClaimed |
beneficiary, amount |
| Parameter | Value |
|---|---|
| Network | OPNet Testnet (Signet fork) |
| RPC URL | https://testnet.opnet.org |
| Explorer | https://opscan.org/?network=op_testnet |
| Network constant | networks.opnetTestnet |
Use
networks.opnetTestnet— notnetworks.testnet(that's Testnet4, unsupported).
MIT