diff --git a/Cargo.lock b/Cargo.lock index c56f9ae0b517d0..8115bad83a46d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1919,6 +1919,16 @@ dependencies = [ "version_check 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "num-derive" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "proc-macro2 0.4.30 (registry+https://github.com/rust-lang/crates.io-index)", + "quote 0.6.13 (registry+https://github.com/rust-lang/crates.io-index)", + "syn 0.15.42 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "num-derive" version = "0.3.0" @@ -3887,6 +3897,22 @@ dependencies = [ "tempfile 3.1.0 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "solana-vest-api" +version = "0.20.0-pre0" +dependencies = [ + "bincode 1.2.0 (registry+https://github.com/rust-lang/crates.io-index)", + "chrono 0.4.9 (registry+https://github.com/rust-lang/crates.io-index)", + "log 0.4.8 (registry+https://github.com/rust-lang/crates.io-index)", + "num-derive 0.2.5 (registry+https://github.com/rust-lang/crates.io-index)", + "num-traits 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)", + "serde 1.0.101 (registry+https://github.com/rust-lang/crates.io-index)", + "serde_derive 1.0.101 (registry+https://github.com/rust-lang/crates.io-index)", + "solana-config-api 0.20.0", + "solana-runtime 0.20.0", + "solana-sdk 0.20.0", +] + [[package]] name = "solana-vote-api" version = "0.20.0" @@ -5543,6 +5569,7 @@ dependencies = [ "checksum nix 0.15.0 (registry+https://github.com/rust-lang/crates.io-index)" = "3b2e0b4f3320ed72aaedb9a5ac838690a8047c7b275da22711fddff4f8a14229" "checksum nodrop 0.1.13 (registry+https://github.com/rust-lang/crates.io-index)" = "2f9667ddcc6cc8a43afc9b7917599d7216aa09c463919ea32c59ed6cac8bc945" "checksum nom 4.2.3 (registry+https://github.com/rust-lang/crates.io-index)" = "2ad2a91a8e869eeb30b9cb3119ae87773a8f4ae617f41b1eb9c154b2905f7bd6" +"checksum num-derive 0.2.5 (registry+https://github.com/rust-lang/crates.io-index)" = "eafd0b45c5537c3ba526f79d3e75120036502bebacbb3f3220914067ce39dbf2" "checksum num-derive 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)" = "0c8b15b261814f992e33760b1fca9fe8b693d8a65299f20c9901688636cfb746" "checksum num-integer 0.1.41 (registry+https://github.com/rust-lang/crates.io-index)" = "b85e541ef8255f6cf42bbfe4ef361305c6c135d10919ecc26126c4e5ae94bc09" "checksum num-traits 0.1.43 (registry+https://github.com/rust-lang/crates.io-index)" = "92e5113e9fd4cc14ded8e499429f396a20f98c772a47cc8622a736e1ec843c31" diff --git a/Cargo.toml b/Cargo.toml index 3c45b73c1feef2..cdd3d9a1959dc7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ members = [ "programs/stake_tests", "programs/storage_api", "programs/storage_program", + "programs/vest_api", "programs/vote_api", "programs/vote_program", "replicator", diff --git a/programs/vest_api/Cargo.toml b/programs/vest_api/Cargo.toml new file mode 100644 index 00000000000000..de71b0147a4547 --- /dev/null +++ b/programs/vest_api/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "solana-vest-api" +version = "0.20.0-pre0" +description = "Solana Vest program API" +authors = ["Solana Maintainers "] +repository = "https://github.com/solana-labs/solana" +license = "Apache-2.0" +homepage = "https://solana.com/" +edition = "2018" + +[dependencies] +bincode = "1.1.4" +chrono = { version = "0.4.9", features = ["serde"] } +log = "0.4.8" +num-derive = "0.2" +num-traits = "0.2" +serde = "1.0.100" +serde_derive = "1.0.100" +solana-sdk = { path = "../../sdk", version = "0.20.0-pre0" } +solana-config-api = { path = "../config_api", version = "0.20.0-pre0" } + +[dev-dependencies] +solana-runtime = { path = "../../runtime", version = "0.20.0-pre0" } + +[lib] +crate-type = ["lib"] +name = "solana_budget_api" diff --git a/programs/vest_api/src/date_instruction.rs b/programs/vest_api/src/date_instruction.rs new file mode 100644 index 00000000000000..6b937315e591ee --- /dev/null +++ b/programs/vest_api/src/date_instruction.rs @@ -0,0 +1,58 @@ +/// +/// A library for creating a trusted date oracle. +/// +use bincode::{deserialize, serialized_size}; +use chrono::{ + prelude::{Date, DateTime, TimeZone, Utc}, + serde::ts_seconds, +}; +use serde_derive::{Deserialize, Serialize}; +use solana_config_api::{config_instruction, ConfigState}; +use solana_sdk::{instruction::Instruction, pubkey::Pubkey}; + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] +pub struct DateConfig { + #[serde(with = "ts_seconds")] + pub dt: DateTime, +} + +impl Default for DateConfig { + fn default() -> Self { + Self { + dt: Utc.timestamp(0, 0), + } + } +} +impl DateConfig { + pub fn new(dt: Date) -> Self { + Self { + dt: dt.and_hms(0, 0, 0), + } + } + + pub fn deserialize(input: &[u8]) -> Option { + deserialize(input).ok() + } +} + +impl ConfigState for DateConfig { + fn max_space() -> u64 { + serialized_size(&Self::default()).unwrap() + } +} + +/// Create a date account. The date is set to the Unix epoch. +pub fn create_account( + payer_pubkey: &Pubkey, + date_pubkey: &Pubkey, + lamports: u64, +) -> Vec { + config_instruction::create_account::(payer_pubkey, date_pubkey, lamports, vec![]) +} + +/// Set the date in the date account. The account pubkey must be signed in the +/// transaction containing this instruction. +pub fn store(date_pubkey: &Pubkey, dt: Date) -> Instruction { + let date_config = DateConfig::new(dt); + config_instruction::store(&date_pubkey, true, vec![], &date_config) +} diff --git a/programs/vest_api/src/lib.rs b/programs/vest_api/src/lib.rs new file mode 100644 index 00000000000000..e27e821a3cdd3a --- /dev/null +++ b/programs/vest_api/src/lib.rs @@ -0,0 +1,15 @@ +pub mod date_instruction; +pub mod vest_instruction; +pub mod vest_processor; +pub mod vest_schedule; +pub mod vest_state; + +const VEST_PROGRAM_ID: [u8; 32] = [ + 7, 87, 23, 47, 219, 236, 238, 33, 137, 188, 215, 141, 32, 229, 155, 195, 133, 124, 23, 232, + 113, 153, 252, 252, 111, 5, 187, 128, 0, 0, 0, 0, +]; + +solana_sdk::solana_name_id!( + VEST_PROGRAM_ID, + "Vest111111111111111111111111111111111111111" +); diff --git a/programs/vest_api/src/vest_instruction.rs b/programs/vest_api/src/vest_instruction.rs new file mode 100644 index 00000000000000..bb142ed601f4c4 --- /dev/null +++ b/programs/vest_api/src/vest_instruction.rs @@ -0,0 +1,144 @@ +use crate::{id, vest_state::VestState}; +use bincode::serialized_size; +use chrono::prelude::{Date, DateTime, Utc}; +use num_derive::FromPrimitive; +use serde_derive::{Deserialize, Serialize}; +use solana_sdk::{ + instruction::{AccountMeta, Instruction, InstructionError}, + instruction_processor_utils::DecodeError, + pubkey::Pubkey, + system_instruction, +}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, FromPrimitive)] +pub enum VestError { + DestinationMissing, + Unauthorized, +} + +impl From for InstructionError { + fn from(e: VestError) -> Self { + InstructionError::CustomError(e as u32) + } +} + +impl DecodeError for VestError { + fn type_of() -> &'static str { + "VestError" + } +} + +impl std::fmt::Display for VestError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!( + f, + "{}", + match self { + VestError::DestinationMissing => "destination missing", + VestError::Unauthorized => "unauthorized", + } + ) + } +} +impl std::error::Error for VestError {} + +/// An instruction to progress the smart contract. +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] +pub enum VestInstruction { + /// Declare and instantiate a vesting schedule + InitializeAccount { + terminator_pubkey: Pubkey, // The address authorized to terminate this contract with a signed Terminate instruction + payee_pubkey: Pubkey, // The address authorized to redeem vested tokens + start_date_time: DateTime, // The day from which the vesting contract begins + date_pubkey: Pubkey, // Address of an account containing a trusted date, used to drive the vesting schedule + total_lamports: u64, // The number of lamports to send the payee if the schedule completes + }, + + /// Change the payee pubkey + SetPayee(Pubkey), + + /// Load an account and pass its data to the contract for inspection. + RedeemTokens, + + /// Tell the contract that the `InitializeAccount` with `Signature` has been + /// signed by the containing transaction's `Pubkey`. + Terminate, +} + +fn initialize_account( + terminator_pubkey: &Pubkey, + payee_pubkey: &Pubkey, + contract_pubkey: &Pubkey, + start_date: Date, + date_pubkey: &Pubkey, + total_lamports: u64, +) -> Instruction { + let keys = vec![AccountMeta::new(*contract_pubkey, false)]; + Instruction::new( + id(), + &VestInstruction::InitializeAccount { + terminator_pubkey: *terminator_pubkey, + payee_pubkey: *payee_pubkey, + start_date_time: start_date.and_hms(0, 0, 0), + date_pubkey: *date_pubkey, + total_lamports, + }, + keys, + ) +} + +pub fn create_account( + terminator_pubkey: &Pubkey, + payee_pubkey: &Pubkey, + contract_pubkey: &Pubkey, + start_date: Date, + date_pubkey: &Pubkey, + lamports: u64, +) -> Vec { + let space = serialized_size(&VestState::default()).unwrap(); + vec![ + system_instruction::create_account( + &terminator_pubkey, + contract_pubkey, + lamports, + space, + &id(), + ), + initialize_account( + terminator_pubkey, + payee_pubkey, + contract_pubkey, + start_date, + date_pubkey, + lamports, + ), + ] +} + +pub fn set_payee(old_payee: &Pubkey, contract: &Pubkey, new_payee: &Pubkey) -> Instruction { + let account_metas = vec![ + AccountMeta::new(*old_payee, true), + AccountMeta::new(*contract, false), + ]; + Instruction::new(id(), &VestInstruction::SetPayee(*new_payee), account_metas) +} + +pub fn terminate(from: &Pubkey, contract: &Pubkey, to: &Pubkey) -> Instruction { + let mut account_metas = vec![ + AccountMeta::new(*from, true), + AccountMeta::new(*contract, false), + ]; + if from != to { + account_metas.push(AccountMeta::new_credit_only(*to, false)); + } + Instruction::new(id(), &VestInstruction::Terminate, account_metas) +} + +pub fn redeem_tokens(date_pubkey: &Pubkey, contract: &Pubkey, to: &Pubkey) -> Instruction { + let account_metas = vec![ + AccountMeta::new_credit_only(*date_pubkey, false), + AccountMeta::new(*contract, false), + AccountMeta::new_credit_only(*to, false), + ]; + Instruction::new(id(), &VestInstruction::RedeemTokens, account_metas) +} diff --git a/programs/vest_api/src/vest_processor.rs b/programs/vest_api/src/vest_processor.rs new file mode 100644 index 00000000000000..218dd152569032 --- /dev/null +++ b/programs/vest_api/src/vest_processor.rs @@ -0,0 +1,472 @@ +//! vest program +use crate::date_instruction::DateConfig; +use crate::{ + vest_instruction::{VestError, VestInstruction}, + vest_state::VestState, +}; +use bincode::deserialize; +use chrono::prelude::*; +use solana_config_api::get_config_data; +use solana_sdk::{ + account::{Account, KeyedAccount}, + instruction::InstructionError, + pubkey::Pubkey, +}; + +fn parse_date_account( + keyed_account: &mut KeyedAccount, + expected_pubkey: &Pubkey, +) -> Result, InstructionError> { + if keyed_account.account.owner != solana_config_api::id() { + return Err(InstructionError::IncorrectProgramId); + } + + let account = parse_account(keyed_account, expected_pubkey)?; + + let config_data = + get_config_data(&account.data).map_err(|_| InstructionError::InvalidAccountData)?; + let date_config = + deserialize::(config_data).map_err(|_| InstructionError::InvalidAccountData)?; + + Ok(date_config.dt.date()) +} + +fn parse_account<'a>( + keyed_account: &'a mut KeyedAccount, + expected_pubkey: &Pubkey, +) -> Result<&'a mut Account, InstructionError> { + if keyed_account.unsigned_key() != expected_pubkey { + return Err(VestError::Unauthorized.into()); + } + + Ok(keyed_account.account) +} + +fn parse_signed_account<'a>( + keyed_account: &'a mut KeyedAccount, + expected_pubkey: &Pubkey, +) -> Result<&'a mut Account, InstructionError> { + if keyed_account.signer_key().is_none() { + return Err(InstructionError::MissingRequiredSignature); + } + + parse_account(keyed_account, expected_pubkey) +} + +pub fn process_instruction( + _program_id: &Pubkey, + keyed_accounts: &mut [KeyedAccount], + data: &[u8], +) -> Result<(), InstructionError> { + let instruction = deserialize(data).map_err(|_| InstructionError::InvalidInstructionData)?; + + match instruction { + VestInstruction::InitializeAccount { + terminator_pubkey, + payee_pubkey, + start_date_time, + date_pubkey, + total_lamports, + } => { + let contract_account = &mut keyed_accounts[0].account; + let vest_state = VestState { + terminator_pubkey, + payee_pubkey, + start_date_time, + date_pubkey, + total_lamports, + redeemed_lamports: 0, + }; + vest_state.serialize(&mut contract_account.data) + } + VestInstruction::SetPayee(payee_pubkey) => { + let (old_payee_keyed_account, contract_keyed_account) = match keyed_accounts { + [ka0, ka1] => (ka0, ka1), + _ => return Err(InstructionError::InvalidArgument), + }; + let contract_account = &mut contract_keyed_account.account; + let mut vest_state = VestState::deserialize(&contract_account.data)?; + parse_signed_account(old_payee_keyed_account, &vest_state.payee_pubkey)?; + vest_state.payee_pubkey = payee_pubkey; + vest_state.serialize(&mut contract_account.data) + } + VestInstruction::RedeemTokens => { + let (date_keyed_account, contract_keyed_account, payee_keyed_account) = + match keyed_accounts { + [ka0, ka1, ka2] => (ka0, ka1, ka2), + _ => return Err(InstructionError::InvalidArgument), + }; + let contract_account = &mut contract_keyed_account.account; + let mut vest_state = VestState::deserialize(&contract_account.data)?; + let current_date = parse_date_account(date_keyed_account, &vest_state.date_pubkey)?; + let payee_account = parse_account(payee_keyed_account, &vest_state.payee_pubkey)?; + + vest_state.redeem_tokens(current_date, contract_account, payee_account); + vest_state.serialize(&mut contract_account.data) + } + VestInstruction::Terminate => { + let (terminator_keyed_account, contract_keyed_account, payee_keyed_account) = + match keyed_accounts { + [ka0, ka1] => (ka0, ka1, None), + [ka0, ka1, ka2] => (ka0, ka1, Some(ka2)), + _ => return Err(InstructionError::InvalidArgument), + }; + let contract_account = &mut contract_keyed_account.account; + let mut vest_state = VestState::deserialize(&contract_account.data)?; + let terminator_account = + parse_signed_account(terminator_keyed_account, &vest_state.terminator_pubkey)?; + let payee_account = if let Some(payee_keyed_account) = payee_keyed_account { + &mut payee_keyed_account.account + } else { + terminator_account + }; + vest_state.terminate(contract_account, payee_account); + vest_state.serialize(&mut contract_account.data) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::date_instruction; + use crate::id; + use crate::vest_instruction; + use solana_runtime::bank::Bank; + use solana_runtime::bank_client::BankClient; + use solana_sdk::client::SyncClient; + use solana_sdk::genesis_block::create_genesis_block; + use solana_sdk::hash::hash; + use solana_sdk::message::Message; + use solana_sdk::signature::{Keypair, KeypairUtil, Signature}; + use solana_sdk::transport::TransportError; + use std::sync::Arc; + + fn create_bank(lamports: u64) -> (Bank, Keypair) { + let (genesis_block, mint_keypair) = create_genesis_block(lamports); + let mut bank = Bank::new(&genesis_block); + bank.add_instruction_processor( + solana_config_api::id(), + solana_config_api::config_processor::process_instruction, + ); + bank.add_instruction_processor(id(), process_instruction); + (bank, mint_keypair) + } + + fn create_bank_client(lamports: u64) -> (BankClient, Keypair) { + let (bank, mint_keypair) = create_bank(lamports); + (BankClient::new(bank), mint_keypair) + } + + /// Create a config account and use it as a date oracle. + fn create_date_account( + bank_client: &BankClient, + payer_keypair: &Keypair, + date_keypair: &Keypair, + dt: Date, + ) -> Result { + let date_pubkey = date_keypair.pubkey(); + + let mut instructions = + date_instruction::create_account(&payer_keypair.pubkey(), &date_pubkey, 1); + instructions.push(date_instruction::store(&date_pubkey, dt)); + + let message = Message::new(instructions); + bank_client.send_message(&[&payer_keypair, &date_keypair], message) + } + + fn store_date( + bank_client: &BankClient, + payer_keypair: &Keypair, + date_keypair: &Keypair, + dt: Date, + ) -> Result { + let date_pubkey = date_keypair.pubkey(); + let instruction = date_instruction::store(&date_pubkey, dt); + let message = Message::new_with_payer(vec![instruction], Some(&payer_keypair.pubkey())); + bank_client.send_message(&[&payer_keypair, &date_keypair], message) + } + + fn create_vest_account( + bank_client: &BankClient, + payer_keypair: &Keypair, + payee_pubkey: &Pubkey, + contract_pubkey: &Pubkey, + start_date: Date, + date_pubkey: &Pubkey, + lamports: u64, + ) -> Result { + let instructions = vest_instruction::create_account( + &payer_keypair.pubkey(), + &payee_pubkey, + &contract_pubkey, + start_date, + &date_pubkey, + lamports, + ); + let message = Message::new(instructions); + bank_client.send_message(&[&payer_keypair], message) + } + + fn send_set_payee( + bank_client: &BankClient, + old_payee_keypair: &Keypair, + contract_pubkey: &Pubkey, + new_payee_pubkey: &Pubkey, + ) -> Result { + let instruction = vest_instruction::set_payee( + &old_payee_keypair.pubkey(), + &contract_pubkey, + &new_payee_pubkey, + ); + bank_client.send_instruction(&old_payee_keypair, instruction) + } + + fn send_redeem_tokens( + bank_client: &BankClient, + payer_keypair: &Keypair, + payee_pubkey: &Pubkey, + contract_pubkey: &Pubkey, + date_pubkey: &Pubkey, + ) -> Result { + let instruction = + vest_instruction::redeem_tokens(&date_pubkey, &contract_pubkey, &payee_pubkey); + let message = Message::new_with_payer(vec![instruction], Some(&payer_keypair.pubkey())); + bank_client.send_message(&[&payer_keypair], message) + } + + #[test] + fn test_parse_account_unauthorized() { + // Ensure client can't sneak in with an untrusted date account. + let date_pubkey = Pubkey::new_rand(); + let mut account = Account::new(1, 0, &solana_config_api::id()); + let mut keyed_account = KeyedAccount::new(&date_pubkey, false, &mut account); + + let mallory_pubkey = Pubkey::new_rand(); // <-- Attack! Not the expected account. + assert_eq!( + parse_account(&mut keyed_account, &mallory_pubkey).unwrap_err(), + VestError::Unauthorized.into() + ); + } + + #[test] + fn test_parse_signed_account_missing_signature() { + // Ensure client can't sneak in with an unsigned account. + let date_pubkey = Pubkey::new_rand(); + let mut account = Account::new(1, 0, &solana_config_api::id()); + let mut keyed_account = KeyedAccount::new(&date_pubkey, false, &mut account); // <-- Attack! Unsigned transaction. + + assert_eq!( + parse_signed_account(&mut keyed_account, &date_pubkey).unwrap_err(), + InstructionError::MissingRequiredSignature.into() + ); + } + + #[test] + fn test_parse_date_account_incorrect_program_id() { + // Ensure client can't sneak in with a non-Config account. + let date_pubkey = Pubkey::new_rand(); + let mut account = Account::new(1, 0, &id()); // <-- Attack! Pass Vest account where Config account is expected. + let mut keyed_account = KeyedAccount::new(&date_pubkey, false, &mut account); + assert_eq!( + parse_date_account(&mut keyed_account, &date_pubkey).unwrap_err(), + InstructionError::IncorrectProgramId + ); + } + + #[test] + fn test_parse_date_account_uninitialized_config() { + // Ensure no panic when `get_config_data()` returns an error. + let date_pubkey = Pubkey::new_rand(); + let mut account = Account::new(1, 0, &solana_config_api::id()); // <-- Attack! Zero space. + let mut keyed_account = KeyedAccount::new(&date_pubkey, false, &mut account); + assert_eq!( + parse_date_account(&mut keyed_account, &date_pubkey).unwrap_err(), + InstructionError::InvalidAccountData + ); + } + + #[test] + fn test_parse_date_account_invalid_date_config() { + // Ensure no panic when `deserialize::()` returns an error. + let date_pubkey = Pubkey::new_rand(); + let mut account = Account::new(1, 1, &solana_config_api::id()); // Attack! 1 byte, enough to sneak by `get_config_data()`, but not DateConfig deserialize. + let mut keyed_account = KeyedAccount::new(&date_pubkey, false, &mut account); + assert_eq!( + parse_date_account(&mut keyed_account, &date_pubkey).unwrap_err(), + InstructionError::InvalidAccountData + ); + } + + #[test] + fn test_parse_date_account_deserialize() { + // Ensure no panic when `deserialize::()` returns an error. + let date_pubkey = Pubkey::new_rand(); + let mut account = Account::new(1, 1, &solana_config_api::id()); // Attack! 1 byte, enough to sneak by `get_config_data()`, but not DateConfig deserialize. + let mut keyed_account = KeyedAccount::new(&date_pubkey, false, &mut account); + assert_eq!( + parse_date_account(&mut keyed_account, &date_pubkey).unwrap_err(), + InstructionError::InvalidAccountData + ); + } + + #[test] + fn test_set_payee() { + let (bank_client, alice_keypair) = create_bank_client(38); + let date_pubkey = Pubkey::new_rand(); + let contract_pubkey = Pubkey::new_rand(); + let bob_keypair = Keypair::new(); + let bob_pubkey = bob_keypair.pubkey(); + let start_date = Utc.ymd(2018, 1, 1); + + create_vest_account( + &bank_client, + &alice_keypair, + &bob_pubkey, + &contract_pubkey, + start_date, + &date_pubkey, + 36, + ) + .unwrap(); + + let new_bob_pubkey = Pubkey::new_rand(); + + // Ensure some rando can't change the payee. + // Transfer bob a token to pay the transaction fee. + let mallory_keypair = Keypair::new(); + bank_client + .transfer(1, &alice_keypair, &mallory_keypair.pubkey()) + .unwrap(); + send_set_payee( + &bank_client, + &mallory_keypair, + &contract_pubkey, + &new_bob_pubkey, + ) + .unwrap_err(); + + // Ensure bob can update which account he wants vested funds transfered to. + bank_client + .transfer(1, &alice_keypair, &bob_pubkey) + .unwrap(); + send_set_payee( + &bank_client, + &bob_keypair, + &contract_pubkey, + &new_bob_pubkey, + ) + .unwrap(); + } + + #[test] + fn test_redeem_tokens() { + let (bank, alice_keypair) = create_bank(38); + let bank = Arc::new(bank); + let bank_client = BankClient::new_shared(&bank); + let alice_pubkey = alice_keypair.pubkey(); + + let date_keypair = Keypair::new(); + let date_pubkey = date_keypair.pubkey(); + + let current_date = Utc.ymd(2019, 1, 1); + create_date_account(&bank_client, &alice_keypair, &date_keypair, current_date).unwrap(); + + let contract_pubkey = Pubkey::new_rand(); + let bob_pubkey = Pubkey::new_rand(); + let start_date = Utc.ymd(2018, 1, 1); + + create_vest_account( + &bank_client, + &alice_keypair, + &bob_pubkey, + &contract_pubkey, + start_date, + &date_pubkey, + 36, + ) + .unwrap(); + assert_eq!(bank_client.get_balance(&alice_pubkey).unwrap(), 1); + assert_eq!(bank_client.get_balance(&contract_pubkey).unwrap(), 36); + + send_redeem_tokens( + &bank_client, + &alice_keypair, + &bob_pubkey, + &contract_pubkey, + &date_pubkey, + ) + .unwrap(); + assert_eq!(bank_client.get_balance(&alice_pubkey).unwrap(), 1); + assert_eq!(bank_client.get_balance(&contract_pubkey).unwrap(), 24); + assert_eq!(bank_client.get_balance(&bob_pubkey).unwrap(), 12); + + // Update the date oracle and redeem more tokens + store_date( + &bank_client, + &alice_keypair, + &date_keypair, + Utc.ymd(2019, 2, 1), + ) + .unwrap(); + + // Force a new blockhash so that there's not a duplicate signature. + for _ in 0..bank.ticks_per_slot() { + bank.register_tick(&hash(&[1])); + } + + send_redeem_tokens( + &bank_client, + &alice_keypair, + &bob_pubkey, + &contract_pubkey, + &date_pubkey, + ) + .unwrap(); + assert_eq!(bank_client.get_balance(&alice_pubkey).unwrap(), 1); + assert_eq!(bank_client.get_balance(&contract_pubkey).unwrap(), 23); + assert_eq!(bank_client.get_balance(&bob_pubkey).unwrap(), 13); + } + + #[test] + fn test_cancel_payment() { + let (bank_client, alice_keypair) = create_bank_client(3); + let alice_pubkey = alice_keypair.pubkey(); + let contract_pubkey = Pubkey::new_rand(); + let bob_pubkey = Pubkey::new_rand(); + let start_date = Utc::now().date(); + + let date_keypair = Keypair::new(); + let date_pubkey = date_keypair.pubkey(); + + let current_date = Utc.ymd(2019, 1, 1); + create_date_account(&bank_client, &alice_keypair, &date_keypair, current_date).unwrap(); + + create_vest_account( + &bank_client, + &alice_keypair, + &bob_pubkey, + &contract_pubkey, + start_date, + &date_pubkey, + 1, + ) + .unwrap(); + assert_eq!(bank_client.get_balance(&alice_pubkey).unwrap(), 1); + assert_eq!(bank_client.get_balance(&contract_pubkey).unwrap(), 1); + + // Now, terminate the transaction. alice gets her funds back + // Note: that tokens up until the oracle date are *not* redeemed automatically. + let instruction = + vest_instruction::terminate(&alice_pubkey, &contract_pubkey, &alice_pubkey); + bank_client + .send_instruction(&alice_keypair, instruction) + .unwrap(); + assert_eq!(bank_client.get_balance(&alice_pubkey).unwrap(), 2); + assert_eq!( + bank_client.get_account_data(&contract_pubkey).unwrap(), + None + ); + assert_eq!(bank_client.get_account_data(&bob_pubkey).unwrap(), None); + } +} diff --git a/programs/vest_api/src/vest_schedule.rs b/programs/vest_api/src/vest_schedule.rs new file mode 100644 index 00000000000000..6570d6f64a4062 --- /dev/null +++ b/programs/vest_api/src/vest_schedule.rs @@ -0,0 +1,166 @@ +//! A library for creating vesting schedules + +use chrono::prelude::*; + +/// Return the date that is 'n' months from 'start'. +fn get_month(start: Date, n: u32) -> Date { + let year = start.year() + (start.month0() + n) as i32 / 12; + let month0 = (start.month0() + n) % 12; + + // For those that started on the 31st, pay out on the latest day of the month. + let mut dt = None; + let mut days_back = 0; + while dt.is_none() { + dt = Utc + .ymd_opt(year, month0 + 1, start.day() - days_back) + .single(); + days_back += 1; + } + dt.unwrap() +} + +/// Integer division that also returns the remainder. +fn div(dividend: u64, divisor: u64) -> (u64, u64) { + (dividend / divisor, dividend % divisor) +} + +/// Return a list of contract messages and a list of vesting-date/lamports pairs. +pub fn create_vesting_schedule(start_date: Date, mut lamports: u64) -> Vec<(Date, u64)> { + let mut schedule = vec![]; + + // 1/3 vest after one year from start date. + let (mut stipend, remainder) = div(lamports, 3); + stipend += remainder; + + let dt = get_month(start_date, 12); + schedule.push((dt, stipend)); + + lamports -= stipend; + + // Remaining 66% vest monthly after one year. + let payments = 24u32; + let (stipend, remainder) = div(lamports, u64::from(payments)); + for n in 0..payments { + let mut stipend = stipend; + if u64::from(n) < remainder { + stipend += 1; + } + let dt = get_month(start_date, n + 13); + schedule.push((dt, stipend)); + lamports -= stipend; + } + assert_eq!(lamports, 0); + + schedule +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_month() { + let start = Utc.ymd(2018, 1, 31); + assert_eq!(get_month(start, 0), Utc.ymd(2018, 1, 31)); + assert_eq!(get_month(start, 1), Utc.ymd(2018, 2, 28)); + assert_eq!(get_month(start, 2), Utc.ymd(2018, 3, 31)); + } + + #[test] + fn test_create_vesting_schedule() { + assert_eq!( + create_vesting_schedule(Utc.ymd(2018, 1, 1), 36_000), + vec![ + (Utc.ymd(2019, 1, 1), 12000), + (Utc.ymd(2019, 2, 1), 1000), + (Utc.ymd(2019, 3, 1), 1000), + (Utc.ymd(2019, 4, 1), 1000), + (Utc.ymd(2019, 5, 1), 1000), + (Utc.ymd(2019, 6, 1), 1000), + (Utc.ymd(2019, 7, 1), 1000), + (Utc.ymd(2019, 8, 1), 1000), + (Utc.ymd(2019, 9, 1), 1000), + (Utc.ymd(2019, 10, 1), 1000), + (Utc.ymd(2019, 11, 1), 1000), + (Utc.ymd(2019, 12, 1), 1000), + (Utc.ymd(2020, 1, 1), 1000), + (Utc.ymd(2020, 2, 1), 1000), + (Utc.ymd(2020, 3, 1), 1000), + (Utc.ymd(2020, 4, 1), 1000), + (Utc.ymd(2020, 5, 1), 1000), + (Utc.ymd(2020, 6, 1), 1000), + (Utc.ymd(2020, 7, 1), 1000), + (Utc.ymd(2020, 8, 1), 1000), + (Utc.ymd(2020, 9, 1), 1000), + (Utc.ymd(2020, 10, 1), 1000), + (Utc.ymd(2020, 11, 1), 1000), + (Utc.ymd(2020, 12, 1), 1000), + (Utc.ymd(2021, 1, 1), 1000), + ] + ); + + // Ensure vesting date is sensible if start date was at the end of the month. + assert_eq!( + create_vesting_schedule(Utc.ymd(2018, 1, 31), 36_000), + vec![ + (Utc.ymd(2019, 1, 31), 12000), + (Utc.ymd(2019, 2, 28), 1000), + (Utc.ymd(2019, 3, 31), 1000), + (Utc.ymd(2019, 4, 30), 1000), + (Utc.ymd(2019, 5, 31), 1000), + (Utc.ymd(2019, 6, 30), 1000), + (Utc.ymd(2019, 7, 31), 1000), + (Utc.ymd(2019, 8, 31), 1000), + (Utc.ymd(2019, 9, 30), 1000), + (Utc.ymd(2019, 10, 31), 1000), + (Utc.ymd(2019, 11, 30), 1000), + (Utc.ymd(2019, 12, 31), 1000), + (Utc.ymd(2020, 1, 31), 1000), + (Utc.ymd(2020, 2, 29), 1000), // Leap year + (Utc.ymd(2020, 3, 31), 1000), + (Utc.ymd(2020, 4, 30), 1000), + (Utc.ymd(2020, 5, 31), 1000), + (Utc.ymd(2020, 6, 30), 1000), + (Utc.ymd(2020, 7, 31), 1000), + (Utc.ymd(2020, 8, 31), 1000), + (Utc.ymd(2020, 9, 30), 1000), + (Utc.ymd(2020, 10, 31), 1000), + (Utc.ymd(2020, 11, 30), 1000), + (Utc.ymd(2020, 12, 31), 1000), + (Utc.ymd(2021, 1, 31), 1000), + ] + ); + + // Awkward numbers + assert_eq!( + create_vesting_schedule(Utc.ymd(2018, 1, 1), 123_123), + vec![ + (Utc.ymd(2019, 1, 1), 41041), // floor(123_123 / 3) + 123_123 % 3 + (Utc.ymd(2019, 2, 1), 3421), // ceil(82_082 / 24) + (Utc.ymd(2019, 3, 1), 3421), // ceil(82_082 / 24) + (Utc.ymd(2019, 4, 1), 3420), // floor(82_082 / 24) + (Utc.ymd(2019, 5, 1), 3420), + (Utc.ymd(2019, 6, 1), 3420), + (Utc.ymd(2019, 7, 1), 3420), + (Utc.ymd(2019, 8, 1), 3420), + (Utc.ymd(2019, 9, 1), 3420), + (Utc.ymd(2019, 10, 1), 3420), + (Utc.ymd(2019, 11, 1), 3420), + (Utc.ymd(2019, 12, 1), 3420), + (Utc.ymd(2020, 1, 1), 3420), + (Utc.ymd(2020, 2, 1), 3420), + (Utc.ymd(2020, 3, 1), 3420), + (Utc.ymd(2020, 4, 1), 3420), + (Utc.ymd(2020, 5, 1), 3420), + (Utc.ymd(2020, 6, 1), 3420), + (Utc.ymd(2020, 7, 1), 3420), + (Utc.ymd(2020, 8, 1), 3420), + (Utc.ymd(2020, 9, 1), 3420), + (Utc.ymd(2020, 10, 1), 3420), + (Utc.ymd(2020, 11, 1), 3420), + (Utc.ymd(2020, 12, 1), 3420), + (Utc.ymd(2021, 1, 1), 3420), + ] + ); + } +} diff --git a/programs/vest_api/src/vest_state.rs b/programs/vest_api/src/vest_state.rs new file mode 100644 index 00000000000000..cf66e138ec585a --- /dev/null +++ b/programs/vest_api/src/vest_state.rs @@ -0,0 +1,110 @@ +//! vest state +use crate::vest_schedule::create_vesting_schedule; +use bincode::{self, deserialize, serialize_into}; +use chrono::prelude::*; +use chrono::{ + prelude::{DateTime, TimeZone, Utc}, + serde::ts_seconds, +}; +use serde_derive::{Deserialize, Serialize}; +use solana_sdk::{account::Account, instruction::InstructionError, pubkey::Pubkey}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct VestState { + /// The address authorized to terminate this contract with a signed Terminate instruction + pub terminator_pubkey: Pubkey, + + /// The address authorized to redeem vested tokens + pub payee_pubkey: Pubkey, + + /// The day from which the vesting contract begins + #[serde(with = "ts_seconds")] + pub start_date_time: DateTime, + + /// Address of an account containing a trusted date, used to drive the vesting schedule + pub date_pubkey: Pubkey, + + /// The number of lamports to send the payee if the schedule completes + pub total_lamports: u64, + + /// The number of lamports the payee has already redeemed + pub redeemed_lamports: u64, +} + +impl Default for VestState { + fn default() -> Self { + Self { + terminator_pubkey: Pubkey::default(), + payee_pubkey: Pubkey::default(), + start_date_time: Utc.timestamp(0, 0), + date_pubkey: Pubkey::default(), + total_lamports: 0, + redeemed_lamports: 0, + } + } +} + +impl VestState { + pub fn serialize(&self, output: &mut [u8]) -> Result<(), InstructionError> { + serialize_into(output, self).map_err(|_| InstructionError::AccountDataTooSmall) + } + + pub fn deserialize(input: &[u8]) -> Result { + deserialize(input).map_err(|_| InstructionError::InvalidAccountData) + } + + /// Redeem vested tokens. + pub fn redeem_tokens( + &mut self, + current_date: Date, + contract_account: &mut Account, + payee_account: &mut Account, + ) { + let schedule = create_vesting_schedule(self.start_date_time.date(), self.total_lamports); + + let vested_lamports = schedule + .into_iter() + .take_while(|(dt, _)| *dt <= current_date) + .map(|(_, lamports)| lamports) + .sum::(); + + let redeemable_lamports = vested_lamports.saturating_sub(self.redeemed_lamports); + + contract_account.lamports -= redeemable_lamports; + payee_account.lamports += redeemable_lamports; + + self.redeemed_lamports += redeemable_lamports; + } + + /// Terminate the contract and return all tokens to the given pubkey. + pub fn terminate(&mut self, contract_account: &mut Account, payee_account: &mut Account) { + payee_account.lamports += contract_account.lamports; + contract_account.lamports = 0; + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::id; + use solana_sdk::account::Account; + + #[test] + fn test_serializer() { + let mut a = Account::new(0, 512, &id()); + let b = VestState::default(); + b.serialize(&mut a.data).unwrap(); + let c = VestState::deserialize(&a.data).unwrap(); + assert_eq!(b, c); + } + + #[test] + fn test_serializer_data_too_small() { + let mut a = Account::new(0, 1, &id()); + let b = VestState::default(); + assert_eq!( + b.serialize(&mut a.data), + Err(InstructionError::AccountDataTooSmall) + ); + } +}