├── .gitignore ├── Anchor.toml ├── LICENSE ├── README.md ├── assets ├── diagram.png ├── structure.drawio ├── structure.png └── structure.svg ├── audit └── Bonfida_SecurityAssessment_Vesting_Final050521.pdf ├── cli ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── README.md └── src │ └── main.rs ├── js ├── .eslintignore ├── .eslintrc.js ├── .prettierrc.yaml ├── README.md ├── package.json ├── rollup.config.mjs ├── src │ ├── example.ts │ ├── index.ts │ ├── instructions.ts │ ├── main.ts │ ├── state.ts │ └── utils.ts ├── tsconfig.json ├── yarn-error.log └── yarn.lock └── program ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── Xargo.toml ├── fuzz ├── Cargo.toml ├── README.md └── src │ └── vesting_fuzz.rs ├── solana-llvm-linux.tar.bz2 ├── src ├── entrypoint.rs ├── error.rs ├── instruction.rs ├── lib.rs ├── processor.rs └── state.rs └── tests └── functional.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .DS_Store 3 | .vscode 4 | 5 | */node_modules 6 | js/lib 7 | 8 | cli/target 9 | cli/target/.rustc_info.json 10 | /node_modules 11 | 12 | js/dist -------------------------------------------------------------------------------- /Anchor.toml: -------------------------------------------------------------------------------- 1 | anchor_version = "0.13.2" 2 | 3 | [workspace] 4 | members = ["program"] 5 | 6 | [provider] 7 | cluster = "mainnet" 8 | wallet = "~/.config/solana/id.json" 9 | 10 | [programs.mainnet] 11 | token_vesting = "CChTq6PthWU82YZkbveA3WDf7s97BWhBK4Vx9bmsT743" 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Bonfida 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Token vesting

2 |
3 |

4 | 5 |

6 |

7 | 8 | 9 | 10 |

11 | 12 |
13 | 14 |
15 | 16 | 17 | 18 |
19 | 20 |
21 |

Table of contents

22 |
23 | 24 | 1. [Program ID](#program-id) 25 | 2. [Audit](#audit) 26 | 3. [UI](#ui) 27 | 4. [Overview](#overview) 28 | 5. [Structure](#structure) 29 | 30 |
31 | 32 |

Program ID

33 |
34 | 35 | - mainnet: `CChTq6PthWU82YZkbveA3WDf7s97BWhBK4Vx9bmsT743` 36 | - devnet: `DLxB9dSQtA4WJ49hWFhxqiQkD9v6m67Yfk9voxpxrBs4` 37 | 38 |
39 | 40 |

Audit

41 |
42 | 43 | This code has been audited by Kudelski ✅ 44 | 45 | - Audit report: [Bonfida Token Vesting Report](/audit/Bonfida_SecurityAssessment_Vesting_Final050521.pdf) 46 | 47 |
48 | 49 |

UI

50 |
51 | 52 | The [Bonfida Token Vesting UI](https://vesting.bonfida.org) can be used to unlock tokens. The UI **only** works for vesting accounts using the mainnet deployment `CChTq6PthWU82YZkbveA3WDf7s97BWhBK4Vx9bmsT743` 53 | 54 |
55 | 56 |

Overview

57 |
58 | 59 | - Simple vesting contract (SVC) that allows you to deposit X SPL tokens that are unlocked to a specified public key at a certain block height/ slot. 60 | - Unlocking works by pushing a permissionless crank on the contract that moves the tokens to the pre-specified address 61 | - Token Address should be derived from https://spl.solana.com/associated-token-account 62 | - 'Vesting Schedule contract' - A contract containing an array of the SVC's that can be used to develop arbitrary- vesting schedules. 63 | - Tooling to easily setup vesting schedule contracts 64 | - Recipient address should be modifiable by the owner of the current recipient key 65 | - Implementation should be a rust spl compatible program, plus client side javascript bindings that include a CLI- interface. Rust program should be unit tested and fuzzed. 66 | 67 |
68 | 69 |

Structure

70 |
71 | 72 | - `cli` : CLI tool to interact with on-chain token vesting contract 73 | - `js` : JavaScript binding to interact with on-chain token vesting contract 74 | - `program` : The BPF compatible token vesting on-chain program/smart contract 75 | 76 | ![diagram](assets/structure.png) 77 | -------------------------------------------------------------------------------- /assets/diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Upwork-Job32/token-vesting.blockchain-rust.project/91a042157e0565de676fad81d7bea03aaa25b288/assets/diagram.png -------------------------------------------------------------------------------- /assets/structure.drawio: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | -------------------------------------------------------------------------------- /assets/structure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Upwork-Job32/token-vesting.blockchain-rust.project/91a042157e0565de676fad81d7bea03aaa25b288/assets/structure.png -------------------------------------------------------------------------------- /assets/structure.svg: -------------------------------------------------------------------------------- 1 |
Contract creator
Contr...

  1. Destination address
  2. Mint address
  3. Is initialized ?
  4. Array of Schedules
    • Amount
    • Release Slot
Destination address...
Token Vesting Contract
Token Ve...
CLI or JS bindings
CLI or JS bindings
- Source (sign)
- Destination (Pubkey)
- Mint (Pubkey)
- Fee Payer (sign)
- Schedules
- Source (sign)...
Contract Account
Contract Acc...
Transfer total vested amount from sender
Transfer total vested amount...
Initial Recipient
Initi...
CLI or JS bindings
CLI or JS bindings
- Contract Seed (256 bits)
- Current Destination (sign)
- New Destination (Pubkey)
- Fee Payer (sign)
- Contract Seed (256 bits)...
Cranker
Crank...
CLI or JS bindings
CLI or JS bindings
- Contract Seeds (256 bits)
- Payer (sign)
- Contract Seeds (256 bits)...
 Recipient at unlock time
Reci...
Transfer maximum amount for current timeslot (fail if 0)
Transfer maximum amount for current...
Create Operations
Create Operatio...
Change Destination Operations
Change Destinat...
Permissionless crank (Unlock Operation)
Permissionless...
Contract Seed
(256 bits)
Contract Seed...
Contract Seed
(256 bits)
Contract Seed...
Viewer does not support full SVG 1.1
-------------------------------------------------------------------------------- /audit/Bonfida_SecurityAssessment_Vesting_Final050521.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Upwork-Job32/token-vesting.blockchain-rust.project/91a042157e0565de676fad81d7bea03aaa25b288/audit/Bonfida_SecurityAssessment_Vesting_Final050521.pdf -------------------------------------------------------------------------------- /cli/.gitignore: -------------------------------------------------------------------------------- 1 | target -------------------------------------------------------------------------------- /cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "vesting-contract-cli" 3 | version = "0.1.0" 4 | authors = ["Bonfida "] 5 | edition = "2018" 6 | 7 | [dependencies] 8 | solana-program = "1.5.0" 9 | solana-client = "1.5.0" 10 | solana-sdk = "1.5.0" 11 | solana-clap-utils = "1.5.0" 12 | token-vesting = { version = "0.1.0", path="../program", features=["no-entrypoint"] } 13 | spl-token = {version = "3.0.1", features = ["no-entrypoint"]} 14 | spl-associated-token-account = {version = "1.0.2", features = ["no-entrypoint"]} 15 | clap = "2.33.3" 16 | chrono = "0.4.19" 17 | iso8601-duration = { git = "https://github.com/rrichardson/iso8601-duration.git", rev = "9e01f51ea253e95e0fba5e4d7ad0c537922931e7"} -------------------------------------------------------------------------------- /cli/README.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | Create participants accounts: 4 | ```bash 5 | solana-keygen new --outfile ~/.config/solana/id_owner.json --force 6 | solana-keygen new --outfile ~/.config/solana/id_dest.json --force 7 | solana-keygen new --outfile ~/.config/solana/id_new_dest.json --force 8 | ``` 9 | 10 | Owner would do all operations, so put some SOL to his account: 11 | ```bash 12 | solana airdrop 2 --url https://api.devnet.solana.com ~/.config/solana/id_owner.json 13 | ``` 14 | 15 | Build program: 16 | ```bash 17 | ( cd ../program ; cargo build-bpf; ) 18 | ``` 19 | 20 | Deploy program and copy `PROGRAM_ID`. 21 | ```bash 22 | solana deploy ../program/target/deploy/token_vesting.so --url https://api.devnet.solana.com --keypair ~/.config/solana/id_owner.json 23 | ``` 24 | 25 | Create mint and get its public key(`MINT`): 26 | ```bash 27 | spl-token create-token --url https://api.devnet.solana.com --fee-payer ~/.config/solana/id_owner.json 28 | ``` 29 | 30 | Create source token account(`TOKEN_ACCOUNT_SOURCE`) 31 | ```bash 32 | spl-token create-account $MINT --url https://api.devnet.solana.com --owner ~/.config/solana/id_owner.json --fee-payer ~/.config/solana/id_owner.json 33 | ``` 34 | 35 | Mint test source token: 36 | ```bash 37 | spl-token mint $MINT 100000 --url https://api.devnet.solana.com $TOKEN_ACCOUNT_SOURCE --fee-payer ~/.config/solana/id_owner.json 38 | ``` 39 | 40 | Create vesting destination token account(`ACCOUNT_TOKEN_DEST`): 41 | ```bash 42 | spl-token create-account $MINT --url https://api.devnet.solana.com --owner ~/.config/solana/id_dest.json --fee-payer ~/.config/solana/id_owner.json 43 | ``` 44 | 45 | And new one(`ACCOUNT_TOKEN_NEW_DEST`): 46 | ```bash 47 | spl-token create-account $MINT --url https://api.devnet.solana.com --owner ~/.config/solana/id_new_dest.json --fee-payer ~/.config/solana/id_owner.json 48 | ``` 49 | 50 | Build CLI: 51 | 52 | ```bash 53 | cargo build 54 | ``` 55 | 56 | Create vesting instance and store its SEED value 57 | ```bash 58 | echo "RUST_BACKTRACE=1 ./target/debug/vesting-contract-cli \ 59 | --url https://api.devnet.solana.com \ 60 | --program_id $PROGRAM_ID \ 61 | create \ 62 | --mint_address $MINT \ 63 | --source_owner ~/.config/solana/id_owner.json \ 64 | --source_token_address $TOKEN_ACCOUNT_SOURCE \ 65 | --destination_token_address $ACCOUNT_TOKEN_DEST \ 66 | --amounts 2,1,3,! \ 67 | --release-times 1,28504431,2850600000000000,! \ 68 | --payer ~/.config/solana/id_owner.json" \ 69 | --verbose | bash 70 | ``` 71 | 72 | To use [Associated Token Account](https://spl.solana.com/associated-token-account) as destination use `--destination_address`(with public key of `id_dest`) instead of `--destination_token_address`. 73 | 74 | Observe contract state: 75 | ```bash 76 | echo "RUST_BACKTRACE=1 ./target/debug/vesting-contract-cli \ 77 | --url https://api.devnet.solana.com \ 78 | --program_id $PROGRAM_ID \ 79 | info \ 80 | --seed $SEED " | bash 81 | ``` 82 | 83 | Change owner: 84 | ```bash 85 | echo "RUST_BACKTRACE=1 ./target/debug/vesting-contract-cli \ 86 | --url https://api.devnet.solana.com \ 87 | --program_id $PROGRAM_ID \ 88 | change-destination \ 89 | --seed $SEED \ 90 | --current_destination_owner ~/.config/solana/id_dest.json \ 91 | --new_destination_token_address $ACCOUNT_TOKEN_NEW_DEST \ 92 | --payer ~/.config/solana/id_owner.json" | bash 93 | ``` 94 | 95 | And unlock tokens according schedule: 96 | ```bash 97 | echo "RUST_BACKTRACE=1 ./target/debug/vesting-contract-cli \ 98 | --url https://api.devnet.solana.com \ 99 | --program_id $PROGRAM_ID \ 100 | unlock \ 101 | --seed $SEED \ 102 | --payer ~/.config/solana/id_owner.json" | bash 103 | ``` 104 | 105 | Create linear vesting: 106 | ```bash 107 | echo "RUST_BACKTRACE=1 ./target/debug/vesting-contract-cli \ 108 | --url https://api.devnet.solana.com \ 109 | --program_id $PROGRAM_ID \ 110 | create \ 111 | --mint_address $MINT \ 112 | --source_owner ~/.config/solana/id_owner.json \ 113 | --source_token_address $TOKEN_ACCOUNT_SOURCE \ 114 | --destination_token_address $ACCOUNT_TOKEN_DEST \ 115 | --amounts 42,! \ 116 | --release-frequency 'P1D' \ 117 | --start-date-time '2022-01-06T20:11:18Z' \ 118 | --end-date-time '2022-01-12T20:11:18Z' \ 119 | --payer ~/.config/solana/id_owner.json" \ 120 | --verbose | bash 121 | ``` 122 | 123 | ## Links 124 | 125 | https://spl.solana.com/token 126 | -------------------------------------------------------------------------------- /cli/src/main.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Duration}; 2 | use clap::{ 3 | crate_description, crate_name, crate_version, value_t, App, AppSettings, Arg, SubCommand, 4 | }; 5 | use solana_clap_utils::{ 6 | input_parsers::{keypair_of, pubkey_of, value_of, values_of}, 7 | input_validators::{is_amount, is_keypair, is_parsable, is_pubkey, is_slot, is_url}, 8 | }; 9 | use solana_client::rpc_client::RpcClient; 10 | use solana_program::{msg, program_pack::Pack, pubkey::Pubkey, system_program, sysvar}; 11 | use solana_sdk::{ 12 | self, commitment_config::CommitmentConfig, signature::Keypair, signature::Signer, 13 | transaction::Transaction, 14 | }; 15 | use spl_associated_token_account::{create_associated_token_account, get_associated_token_address}; 16 | use spl_token; 17 | use std::convert::TryInto; 18 | use token_vesting::{ 19 | instruction::{change_destination, create, init, unlock, Schedule}, 20 | state::{unpack_schedules, VestingScheduleHeader}, 21 | }; 22 | 23 | // Lock the vesting contract 24 | fn command_create_svc( 25 | rpc_client: RpcClient, 26 | program_id: Pubkey, 27 | payer: Keypair, 28 | source_token_owner: Keypair, 29 | possible_source_token_pubkey: Option, 30 | destination_token_pubkey: Pubkey, 31 | mint_address: Pubkey, 32 | schedules: Vec, 33 | confirm: bool, 34 | ) { 35 | // If no source token account was given, use the associated source account 36 | let source_token_pubkey = match possible_source_token_pubkey { 37 | None => get_associated_token_address(&source_token_owner.pubkey(), &mint_address), 38 | _ => possible_source_token_pubkey.unwrap(), 39 | }; 40 | 41 | // Find a valid seed for the vesting program account key to be non reversible and unused 42 | let mut not_found = true; 43 | let mut vesting_seed: [u8; 32] = [0; 32]; 44 | let mut vesting_pubkey = Pubkey::new_unique(); 45 | while not_found { 46 | vesting_seed = Pubkey::new_unique().to_bytes(); 47 | let program_id_bump = Pubkey::find_program_address(&[&vesting_seed[..31]], &program_id); 48 | vesting_pubkey = program_id_bump.0; 49 | vesting_seed[31] = program_id_bump.1; 50 | not_found = match rpc_client.get_account(&vesting_pubkey) { 51 | Ok(_) => true, 52 | Err(_) => false, 53 | } 54 | } 55 | 56 | let vesting_token_pubkey = get_associated_token_address(&vesting_pubkey, &mint_address); 57 | 58 | let instructions = [ 59 | init( 60 | &system_program::id(), 61 | &sysvar::rent::id(), 62 | &program_id, 63 | &payer.pubkey(), 64 | &vesting_pubkey, 65 | vesting_seed, 66 | schedules.len() as u32, 67 | ) 68 | .unwrap(), 69 | create_associated_token_account( 70 | &source_token_owner.pubkey(), 71 | &vesting_pubkey, 72 | &mint_address, 73 | ), 74 | create( 75 | &program_id, 76 | &spl_token::id(), 77 | &vesting_pubkey, 78 | &vesting_token_pubkey, 79 | &source_token_owner.pubkey(), 80 | &source_token_pubkey, 81 | &destination_token_pubkey, 82 | &mint_address, 83 | schedules, 84 | vesting_seed, 85 | ) 86 | .unwrap(), 87 | ]; 88 | 89 | let mut transaction = Transaction::new_with_payer(&instructions, Some(&payer.pubkey())); 90 | 91 | let recent_blockhash = rpc_client.get_recent_blockhash().unwrap().0; 92 | transaction.sign(&[&payer], recent_blockhash); 93 | 94 | msg!( 95 | "\nThe seed of the contract is: {:?}", 96 | Pubkey::new_from_array(vesting_seed) 97 | ); 98 | msg!("Please write it down as it is needed to interact with the contract!"); 99 | 100 | msg!("The vesting account pubkey: {:?}", vesting_pubkey,); 101 | 102 | if confirm { 103 | rpc_client 104 | .send_and_confirm_transaction_with_spinner_and_commitment( 105 | &transaction, 106 | CommitmentConfig::finalized(), 107 | ) 108 | .unwrap(); 109 | } else { 110 | rpc_client.send_transaction(&transaction).unwrap(); 111 | } 112 | } 113 | 114 | fn command_unlock_svc( 115 | rpc_client: RpcClient, 116 | program_id: Pubkey, 117 | vesting_seed: [u8; 32], 118 | payer: Keypair, 119 | ) { 120 | // Find the non reversible public key for the vesting contract via the seed 121 | let (vesting_pubkey, _) = Pubkey::find_program_address(&[&vesting_seed[..31]], &program_id); 122 | 123 | let packed_state = rpc_client.get_account_data(&vesting_pubkey).unwrap(); 124 | let header_state = 125 | VestingScheduleHeader::unpack(&packed_state[..VestingScheduleHeader::LEN]).unwrap(); 126 | let destination_token_pubkey = header_state.destination_address; 127 | 128 | let vesting_token_pubkey = 129 | get_associated_token_address(&vesting_pubkey, &header_state.mint_address); 130 | 131 | let unlock_instruction = unlock( 132 | &program_id, 133 | &spl_token::id(), 134 | &sysvar::clock::id(), 135 | &vesting_pubkey, 136 | &vesting_token_pubkey, 137 | &destination_token_pubkey, 138 | vesting_seed, 139 | ) 140 | .unwrap(); 141 | 142 | let mut transaction = Transaction::new_with_payer(&[unlock_instruction], Some(&payer.pubkey())); 143 | 144 | let recent_blockhash = rpc_client.get_recent_blockhash().unwrap().0; 145 | transaction.sign(&[&payer], recent_blockhash); 146 | 147 | rpc_client.send_transaction(&transaction).unwrap(); 148 | } 149 | 150 | fn command_change_destination( 151 | rpc_client: RpcClient, 152 | program_id: Pubkey, 153 | destination_token_account_owner: Keypair, 154 | opt_new_destination_account: Option, 155 | opt_new_destination_token_account: Option, 156 | vesting_seed: [u8; 32], 157 | payer: Keypair, 158 | ) { 159 | // Find the non reversible public key for the vesting contract via the seed 160 | let (vesting_pubkey, _) = Pubkey::find_program_address(&[&vesting_seed[..31]], &program_id); 161 | 162 | let packed_state = rpc_client.get_account_data(&vesting_pubkey).unwrap(); 163 | let state_header = 164 | VestingScheduleHeader::unpack(&packed_state[..VestingScheduleHeader::LEN]).unwrap(); 165 | let destination_token_pubkey = state_header.destination_address; 166 | 167 | let new_destination_token_account = match opt_new_destination_token_account { 168 | None => get_associated_token_address( 169 | &opt_new_destination_account.unwrap(), 170 | &state_header.mint_address, 171 | ), 172 | Some(new_destination_token_account) => new_destination_token_account, 173 | }; 174 | 175 | let unlock_instruction = change_destination( 176 | &program_id, 177 | &vesting_pubkey, 178 | &destination_token_account_owner.pubkey(), 179 | &destination_token_pubkey, 180 | &new_destination_token_account, 181 | vesting_seed, 182 | ) 183 | .unwrap(); 184 | 185 | let mut transaction = Transaction::new_with_payer(&[unlock_instruction], Some(&payer.pubkey())); 186 | 187 | let recent_blockhash = rpc_client.get_recent_blockhash().unwrap().0; 188 | transaction.sign( 189 | &[&payer, &destination_token_account_owner], 190 | recent_blockhash, 191 | ); 192 | 193 | rpc_client.send_transaction(&transaction).unwrap(); 194 | } 195 | 196 | fn command_info( 197 | rpc_client: RpcClient, 198 | rpc_url: String, 199 | program_id: Pubkey, 200 | vesting_seed: [u8; 32], 201 | ) { 202 | msg!("\n---------------VESTING--CONTRACT--INFO-----------------\n"); 203 | msg!("RPC URL: {:?}", &rpc_url); 204 | msg!("Program ID: {:?}", &program_id); 205 | msg!("Vesting Seed: {:?}", Pubkey::new_from_array(vesting_seed)); 206 | 207 | // Find the non reversible public key for the vesting contract via the seed 208 | let (vesting_pubkey, _) = Pubkey::find_program_address(&[&vesting_seed[..31]], &program_id); 209 | msg!("Vesting Account Pubkey: {:?}", &vesting_pubkey); 210 | 211 | let packed_state = rpc_client.get_account_data(&vesting_pubkey).unwrap(); 212 | let state_header = 213 | VestingScheduleHeader::unpack(&packed_state[..VestingScheduleHeader::LEN]).unwrap(); 214 | let vesting_token_pubkey = 215 | get_associated_token_address(&vesting_pubkey, &state_header.mint_address); 216 | msg!("Vesting Token Account Pubkey: {:?}", &vesting_token_pubkey); 217 | msg!("Initialized: {:?}", &state_header.is_initialized); 218 | msg!("Mint Address: {:?}", &state_header.mint_address); 219 | msg!( 220 | "Destination Token Address: {:?}", 221 | &state_header.destination_address 222 | ); 223 | 224 | let schedules = unpack_schedules(&packed_state[VestingScheduleHeader::LEN..]).unwrap(); 225 | 226 | for i in 0..schedules.len() { 227 | msg!("\nSCHEDULE {:?}", i); 228 | msg!("Release Height: {:?}", &schedules[i].release_time); 229 | msg!("Amount: {:?}", &schedules[i].amount); 230 | } 231 | } 232 | 233 | fn main() { 234 | let matches = App::new(crate_name!()) 235 | .about(crate_description!()) 236 | .version(crate_version!()) 237 | .setting(AppSettings::SubcommandRequiredElseHelp) 238 | .arg( 239 | Arg::with_name("verbose") 240 | .long("verbose") 241 | .short("v") 242 | .takes_value(false) 243 | .global(true) 244 | .help("Show additional information"), 245 | ) 246 | .arg( 247 | Arg::with_name("rpc_url") 248 | .long("url") 249 | .value_name("URL") 250 | .validator(is_url) 251 | .takes_value(true) 252 | .global(true) 253 | .help( 254 | "Specify the url of the rpc client (solana network).", 255 | ), 256 | ) 257 | .arg( 258 | Arg::with_name("program_id") 259 | .long("program_id") 260 | .value_name("ADDRESS") 261 | .validator(is_pubkey) 262 | .takes_value(true) 263 | .help( 264 | "Specify the address (public key) of the program.", 265 | ), 266 | ) 267 | .subcommand(SubCommand::with_name("create").about("Create a new vesting contract with an optional release schedule") 268 | .arg( 269 | Arg::with_name("mint_address") 270 | .long("mint_address") 271 | .value_name("ADDRESS") 272 | .validator(is_pubkey) 273 | .takes_value(true) 274 | .help( 275 | "Specify the address (publickey) of the mint for the token that should be used.", 276 | ), 277 | ) 278 | .arg( 279 | Arg::with_name("source_owner") 280 | .long("source_owner") 281 | .value_name("KEYPAIR") 282 | .validator(is_keypair) 283 | .takes_value(true) 284 | .help( 285 | "Specify the source account owner. \ 286 | This may be a keypair file, the ASK keyword. \ 287 | Defaults to the client keypair.", 288 | ), 289 | ) 290 | .arg( 291 | Arg::with_name("source_token_address") 292 | .long("source_token_address") 293 | .value_name("ADDRESS") 294 | .validator(is_pubkey) 295 | .takes_value(true) 296 | .help( 297 | "Specify the source token account address.", 298 | ), 299 | ) 300 | .arg( 301 | Arg::with_name("destination_address") 302 | .long("destination_address") 303 | .value_name("ADDRESS") 304 | .validator(is_pubkey) 305 | .takes_value(true) 306 | .help( 307 | "Specify the destination (non-token) account address. \ 308 | If specified, the vesting destination will be the associated \ 309 | token account for the mint of the contract." 310 | ), 311 | ) 312 | .arg( 313 | Arg::with_name("destination_token_address") 314 | .long("destination_token_address") 315 | .value_name("ADDRESS") 316 | .validator(is_pubkey) 317 | .takes_value(true) 318 | .help( 319 | "Specify the destination token account address. \ 320 | If specified, this address will be used as a destination, \ 321 | and overwrite the associated token account.", 322 | ), 323 | ) 324 | .arg( 325 | Arg::with_name("amounts") 326 | .long("amounts") 327 | .value_name("AMOUNT") 328 | .validator(is_amount) 329 | .takes_value(true) 330 | .multiple(true) 331 | .use_delimiter(true) 332 | .value_terminator("!") 333 | .allow_hyphen_values(true) 334 | .help( 335 | "Amounts of tokens to transfer via the vesting \ 336 | contract. Multiple inputs separated by a comma are 337 | accepted for the creation of multiple schedules. The sequence of inputs \ 338 | needs to end with an exclamation mark ( e.g. 1,2,3,! )", 339 | ), 340 | ) 341 | // scheduled vesting 342 | .arg( 343 | Arg::with_name("release-times") 344 | .long("release-times") 345 | .conflicts_with("release-frequency") 346 | .value_name("SLOT") 347 | .validator(is_slot) 348 | .takes_value(true) 349 | .multiple(true) 350 | .use_delimiter(true) 351 | .value_terminator("!") 352 | .allow_hyphen_values(true) 353 | .help( 354 | "Release times in unix timestamp to decide when the contract is \ 355 | unlockable. Multiple inputs separated by a comma are 356 | accepted for the creation of multiple schedules. The sequence of inputs \ 357 | needs to end with an exclamation mark ( e.g. 1,2,3,! ).", 358 | ), 359 | ) 360 | // linear vesting 361 | .arg( 362 | Arg::with_name("release-frequency") 363 | .long("release-frequency") 364 | .value_name("RELEASE_FREQUENCY") 365 | .takes_value(true) 366 | .conflicts_with("release-times") 367 | .help( 368 | "Frequency of release amount. \ 369 | You start on 1sth of Nov and end on 5th of Nov. \ 370 | With 1 day frequency it will vest from total amount 5 times \ 371 | splitted linearly. 372 | Duration must be ISO8601 duration format. Example, P1D. 373 | Internally all dates will be transformed into schedule.", 374 | ), 375 | ) 376 | .arg( 377 | Arg::with_name("start-date-time") 378 | .long("start-date-time") 379 | .value_name("START_DATE_TIME") 380 | .takes_value(true) 381 | .help( 382 | "First time of release in linear vesting. \ 383 | Must be RFC 3339 and ISO 8601 sortable date time. \ 384 | Example, 2022-01-06T20:11:18Z", 385 | ), 386 | ) 387 | .arg( 388 | Arg::with_name("end-date-time") 389 | .long("end-date-time") 390 | .value_name("END_DATE_TIME") 391 | .takes_value(true) 392 | .help( 393 | "Last time of release in linear vesting. \ 394 | If frequency will go over last release time, \ 395 | tokens will be released later than end date. 396 | Must be RFC 3339 and ISO 8601 sortable date time. \ 397 | Example, 2022-17-06T20:11:18Z", 398 | ), 399 | ) 400 | .arg( 401 | Arg::with_name("payer") 402 | .long("payer") 403 | .value_name("KEYPAIR") 404 | .validator(is_keypair) 405 | .takes_value(true) 406 | .help( 407 | "Specify the transaction fee payer account address. \ 408 | This may be a keypair file, the ASK keyword. \ 409 | Defaults to the client keypair.", 410 | ), 411 | ) 412 | .arg( 413 | Arg::with_name("confirm") 414 | .long("confirm") 415 | .value_name("CONFIRM") 416 | .takes_value(true) 417 | .default_value("true") 418 | .help( 419 | "Specify whether to wait transaction confirmation" 420 | ), 421 | ) 422 | ) 423 | .subcommand(SubCommand::with_name("unlock").about("Unlock a vesting contract. This will only release \ 424 | the schedules that have reached maturity.") 425 | .arg( 426 | Arg::with_name("seed") 427 | .long("seed") 428 | .value_name("SEED") 429 | .validator(is_parsable::) 430 | .takes_value(true) 431 | .help( 432 | "Specify the seed for the vesting contract.", 433 | ), 434 | ) 435 | .arg( 436 | Arg::with_name("payer") 437 | .long("payer") 438 | .value_name("KEYPAIR") 439 | .validator(is_keypair) 440 | .takes_value(true) 441 | .help( 442 | "Specify the transaction fee payer account address. \ 443 | This may be a keypair file, the ASK keyword. \ 444 | Defaults to the client keypair.", 445 | ), 446 | ) 447 | ) 448 | .subcommand(SubCommand::with_name("change-destination").about("Change the destination of a vesting contract") 449 | .arg( 450 | Arg::with_name("seed") 451 | .long("seed") 452 | .value_name("SEED") 453 | .validator(is_parsable::) 454 | .takes_value(true) 455 | .help( 456 | "Specify the seed for the vesting contract.", 457 | ), 458 | ) 459 | .arg( 460 | Arg::with_name("current_destination_owner") 461 | .long("current_destination_owner") 462 | .value_name("KEYPAIR") 463 | .validator(is_keypair) 464 | .takes_value(true) 465 | .help( 466 | "Specify the current destination owner account keypair. \ 467 | This may be a keypair file, the ASK keyword. \ 468 | Defaults to the client keypair.", 469 | ), 470 | ) 471 | .arg( 472 | Arg::with_name("new_destination_address") 473 | .long("new_destination_address") 474 | .value_name("ADDRESS") 475 | .validator(is_pubkey) 476 | .takes_value(true) 477 | .help( 478 | "Specify the new destination (non-token) account address. \ 479 | If specified, the vesting destination will be the associated \ 480 | token account for the mint of the contract." 481 | ), 482 | ) 483 | .arg( 484 | Arg::with_name("new_destination_token_address") 485 | .long("new_destination_token_address") 486 | .value_name("ADDRESS") 487 | .validator(is_pubkey) 488 | .takes_value(true) 489 | .help( 490 | "Specify the new destination token account address. \ 491 | If specified, this address will be used as a destination, \ 492 | and overwrite the associated token account.", 493 | ), 494 | ) 495 | .arg( 496 | Arg::with_name("payer") 497 | .long("payer") 498 | .value_name("KEYPAIR") 499 | .validator(is_keypair) 500 | .takes_value(true) 501 | .help( 502 | "Specify the transaction fee payer account address. \ 503 | This may be a keypair file, the ASK keyword. \ 504 | Defaults to the client keypair.", 505 | ), 506 | ) 507 | ) 508 | .subcommand(SubCommand::with_name("info").about("Print information about a vesting contract") 509 | .arg( 510 | Arg::with_name("seed") 511 | .long("seed") 512 | .value_name("SEED") 513 | .validator(is_parsable::) 514 | .takes_value(true) 515 | .help( 516 | "Specify the seed for the vesting contract.", 517 | ), 518 | ) 519 | ) 520 | .get_matches(); 521 | 522 | let rpc_url = value_t!(matches, "rpc_url", String).unwrap(); 523 | let rpc_client = RpcClient::new(rpc_url); 524 | let program_id = pubkey_of(&matches, "program_id").unwrap(); 525 | 526 | let _ = match matches.subcommand() { 527 | ("create", Some(arg_matches)) => { 528 | let source_keypair = keypair_of(arg_matches, "source_owner").unwrap(); 529 | let source_token_pubkey = pubkey_of(arg_matches, "source_token_address"); 530 | let mint_address = pubkey_of(arg_matches, "mint_address").unwrap(); 531 | let destination_pubkey = match pubkey_of(arg_matches, "destination_token_address") { 532 | None => get_associated_token_address( 533 | &pubkey_of(arg_matches, "destination_address").unwrap(), 534 | &mint_address, 535 | ), 536 | Some(destination_token_pubkey) => destination_token_pubkey, 537 | }; 538 | let payer_keypair = keypair_of(arg_matches, "payer").unwrap(); 539 | 540 | // Parsing schedules 541 | let mut schedule_amounts: Vec = values_of(arg_matches, "amounts").unwrap(); 542 | let confirm: bool = value_of(arg_matches, "confirm").unwrap(); 543 | let release_frequency: Option = value_of(arg_matches, "release-frequency"); 544 | 545 | let schedule_times = if release_frequency.is_some() { 546 | // best found in rust 547 | let release_frequency: iso8601_duration::Duration = 548 | release_frequency.unwrap().parse().unwrap(); 549 | let release_frequency: u64 = Duration::from_std(release_frequency.to_std()) 550 | .unwrap() 551 | .num_seconds() 552 | .try_into() 553 | .unwrap(); 554 | if schedule_amounts.len() > 1 { 555 | panic!("Linear vesting must have one amount which will split into parts per period") 556 | } 557 | let start: u64 = DateTime::parse_from_rfc3339( 558 | &value_of::(arg_matches, "start-date-time").unwrap(), 559 | ) 560 | .unwrap() 561 | .timestamp() 562 | .try_into() 563 | .unwrap(); 564 | let end: u64 = DateTime::parse_from_rfc3339( 565 | &value_of::(arg_matches, "end-date-time").unwrap(), 566 | ) 567 | .unwrap() 568 | .timestamp() 569 | .try_into() 570 | .unwrap(); 571 | let total = schedule_amounts[0]; 572 | let part = (((total as u128) * (release_frequency as u128)) 573 | / ((end - start) as u128)) 574 | .try_into() 575 | .unwrap(); 576 | schedule_amounts.clear(); 577 | let mut linear_vesting = Vec::new(); 578 | 579 | let q = total / part; 580 | let r = total % part; 581 | 582 | for n in 0..q { 583 | linear_vesting.push(start + n * release_frequency); 584 | schedule_amounts.push(part); 585 | } 586 | 587 | if r != 0 { 588 | schedule_amounts[(q - 1) as usize] += r; 589 | } 590 | 591 | if linear_vesting.len() > 365 { 592 | panic!("Total count of vesting periods is more than 365. Not sure if you want to do that.") 593 | } 594 | 595 | assert_eq!(schedule_amounts.iter().sum::(), total); 596 | 597 | linear_vesting 598 | } else { 599 | values_of(arg_matches, "release-times").unwrap() 600 | }; 601 | 602 | if schedule_amounts.len() != schedule_times.len() { 603 | eprintln!("error: Number of amounts given is not equal to number of release heights given."); 604 | std::process::exit(1); 605 | } 606 | let mut schedules: Vec = Vec::with_capacity(schedule_amounts.len()); 607 | for (&a, &h) in schedule_amounts.iter().zip(schedule_times.iter()) { 608 | schedules.push(Schedule { 609 | release_time: h, 610 | amount: a, 611 | }); 612 | } 613 | 614 | command_create_svc( 615 | rpc_client, 616 | program_id, 617 | payer_keypair, 618 | source_keypair, 619 | source_token_pubkey, 620 | destination_pubkey, 621 | mint_address, 622 | schedules, 623 | confirm, 624 | ) 625 | } 626 | ("unlock", Some(arg_matches)) => { 627 | // The seed is given in the format of a pubkey on the user side but it's handled as a [u8;32] in the program 628 | let vesting_seed = pubkey_of(arg_matches, "seed").unwrap().to_bytes(); 629 | let payer_keypair = keypair_of(arg_matches, "payer").unwrap(); 630 | command_unlock_svc(rpc_client, program_id, vesting_seed, payer_keypair) 631 | } 632 | ("change-destination", Some(arg_matches)) => { 633 | let vesting_seed = pubkey_of(arg_matches, "seed").unwrap().to_bytes(); 634 | let destination_account_owner = 635 | keypair_of(arg_matches, "current_destination_owner").unwrap(); 636 | let opt_new_destination_account = pubkey_of(arg_matches, "new_destination_address"); 637 | let opt_new_destination_token_account = 638 | pubkey_of(arg_matches, "new_destination_token_address"); 639 | let payer_keypair = keypair_of(arg_matches, "payer").unwrap(); 640 | command_change_destination( 641 | rpc_client, 642 | program_id, 643 | destination_account_owner, 644 | opt_new_destination_account, 645 | opt_new_destination_token_account, 646 | vesting_seed, 647 | payer_keypair, 648 | ) 649 | } 650 | ("info", Some(arg_matches)) => { 651 | let vesting_seed = pubkey_of(arg_matches, "seed").unwrap().to_bytes(); 652 | let rpcurl = value_of(arg_matches, "rpc_url").unwrap(); 653 | command_info(rpc_client, rpcurl, program_id, vesting_seed) 654 | } 655 | _ => unreachable!(), 656 | }; 657 | } 658 | -------------------------------------------------------------------------------- /js/.eslintignore: -------------------------------------------------------------------------------- 1 | /lib 2 | -------------------------------------------------------------------------------- /js/.eslintrc.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line 2 | module.exports = { 3 | // eslint-disable-line import/no-commonjs 4 | env: { 5 | browser: true, 6 | es6: true, 7 | node: true, 8 | }, 9 | extends: [ 10 | 'eslint:recommended', 11 | 'plugin:import/errors', 12 | 'plugin:import/warnings', 13 | ], 14 | parser: 'babel-eslint', 15 | parserOptions: { 16 | sourceType: 'module', 17 | ecmaVersion: 8, 18 | }, 19 | rules: { 20 | 'no-trailing-spaces': ['error'], 21 | 'import/first': ['error'], 22 | 'import/no-commonjs': ['error'], 23 | 'import/order': [ 24 | 'error', 25 | { 26 | groups: [ 27 | ['internal', 'external', 'builtin'], 28 | ['index', 'sibling', 'parent'], 29 | ], 30 | 'newlines-between': 'always', 31 | }, 32 | ], 33 | indent: [ 34 | 'error', 35 | 2, 36 | { 37 | MemberExpression: 1, 38 | SwitchCase: 1, 39 | }, 40 | ], 41 | 'linebreak-style': ['error', 'unix'], 42 | 'no-console': [0], 43 | quotes: [ 44 | 'error', 45 | 'single', 46 | {avoidEscape: true, allowTemplateLiterals: true}, 47 | ], 48 | 'require-await': ['error'], 49 | semi: ['error', 'always'], 50 | }, 51 | settings: { 52 | react: { 53 | version: 'detect', 54 | }, 55 | }, 56 | }; 57 | -------------------------------------------------------------------------------- /js/.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | arrowParens: 'avoid' 2 | bracketSpacing: true 3 | jsxBracketSameLine: false 4 | semi: true 5 | singleQuote: true 6 | tabWidth: 2 7 | trailingComma: 'all' 8 | -------------------------------------------------------------------------------- /js/README.md: -------------------------------------------------------------------------------- 1 | # Simple JS bindings 2 | 3 | ## Example 4 | 5 | A linear unlock example can be found in `example.ts` 6 | 7 | ## Quickstart 8 | 9 | Contract address on Devnet 10 | 11 | ``` 12 | DLxB9dSQtA4WJ49hWFhxqiQkD9v6m67Yfk9voxpxrBs4 13 | ``` 14 | 15 | Contract address on Mainnet 16 | 17 | ``` 18 | CChTq6PthWU82YZkbveA3WDf7s97BWhBK4Vx9bmsT743 19 | ``` 20 | 21 | The code allows you to 22 | 23 | - Create vesting instructions for any SPL token: `createCreateInstruction` 24 | - Create unlock instructions: `createUnlockInstruction` 25 | - Change the destination of the vested tokens: `createChangeDestinationInstruction` 26 | 27 | (To import Solana accounts created with [Sollet](https://sollet.io) you can use `getAccountFromSeed`) 28 | 29 | ``` 30 | Seed 9043936629442508205162695100279588102353854608998701852963634059 31 | Vesting contract account pubkey: r2p2mLJvyrTzetxxsttQ54CS1m18zMgYqKSRzxP9WpE 32 | contract ID: 90439366294425082051626951002795881023538546089987018529636340fe 33 | ✅ Successfully created vesting instructions 34 | 🚚 Transaction signature: 2uypTM3QcroR7uk6g9Y4eLdniCHqdQBDq4XyrFM7hCtTbb4rftkEHMM6vJ6tTYpihYubHt55xWD86vHB857bqXXb 35 | 36 | Fetching contract r2p2mLJvyrTzetxxsttQ54CS1m18zMgYqKSRzxP9WpE 37 | ✅ Successfully created unlocking instructions 38 | 🚚 Transaction signature: 2Vg3W1w8WBdRAWBEwFTn2BtMkKPD3Xor7SRvzC193UnsUnhmneUChPHe7vLF9Lfw9BKxWH5JbbJmnda4XztHMVHz 39 | 40 | Fetching contract r2p2mLJvyrTzetxxsttQ54CS1m18zMgYqKSRzxP9WpE 41 | ✅ Successfully changed destination 42 | 🚚 Transaction signature: 4tgPgCdM5ubaSKNLKD1WrfAJPZgRajxRSnmcPkHcN1TCeCRmq3cUCYVdCzsYwr63JRf4D2K1UZnt8rwu2pkGxeYe 43 | ``` 44 | -------------------------------------------------------------------------------- /js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bonfida/token-vesting", 3 | "version": "0.0.9", 4 | "license": "MIT", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/bonfida/token-vesting" 8 | }, 9 | "publishConfig": { 10 | "access": "public" 11 | }, 12 | "main": "dist/index.js", 13 | "types": "dist/index.d.ts", 14 | "files": [ 15 | "dist", 16 | "src" 17 | ], 18 | "scripts": { 19 | "dev": "tsc && node dist/dev.js", 20 | "build": "rm -rf dist && rollup -c", 21 | "prepublish": "rm -rf dist && rollup -c", 22 | "lint": "npm run pretty && eslint .", 23 | "lint:fix": "npm run pretty:fix && eslint . --fix", 24 | "pretty": "prettier --check 'src/*.[jt]s'", 25 | "pretty:fix": "prettier --write 'src/*.[jt]s'" 26 | }, 27 | "devDependencies": { 28 | "@bonfida/utils": "0.0.4", 29 | "@rollup/plugin-commonjs": "^24.0.1", 30 | "@rollup/plugin-typescript": "^11.0.0", 31 | "@solana/spl-token": "0.3.7", 32 | "@solana/web3.js": "^1.73.2", 33 | "@tsconfig/recommended": "^1.0.1", 34 | "@types/node": "^14.14.20", 35 | "babel-eslint": "^10.1.0", 36 | "eslint": "^7.17.0", 37 | "eslint-plugin-import": "^2.22.1", 38 | "nodemon": "^2.0.7", 39 | "prettier": "^2.2.1", 40 | "rollup": "^3.14.0", 41 | "rollup-plugin-terser": "^7.0.2", 42 | "ts-node": "^9.1.1", 43 | "tslib": "^2.1.0", 44 | "typescript": "^4.1.3" 45 | }, 46 | "peerDependencies": { 47 | "@solana/spl-token": "0.3.7", 48 | "@solana/web3.js": "^1.73.2" 49 | }, 50 | "dependencies": { 51 | "bip32": "^2.0.6", 52 | "bn.js": "^5.1.3", 53 | "bs58": "^4.0.1", 54 | "buffer-layout": "^1.2.0", 55 | "tweetnacl": "^1.0.3" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /js/rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import typescript from "@rollup/plugin-typescript"; 2 | import commonjs from "@rollup/plugin-commonjs"; 3 | import { terser } from "rollup-plugin-terser"; 4 | 5 | export default { 6 | input: "src/index.ts", 7 | output: { 8 | dir: "dist", 9 | format: "cjs", 10 | }, 11 | plugins: [typescript(), commonjs(), terser()], 12 | }; 13 | -------------------------------------------------------------------------------- /js/src/example.ts: -------------------------------------------------------------------------------- 1 | import { Connection, PublicKey, Keypair } from '@solana/web3.js'; 2 | import fs from 'fs'; 3 | import { Numberu64, generateRandomSeed } from './utils'; 4 | import { Schedule } from './state'; 5 | import { create, TOKEN_VESTING_PROGRAM_ID } from './main'; 6 | import { signAndSendInstructions } from '@bonfida/utils'; 7 | 8 | /** 9 | * 10 | * Simple example of a linear unlock. 11 | * 12 | * This is just an example, please be careful using the vesting contract and test it first with test tokens. 13 | * 14 | */ 15 | 16 | /** Path to your wallet */ 17 | const WALLET_PATH = ''; 18 | const wallet = Keypair.fromSecretKey( 19 | new Uint8Array(JSON.parse(fs.readFileSync(WALLET_PATH).toString())), 20 | ); 21 | 22 | /** There are better way to generate an array of dates but be careful as it's irreversible */ 23 | const DATES = [ 24 | new Date(2022, 12), 25 | new Date(2023, 1), 26 | new Date(2023, 2), 27 | new Date(2023, 3), 28 | new Date(2023, 4), 29 | new Date(2023, 5), 30 | new Date(2023, 6), 31 | new Date(2023, 7), 32 | new Date(2023, 8), 33 | new Date(2023, 9), 34 | new Date(2023, 10), 35 | new Date(2023, 11), 36 | new Date(2024, 12), 37 | new Date(2024, 2), 38 | new Date(2024, 3), 39 | new Date(2024, 4), 40 | new Date(2024, 5), 41 | new Date(2024, 6), 42 | new Date(2024, 7), 43 | new Date(2024, 8), 44 | new Date(2024, 9), 45 | new Date(2024, 10), 46 | new Date(2024, 11), 47 | new Date(2024, 12), 48 | ]; 49 | 50 | /** Info about the desintation */ 51 | const DESTINATION_OWNER = new PublicKey(''); 52 | const DESTINATION_TOKEN_ACCOUNT = new PublicKey(''); 53 | 54 | /** Token info */ 55 | const MINT = new PublicKey(''); 56 | const DECIMALS = 0; 57 | 58 | /** Info about the source */ 59 | const SOURCE_TOKEN_ACCOUNT = new PublicKey(''); 60 | 61 | /** Amount to give per schedule */ 62 | const AMOUNT_PER_SCHEDULE = 0; 63 | 64 | /** Your RPC connection */ 65 | const connection = new Connection(''); 66 | 67 | /** Do some checks before sending the tokens */ 68 | const checks = async () => { 69 | const tokenInfo = await connection.getParsedAccountInfo( 70 | DESTINATION_TOKEN_ACCOUNT, 71 | ); 72 | 73 | // @ts-ignore 74 | const parsed = tokenInfo.value.data.parsed; 75 | if (parsed.info.mint !== MINT.toBase58()) { 76 | throw new Error('Invalid mint'); 77 | } 78 | if (parsed.info.owner !== DESTINATION_OWNER.toBase58()) { 79 | throw new Error('Invalid owner'); 80 | } 81 | if (parsed.info.tokenAmount.decimals !== DECIMALS) { 82 | throw new Error('Invalid decimals'); 83 | } 84 | }; 85 | 86 | /** Function that locks the tokens */ 87 | const lock = async () => { 88 | await checks(); 89 | const schedules: Schedule[] = []; 90 | for (let date of DATES) { 91 | schedules.push( 92 | new Schedule( 93 | /** Has to be in seconds */ 94 | // @ts-ignore 95 | new Numberu64(date.getTime() / 1_000), 96 | /** Don't forget to add decimals */ 97 | // @ts-ignore 98 | new Numberu64(AMOUNT_PER_SCHEDULE * Math.pow(10, DECIMALS)), 99 | ), 100 | ); 101 | } 102 | const seed = generateRandomSeed(); 103 | 104 | console.log(`Seed: ${seed}`); 105 | 106 | const instruction = await create( 107 | connection, 108 | TOKEN_VESTING_PROGRAM_ID, 109 | Buffer.from(seed), 110 | wallet.publicKey, 111 | wallet.publicKey, 112 | SOURCE_TOKEN_ACCOUNT, 113 | DESTINATION_TOKEN_ACCOUNT, 114 | MINT, 115 | schedules, 116 | ); 117 | 118 | const tx = await signAndSendInstructions(connection, [], wallet, instruction); 119 | 120 | console.log(`Transaction: ${tx}`); 121 | 122 | const txInfo = await connection.getConfirmedTransaction(tx, 'confirmed'); 123 | if (txInfo && !txInfo.meta?.err) { 124 | console.log( 125 | txInfo?.transaction.instructions[2].data.slice(1, 32 + 1).toString('hex'), 126 | ); 127 | } else { 128 | throw new Error('Transaction not confirmed.'); 129 | } 130 | }; 131 | 132 | lock(); 133 | -------------------------------------------------------------------------------- /js/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './instructions'; 2 | export * from './main'; 3 | export * from './state'; 4 | export * from './utils'; 5 | -------------------------------------------------------------------------------- /js/src/instructions.ts: -------------------------------------------------------------------------------- 1 | import { PublicKey, SYSVAR_RENT_PUBKEY, TransactionInstruction } from '@solana/web3.js'; 2 | import { Schedule } from './state'; 3 | import { Numberu32 } from './utils'; 4 | 5 | export enum Instruction { 6 | Init, 7 | Create, 8 | } 9 | 10 | export function createInitInstruction( 11 | systemProgramId: PublicKey, 12 | vestingProgramId: PublicKey, 13 | payerKey: PublicKey, 14 | vestingAccountKey: PublicKey, 15 | seeds: Array, 16 | numberOfSchedules: number, 17 | ): TransactionInstruction { 18 | let buffers = [ 19 | Buffer.from(Int8Array.from([0]).buffer), 20 | Buffer.concat(seeds), 21 | // @ts-ignore 22 | new Numberu32(numberOfSchedules).toBuffer(), 23 | ]; 24 | 25 | const data = Buffer.concat(buffers); 26 | const keys = [ 27 | { 28 | pubkey: systemProgramId, 29 | isSigner: false, 30 | isWritable: false, 31 | }, 32 | { 33 | pubkey: SYSVAR_RENT_PUBKEY, 34 | isSigner: false, 35 | isWritable: false, 36 | }, 37 | { 38 | pubkey: payerKey, 39 | isSigner: true, 40 | isWritable: true, 41 | }, 42 | { 43 | pubkey: vestingAccountKey, 44 | isSigner: false, 45 | isWritable: true, 46 | }, 47 | ]; 48 | 49 | return new TransactionInstruction({ 50 | keys, 51 | programId: vestingProgramId, 52 | data, 53 | }); 54 | } 55 | 56 | export function createCreateInstruction( 57 | vestingProgramId: PublicKey, 58 | tokenProgramId: PublicKey, 59 | vestingAccountKey: PublicKey, 60 | vestingTokenAccountKey: PublicKey, 61 | sourceTokenAccountOwnerKey: PublicKey, 62 | sourceTokenAccountKey: PublicKey, 63 | destinationTokenAccountKey: PublicKey, 64 | mintAddress: PublicKey, 65 | schedules: Array, 66 | seeds: Array, 67 | ): TransactionInstruction { 68 | let buffers = [ 69 | Buffer.from(Int8Array.from([1]).buffer), 70 | Buffer.concat(seeds), 71 | mintAddress.toBuffer(), 72 | destinationTokenAccountKey.toBuffer(), 73 | ]; 74 | 75 | schedules.forEach(s => { 76 | buffers.push(s.toBuffer()); 77 | }); 78 | 79 | const data = Buffer.concat(buffers); 80 | const keys = [ 81 | { 82 | pubkey: tokenProgramId, 83 | isSigner: false, 84 | isWritable: false, 85 | }, 86 | { 87 | pubkey: vestingAccountKey, 88 | isSigner: false, 89 | isWritable: true, 90 | }, 91 | { 92 | pubkey: vestingTokenAccountKey, 93 | isSigner: false, 94 | isWritable: true, 95 | }, 96 | { 97 | pubkey: sourceTokenAccountOwnerKey, 98 | isSigner: true, 99 | isWritable: false, 100 | }, 101 | { 102 | pubkey: sourceTokenAccountKey, 103 | isSigner: false, 104 | isWritable: true, 105 | }, 106 | ]; 107 | return new TransactionInstruction({ 108 | keys, 109 | programId: vestingProgramId, 110 | data, 111 | }); 112 | } 113 | 114 | export function createUnlockInstruction( 115 | vestingProgramId: PublicKey, 116 | tokenProgramId: PublicKey, 117 | clockSysvarId: PublicKey, 118 | vestingAccountKey: PublicKey, 119 | vestingTokenAccountKey: PublicKey, 120 | destinationTokenAccountKey: PublicKey, 121 | seeds: Array, 122 | ): TransactionInstruction { 123 | const data = Buffer.concat([ 124 | Buffer.from(Int8Array.from([2]).buffer), 125 | Buffer.concat(seeds), 126 | ]); 127 | 128 | const keys = [ 129 | { 130 | pubkey: tokenProgramId, 131 | isSigner: false, 132 | isWritable: false, 133 | }, 134 | { 135 | pubkey: clockSysvarId, 136 | isSigner: false, 137 | isWritable: false, 138 | }, 139 | { 140 | pubkey: vestingAccountKey, 141 | isSigner: false, 142 | isWritable: true, 143 | }, 144 | { 145 | pubkey: vestingTokenAccountKey, 146 | isSigner: false, 147 | isWritable: true, 148 | }, 149 | { 150 | pubkey: destinationTokenAccountKey, 151 | isSigner: false, 152 | isWritable: true, 153 | }, 154 | ]; 155 | return new TransactionInstruction({ 156 | keys, 157 | programId: vestingProgramId, 158 | data, 159 | }); 160 | } 161 | 162 | export function createChangeDestinationInstruction( 163 | vestingProgramId: PublicKey, 164 | vestingAccountKey: PublicKey, 165 | currentDestinationTokenAccountOwner: PublicKey, 166 | currentDestinationTokenAccount: PublicKey, 167 | targetDestinationTokenAccount: PublicKey, 168 | seeds: Array, 169 | ): TransactionInstruction { 170 | const data = Buffer.concat([ 171 | Buffer.from(Int8Array.from([3]).buffer), 172 | Buffer.concat(seeds), 173 | ]); 174 | 175 | const keys = [ 176 | { 177 | pubkey: vestingAccountKey, 178 | isSigner: false, 179 | isWritable: true, 180 | }, 181 | { 182 | pubkey: currentDestinationTokenAccount, 183 | isSigner: false, 184 | isWritable: false, 185 | }, 186 | { 187 | pubkey: currentDestinationTokenAccountOwner, 188 | isSigner: true, 189 | isWritable: false, 190 | }, 191 | { 192 | pubkey: targetDestinationTokenAccount, 193 | isSigner: false, 194 | isWritable: false, 195 | }, 196 | ]; 197 | return new TransactionInstruction({ 198 | keys, 199 | programId: vestingProgramId, 200 | data, 201 | }); 202 | } 203 | -------------------------------------------------------------------------------- /js/src/main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PublicKey, 3 | SystemProgram, 4 | SYSVAR_CLOCK_PUBKEY, 5 | TransactionInstruction, 6 | Connection, 7 | } from '@solana/web3.js'; 8 | import { 9 | createAssociatedTokenAccountInstruction, 10 | TOKEN_PROGRAM_ID, 11 | getAssociatedTokenAddress, 12 | } from '@solana/spl-token'; 13 | import { 14 | createChangeDestinationInstruction, 15 | createCreateInstruction, 16 | createInitInstruction, 17 | createUnlockInstruction, 18 | } from './instructions'; 19 | import { ContractInfo, Schedule } from './state'; 20 | import { assert } from 'console'; 21 | import bs58 from 'bs58'; 22 | 23 | /** 24 | * The vesting schedule program ID on mainnet 25 | */ 26 | export const TOKEN_VESTING_PROGRAM_ID = new PublicKey( 27 | 'CChTq6PthWU82YZkbveA3WDf7s97BWhBK4Vx9bmsT743', 28 | ); 29 | 30 | /** 31 | * This function can be used to lock tokens 32 | * @param connection The Solana RPC connection object 33 | * @param programId The token vesting program ID 34 | * @param seedWord Seed words used to derive the vesting account 35 | * @param payer The fee payer of the transaction 36 | * @param sourceTokenOwner The owner of the source token account (i.e where locked tokens are originating from) 37 | * @param possibleSourceTokenPubkey The source token account (i.e where locked tokens are originating from), if null it defaults to the ATA 38 | * @param destinationTokenPubkey The destination token account i.e where unlocked tokens will be transfered 39 | * @param mintAddress The mint of the tokens being vested 40 | * @param schedules The array of vesting schedules 41 | * @returns An array of `TransactionInstruction` 42 | */ 43 | export async function create( 44 | connection: Connection, 45 | programId: PublicKey, 46 | seedWord: Buffer | Uint8Array, 47 | payer: PublicKey, 48 | sourceTokenOwner: PublicKey, 49 | possibleSourceTokenPubkey: PublicKey | null, 50 | destinationTokenPubkey: PublicKey, 51 | mintAddress: PublicKey, 52 | schedules: Array, 53 | ): Promise> { 54 | // If no source token account was given, use the associated source account 55 | if (possibleSourceTokenPubkey == null) { 56 | possibleSourceTokenPubkey = await getAssociatedTokenAddress( 57 | mintAddress, 58 | sourceTokenOwner, 59 | true, 60 | ); 61 | } 62 | 63 | // Find the non reversible public key for the vesting contract via the seed 64 | seedWord = seedWord.slice(0, 31); 65 | const [vestingAccountKey, bump] = await PublicKey.findProgramAddress( 66 | [seedWord], 67 | programId, 68 | ); 69 | 70 | const vestingTokenAccountKey = await getAssociatedTokenAddress( 71 | mintAddress, 72 | vestingAccountKey, 73 | true, 74 | ); 75 | 76 | seedWord = Buffer.from(seedWord.toString('hex') + bump.toString(16), 'hex'); 77 | 78 | console.log( 79 | 'Vesting contract account pubkey: ', 80 | vestingAccountKey.toBase58(), 81 | ); 82 | 83 | console.log('contract ID: ', bs58.encode(seedWord)); 84 | 85 | const check_existing = await connection.getAccountInfo(vestingAccountKey); 86 | if (!!check_existing) { 87 | throw 'Contract already exists.'; 88 | } 89 | 90 | let instruction = [ 91 | createInitInstruction( 92 | SystemProgram.programId, 93 | programId, 94 | payer, 95 | vestingAccountKey, 96 | [seedWord], 97 | schedules.length, 98 | ), 99 | createAssociatedTokenAccountInstruction( 100 | payer, 101 | vestingTokenAccountKey, 102 | vestingAccountKey, 103 | mintAddress, 104 | ), 105 | createCreateInstruction( 106 | programId, 107 | TOKEN_PROGRAM_ID, 108 | vestingAccountKey, 109 | vestingTokenAccountKey, 110 | sourceTokenOwner, 111 | possibleSourceTokenPubkey, 112 | destinationTokenPubkey, 113 | mintAddress, 114 | schedules, 115 | [seedWord], 116 | ), 117 | ]; 118 | return instruction; 119 | } 120 | 121 | /** 122 | * This function can be used to unlock vested tokens 123 | * @param connection The Solana RPC connection object 124 | * @param programId The token vesting program ID 125 | * @param seedWord Seed words used to derive the vesting account 126 | * @param mintAddress The mint of the vested tokens 127 | * @returns An array of `TransactionInstruction` 128 | */ 129 | export async function unlock( 130 | connection: Connection, 131 | programId: PublicKey, 132 | seedWord: Buffer | Uint8Array, 133 | mintAddress: PublicKey, 134 | ): Promise> { 135 | seedWord = seedWord.slice(0, 31); 136 | const [vestingAccountKey, bump] = await PublicKey.findProgramAddress( 137 | [seedWord], 138 | programId, 139 | ); 140 | seedWord = Buffer.from(seedWord.toString('hex') + bump.toString(16), 'hex'); 141 | 142 | const vestingTokenAccountKey = await getAssociatedTokenAddress( 143 | mintAddress, 144 | vestingAccountKey, 145 | true, 146 | ); 147 | 148 | const vestingInfo = await getContractInfo(connection, vestingAccountKey); 149 | 150 | let instruction = [ 151 | createUnlockInstruction( 152 | programId, 153 | TOKEN_PROGRAM_ID, 154 | SYSVAR_CLOCK_PUBKEY, 155 | vestingAccountKey, 156 | vestingTokenAccountKey, 157 | vestingInfo.destinationAddress, 158 | [seedWord], 159 | ), 160 | ]; 161 | 162 | return instruction; 163 | } 164 | 165 | /** 166 | * This function can be used retrieve information about a vesting account 167 | * @param connection The Solana RPC connection object 168 | * @param vestingAccountKey The vesting account public key 169 | * @returns A `ContractInfo` object 170 | */ 171 | export async function getContractInfo( 172 | connection: Connection, 173 | vestingAccountKey: PublicKey, 174 | ): Promise { 175 | console.log('Fetching contract ', vestingAccountKey.toBase58()); 176 | const vestingInfo = await connection.getAccountInfo( 177 | vestingAccountKey, 178 | 'single', 179 | ); 180 | if (!vestingInfo) { 181 | throw new Error('Vesting contract account is unavailable'); 182 | } 183 | const info = ContractInfo.fromBuffer(vestingInfo!.data); 184 | if (!info) { 185 | throw new Error('Vesting contract account is not initialized'); 186 | } 187 | return info!; 188 | } 189 | 190 | /** 191 | * This function can be used to transfer a vesting account to a new wallet. It requires the current owner to sign. 192 | * @param connection The Solana RPC connection object 193 | * @param programId The token vesting program ID 194 | * @param currentDestinationTokenAccountPublicKey The current token account to which the vested tokens are transfered to as they unlock 195 | * @param newDestinationTokenAccountOwner The new owner of the vesting account 196 | * @param newDestinationTokenAccount The new token account to which the vested tokens will be transfered to as they unlock 197 | * @param vestingSeed Seed words used to derive the vesting account 198 | * @returns An array of `TransactionInstruction` 199 | */ 200 | export async function changeDestination( 201 | connection: Connection, 202 | programId: PublicKey, 203 | currentDestinationTokenAccountPublicKey: PublicKey, 204 | newDestinationTokenAccountOwner: PublicKey | undefined, 205 | newDestinationTokenAccount: PublicKey | undefined, 206 | vestingSeed: Array, 207 | ): Promise> { 208 | let seedWord = vestingSeed[0]; 209 | seedWord = seedWord.slice(0, 31); 210 | const [vestingAccountKey, bump] = await PublicKey.findProgramAddress( 211 | [seedWord], 212 | programId, 213 | ); 214 | seedWord = Buffer.from(seedWord.toString('hex') + bump.toString(16), 'hex'); 215 | 216 | const contractInfo = await getContractInfo(connection, vestingAccountKey); 217 | if (!newDestinationTokenAccount) { 218 | assert( 219 | !!newDestinationTokenAccountOwner, 220 | 'At least one of newDestinationTokenAccount and newDestinationTokenAccountOwner must be provided!', 221 | ); 222 | newDestinationTokenAccount = await getAssociatedTokenAddress( 223 | contractInfo.mintAddress, 224 | newDestinationTokenAccountOwner!, 225 | true, 226 | ); 227 | } 228 | 229 | return [ 230 | createChangeDestinationInstruction( 231 | programId, 232 | vestingAccountKey, 233 | currentDestinationTokenAccountPublicKey, 234 | contractInfo.destinationAddress, 235 | newDestinationTokenAccount, 236 | [seedWord], 237 | ), 238 | ]; 239 | } 240 | -------------------------------------------------------------------------------- /js/src/state.ts: -------------------------------------------------------------------------------- 1 | import { PublicKey } from '@solana/web3.js'; 2 | import { Numberu64 } from './utils'; 3 | 4 | export class Schedule { 5 | // Release time in unix timestamp 6 | releaseTime!: Numberu64; 7 | amount!: Numberu64; 8 | 9 | constructor(releaseTime: Numberu64, amount: Numberu64) { 10 | this.releaseTime = releaseTime; 11 | this.amount = amount; 12 | } 13 | 14 | public toBuffer(): Buffer { 15 | return Buffer.concat([this.releaseTime.toBuffer(), this.amount.toBuffer()]); 16 | } 17 | 18 | static fromBuffer(buf: Buffer): Schedule { 19 | const releaseTime: Numberu64 = Numberu64.fromBuffer(buf.slice(0, 8)); 20 | const amount: Numberu64 = Numberu64.fromBuffer(buf.slice(8, 16)); 21 | return new Schedule(releaseTime, amount); 22 | } 23 | } 24 | 25 | export class VestingScheduleHeader { 26 | destinationAddress!: PublicKey; 27 | mintAddress!: PublicKey; 28 | isInitialized!: boolean; 29 | 30 | constructor( 31 | destinationAddress: PublicKey, 32 | mintAddress: PublicKey, 33 | isInitialized: boolean, 34 | ) { 35 | this.destinationAddress = destinationAddress; 36 | this.mintAddress = mintAddress; 37 | this.isInitialized = isInitialized; 38 | } 39 | 40 | static fromBuffer(buf: Buffer): VestingScheduleHeader { 41 | const destinationAddress = new PublicKey(buf.slice(0, 32)); 42 | const mintAddress = new PublicKey(buf.slice(32, 64)); 43 | const isInitialized = buf[64] == 1; 44 | const header: VestingScheduleHeader = { 45 | destinationAddress, 46 | mintAddress, 47 | isInitialized, 48 | }; 49 | return header; 50 | } 51 | } 52 | 53 | export class ContractInfo { 54 | destinationAddress!: PublicKey; 55 | mintAddress!: PublicKey; 56 | schedules!: Array; 57 | 58 | constructor( 59 | destinationAddress: PublicKey, 60 | mintAddress: PublicKey, 61 | schedules: Array, 62 | ) { 63 | this.destinationAddress = destinationAddress; 64 | this.mintAddress = mintAddress; 65 | this.schedules = schedules; 66 | } 67 | 68 | static fromBuffer(buf: Buffer): ContractInfo | undefined { 69 | const header = VestingScheduleHeader.fromBuffer(buf.slice(0, 65)); 70 | if (!header.isInitialized) { 71 | return undefined; 72 | } 73 | const schedules: Array = []; 74 | for (let i = 65; i < buf.length; i += 16) { 75 | schedules.push(Schedule.fromBuffer(buf.slice(i, i + 16))); 76 | } 77 | return new ContractInfo( 78 | header.destinationAddress, 79 | header.mintAddress, 80 | schedules, 81 | ); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /js/src/utils.ts: -------------------------------------------------------------------------------- 1 | import BN from 'bn.js'; 2 | 3 | export const generateRandomSeed = () => { 4 | // Generate a random seed 5 | let seed = ''; 6 | for (let i = 0; i < 64; i++) { 7 | seed += Math.floor(Math.random() * 10); 8 | } 9 | return seed; 10 | }; 11 | 12 | export class Numberu64 extends BN { 13 | /** 14 | * Convert to Buffer representation 15 | */ 16 | toBuffer(): Buffer { 17 | const a = super.toArray().reverse(); 18 | const b = Buffer.from(a); 19 | if (b.length === 8) { 20 | return b; 21 | } 22 | if (b.length > 8) { 23 | throw new Error('Numberu64 too large'); 24 | } 25 | 26 | const zeroPad = Buffer.alloc(8); 27 | b.copy(zeroPad); 28 | return zeroPad; 29 | } 30 | 31 | /** 32 | * Construct a Numberu64 from Buffer representation 33 | */ 34 | static fromBuffer(buffer): any { 35 | if (buffer.length !== 8) { 36 | throw new Error(`Invalid buffer length: ${buffer.length}`); 37 | } 38 | 39 | return new BN( 40 | [...buffer] 41 | .reverse() 42 | .map(i => `00${i.toString(16)}`.slice(-2)) 43 | .join(''), 44 | 16, 45 | ); 46 | } 47 | } 48 | 49 | export class Numberu32 extends BN { 50 | /** 51 | * Convert to Buffer representation 52 | */ 53 | toBuffer(): Buffer { 54 | const a = super.toArray().reverse(); 55 | const b = Buffer.from(a); 56 | if (b.length === 4) { 57 | return b; 58 | } 59 | if (b.length > 4) { 60 | throw new Error('Numberu32 too large'); 61 | } 62 | 63 | const zeroPad = Buffer.alloc(4); 64 | b.copy(zeroPad); 65 | return zeroPad; 66 | } 67 | 68 | /** 69 | * Construct a Numberu32 from Buffer representation 70 | */ 71 | static fromBuffer(buffer): any { 72 | if (buffer.length !== 4) { 73 | throw new Error(`Invalid buffer length: ${buffer.length}`); 74 | } 75 | 76 | return new BN( 77 | [...buffer] 78 | .reverse() 79 | .map(i => `00${i.toString(16)}`.slice(-2)) 80 | .join(''), 81 | 16, 82 | ); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /js/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/recommended/tsconfig.json", 3 | "ts-node": { 4 | "compilerOptions": { 5 | "module": "commonjs", 6 | "baseUrl": "./", 7 | "paths": { 8 | "*": ["types/*"] 9 | } 10 | } 11 | }, 12 | "compilerOptions": { 13 | "allowJs": true, 14 | "module": "esnext", 15 | "esModuleInterop": true, 16 | "allowSyntheticDefaultImports": true, 17 | "target": "es2019", 18 | "outDir": "dist", 19 | "rootDir": "./src", 20 | "declaration": true, 21 | "noImplicitAny": false, 22 | "moduleResolution": "node", 23 | "sourceMap": false, 24 | "baseUrl": ".", 25 | "resolveJsonModule": true 26 | }, 27 | "include": ["src/*", "src/.ts"], 28 | "exclude": ["**/node_modules", "dist", "tests", "src/example.ts"] 29 | } 30 | -------------------------------------------------------------------------------- /program/.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | .vscode 3 | hfuzz* -------------------------------------------------------------------------------- /program/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "token-vesting" 3 | version = "0.1.0" 4 | authors = ["Elliott Benisty ", "Lucas Chaumeny "] 5 | edition = "2018" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [workspace] 10 | members = [ 11 | "fuzz" 12 | ] 13 | 14 | [features] 15 | no-entrypoint = [] 16 | test-bpf = [] 17 | fuzz = ["arbitrary", "honggfuzz"] 18 | 19 | [dependencies] 20 | thiserror = "1.0.23" 21 | num-traits = "0.2" 22 | num-derive = "0.3" 23 | arrayref = "0.3.6" 24 | solana-program = "1.5.6" 25 | spl-token = { version = "3.0.1", features = ["no-entrypoint"] } 26 | spl-associated-token-account = { version = "1.0.2", features = ["no-entrypoint"] } 27 | arbitrary = { version = "0.4", features = ["derive"], optional = true } 28 | honggfuzz = { version = "0.5", optional = true } 29 | 30 | [dev-dependencies] 31 | solana-sdk = "1.5.6" 32 | solana-program-test = "1.5.6" 33 | tokio = { version = "1.0", features = ["macros"]} 34 | 35 | [lib] 36 | crate-type = ["cdylib", "lib"] 37 | -------------------------------------------------------------------------------- /program/Xargo.toml: -------------------------------------------------------------------------------- 1 | [target.bpfel-unknown-unknown.dependencies.std] 2 | features = [] -------------------------------------------------------------------------------- /program/fuzz/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "token-vesting-fuzz" 3 | version = "0.1.0" 4 | authors = ["Elliott Benisty ", "Lucas Chaumeny "] 5 | publish = false 6 | edition = "2018" 7 | 8 | [dependencies] 9 | honggfuzz = { version = "0.5" } 10 | arbitrary = { version = "0.4", features = ["derive"] } 11 | solana-program = "1.5.6" 12 | solana-sdk = "1.5.6" 13 | solana-program-test = "1.5.6" 14 | spl-token = { version = "3.0.1", features = ["no-entrypoint"] } 15 | spl-associated-token-account = { version = "1.0.2", features = ["no-entrypoint"] } 16 | token-vesting = { version = "0.1.0", path="..", features=["fuzz", "no-entrypoint"] } 17 | tokio = { version = "1.0", features = ["macros"]} 18 | 19 | [[bin]] 20 | name = "vesting_fuzz" 21 | path = "src/vesting_fuzz.rs" 22 | test = false 23 | doc = false 24 | -------------------------------------------------------------------------------- /program/fuzz/README.md: -------------------------------------------------------------------------------- 1 | CMD used to run the fuzzing (run cargo build-bpf at least once before): 2 | ``` 3 | BPF_OUT_DIR="/home/lcchy/repos/bonfida/token-vesting/program/target/deploy" HFUZZ_RUN_ARGS="-t 10 -n 32 -N 1000000" cargo hfuzz run vesting_fuzz 4 | ``` 5 | 6 | CMD used to debug the last crash: 7 | ``` 8 | BPF_OUT_DIR="/home/lcchy/repos/bonfida/token-vesting/program/target/deploy" cargo hfuzz run-debug vesting_fuzz hfuzz_workspace/*/*.fuzz 9 | ``` 10 | 11 | `BPF_OUT_DIR` is there to force ProgramTest to load token_vesting as BPF code (see bug mentioned in /README.md). 12 | -------------------------------------------------------------------------------- /program/fuzz/src/vesting_fuzz.rs: -------------------------------------------------------------------------------- 1 | use token_vesting::instruction; 2 | use spl_token::instruction::{initialize_mint, mint_to}; 3 | 4 | use std::{convert::TryInto, str::FromStr}; 5 | use spl_associated_token_account::{get_associated_token_address, create_associated_token_account}; 6 | 7 | use solana_program::{hash::Hash, instruction::Instruction, pubkey::Pubkey, rent::Rent, system_program, sysvar}; 8 | use honggfuzz::fuzz; 9 | use solana_program_test::{BanksClient, ProgramTest, processor}; 10 | use solana_sdk::{signature::Keypair, signature::Signer, system_instruction, transaction::Transaction, transport::TransportError}; 11 | use arbitrary::Arbitrary; 12 | use std::collections::HashMap; 13 | use token_vesting::{instruction::{Schedule, VestingInstruction}, processor::Processor}; 14 | use token_vesting::instruction::{init, unlock, change_destination, create}; 15 | use solana_sdk::{account::Account, instruction::InstructionError, transaction::TransactionError}; 16 | struct TokenVestingEnv { 17 | system_program_id: Pubkey, 18 | token_program_id: Pubkey, 19 | sysvarclock_program_id: Pubkey, 20 | rent_program_id: Pubkey, 21 | vesting_program_id: Pubkey, 22 | mint_authority: Keypair 23 | } 24 | 25 | #[derive(Debug, Arbitrary, Clone)] 26 | struct FuzzInstruction { 27 | vesting_account_key: AccountId, 28 | vesting_token_account_key: AccountId, 29 | source_token_account_owner_key: AccountId, 30 | source_token_account_key: AccountId, 31 | source_token_amount: u64, 32 | destination_token_owner_key: AccountId, 33 | destination_token_key: AccountId, 34 | new_destination_token_key: AccountId, 35 | mint_key: AccountId, 36 | schedules: Vec, 37 | payer_key: AccountId, 38 | vesting_program_account: AccountId, 39 | seeds:[u8; 32], 40 | number_of_schedules: u8, 41 | instruction: instruction::VestingInstruction, 42 | // This flag decides wether the instruction will be executed with inputs that should 43 | // not provoke any errors. (The accounts and contracts will be set up before if needed) 44 | correct_inputs: bool 45 | } 46 | /// Use u8 as an account id to simplify the address space and re-use accounts 47 | /// more often. 48 | type AccountId = u8; 49 | 50 | 51 | fn main() { 52 | let rt = tokio::runtime::Runtime::new().unwrap(); 53 | 54 | // Set up the fixed test environment 55 | let token_vesting_testenv = TokenVestingEnv { 56 | system_program_id: system_program::id(), 57 | sysvarclock_program_id: sysvar::clock::id(), 58 | rent_program_id: sysvar::rent::id(), 59 | token_program_id: spl_token::id(), 60 | vesting_program_id: Pubkey::from_str("VestingbGKPFXCWuBvfkegQfZyiNwAJb9Ss623VQ5DA").unwrap(), 61 | mint_authority: Keypair::new() 62 | }; 63 | 64 | 65 | loop { 66 | fuzz!(|fuzz_instructions: Vec| { 67 | // Initialize and start the test network 68 | let mut program_test = ProgramTest::new( 69 | "token_vesting", 70 | token_vesting_testenv.vesting_program_id, 71 | processor!(Processor::process_instruction), 72 | ); 73 | 74 | program_test.add_account(token_vesting_testenv.mint_authority.pubkey(), Account { 75 | lamports: u32::MAX as u64, 76 | ..Account::default() 77 | }); 78 | let mut test_state = rt.block_on(program_test.start_with_context()); 79 | 80 | rt.block_on(run_fuzz_instructions(&token_vesting_testenv, &mut test_state.banks_client, fuzz_instructions, &test_state.payer, test_state.last_blockhash)); 81 | }); 82 | } 83 | } 84 | 85 | 86 | async fn run_fuzz_instructions( 87 | token_vesting_testenv: &TokenVestingEnv, 88 | banks_client: &mut BanksClient, 89 | fuzz_instructions: Vec, 90 | correct_payer: &Keypair, 91 | recent_blockhash: Hash 92 | ) { 93 | // keep track of all accounts 94 | let mut vesting_account_keys: HashMap = HashMap::new(); 95 | let mut vesting_token_account_keys: HashMap = HashMap::new(); 96 | let mut source_token_account_owner_keys: HashMap = HashMap::new(); 97 | let mut destination_token_owner_keys: HashMap = HashMap::new(); 98 | let mut destination_token_keys: HashMap = HashMap::new(); 99 | let mut new_destination_token_keys: HashMap = HashMap::new(); 100 | let mut mint_keys: HashMap = HashMap::new(); 101 | let mut payer_keys: HashMap = HashMap::new(); 102 | 103 | let mut global_output_instructions = vec![]; 104 | let mut global_signer_keys = vec![]; 105 | 106 | for fuzz_instruction in fuzz_instructions { 107 | 108 | // Add accounts 109 | vesting_account_keys 110 | .entry(fuzz_instruction.vesting_account_key) 111 | .or_insert_with(|| Pubkey::new_unique()); 112 | vesting_token_account_keys 113 | .entry(fuzz_instruction.vesting_token_account_key) 114 | .or_insert_with(|| Pubkey::new_unique()); 115 | source_token_account_owner_keys 116 | .entry(fuzz_instruction.source_token_account_owner_key) 117 | .or_insert_with(|| Keypair::new()); 118 | destination_token_owner_keys 119 | .entry(fuzz_instruction.destination_token_owner_key) 120 | .or_insert_with(|| Keypair::new()); 121 | destination_token_keys 122 | .entry(fuzz_instruction.destination_token_key) 123 | .or_insert_with(|| Pubkey::new_unique()); 124 | new_destination_token_keys 125 | .entry(fuzz_instruction.new_destination_token_key) 126 | .or_insert_with(|| Pubkey::new_unique()); 127 | mint_keys 128 | .entry(fuzz_instruction.mint_key) 129 | .or_insert_with(|| Keypair::new()); 130 | payer_keys 131 | .entry(fuzz_instruction.payer_key) 132 | .or_insert_with(|| Keypair::new()); 133 | 134 | let (mut output_instructions, mut signer_keys) = run_fuzz_instruction( 135 | &token_vesting_testenv, 136 | &fuzz_instruction, 137 | &correct_payer, 138 | mint_keys.get(&fuzz_instruction.mint_key).unwrap(), 139 | vesting_account_keys.get(&fuzz_instruction.vesting_account_key).unwrap(), 140 | vesting_token_account_keys.get(&fuzz_instruction.vesting_token_account_key).unwrap(), 141 | source_token_account_owner_keys.get( 142 | &fuzz_instruction.source_token_account_owner_key 143 | ).unwrap(), 144 | destination_token_owner_keys.get( 145 | &fuzz_instruction.destination_token_owner_key 146 | ).unwrap(), 147 | destination_token_keys.get( 148 | &fuzz_instruction.destination_token_key 149 | ).unwrap(), 150 | new_destination_token_keys.get( 151 | &fuzz_instruction.new_destination_token_key 152 | ).unwrap(), 153 | payer_keys.get(&fuzz_instruction.payer_key).unwrap() 154 | ); 155 | global_output_instructions.append(&mut output_instructions); 156 | global_signer_keys.append(&mut signer_keys); 157 | } 158 | // Process transaction on test network 159 | let mut transaction = Transaction::new_with_payer( 160 | &global_output_instructions, 161 | Some(&correct_payer.pubkey()), 162 | ); 163 | let signers = [correct_payer].iter().map(|&v| v).chain(global_signer_keys.iter()).collect::>(); 164 | transaction.partial_sign( 165 | &signers, 166 | recent_blockhash 167 | ); 168 | 169 | banks_client.process_transaction(transaction).await.unwrap_or_else(|e| { 170 | if let TransportError::TransactionError(te) = e { 171 | match te { 172 | TransactionError::InstructionError(_, ie) => { 173 | match ie { 174 | InstructionError::InvalidArgument 175 | | InstructionError::InvalidInstructionData 176 | | InstructionError::InvalidAccountData 177 | | InstructionError::InsufficientFunds 178 | | InstructionError::AccountAlreadyInitialized 179 | | InstructionError::InvalidSeeds 180 | | InstructionError::Custom(0) => {}, 181 | _ => { 182 | print!("{:?}", ie); 183 | Err(ie).unwrap() 184 | } 185 | } 186 | }, 187 | TransactionError::SignatureFailure 188 | | TransactionError::InvalidAccountForFee 189 | | TransactionError::InsufficientFundsForFee => {}, 190 | _ => { 191 | print!("{:?}", te); 192 | panic!() 193 | } 194 | } 195 | 196 | } else { 197 | print!("{:?}", e); 198 | panic!() 199 | } 200 | }); 201 | } 202 | 203 | 204 | fn run_fuzz_instruction( 205 | token_vesting_testenv: &TokenVestingEnv, 206 | fuzz_instruction: &FuzzInstruction, 207 | correct_payer: &Keypair, 208 | mint_key: &Keypair, 209 | vesting_account_key: &Pubkey, 210 | vesting_token_account_key: &Pubkey, 211 | source_token_account_owner_key: &Keypair, 212 | destination_token_owner_key: &Keypair, 213 | destination_token_key: &Pubkey, 214 | new_destination_token_key: &Pubkey, 215 | payer_key: &Keypair 216 | ) -> (Vec, Vec) { 217 | 218 | // Execute the fuzzing in a more restrained way in order to go deeper into the program branches. 219 | // For each possible fuzz instruction we first instantiate the needed accounts for the instruction 220 | if fuzz_instruction.correct_inputs { 221 | 222 | let mut correct_seeds = fuzz_instruction.seeds; 223 | let (correct_vesting_account_key, bump) = Pubkey::find_program_address( 224 | &[&correct_seeds[..31]], 225 | &token_vesting_testenv.vesting_program_id 226 | ); 227 | correct_seeds[31] = bump; 228 | let correct_vesting_token_key = get_associated_token_address( 229 | &correct_vesting_account_key, 230 | &mint_key.pubkey() 231 | ); 232 | let correct_source_token_account_key = get_associated_token_address( 233 | &source_token_account_owner_key.pubkey(), 234 | &mint_key.pubkey() 235 | ); 236 | 237 | match fuzz_instruction { 238 | FuzzInstruction { 239 | instruction: VestingInstruction::Init{ .. }, 240 | .. 241 | } => { 242 | return (vec![init( 243 | &token_vesting_testenv.system_program_id, 244 | &token_vesting_testenv.rent_program_id, 245 | &token_vesting_testenv.vesting_program_id, 246 | &correct_payer.pubkey(), 247 | &correct_vesting_account_key, 248 | correct_seeds, 249 | fuzz_instruction.number_of_schedules as u32 250 | ).unwrap()], vec![]); 251 | }, 252 | 253 | FuzzInstruction { 254 | instruction: VestingInstruction::Create { .. }, 255 | .. 256 | } => { 257 | let mut instructions_acc = vec![init( 258 | &token_vesting_testenv.system_program_id, 259 | &token_vesting_testenv.rent_program_id, 260 | &token_vesting_testenv.vesting_program_id, 261 | &correct_payer.pubkey(), 262 | &correct_vesting_account_key, 263 | correct_seeds, 264 | fuzz_instruction.number_of_schedules as u32 265 | ).unwrap()]; 266 | let mut create_instructions = create_fuzzinstruction( 267 | token_vesting_testenv, 268 | fuzz_instruction, 269 | correct_payer, 270 | &correct_source_token_account_key, 271 | source_token_account_owner_key, 272 | destination_token_key, 273 | &destination_token_owner_key.pubkey(), 274 | &correct_vesting_account_key, 275 | &correct_vesting_token_key, 276 | correct_seeds, 277 | mint_key, 278 | fuzz_instruction.source_token_amount 279 | ); 280 | instructions_acc.append(&mut create_instructions); 281 | return (instructions_acc, vec![clone_keypair(mint_key), 282 | clone_keypair(&token_vesting_testenv.mint_authority), 283 | clone_keypair(source_token_account_owner_key)]); 284 | }, 285 | 286 | FuzzInstruction { 287 | instruction: VestingInstruction::Unlock{ .. }, 288 | .. 289 | } => { 290 | let mut instructions_acc = vec![init( 291 | &token_vesting_testenv.system_program_id, 292 | &token_vesting_testenv.rent_program_id, 293 | &token_vesting_testenv.vesting_program_id, 294 | &correct_payer.pubkey(), 295 | &correct_vesting_account_key, 296 | correct_seeds, 297 | fuzz_instruction.number_of_schedules as u32 298 | ).unwrap()]; 299 | let mut create_instructions = create_fuzzinstruction( 300 | token_vesting_testenv, 301 | fuzz_instruction, 302 | correct_payer, 303 | &correct_source_token_account_key, 304 | source_token_account_owner_key, 305 | destination_token_key, 306 | &destination_token_owner_key.pubkey(), 307 | &correct_vesting_account_key, 308 | &correct_vesting_token_key, 309 | correct_seeds, 310 | mint_key, 311 | fuzz_instruction.source_token_amount 312 | ); 313 | instructions_acc.append(&mut create_instructions); 314 | 315 | let unlock_instruction = unlock( 316 | &token_vesting_testenv.vesting_program_id, 317 | &token_vesting_testenv.token_program_id, 318 | &token_vesting_testenv.sysvarclock_program_id, 319 | &correct_vesting_account_key, 320 | &correct_vesting_token_key, 321 | destination_token_key, 322 | correct_seeds 323 | ).unwrap(); 324 | instructions_acc.push(unlock_instruction); 325 | return (instructions_acc, vec![ 326 | clone_keypair(mint_key), 327 | clone_keypair(&token_vesting_testenv.mint_authority), 328 | clone_keypair(source_token_account_owner_key), 329 | ]); 330 | }, 331 | 332 | FuzzInstruction { 333 | instruction: VestingInstruction::ChangeDestination{ .. }, 334 | .. 335 | } => { 336 | let mut instructions_acc = vec![init( 337 | &token_vesting_testenv.system_program_id, 338 | &token_vesting_testenv.rent_program_id, 339 | &token_vesting_testenv.vesting_program_id, 340 | &correct_payer.pubkey(), 341 | &correct_vesting_account_key, 342 | correct_seeds, 343 | fuzz_instruction.number_of_schedules as u32 344 | ).unwrap()]; 345 | let mut create_instructions = create_fuzzinstruction( 346 | token_vesting_testenv, 347 | fuzz_instruction, 348 | correct_payer, 349 | &correct_source_token_account_key, 350 | source_token_account_owner_key, 351 | destination_token_key, 352 | &destination_token_owner_key.pubkey(), 353 | &correct_vesting_account_key, 354 | &correct_vesting_token_key, 355 | correct_seeds, 356 | mint_key, 357 | fuzz_instruction.source_token_amount 358 | ); 359 | instructions_acc.append(&mut create_instructions); 360 | 361 | let new_destination_instruction = create_associated_token_account( 362 | &correct_payer.pubkey(), 363 | &Pubkey::new_unique(), // Arbitrary 364 | &mint_key.pubkey() 365 | ); 366 | instructions_acc.push(new_destination_instruction); 367 | 368 | let change_instruction = change_destination( 369 | &token_vesting_testenv.vesting_program_id, 370 | &correct_vesting_account_key, 371 | &destination_token_owner_key.pubkey(), 372 | &destination_token_key, 373 | new_destination_token_key, 374 | correct_seeds 375 | ).unwrap(); 376 | instructions_acc.push(change_instruction); 377 | return (instructions_acc, vec![ 378 | clone_keypair(mint_key), 379 | clone_keypair(&token_vesting_testenv.mint_authority), 380 | clone_keypair(source_token_account_owner_key), 381 | clone_keypair(destination_token_owner_key), 382 | ]); 383 | } 384 | }; 385 | 386 | // Execute a more random input fuzzing (these should give an error almost surely) 387 | } else { 388 | match fuzz_instruction { 389 | 390 | FuzzInstruction { 391 | instruction: VestingInstruction::Init{ .. }, 392 | .. 393 | } => { 394 | return (vec![init( 395 | &token_vesting_testenv.system_program_id, 396 | &token_vesting_testenv.rent_program_id, 397 | &token_vesting_testenv.vesting_program_id, 398 | &payer_key.pubkey(), 399 | vesting_account_key, 400 | fuzz_instruction.seeds, 401 | fuzz_instruction.number_of_schedules as u32 402 | ).unwrap()], vec![]); 403 | }, 404 | 405 | FuzzInstruction { 406 | instruction: VestingInstruction::Create { .. }, 407 | .. 408 | } => { 409 | let create_instructions = create( 410 | &token_vesting_testenv.vesting_program_id, 411 | &token_vesting_testenv.token_program_id, 412 | vesting_account_key, 413 | vesting_token_account_key, 414 | &source_token_account_owner_key.pubkey(), 415 | &destination_token_owner_key.pubkey(), 416 | destination_token_key, 417 | &mint_key.pubkey(), 418 | fuzz_instruction.schedules.clone(), 419 | fuzz_instruction.seeds 420 | ).unwrap(); 421 | return ( 422 | vec![create_instructions], 423 | vec![clone_keypair(source_token_account_owner_key)] 424 | ); 425 | }, 426 | 427 | FuzzInstruction { 428 | instruction: VestingInstruction::Unlock{ .. }, 429 | .. 430 | } => { 431 | let unlock_instruction = unlock( 432 | &token_vesting_testenv.vesting_program_id, 433 | &token_vesting_testenv.token_program_id, 434 | &token_vesting_testenv.sysvarclock_program_id, 435 | vesting_account_key, 436 | vesting_token_account_key, 437 | destination_token_key, 438 | fuzz_instruction.seeds, 439 | ).unwrap(); 440 | return ( 441 | vec![unlock_instruction], 442 | vec![] 443 | ); 444 | }, 445 | 446 | FuzzInstruction { 447 | instruction: VestingInstruction::ChangeDestination{ .. }, 448 | .. 449 | } => { 450 | let change_instruction = change_destination( 451 | &token_vesting_testenv.vesting_program_id, 452 | vesting_account_key, 453 | &destination_token_owner_key.pubkey(), 454 | &destination_token_key, 455 | new_destination_token_key, 456 | fuzz_instruction.seeds, 457 | ).unwrap(); 458 | return ( 459 | vec![change_instruction], 460 | vec![clone_keypair(destination_token_owner_key)] 461 | ); 462 | } 463 | }; 464 | } 465 | 466 | } 467 | 468 | 469 | // A correct vesting create fuzz instruction 470 | fn create_fuzzinstruction( 471 | token_vesting_testenv: &TokenVestingEnv, 472 | fuzz_instruction: &FuzzInstruction, 473 | payer: &Keypair, 474 | correct_source_token_account_key: &Pubkey, 475 | source_token_account_owner_key: &Keypair, 476 | destination_token_key: &Pubkey, 477 | destination_token_owner_key: &Pubkey, 478 | correct_vesting_account_key: &Pubkey, 479 | correct_vesting_token_key: &Pubkey, 480 | correct_seeds: [u8; 32], 481 | mint_key: &Keypair, 482 | source_amount: u64 483 | ) -> Vec { 484 | 485 | // Initialize the token mint account 486 | let mut instructions_acc = mint_init_instruction( 487 | &payer, 488 | &mint_key, 489 | &token_vesting_testenv.mint_authority 490 | ); 491 | 492 | // Create the associated token accounts 493 | let source_instruction = create_associated_token_account( 494 | &payer.pubkey(), 495 | &source_token_account_owner_key.pubkey(), 496 | &mint_key.pubkey() 497 | ); 498 | instructions_acc.push(source_instruction); 499 | 500 | let vesting_instruction = create_associated_token_account( 501 | &payer.pubkey(), 502 | &correct_vesting_account_key, 503 | &mint_key.pubkey() 504 | ); 505 | instructions_acc.push(vesting_instruction); 506 | 507 | let destination_instruction = create_associated_token_account( 508 | &payer.pubkey(), 509 | &destination_token_owner_key, 510 | &mint_key.pubkey() 511 | ); 512 | instructions_acc.push(destination_instruction); 513 | 514 | // Credit the source account 515 | let setup_instruction = mint_to( 516 | &spl_token::id(), 517 | &mint_key.pubkey(), 518 | &correct_source_token_account_key, 519 | &token_vesting_testenv.mint_authority.pubkey(), 520 | &[], 521 | source_amount 522 | ).unwrap(); 523 | instructions_acc.push(setup_instruction); 524 | 525 | let used_number_of_schedules = fuzz_instruction.number_of_schedules.min( 526 | fuzz_instruction.schedules.len().try_into().unwrap_or(u8::MAX) 527 | ); 528 | // Initialize the vesting program account 529 | let create_instruction = create( 530 | &token_vesting_testenv.vesting_program_id, 531 | &token_vesting_testenv.token_program_id, 532 | &correct_vesting_account_key, 533 | &correct_vesting_token_key, 534 | &source_token_account_owner_key.pubkey(), 535 | &correct_source_token_account_key, 536 | &destination_token_key, 537 | &mint_key.pubkey(), 538 | fuzz_instruction.schedules.clone()[..used_number_of_schedules.into()].into(), 539 | correct_seeds, 540 | ).unwrap(); 541 | instructions_acc.push(create_instruction); 542 | 543 | return instructions_acc; 544 | } 545 | 546 | 547 | // Helper functions 548 | fn mint_init_instruction( 549 | payer: &Keypair, 550 | mint:&Keypair, 551 | mint_authority: &Keypair) -> Vec { 552 | let instructions = vec![ 553 | system_instruction::create_account( 554 | &payer.pubkey(), 555 | &mint.pubkey(), 556 | Rent::default().minimum_balance(82), 557 | 82, 558 | &spl_token::id() 559 | 560 | ), 561 | initialize_mint( 562 | &spl_token::id(), 563 | &mint.pubkey(), 564 | &mint_authority.pubkey(), 565 | None, 566 | 0 567 | ).unwrap(), 568 | ]; 569 | return instructions; 570 | } 571 | 572 | fn clone_keypair(keypair: &Keypair) -> Keypair { 573 | return Keypair::from_bytes(&keypair.to_bytes().clone()).unwrap(); 574 | } 575 | -------------------------------------------------------------------------------- /program/solana-llvm-linux.tar.bz2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Upwork-Job32/token-vesting.blockchain-rust.project/91a042157e0565de676fad81d7bea03aaa25b288/program/solana-llvm-linux.tar.bz2 -------------------------------------------------------------------------------- /program/src/entrypoint.rs: -------------------------------------------------------------------------------- 1 | use solana_program::{ 2 | account_info::AccountInfo, entrypoint, entrypoint::ProgramResult, msg, 3 | program_error::PrintProgramError, pubkey::Pubkey, 4 | }; 5 | 6 | use crate::{error::VestingError, processor::Processor}; 7 | 8 | entrypoint!(process_instruction); 9 | 10 | pub fn process_instruction( 11 | program_id: &Pubkey, 12 | accounts: &[AccountInfo], 13 | instruction_data: &[u8], 14 | ) -> ProgramResult { 15 | msg!("Entrypoint"); 16 | if let Err(error) = Processor::process_instruction(program_id, accounts, instruction_data) { 17 | // catch the error so we can print it 18 | error.print::(); 19 | return Err(error); 20 | } 21 | Ok(()) 22 | } 23 | 24 | // Deploy the program with the following id: 25 | // solana_program::declare_id!("VestingbGKPFXCWuBvfkegQfZyiNwAJb9Ss623VQ5DA"); 26 | -------------------------------------------------------------------------------- /program/src/error.rs: -------------------------------------------------------------------------------- 1 | use num_derive::FromPrimitive; 2 | use solana_program::{decode_error::DecodeError, program_error::ProgramError}; 3 | use thiserror::Error; 4 | 5 | /// Errors that may be returned by the Token vesting program. 6 | #[derive(Clone, Debug, Eq, Error, FromPrimitive, PartialEq)] 7 | pub enum VestingError { 8 | // Invalid instruction 9 | #[error("Invalid Instruction")] 10 | InvalidInstruction 11 | } 12 | 13 | impl From for ProgramError { 14 | fn from(e: VestingError) -> Self { 15 | ProgramError::Custom(e as u32) 16 | } 17 | } 18 | 19 | impl DecodeError for VestingError { 20 | fn type_of() -> &'static str { 21 | "VestingError" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /program/src/instruction.rs: -------------------------------------------------------------------------------- 1 | use crate::error::VestingError; 2 | 3 | use solana_program::{ 4 | instruction::{AccountMeta, Instruction}, 5 | msg, 6 | program_error::ProgramError, 7 | pubkey::Pubkey 8 | }; 9 | 10 | use std::convert::TryInto; 11 | use std::mem::size_of; 12 | 13 | #[cfg(feature = "fuzz")] 14 | use arbitrary::Arbitrary; 15 | 16 | #[cfg(feature = "fuzz")] 17 | impl Arbitrary for VestingInstruction { 18 | fn arbitrary(u: &mut arbitrary::Unstructured<'_>) -> arbitrary::Result { 19 | let seeds: [u8; 32] = u.arbitrary()?; 20 | let choice = u.choose(&[0, 1, 2, 3])?; 21 | match choice { 22 | 0 => { 23 | let number_of_schedules = u.arbitrary()?; 24 | return Ok(Self::Init { 25 | seeds, 26 | number_of_schedules, 27 | }); 28 | } 29 | 1 => { 30 | let schedules: [Schedule; 10] = u.arbitrary()?; 31 | let key_bytes: [u8; 32] = u.arbitrary()?; 32 | let mint_address: Pubkey = Pubkey::new(&key_bytes); 33 | let key_bytes: [u8; 32] = u.arbitrary()?; 34 | let destination_token_address: Pubkey = Pubkey::new(&key_bytes); 35 | return Ok(Self::Create { 36 | seeds, 37 | mint_address, 38 | destination_token_address, 39 | schedules: schedules.to_vec(), 40 | }); 41 | } 42 | 2 => return Ok(Self::Unlock { seeds }), 43 | _ => return Ok(Self::ChangeDestination { seeds }), 44 | } 45 | } 46 | } 47 | 48 | #[cfg_attr(feature = "fuzz", derive(Arbitrary))] 49 | #[repr(C)] 50 | #[derive(Clone, Debug, PartialEq)] 51 | pub struct Schedule { 52 | // Schedule release time in unix timestamp 53 | pub release_time: u64, 54 | pub amount: u64, 55 | } 56 | 57 | pub const SCHEDULE_SIZE: usize = 16; 58 | 59 | #[repr(C)] 60 | #[derive(Clone, Debug, PartialEq)] 61 | pub enum VestingInstruction { 62 | /// Initializes an empty program account for the token_vesting program 63 | /// 64 | /// Accounts expected by this instruction: 65 | /// 66 | /// * Single owner 67 | /// 0. `[]` The system program account 68 | /// 1. `[]` The sysvar Rent account 69 | /// 1. `[signer]` The fee payer account 70 | /// 1. `[]` The vesting account 71 | Init { 72 | // The seed used to derive the vesting accounts address 73 | seeds: [u8; 32], 74 | // The number of release schedules for this contract to hold 75 | number_of_schedules: u32, 76 | }, 77 | /// Creates a new vesting schedule contract 78 | /// 79 | /// Accounts expected by this instruction: 80 | /// 81 | /// * Single owner 82 | /// 0. `[]` The spl-token program account 83 | /// 1. `[writable]` The vesting account 84 | /// 2. `[writable]` The vesting spl-token account 85 | /// 3. `[signer]` The source spl-token account owner 86 | /// 4. `[writable]` The source spl-token account 87 | Create { 88 | seeds: [u8; 32], 89 | mint_address: Pubkey, 90 | destination_token_address: Pubkey, 91 | schedules: Vec, 92 | }, 93 | /// Unlocks a simple vesting contract (SVC) - can only be invoked by the program itself 94 | /// Accounts expected by this instruction: 95 | /// 96 | /// * Single owner 97 | /// 0. `[]` The spl-token program account 98 | /// 1. `[]` The clock sysvar account 99 | /// 1. `[writable]` The vesting account 100 | /// 2. `[writable]` The vesting spl-token account 101 | /// 3. `[writable]` The destination spl-token account 102 | Unlock { seeds: [u8; 32] }, 103 | 104 | /// Change the destination account of a given simple vesting contract (SVC) 105 | /// - can only be invoked by the present destination address of the contract. 106 | /// 107 | /// Accounts expected by this instruction: 108 | /// 109 | /// * Single owner 110 | /// 0. `[]` The vesting account 111 | /// 1. `[]` The current destination token account 112 | /// 2. `[signer]` The destination spl-token account owner 113 | /// 3. `[]` The new destination spl-token account 114 | ChangeDestination { seeds: [u8; 32] }, 115 | } 116 | 117 | impl VestingInstruction { 118 | pub fn unpack(input: &[u8]) -> Result { 119 | use VestingError::InvalidInstruction; 120 | let (&tag, rest) = input.split_first().ok_or(InvalidInstruction)?; 121 | Ok(match tag { 122 | 0 => { 123 | let seeds: [u8; 32] = rest 124 | .get(..32) 125 | .and_then(|slice| slice.try_into().ok()) 126 | .unwrap(); 127 | let number_of_schedules = rest 128 | .get(32..36) 129 | .and_then(|slice| slice.try_into().ok()) 130 | .map(u32::from_le_bytes) 131 | .ok_or(InvalidInstruction)?; 132 | Self::Init { 133 | seeds, 134 | number_of_schedules, 135 | } 136 | } 137 | 1 => { 138 | let seeds: [u8; 32] = rest 139 | .get(..32) 140 | .and_then(|slice| slice.try_into().ok()) 141 | .unwrap(); 142 | let mint_address = rest 143 | .get(32..64) 144 | .and_then(|slice| slice.try_into().ok()) 145 | .map(Pubkey::new) 146 | .ok_or(InvalidInstruction)?; 147 | let destination_token_address = rest 148 | .get(64..96) 149 | .and_then(|slice| slice.try_into().ok()) 150 | .map(Pubkey::new) 151 | .ok_or(InvalidInstruction)?; 152 | let number_of_schedules = rest[96..].len() / SCHEDULE_SIZE; 153 | let mut schedules: Vec = Vec::with_capacity(number_of_schedules); 154 | let mut offset = 96; 155 | for _ in 0..number_of_schedules { 156 | let release_time = rest 157 | .get(offset..offset + 8) 158 | .and_then(|slice| slice.try_into().ok()) 159 | .map(u64::from_le_bytes) 160 | .ok_or(InvalidInstruction)?; 161 | let amount = rest 162 | .get(offset + 8..offset + 16) 163 | .and_then(|slice| slice.try_into().ok()) 164 | .map(u64::from_le_bytes) 165 | .ok_or(InvalidInstruction)?; 166 | offset += SCHEDULE_SIZE; 167 | schedules.push(Schedule { 168 | release_time, 169 | amount, 170 | }) 171 | } 172 | Self::Create { 173 | seeds, 174 | mint_address, 175 | destination_token_address, 176 | schedules, 177 | } 178 | } 179 | 2 | 3 => { 180 | let seeds: [u8; 32] = rest 181 | .get(..32) 182 | .and_then(|slice| slice.try_into().ok()) 183 | .unwrap(); 184 | match tag { 185 | 2 => Self::Unlock { seeds }, 186 | _ => Self::ChangeDestination { seeds }, 187 | } 188 | } 189 | _ => { 190 | msg!("Unsupported tag"); 191 | return Err(InvalidInstruction.into()); 192 | } 193 | }) 194 | } 195 | 196 | pub fn pack(&self) -> Vec { 197 | let mut buf = Vec::with_capacity(size_of::()); 198 | match self { 199 | &Self::Init { 200 | seeds, 201 | number_of_schedules, 202 | } => { 203 | buf.push(0); 204 | buf.extend_from_slice(&seeds); 205 | buf.extend_from_slice(&number_of_schedules.to_le_bytes()) 206 | } 207 | Self::Create { 208 | seeds, 209 | mint_address, 210 | destination_token_address, 211 | schedules, 212 | } => { 213 | buf.push(1); 214 | buf.extend_from_slice(seeds); 215 | buf.extend_from_slice(&mint_address.to_bytes()); 216 | buf.extend_from_slice(&destination_token_address.to_bytes()); 217 | for s in schedules.iter() { 218 | buf.extend_from_slice(&s.release_time.to_le_bytes()); 219 | buf.extend_from_slice(&s.amount.to_le_bytes()); 220 | } 221 | } 222 | &Self::Unlock { seeds } => { 223 | buf.push(2); 224 | buf.extend_from_slice(&seeds); 225 | } 226 | &Self::ChangeDestination { seeds } => { 227 | buf.push(3); 228 | buf.extend_from_slice(&seeds); 229 | } 230 | }; 231 | buf 232 | } 233 | } 234 | 235 | // Creates a `Init` instruction to create and initialize the vesting token account. 236 | pub fn init( 237 | system_program_id: &Pubkey, 238 | rent_program_id: &Pubkey, 239 | vesting_program_id: &Pubkey, 240 | payer_key: &Pubkey, 241 | vesting_account: &Pubkey, 242 | seeds: [u8; 32], 243 | number_of_schedules: u32, 244 | ) -> Result { 245 | let data = VestingInstruction::Init { 246 | seeds, 247 | number_of_schedules, 248 | } 249 | .pack(); 250 | let accounts = vec![ 251 | AccountMeta::new_readonly(*system_program_id, false), 252 | AccountMeta::new_readonly(*rent_program_id, false), 253 | AccountMeta::new(*payer_key, true), 254 | AccountMeta::new(*vesting_account, false), 255 | ]; 256 | Ok(Instruction { 257 | program_id: *vesting_program_id, 258 | accounts, 259 | data, 260 | }) 261 | } 262 | 263 | // Creates a `CreateSchedule` instruction 264 | pub fn create( 265 | vesting_program_id: &Pubkey, 266 | token_program_id: &Pubkey, 267 | vesting_account_key: &Pubkey, 268 | vesting_token_account_key: &Pubkey, 269 | source_token_account_owner_key: &Pubkey, 270 | source_token_account_key: &Pubkey, 271 | destination_token_account_key: &Pubkey, 272 | mint_address: &Pubkey, 273 | schedules: Vec, 274 | seeds: [u8; 32], 275 | ) -> Result { 276 | let data = VestingInstruction::Create { 277 | mint_address: *mint_address, 278 | seeds, 279 | destination_token_address: *destination_token_account_key, 280 | schedules, 281 | } 282 | .pack(); 283 | let accounts = vec![ 284 | AccountMeta::new_readonly(*token_program_id, false), 285 | AccountMeta::new(*vesting_account_key, false), 286 | AccountMeta::new(*vesting_token_account_key, false), 287 | AccountMeta::new_readonly(*source_token_account_owner_key, true), 288 | AccountMeta::new(*source_token_account_key, false), 289 | ]; 290 | Ok(Instruction { 291 | program_id: *vesting_program_id, 292 | accounts, 293 | data, 294 | }) 295 | } 296 | 297 | // Creates an `Unlock` instruction 298 | pub fn unlock( 299 | vesting_program_id: &Pubkey, 300 | token_program_id: &Pubkey, 301 | clock_sysvar_id: &Pubkey, 302 | vesting_account_key: &Pubkey, 303 | vesting_token_account_key: &Pubkey, 304 | destination_token_account_key: &Pubkey, 305 | seeds: [u8; 32], 306 | ) -> Result { 307 | let data = VestingInstruction::Unlock { seeds }.pack(); 308 | let accounts = vec![ 309 | AccountMeta::new_readonly(*token_program_id, false), 310 | AccountMeta::new_readonly(*clock_sysvar_id, false), 311 | AccountMeta::new(*vesting_account_key, false), 312 | AccountMeta::new(*vesting_token_account_key, false), 313 | AccountMeta::new(*destination_token_account_key, false), 314 | ]; 315 | Ok(Instruction { 316 | program_id: *vesting_program_id, 317 | accounts, 318 | data, 319 | }) 320 | } 321 | 322 | pub fn change_destination( 323 | vesting_program_id: &Pubkey, 324 | vesting_account_key: &Pubkey, 325 | current_destination_token_account_owner: &Pubkey, 326 | current_destination_token_account: &Pubkey, 327 | target_destination_token_account: &Pubkey, 328 | seeds: [u8; 32], 329 | ) -> Result { 330 | let data = VestingInstruction::ChangeDestination { seeds }.pack(); 331 | let accounts = vec![ 332 | AccountMeta::new(*vesting_account_key, false), 333 | AccountMeta::new_readonly(*current_destination_token_account, false), 334 | AccountMeta::new_readonly(*current_destination_token_account_owner, true), 335 | AccountMeta::new_readonly(*target_destination_token_account, false), 336 | ]; 337 | Ok(Instruction { 338 | program_id: *vesting_program_id, 339 | accounts, 340 | data, 341 | }) 342 | } 343 | 344 | #[cfg(test)] 345 | mod test { 346 | use super::*; 347 | 348 | #[test] 349 | fn test_instruction_packing() { 350 | let mint_address = Pubkey::new_unique(); 351 | let destination_token_address = Pubkey::new_unique(); 352 | 353 | let original_create = VestingInstruction::Create { 354 | seeds: [50u8; 32], 355 | schedules: vec![Schedule { 356 | amount: 42, 357 | release_time: 250, 358 | }], 359 | mint_address: mint_address.clone(), 360 | destination_token_address, 361 | }; 362 | let packed_create = original_create.pack(); 363 | let unpacked_create = VestingInstruction::unpack(&packed_create).unwrap(); 364 | assert_eq!(original_create, unpacked_create); 365 | 366 | let original_unlock = VestingInstruction::Unlock { seeds: [50u8; 32] }; 367 | assert_eq!( 368 | original_unlock, 369 | VestingInstruction::unpack(&original_unlock.pack()).unwrap() 370 | ); 371 | 372 | let original_init = VestingInstruction::Init { 373 | number_of_schedules: 42, 374 | seeds: [50u8; 32], 375 | }; 376 | assert_eq!( 377 | original_init, 378 | VestingInstruction::unpack(&original_init.pack()).unwrap() 379 | ); 380 | 381 | let original_change = VestingInstruction::ChangeDestination { seeds: [50u8; 32] }; 382 | assert_eq!( 383 | original_change, 384 | VestingInstruction::unpack(&original_change.pack()).unwrap() 385 | ); 386 | } 387 | } 388 | -------------------------------------------------------------------------------- /program/src/lib.rs: -------------------------------------------------------------------------------- 1 | #[cfg(not(feature = "no-entrypoint"))] 2 | pub mod entrypoint; 3 | 4 | pub mod error; 5 | pub mod instruction; 6 | pub mod state; 7 | 8 | pub mod processor; 9 | -------------------------------------------------------------------------------- /program/src/processor.rs: -------------------------------------------------------------------------------- 1 | use solana_program::{ 2 | account_info::{next_account_info, AccountInfo}, 3 | decode_error::DecodeError, 4 | entrypoint::ProgramResult, 5 | msg, 6 | program::{invoke, invoke_signed}, 7 | program_error::PrintProgramError, 8 | program_error::ProgramError, 9 | program_pack::Pack, 10 | pubkey::Pubkey, 11 | rent::Rent, 12 | system_instruction::create_account, 13 | sysvar::{clock::Clock, Sysvar}, 14 | }; 15 | 16 | use num_traits::FromPrimitive; 17 | use spl_token::{instruction::transfer, state::Account}; 18 | 19 | use crate::{ 20 | error::VestingError, 21 | instruction::{Schedule, VestingInstruction, SCHEDULE_SIZE}, 22 | state::{pack_schedules_into_slice, unpack_schedules, VestingSchedule, VestingScheduleHeader}, 23 | }; 24 | 25 | pub struct Processor {} 26 | 27 | impl Processor { 28 | pub fn process_init( 29 | program_id: &Pubkey, 30 | accounts: &[AccountInfo], 31 | seeds: [u8; 32], 32 | schedules: u32 33 | ) -> ProgramResult { 34 | let accounts_iter = &mut accounts.iter(); 35 | 36 | let system_program_account = next_account_info(accounts_iter)?; 37 | let rent_sysvar_account = next_account_info(accounts_iter)?; 38 | let payer = next_account_info(accounts_iter)?; 39 | let vesting_account = next_account_info(accounts_iter)?; 40 | 41 | let rent = Rent::from_account_info(rent_sysvar_account)?; 42 | 43 | // Find the non reversible public key for the vesting contract via the seed 44 | let vesting_account_key = Pubkey::create_program_address(&[&seeds], &program_id).unwrap(); 45 | if vesting_account_key != *vesting_account.key { 46 | msg!("Provided vesting account is invalid"); 47 | return Err(ProgramError::InvalidArgument); 48 | } 49 | 50 | let state_size = (schedules as usize) * VestingSchedule::LEN + VestingScheduleHeader::LEN; 51 | 52 | let init_vesting_account = create_account( 53 | &payer.key, 54 | &vesting_account_key, 55 | rent.minimum_balance(state_size), 56 | state_size as u64, 57 | &program_id, 58 | ); 59 | 60 | invoke_signed( 61 | &init_vesting_account, 62 | &[ 63 | system_program_account.clone(), 64 | payer.clone(), 65 | vesting_account.clone(), 66 | ], 67 | &[&[&seeds]], 68 | )?; 69 | Ok(()) 70 | } 71 | 72 | pub fn process_create( 73 | program_id: &Pubkey, 74 | accounts: &[AccountInfo], 75 | seeds: [u8; 32], 76 | mint_address: &Pubkey, 77 | destination_token_address: &Pubkey, 78 | schedules: Vec, 79 | ) -> ProgramResult { 80 | let accounts_iter = &mut accounts.iter(); 81 | 82 | let spl_token_account = next_account_info(accounts_iter)?; 83 | let vesting_account = next_account_info(accounts_iter)?; 84 | let vesting_token_account = next_account_info(accounts_iter)?; 85 | let source_token_account_owner = next_account_info(accounts_iter)?; 86 | let source_token_account = next_account_info(accounts_iter)?; 87 | 88 | let vesting_account_key = Pubkey::create_program_address(&[&seeds], program_id)?; 89 | if vesting_account_key != *vesting_account.key { 90 | msg!("Provided vesting account is invalid"); 91 | return Err(ProgramError::InvalidArgument); 92 | } 93 | 94 | if !source_token_account_owner.is_signer { 95 | msg!("Source token account owner should be a signer."); 96 | return Err(ProgramError::InvalidArgument); 97 | } 98 | 99 | if *vesting_account.owner != *program_id { 100 | msg!("Program should own vesting account"); 101 | return Err(ProgramError::InvalidArgument); 102 | } 103 | 104 | // Verifying that no SVC was already created with this seed 105 | let is_initialized = 106 | vesting_account.try_borrow_data()?[VestingScheduleHeader::LEN - 1] == 1; 107 | 108 | if is_initialized { 109 | msg!("Cannot overwrite an existing vesting contract."); 110 | return Err(ProgramError::InvalidArgument); 111 | } 112 | 113 | let vesting_token_account_data = Account::unpack(&vesting_token_account.data.borrow())?; 114 | 115 | if vesting_token_account_data.owner != vesting_account_key { 116 | msg!("The vesting token account should be owned by the vesting account."); 117 | return Err(ProgramError::InvalidArgument); 118 | } 119 | 120 | if vesting_token_account_data.delegate.is_some() { 121 | msg!("The vesting token account should not have a delegate authority"); 122 | return Err(ProgramError::InvalidAccountData); 123 | } 124 | 125 | if vesting_token_account_data.close_authority.is_some() { 126 | msg!("The vesting token account should not have a close authority"); 127 | return Err(ProgramError::InvalidAccountData); 128 | } 129 | 130 | let state_header = VestingScheduleHeader { 131 | destination_address: *destination_token_address, 132 | mint_address: *mint_address, 133 | is_initialized: true, 134 | }; 135 | 136 | let mut data = vesting_account.data.borrow_mut(); 137 | if data.len() != VestingScheduleHeader::LEN + schedules.len() * VestingSchedule::LEN { 138 | return Err(ProgramError::InvalidAccountData) 139 | } 140 | state_header.pack_into_slice(&mut data); 141 | 142 | let mut offset = VestingScheduleHeader::LEN; 143 | let mut total_amount: u64 = 0; 144 | 145 | for s in schedules.iter() { 146 | let state_schedule = VestingSchedule { 147 | release_time: s.release_time, 148 | amount: s.amount, 149 | }; 150 | state_schedule.pack_into_slice(&mut data[offset..]); 151 | let delta = total_amount.checked_add(s.amount); 152 | match delta { 153 | Some(n) => total_amount = n, 154 | None => return Err(ProgramError::InvalidInstructionData), // Total amount overflows u64 155 | } 156 | offset += SCHEDULE_SIZE; 157 | } 158 | 159 | if Account::unpack(&source_token_account.data.borrow())?.amount < total_amount { 160 | msg!("The source token account has insufficient funds."); 161 | return Err(ProgramError::InsufficientFunds) 162 | }; 163 | 164 | let transfer_tokens_to_vesting_account = transfer( 165 | spl_token_account.key, 166 | source_token_account.key, 167 | vesting_token_account.key, 168 | source_token_account_owner.key, 169 | &[], 170 | total_amount, 171 | )?; 172 | 173 | invoke( 174 | &transfer_tokens_to_vesting_account, 175 | &[ 176 | source_token_account.clone(), 177 | vesting_token_account.clone(), 178 | spl_token_account.clone(), 179 | source_token_account_owner.clone(), 180 | ], 181 | )?; 182 | Ok(()) 183 | } 184 | 185 | pub fn process_unlock( 186 | program_id: &Pubkey, 187 | _accounts: &[AccountInfo], 188 | seeds: [u8; 32], 189 | ) -> ProgramResult { 190 | let accounts_iter = &mut _accounts.iter(); 191 | 192 | let spl_token_account = next_account_info(accounts_iter)?; 193 | let clock_sysvar_account = next_account_info(accounts_iter)?; 194 | let vesting_account = next_account_info(accounts_iter)?; 195 | let vesting_token_account = next_account_info(accounts_iter)?; 196 | let destination_token_account = next_account_info(accounts_iter)?; 197 | 198 | let vesting_account_key = Pubkey::create_program_address(&[&seeds], program_id)?; 199 | if vesting_account_key != *vesting_account.key { 200 | msg!("Invalid vesting account key"); 201 | return Err(ProgramError::InvalidArgument); 202 | } 203 | 204 | if spl_token_account.key != &spl_token::id() { 205 | msg!("The provided spl token program account is invalid"); 206 | return Err(ProgramError::InvalidArgument) 207 | } 208 | 209 | let packed_state = &vesting_account.data; 210 | let header_state = 211 | VestingScheduleHeader::unpack(&packed_state.borrow()[..VestingScheduleHeader::LEN])?; 212 | 213 | if header_state.destination_address != *destination_token_account.key { 214 | msg!("Contract destination account does not matched provided account"); 215 | return Err(ProgramError::InvalidArgument); 216 | } 217 | 218 | let vesting_token_account_data = Account::unpack(&vesting_token_account.data.borrow())?; 219 | 220 | if vesting_token_account_data.owner != vesting_account_key { 221 | msg!("The vesting token account should be owned by the vesting account."); 222 | return Err(ProgramError::InvalidArgument); 223 | } 224 | 225 | // Unlock the schedules that have reached maturity 226 | let clock = Clock::from_account_info(&clock_sysvar_account)?; 227 | let mut total_amount_to_transfer = 0; 228 | let mut schedules = unpack_schedules(&packed_state.borrow()[VestingScheduleHeader::LEN..])?; 229 | 230 | for s in schedules.iter_mut() { 231 | if clock.unix_timestamp as u64 >= s.release_time { 232 | total_amount_to_transfer += s.amount; 233 | s.amount = 0; 234 | } 235 | } 236 | if total_amount_to_transfer == 0 { 237 | msg!("Vesting contract has not yet reached release time"); 238 | return Err(ProgramError::InvalidArgument); 239 | } 240 | 241 | let transfer_tokens_from_vesting_account = transfer( 242 | &spl_token_account.key, 243 | &vesting_token_account.key, 244 | destination_token_account.key, 245 | &vesting_account_key, 246 | &[], 247 | total_amount_to_transfer, 248 | )?; 249 | 250 | invoke_signed( 251 | &transfer_tokens_from_vesting_account, 252 | &[ 253 | spl_token_account.clone(), 254 | vesting_token_account.clone(), 255 | destination_token_account.clone(), 256 | vesting_account.clone(), 257 | ], 258 | &[&[&seeds]], 259 | )?; 260 | 261 | // Reset released amounts to 0. This makes the simple unlock safe with complex scheduling contracts 262 | pack_schedules_into_slice( 263 | schedules, 264 | &mut packed_state.borrow_mut()[VestingScheduleHeader::LEN..], 265 | ); 266 | 267 | Ok(()) 268 | } 269 | 270 | pub fn process_change_destination( 271 | program_id: &Pubkey, 272 | accounts: &[AccountInfo], 273 | seeds: [u8; 32], 274 | ) -> ProgramResult { 275 | let accounts_iter = &mut accounts.iter(); 276 | 277 | let vesting_account = next_account_info(accounts_iter)?; 278 | let destination_token_account = next_account_info(accounts_iter)?; 279 | let destination_token_account_owner = next_account_info(accounts_iter)?; 280 | let new_destination_token_account = next_account_info(accounts_iter)?; 281 | 282 | if vesting_account.data.borrow().len() < VestingScheduleHeader::LEN { 283 | return Err(ProgramError::InvalidAccountData) 284 | } 285 | let vesting_account_key = Pubkey::create_program_address(&[&seeds], program_id)?; 286 | let state = VestingScheduleHeader::unpack( 287 | &vesting_account.data.borrow()[..VestingScheduleHeader::LEN], 288 | )?; 289 | 290 | if vesting_account_key != *vesting_account.key { 291 | msg!("Invalid vesting account key"); 292 | return Err(ProgramError::InvalidArgument); 293 | } 294 | 295 | if state.destination_address != *destination_token_account.key { 296 | msg!("Contract destination account does not matched provided account"); 297 | return Err(ProgramError::InvalidArgument); 298 | } 299 | 300 | if !destination_token_account_owner.is_signer { 301 | msg!("Destination token account owner should be a signer."); 302 | return Err(ProgramError::InvalidArgument); 303 | } 304 | 305 | let destination_token_account = Account::unpack(&destination_token_account.data.borrow())?; 306 | 307 | if destination_token_account.owner != *destination_token_account_owner.key { 308 | msg!("The current destination token account isn't owned by the provided owner"); 309 | return Err(ProgramError::InvalidArgument); 310 | } 311 | 312 | let mut new_state = state; 313 | new_state.destination_address = *new_destination_token_account.key; 314 | new_state 315 | .pack_into_slice(&mut vesting_account.data.borrow_mut()[..VestingScheduleHeader::LEN]); 316 | 317 | Ok(()) 318 | } 319 | 320 | pub fn process_instruction( 321 | program_id: &Pubkey, 322 | accounts: &[AccountInfo], 323 | instruction_data: &[u8], 324 | ) -> ProgramResult { 325 | msg!("Beginning processing"); 326 | let instruction = VestingInstruction::unpack(instruction_data)?; 327 | msg!("Instruction unpacked"); 328 | match instruction { 329 | VestingInstruction::Init { 330 | seeds, 331 | number_of_schedules, 332 | } => { 333 | msg!("Instruction: Init"); 334 | Self::process_init(program_id, accounts, seeds, number_of_schedules) 335 | } 336 | VestingInstruction::Unlock { seeds } => { 337 | msg!("Instruction: Unlock"); 338 | Self::process_unlock(program_id, accounts, seeds) 339 | } 340 | VestingInstruction::ChangeDestination { seeds } => { 341 | msg!("Instruction: Change Destination"); 342 | Self::process_change_destination(program_id, accounts, seeds) 343 | } 344 | VestingInstruction::Create { 345 | seeds, 346 | mint_address, 347 | destination_token_address, 348 | schedules, 349 | } => { 350 | msg!("Instruction: Create Schedule"); 351 | Self::process_create( 352 | program_id, 353 | accounts, 354 | seeds, 355 | &mint_address, 356 | &destination_token_address, 357 | schedules, 358 | ) 359 | } 360 | } 361 | } 362 | } 363 | 364 | impl PrintProgramError for VestingError { 365 | fn print(&self) 366 | where 367 | E: 'static + std::error::Error + DecodeError + PrintProgramError + FromPrimitive, 368 | { 369 | match self { 370 | VestingError::InvalidInstruction => msg!("Error: Invalid instruction!"), 371 | } 372 | } 373 | } 374 | -------------------------------------------------------------------------------- /program/src/state.rs: -------------------------------------------------------------------------------- 1 | use solana_program::{ 2 | program_error::ProgramError, 3 | program_pack::{IsInitialized, Pack, Sealed}, 4 | pubkey::Pubkey, 5 | }; 6 | 7 | use std::convert::TryInto; 8 | #[derive(Debug, PartialEq)] 9 | pub struct VestingSchedule { 10 | pub release_time: u64, 11 | pub amount: u64, 12 | } 13 | 14 | #[derive(Debug, PartialEq)] 15 | pub struct VestingScheduleHeader { 16 | pub destination_address: Pubkey, 17 | pub mint_address: Pubkey, 18 | pub is_initialized: bool, 19 | } 20 | 21 | impl Sealed for VestingScheduleHeader {} 22 | 23 | impl Pack for VestingScheduleHeader { 24 | const LEN: usize = 65; 25 | 26 | fn pack_into_slice(&self, target: &mut [u8]) { 27 | let destination_address_bytes = self.destination_address.to_bytes(); 28 | let mint_address_bytes = self.mint_address.to_bytes(); 29 | for i in 0..32 { 30 | target[i] = destination_address_bytes[i]; 31 | } 32 | 33 | for i in 32..64 { 34 | target[i] = mint_address_bytes[i - 32]; 35 | } 36 | 37 | target[64] = self.is_initialized as u8; 38 | } 39 | 40 | fn unpack_from_slice(src: &[u8]) -> Result { 41 | if src.len() < 65 { 42 | return Err(ProgramError::InvalidAccountData) 43 | } 44 | let destination_address = Pubkey::new(&src[..32]); 45 | let mint_address = Pubkey::new(&src[32..64]); 46 | let is_initialized = src[64] == 1; 47 | Ok(Self { 48 | destination_address, 49 | mint_address, 50 | is_initialized, 51 | }) 52 | } 53 | } 54 | 55 | impl Sealed for VestingSchedule {} 56 | 57 | impl Pack for VestingSchedule { 58 | const LEN: usize = 16; 59 | 60 | fn pack_into_slice(&self, dst: &mut [u8]) { 61 | let release_time_bytes = self.release_time.to_le_bytes(); 62 | let amount_bytes = self.amount.to_le_bytes(); 63 | for i in 0..8 { 64 | dst[i] = release_time_bytes[i]; 65 | } 66 | 67 | for i in 8..16 { 68 | dst[i] = amount_bytes[i - 8]; 69 | } 70 | } 71 | 72 | fn unpack_from_slice(src: &[u8]) -> Result { 73 | if src.len() < 16 { 74 | return Err(ProgramError::InvalidAccountData) 75 | } 76 | let release_time = u64::from_le_bytes(src[0..8].try_into().unwrap()); 77 | let amount = u64::from_le_bytes(src[8..16].try_into().unwrap()); 78 | Ok(Self { 79 | release_time, 80 | amount, 81 | }) 82 | } 83 | } 84 | 85 | impl IsInitialized for VestingScheduleHeader { 86 | fn is_initialized(&self) -> bool { 87 | self.is_initialized 88 | } 89 | } 90 | 91 | pub fn unpack_schedules(input: &[u8]) -> Result, ProgramError> { 92 | let number_of_schedules = input.len() / VestingSchedule::LEN; 93 | let mut output: Vec = Vec::with_capacity(number_of_schedules); 94 | let mut offset = 0; 95 | for _ in 0..number_of_schedules { 96 | output.push(VestingSchedule::unpack_from_slice( 97 | &input[offset..offset + VestingSchedule::LEN], 98 | )?); 99 | offset += VestingSchedule::LEN; 100 | } 101 | Ok(output) 102 | } 103 | 104 | pub fn pack_schedules_into_slice(schedules: Vec, target: &mut [u8]) { 105 | let mut offset = 0; 106 | for s in schedules.iter() { 107 | s.pack_into_slice(&mut target[offset..]); 108 | offset += VestingSchedule::LEN; 109 | } 110 | } 111 | 112 | #[cfg(test)] 113 | mod tests { 114 | use super::{unpack_schedules, VestingSchedule, VestingScheduleHeader}; 115 | use solana_program::{program_pack::Pack, pubkey::Pubkey}; 116 | 117 | #[test] 118 | fn test_state_packing() { 119 | let header_state = VestingScheduleHeader { 120 | destination_address: Pubkey::new_unique(), 121 | mint_address: Pubkey::new_unique(), 122 | is_initialized: true, 123 | }; 124 | let schedule_state_0 = VestingSchedule { 125 | release_time: 30767976, 126 | amount: 969, 127 | }; 128 | let schedule_state_1 = VestingSchedule { 129 | release_time: 32767076, 130 | amount: 420, 131 | }; 132 | let state_size = VestingScheduleHeader::LEN + 2 * VestingSchedule::LEN; 133 | let mut state_array = [0u8; 97]; 134 | header_state.pack_into_slice(&mut state_array[..VestingScheduleHeader::LEN]); 135 | schedule_state_0.pack_into_slice( 136 | &mut state_array 137 | [VestingScheduleHeader::LEN..VestingScheduleHeader::LEN + VestingSchedule::LEN], 138 | ); 139 | schedule_state_1 140 | .pack_into_slice(&mut state_array[VestingScheduleHeader::LEN + VestingSchedule::LEN..]); 141 | let packed = Vec::from(state_array); 142 | let mut expected = Vec::with_capacity(state_size); 143 | expected.extend_from_slice(&header_state.destination_address.to_bytes()); 144 | expected.extend_from_slice(&header_state.mint_address.to_bytes()); 145 | expected.extend_from_slice(&[header_state.is_initialized as u8]); 146 | expected.extend_from_slice(&schedule_state_0.release_time.to_le_bytes()); 147 | expected.extend_from_slice(&schedule_state_0.amount.to_le_bytes()); 148 | expected.extend_from_slice(&schedule_state_1.release_time.to_le_bytes()); 149 | expected.extend_from_slice(&schedule_state_1.amount.to_le_bytes()); 150 | 151 | assert_eq!(expected, packed); 152 | assert_eq!(packed.len(), state_size); 153 | let unpacked_header = 154 | VestingScheduleHeader::unpack(&packed[..VestingScheduleHeader::LEN]).unwrap(); 155 | assert_eq!(unpacked_header, header_state); 156 | let unpacked_schedules = unpack_schedules(&packed[VestingScheduleHeader::LEN..]).unwrap(); 157 | assert_eq!(unpacked_schedules[0], schedule_state_0); 158 | assert_eq!(unpacked_schedules[1], schedule_state_1); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /program/tests/functional.rs: -------------------------------------------------------------------------------- 1 | #![cfg(feature = "test-bpf")] 2 | use std::str::FromStr; 3 | 4 | use solana_program::{hash::Hash, 5 | pubkey::Pubkey, 6 | rent::Rent, 7 | sysvar, 8 | system_program 9 | }; 10 | use solana_program_test::{processor, ProgramTest}; 11 | use solana_sdk::{account::Account, keyed_account, signature::Keypair, signature::Signer, system_instruction, transaction::Transaction}; 12 | use token_vesting::{entrypoint::process_instruction, instruction::Schedule}; 13 | use token_vesting::instruction::{init, unlock, change_destination, create}; 14 | use spl_token::{self, instruction::{initialize_mint, initialize_account, mint_to}}; 15 | 16 | #[tokio::test] 17 | async fn test_token_vesting() { 18 | 19 | // Create program and test environment 20 | let program_id = Pubkey::from_str("VestingbGKPFXCWuBvfkegQfZyiNwAJb9Ss623VQ5DA").unwrap(); 21 | let mint_authority = Keypair::new(); 22 | let mint = Keypair::new(); 23 | 24 | let source_account = Keypair::new(); 25 | let source_token_account = Keypair::new(); 26 | 27 | let destination_account = Keypair::new(); 28 | let destination_token_account = Keypair::new(); 29 | 30 | let new_destination_account = Keypair::new(); 31 | let new_destination_token_account = Keypair::new(); 32 | 33 | let mut seeds = [42u8; 32]; 34 | let (vesting_account_key, bump) = Pubkey::find_program_address(&[&seeds[..31]], &program_id); 35 | seeds[31] = bump; 36 | let vesting_token_account = Keypair::new(); 37 | 38 | let mut program_test = ProgramTest::new( 39 | "token_vesting", 40 | program_id, 41 | processor!(process_instruction), 42 | ); 43 | 44 | // Add accounts 45 | program_test.add_account( 46 | source_account.pubkey(), 47 | Account { 48 | lamports: 5000000, 49 | ..Account::default() 50 | }, 51 | ); 52 | 53 | // Start and process transactions on the test network 54 | let (mut banks_client, payer, recent_blockhash) = program_test.start().await; 55 | 56 | // Initialize the vesting program account 57 | let init_instruction = [init( 58 | &system_program::id(), 59 | &sysvar::rent::id(), 60 | &program_id, 61 | &payer.pubkey(), 62 | &vesting_account_key, 63 | seeds, 64 | 3 65 | ).unwrap() 66 | ]; 67 | let mut init_transaction = Transaction::new_with_payer( 68 | &init_instruction, 69 | Some(&payer.pubkey()), 70 | ); 71 | init_transaction.partial_sign( 72 | &[&payer], 73 | recent_blockhash 74 | ); 75 | banks_client.process_transaction(init_transaction).await.unwrap(); 76 | 77 | 78 | // Initialize the token accounts 79 | banks_client.process_transaction(mint_init_transaction( 80 | &payer, 81 | &mint, 82 | &mint_authority, 83 | recent_blockhash 84 | )).await.unwrap(); 85 | 86 | banks_client.process_transaction( 87 | create_token_account(&payer, &mint, recent_blockhash, &source_token_account, &source_account.pubkey()) 88 | ).await.unwrap(); 89 | banks_client.process_transaction( 90 | create_token_account(&payer, &mint, recent_blockhash, &vesting_token_account, &vesting_account_key) 91 | ).await.unwrap(); 92 | banks_client.process_transaction( 93 | create_token_account(&payer, &mint, recent_blockhash, &destination_token_account, &destination_account.pubkey()) 94 | ).await.unwrap(); 95 | banks_client.process_transaction( 96 | create_token_account(&payer, &mint, recent_blockhash, &new_destination_token_account, &new_destination_account.pubkey()) 97 | ).await.unwrap(); 98 | 99 | 100 | // Create and process the vesting transactions 101 | let setup_instructions = [ 102 | mint_to( 103 | &spl_token::id(), 104 | &mint.pubkey(), 105 | &source_token_account.pubkey(), 106 | &mint_authority.pubkey(), 107 | &[], 108 | 100 109 | ).unwrap() 110 | ]; 111 | 112 | let schedules = vec![ 113 | Schedule {amount: 20, release_time: 0}, 114 | Schedule {amount: 20, release_time: 2}, 115 | Schedule {amount: 20, release_time: 5} 116 | ]; 117 | 118 | let test_instructions = [ 119 | create( 120 | &program_id, 121 | &spl_token::id(), 122 | &vesting_account_key, 123 | &vesting_token_account.pubkey(), 124 | &source_account.pubkey(), 125 | &source_token_account.pubkey(), 126 | &destination_token_account.pubkey(), 127 | &mint.pubkey(), 128 | schedules, 129 | seeds.clone() 130 | ).unwrap(), 131 | unlock( 132 | &program_id, 133 | &spl_token::id(), 134 | &sysvar::clock::id(), 135 | &vesting_account_key, 136 | &vesting_token_account.pubkey(), 137 | &destination_token_account.pubkey(), 138 | seeds.clone() 139 | ).unwrap() 140 | ]; 141 | 142 | let change_destination_instructions = [ 143 | change_destination( 144 | &program_id, 145 | &vesting_account_key, 146 | &destination_account.pubkey(), 147 | &destination_token_account.pubkey(), 148 | &new_destination_token_account.pubkey(), 149 | seeds.clone() 150 | ).unwrap() 151 | ]; 152 | 153 | // Process transaction on test network 154 | let mut setup_transaction = Transaction::new_with_payer( 155 | &setup_instructions, 156 | Some(&payer.pubkey()), 157 | ); 158 | setup_transaction.partial_sign( 159 | &[ 160 | &payer, 161 | &mint_authority 162 | ], 163 | recent_blockhash 164 | ); 165 | 166 | banks_client.process_transaction(setup_transaction).await.unwrap(); 167 | 168 | // Process transaction on test network 169 | let mut test_transaction = Transaction::new_with_payer( 170 | &test_instructions, 171 | Some(&payer.pubkey()), 172 | ); 173 | test_transaction.partial_sign( 174 | &[ 175 | &payer, 176 | &source_account 177 | ], 178 | recent_blockhash 179 | ); 180 | 181 | banks_client.process_transaction(test_transaction).await.unwrap(); 182 | 183 | let mut change_destination_transaction = Transaction::new_with_payer( 184 | &change_destination_instructions, 185 | Some(&payer.pubkey()) 186 | ); 187 | 188 | change_destination_transaction.partial_sign( 189 | &[ 190 | &payer, 191 | &destination_account 192 | ], 193 | recent_blockhash 194 | ); 195 | 196 | banks_client.process_transaction(change_destination_transaction).await.unwrap(); 197 | 198 | } 199 | 200 | fn mint_init_transaction( 201 | payer: &Keypair, 202 | mint:&Keypair, 203 | mint_authority: &Keypair, 204 | recent_blockhash: Hash) -> Transaction{ 205 | let instructions = [ 206 | system_instruction::create_account( 207 | &payer.pubkey(), 208 | &mint.pubkey(), 209 | Rent::default().minimum_balance(82), 210 | 82, 211 | &spl_token::id() 212 | 213 | ), 214 | initialize_mint( 215 | &spl_token::id(), 216 | &mint.pubkey(), 217 | &mint_authority.pubkey(), 218 | None, 219 | 0 220 | ).unwrap(), 221 | ]; 222 | let mut transaction = Transaction::new_with_payer( 223 | &instructions, 224 | Some(&payer.pubkey()), 225 | ); 226 | transaction.partial_sign( 227 | &[ 228 | payer, 229 | mint 230 | ], 231 | recent_blockhash 232 | ); 233 | transaction 234 | } 235 | 236 | fn create_token_account( 237 | payer: &Keypair, 238 | mint:&Keypair, 239 | recent_blockhash: Hash, 240 | token_account:&Keypair, 241 | token_account_owner: &Pubkey 242 | ) -> Transaction { 243 | let instructions = [ 244 | system_instruction::create_account( 245 | &payer.pubkey(), 246 | &token_account.pubkey(), 247 | Rent::default().minimum_balance(165), 248 | 165, 249 | &spl_token::id() 250 | ), 251 | initialize_account( 252 | &spl_token::id(), 253 | &token_account.pubkey(), 254 | &mint.pubkey(), 255 | token_account_owner 256 | ).unwrap() 257 | ]; 258 | let mut transaction = Transaction::new_with_payer( 259 | &instructions, 260 | Some(&payer.pubkey()), 261 | ); 262 | transaction.partial_sign( 263 | &[ 264 | payer, 265 | token_account 266 | ], 267 | recent_blockhash 268 | ); 269 | transaction 270 | } --------------------------------------------------------------------------------