diff --git a/CHANGELOG.md b/CHANGELOG.md index 01a526470..8a82af1c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `into_base_16_string_no_padding` function to the test helpers (#1137) +- Vesting component and VestingWallet preset (#334) ### Changed diff --git a/Scarb.lock b/Scarb.lock index 517b346d8..579ba42fc 100644 --- a/Scarb.lock +++ b/Scarb.lock @@ -7,6 +7,7 @@ version = "0.16.0" dependencies = [ "openzeppelin_access", "openzeppelin_account", + "openzeppelin_finance", "openzeppelin_governance", "openzeppelin_introspection", "openzeppelin_merkle_tree", @@ -42,6 +43,17 @@ dependencies = [ "snforge_std", ] +[[package]] +name = "openzeppelin_finance" +version = "0.16.0" +dependencies = [ + "openzeppelin_access", + "openzeppelin_test_common", + "openzeppelin_testing", + "openzeppelin_token", + "snforge_std", +] + [[package]] name = "openzeppelin_governance" version = "0.16.0" @@ -72,6 +84,7 @@ version = "0.16.0" dependencies = [ "openzeppelin_access", "openzeppelin_account", + "openzeppelin_finance", "openzeppelin_introspection", "openzeppelin_test_common", "openzeppelin_testing", @@ -94,6 +107,7 @@ version = "0.16.0" dependencies = [ "openzeppelin_access", "openzeppelin_account", + "openzeppelin_finance", "openzeppelin_testing", "openzeppelin_token", "openzeppelin_upgrades", diff --git a/Scarb.toml b/Scarb.toml index f1e2bda24..a7b5d5701 100644 --- a/Scarb.toml +++ b/Scarb.toml @@ -2,6 +2,7 @@ members = [ "packages/access", "packages/account", + "packages/finance", "packages/governance", "packages/introspection", "packages/merkle_tree", @@ -47,6 +48,7 @@ snforge_std = "0.30.0" starknet.workspace = true openzeppelin_access = { path = "packages/access" } openzeppelin_account = { path = "packages/account" } +openzeppelin_finance = { path = "packages/finance" } openzeppelin_governance = { path = "packages/governance" } openzeppelin_introspection = { path = "packages/introspection" } openzeppelin_merkle_tree = { path = "packages/merkle_tree" } diff --git a/packages/account/src/utils.cairo b/packages/account/src/utils.cairo index 8fc519484..1b9ef915c 100644 --- a/packages/account/src/utils.cairo +++ b/packages/account/src/utils.cairo @@ -22,7 +22,7 @@ pub fn execute_calls(mut calls: Array) -> Array> { let _res = execute_single_call(call); res.append(_res); }, - Option::None(_) => { break (); }, + Option::None => { break (); }, }; }; res diff --git a/packages/finance/README.md b/packages/finance/README.md new file mode 100644 index 000000000..bf885125d --- /dev/null +++ b/packages/finance/README.md @@ -0,0 +1,11 @@ +## Finance + +This crate includes primitives for financial systems. + +### Interfaces + +- `IVesting` + +### Components + +- `VestingComponent` diff --git a/packages/finance/Scarb.toml b/packages/finance/Scarb.toml new file mode 100644 index 000000000..e1cdf8cb5 --- /dev/null +++ b/packages/finance/Scarb.toml @@ -0,0 +1,39 @@ +[package] +name = "openzeppelin_finance" +readme = "README.md" +keywords = [ + "openzeppelin", + "starknet", + "contracts", + "finance", + "vesting" +] +version.workspace = true +edition.workspace = true +cairo-version.workspace = true +scarb-version.workspace = true +authors.workspace = true +description.workspace = true +documentation.workspace = true +repository.workspace = true +license-file.workspace = true + +[tool] +fmt.workspace = true + +[dependencies] +starknet.workspace = true +openzeppelin_access = { path = "../access" } +openzeppelin_token = { path = "../token" } + +[dev-dependencies] +snforge_std.workspace = true +openzeppelin_testing = { path = "../testing" } +openzeppelin_test_common = { path = "../test_common" } + +[lib] + +[[target.starknet-contract]] +allowed-libfuncs-list.name = "experimental" +sierra = true +casm = false diff --git a/packages/finance/src/lib.cairo b/packages/finance/src/lib.cairo new file mode 100644 index 000000000..a5a96a4c0 --- /dev/null +++ b/packages/finance/src/lib.cairo @@ -0,0 +1,2 @@ +mod tests; +pub mod vesting; diff --git a/packages/finance/src/tests.cairo b/packages/finance/src/tests.cairo new file mode 100644 index 000000000..2ccfca4b9 --- /dev/null +++ b/packages/finance/src/tests.cairo @@ -0,0 +1,7 @@ +#[cfg(test)] +pub(crate) mod common; +pub(crate) mod mocks; +#[cfg(test)] +mod test_vesting_linear; +#[cfg(test)] +mod test_vesting_steps; diff --git a/packages/finance/src/tests/common.cairo b/packages/finance/src/tests/common.cairo new file mode 100644 index 000000000..8dc623a80 --- /dev/null +++ b/packages/finance/src/tests/common.cairo @@ -0,0 +1,71 @@ +use openzeppelin_finance::vesting::interface::IVestingDispatcher; +use openzeppelin_testing as utils; +use openzeppelin_testing::constants; +use openzeppelin_token::erc20::interface::IERC20Dispatcher; +use openzeppelin_utils::serde::SerializedAppend; +use starknet::{ContractAddress, SyscallResultTrait}; + +#[derive(Copy, Drop)] +pub(crate) enum VestingStrategy { + Linear, + Steps: u64 +} + +#[derive(Copy, Drop)] +pub(crate) struct TestData { + pub strategy: VestingStrategy, + pub total_allocation: u256, + pub beneficiary: ContractAddress, + pub start: u64, + pub duration: u64, + pub cliff_duration: u64 +} + +fn deploy_vesting_mock(data: TestData) -> IVestingDispatcher { + let contract_address = match data.strategy { + VestingStrategy::Linear => { + let mut calldata = array![]; + calldata.append_serde(data.beneficiary); + calldata.append_serde(data.start); + calldata.append_serde(data.duration); + calldata.append_serde(data.cliff_duration); + utils::declare_and_deploy("LinearVestingMock", calldata) + }, + VestingStrategy::Steps(total_steps) => { + let mut calldata = array![]; + calldata.append_serde(total_steps); + calldata.append_serde(data.beneficiary); + calldata.append_serde(data.start); + calldata.append_serde(data.duration); + calldata.append_serde(data.cliff_duration); + utils::declare_and_deploy("StepsVestingMock", calldata) + } + }; + IVestingDispatcher { contract_address } +} + +fn deploy_erc20_mock(recipient: ContractAddress, initial_supply: u256) -> IERC20Dispatcher { + let mut calldata = array![]; + calldata.append_serde(constants::NAME()); + calldata.append_serde(constants::SYMBOL()); + calldata.append_serde(initial_supply); + calldata.append_serde(recipient); + + let contract_address = utils::declare_and_deploy("ERC20Mock", calldata); + IERC20Dispatcher { contract_address } +} + +pub(crate) fn setup(data: TestData) -> (IVestingDispatcher, ContractAddress) { + let vesting = deploy_vesting_mock(data); + let token = deploy_erc20_mock(vesting.contract_address, data.total_allocation); + (vesting, token.contract_address) +} + +pub(crate) fn set_transfer_to_fail(token: ContractAddress, should_fail: bool) { + let mut calldata = array![]; + calldata.append_serde(true); + starknet::syscalls::call_contract_syscall( + token, selector!("set_transfer_should_fail"), calldata.span() + ) + .unwrap_syscall(); +} diff --git a/packages/finance/src/tests/mocks.cairo b/packages/finance/src/tests/mocks.cairo new file mode 100644 index 000000000..c5a78361f --- /dev/null +++ b/packages/finance/src/tests/mocks.cairo @@ -0,0 +1,2 @@ +pub(crate) mod erc20_mocks; +pub(crate) mod vesting_mocks; diff --git a/packages/finance/src/tests/mocks/erc20_mocks.cairo b/packages/finance/src/tests/mocks/erc20_mocks.cairo new file mode 100644 index 000000000..21bdec7d9 --- /dev/null +++ b/packages/finance/src/tests/mocks/erc20_mocks.cairo @@ -0,0 +1,91 @@ +#[starknet::contract] +pub(crate) mod ERC20Mock { + use openzeppelin_token::erc20::interface::IERC20; + use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl}; + use starknet::ContractAddress; + use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess}; + + component!(path: ERC20Component, storage: erc20, event: ERC20Event); + + #[abi(embed_v0)] + impl ERC20MetadataImpl = ERC20Component::ERC20MetadataImpl; + + impl InternalImpl = ERC20Component::InternalImpl; + + #[storage] + struct Storage { + transfer_should_fail: bool, + #[substorage(v0)] + erc20: ERC20Component::Storage + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + #[flat] + ERC20Event: ERC20Component::Event + } + + #[constructor] + fn constructor( + ref self: ContractState, + name: ByteArray, + symbol: ByteArray, + initial_supply: u256, + recipient: ContractAddress + ) { + self.erc20.initializer(name, symbol); + self.erc20.mint(recipient, initial_supply); + } + + #[abi(embed_v0)] + impl ERC20Impl of IERC20 { + fn total_supply(self: @ContractState) -> u256 { + self.erc20.total_supply() + } + + fn balance_of(self: @ContractState, account: ContractAddress) -> u256 { + self.erc20.balance_of(account) + } + + fn allowance( + self: @ContractState, owner: ContractAddress, spender: ContractAddress + ) -> u256 { + self.erc20.allowance(owner, spender) + } + + fn transfer(ref self: ContractState, recipient: ContractAddress, amount: u256) -> bool { + if self.transfer_should_fail.read() { + false + } else { + self.erc20.transfer(recipient, amount) + } + } + + fn transfer_from( + ref self: ContractState, + sender: ContractAddress, + recipient: ContractAddress, + amount: u256 + ) -> bool { + if self.transfer_should_fail.read() { + false + } else { + self.erc20.transfer_from(sender, recipient, amount) + } + } + + fn approve(ref self: ContractState, spender: ContractAddress, amount: u256) -> bool { + self.erc20.approve(spender, amount) + } + } + + #[generate_trait] + #[abi(per_item)] + impl ExternalImpl of ExternalTrait { + #[external(v0)] + fn set_transfer_should_fail(ref self: ContractState, should_fail: bool) { + self.transfer_should_fail.write(should_fail); + } + } +} diff --git a/packages/finance/src/tests/mocks/vesting_mocks.cairo b/packages/finance/src/tests/mocks/vesting_mocks.cairo new file mode 100644 index 000000000..9fb938459 --- /dev/null +++ b/packages/finance/src/tests/mocks/vesting_mocks.cairo @@ -0,0 +1,127 @@ +#[starknet::contract] +pub(crate) mod LinearVestingMock { + use openzeppelin_access::ownable::OwnableComponent; + use openzeppelin_finance::vesting::{VestingComponent, LinearVestingSchedule}; + use starknet::ContractAddress; + + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent); + component!(path: VestingComponent, storage: vesting, event: VestingEvent); + + // Ownable Mixin + #[abi(embed_v0)] + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl; + impl OwnableInternalImpl = OwnableComponent::InternalImpl; + + // Vesting + #[abi(embed_v0)] + impl VestingImpl = VestingComponent::VestingImpl; + impl VestingInternalImpl = VestingComponent::InternalImpl; + + #[storage] + struct Storage { + #[substorage(v0)] + ownable: OwnableComponent::Storage, + #[substorage(v0)] + vesting: VestingComponent::Storage + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + #[flat] + OwnableEvent: OwnableComponent::Event, + #[flat] + VestingEvent: VestingComponent::Event + } + + #[constructor] + fn constructor( + ref self: ContractState, + beneficiary: ContractAddress, + start: u64, + duration: u64, + cliff: u64, + ) { + self.ownable.initializer(beneficiary); + self.vesting.initializer(start, duration, cliff); + } +} + +#[starknet::contract] +pub(crate) mod StepsVestingMock { + use openzeppelin_access::ownable::OwnableComponent; + use openzeppelin_finance::vesting::VestingComponent::VestingScheduleTrait; + use openzeppelin_finance::vesting::VestingComponent; + use starknet::ContractAddress; + use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess}; + + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent); + component!(path: VestingComponent, storage: vesting, event: VestingEvent); + + // Ownable Mixin + #[abi(embed_v0)] + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl; + impl OwnableInternalImpl = OwnableComponent::InternalImpl; + + // Vesting + #[abi(embed_v0)] + impl VestingImpl = VestingComponent::VestingImpl; + impl VestingInternalImpl = VestingComponent::InternalImpl; + + #[storage] + struct Storage { + total_steps: u64, + #[substorage(v0)] + ownable: OwnableComponent::Storage, + #[substorage(v0)] + vesting: VestingComponent::Storage + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + #[flat] + OwnableEvent: OwnableComponent::Event, + #[flat] + VestingEvent: VestingComponent::Event + } + + #[constructor] + fn constructor( + ref self: ContractState, + total_steps: u64, + beneficiary: ContractAddress, + start: u64, + duration: u64, + cliff: u64, + ) { + self.total_steps.write(total_steps); + self.ownable.initializer(beneficiary); + self.vesting.initializer(start, duration, cliff); + } + + impl VestingSchedule of VestingScheduleTrait { + fn calculate_vested_amount( + self: @VestingComponent::ComponentState, + token: ContractAddress, + total_allocation: u256, + timestamp: u64, + start: u64, + duration: u64, + cliff: u64, + ) -> u256 { + if timestamp < cliff { + 0 + } else if timestamp >= start + duration { + total_allocation + } else { + let total_steps = self.get_contract().total_steps.read(); + let vested_per_step = total_allocation / total_steps.into(); + let step_duration = duration / total_steps; + let current_step = (timestamp - start) / step_duration; + let vested_amount = vested_per_step * current_step.into(); + vested_amount + } + } + } +} diff --git a/packages/finance/src/tests/test_vesting_linear.cairo b/packages/finance/src/tests/test_vesting_linear.cairo new file mode 100644 index 000000000..f7e6251f8 --- /dev/null +++ b/packages/finance/src/tests/test_vesting_linear.cairo @@ -0,0 +1,237 @@ +use openzeppelin_access::ownable::interface::{IOwnableDispatcher, IOwnableDispatcherTrait}; +use openzeppelin_finance::tests::common::{VestingStrategy, TestData, setup, set_transfer_to_fail}; +use openzeppelin_finance::tests::mocks::vesting_mocks::LinearVestingMock; +use openzeppelin_finance::vesting::VestingComponent::InternalImpl; +use openzeppelin_finance::vesting::VestingComponent; +use openzeppelin_finance::vesting::interface::IVestingDispatcherTrait; +use openzeppelin_test_common::vesting::VestingSpyHelpers; +use openzeppelin_testing::constants::{OWNER, OTHER}; +use openzeppelin_token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait}; +use snforge_std::{spy_events, start_cheat_caller_address, start_cheat_block_timestamp_global}; + +// +// Setup +// + +type ComponentState = VestingComponent::ComponentState; + +fn COMPONENT_STATE() -> ComponentState { + VestingComponent::component_state_for_testing() +} + +fn TEST_DATA() -> TestData { + TestData { + strategy: VestingStrategy::Linear, + total_allocation: 200, + beneficiary: OWNER(), + start: 30, + duration: 100, + cliff_duration: 0 + } +} + +// +// Tests +// + +#[test] +fn test_state_after_init() { + let data = TEST_DATA(); + let (vesting, _) = setup(data); + + assert_eq!(vesting.start(), data.start); + assert_eq!(vesting.duration(), data.duration); + assert_eq!(vesting.cliff(), data.start + data.cliff_duration); + assert_eq!(vesting.end(), data.start + data.duration); + let beneficiary = IOwnableDispatcher { contract_address: vesting.contract_address }.owner(); + assert_eq!(beneficiary, data.beneficiary); +} + +#[test] +#[should_panic(expected: 'Vesting: Invalid cliff duration')] +fn test_init_invalid_cliff_value() { + let mut component_state = COMPONENT_STATE(); + let mut data = TEST_DATA(); + data.cliff_duration = data.duration + 1; + + component_state.initializer(data.start, data.duration, data.cliff_duration); +} + +#[test] +fn test_vesting_schedule_no_cliff() { + let data = TEST_DATA(); + let (vesting, token) = setup(data); + let tokens_per_sec = data.total_allocation / data.duration.into(); + + let mut time_passed = 0; + while time_passed <= data.duration { + let expected_vested_amount = tokens_per_sec * time_passed.into(); + let actual_vested_amount = vesting.vested_amount(token, data.start + time_passed); + assert_eq!(actual_vested_amount, expected_vested_amount); + + time_passed += 1; + }; + + let end_timestamp = data.start + data.duration; + assert_eq!(vesting.vested_amount(token, end_timestamp), data.total_allocation); +} + +#[test] +fn test_vesting_schedule_with_cliff() { + let mut data = TEST_DATA(); + data.cliff_duration = 30; + let (vesting, token) = setup(data); + let tokens_per_sec = data.total_allocation / data.duration.into(); + + let mut time_passed = 0; + while time_passed < data.cliff_duration { + let actual_vested_amount = vesting.vested_amount(token, data.start + time_passed); + assert_eq!(actual_vested_amount, 0); + + time_passed += 1; + }; + + while time_passed <= data.duration { + let expected_vested_amount = tokens_per_sec * time_passed.into(); + let actual_vested_amount = vesting.vested_amount(token, data.start + time_passed); + assert_eq!(actual_vested_amount, expected_vested_amount); + + time_passed += 1; + }; + + let end_timestamp = data.start + data.duration; + assert_eq!(vesting.vested_amount(token, end_timestamp), data.total_allocation); +} + +#[test] +fn test_release_single_call_within_duration() { + let data = TEST_DATA(); + let (vesting, token) = setup(data); + + let time_passed = 40; + let expected_release_amount = time_passed.into() + * (data.total_allocation / data.duration.into()); + start_cheat_block_timestamp_global(data.start + time_passed); + let mut spy = spy_events(); + + assert_eq!(vesting.released(token), 0); + assert_eq!(vesting.releasable(token), expected_release_amount); + + let actual_release_amount = vesting.release(token); + assert_eq!(actual_release_amount, expected_release_amount); + + assert_eq!(vesting.released(token), expected_release_amount); + assert_eq!(vesting.releasable(token), 0); + + spy.assert_only_event_amount_released(vesting.contract_address, token, expected_release_amount); +} + +#[test] +fn test_release_single_call_after_end() { + let data = TEST_DATA(); + let (vesting, token) = setup(data); + + let time_passed = data.duration + 1; + start_cheat_block_timestamp_global(data.start + time_passed); + let mut spy = spy_events(); + + assert_eq!(vesting.released(token), 0); + assert_eq!(vesting.releasable(token), data.total_allocation); + + let actual_release_amount = vesting.release(token); + assert_eq!(actual_release_amount, data.total_allocation); + + assert_eq!(vesting.released(token), data.total_allocation); + assert_eq!(vesting.releasable(token), 0); + + spy.assert_only_event_amount_released(vesting.contract_address, token, data.total_allocation); +} + +#[test] +fn test_release_multiple_calls() { + let mut data = TEST_DATA(); + data.cliff_duration = 30; + let (vesting, token) = setup(data); + + // 1. Before cliff ended + start_cheat_block_timestamp_global(vesting.cliff() - 1); + assert_eq!(vesting.released(token), 0); + assert_eq!(vesting.releasable(token), 0); + + vesting.release(token); + + assert_eq!(vesting.released(token), 0); + assert_eq!(vesting.releasable(token), 0); + + // 2. When the cliff ended + start_cheat_block_timestamp_global(vesting.cliff()); + assert_eq!(vesting.released(token), 0); + assert_eq!(vesting.releasable(token), 60); + + vesting.release(token); + + assert_eq!(vesting.released(token), 60); + assert_eq!(vesting.releasable(token), 0); + + // 3. When 40/100 seconds passed + start_cheat_block_timestamp_global(data.start + 40); + assert_eq!(vesting.released(token), 60); + assert_eq!(vesting.releasable(token), 20); + + vesting.release(token); + + assert_eq!(vesting.released(token), 80); + assert_eq!(vesting.releasable(token), 0); + + // 4. After the vesting ended + start_cheat_block_timestamp_global(data.start + data.duration + 1); + assert_eq!(vesting.released(token), 80); + assert_eq!(vesting.releasable(token), 120); + + vesting.release(token); + + assert_eq!(vesting.released(token), data.total_allocation); + assert_eq!(vesting.releasable(token), 0); +} + +#[test] +fn test_release_after_ownership_transferred() { + let data = TEST_DATA(); + let (vesting, token) = setup(data); + let ownable_vesting = IOwnableDispatcher { contract_address: vesting.contract_address }; + let token_dispatcher = IERC20Dispatcher { contract_address: token }; + + // 1. Release to initial owner + let time_passed = 40; + let release_amount_1 = 80; + start_cheat_block_timestamp_global(data.start + time_passed); + vesting.release(token); + assert_eq!(vesting.released(token), release_amount_1); + assert_eq!(token_dispatcher.balance_of(data.beneficiary), release_amount_1); + + // 2. Transfer ownership + let new_owner = OTHER(); + start_cheat_caller_address(vesting.contract_address, data.beneficiary); + ownable_vesting.transfer_ownership(new_owner); + + // 3. Release to new owner + let release_amount_2 = data.total_allocation - release_amount_1; + start_cheat_block_timestamp_global(data.start + data.duration); + vesting.release(token); + assert_eq!(vesting.released(token), data.total_allocation); + assert_eq!(token_dispatcher.balance_of(data.beneficiary), release_amount_1); + assert_eq!(token_dispatcher.balance_of(new_owner), release_amount_2); +} + +#[test] +#[should_panic(expected: 'Vesting: Token transfer failed')] +fn test_panics_when_transfer_fails() { + let data = TEST_DATA(); + let (vesting, token) = setup(data); + + let time_passed = 40; + start_cheat_block_timestamp_global(data.start + time_passed); + set_transfer_to_fail(token, true); + + vesting.release(token); +} diff --git a/packages/finance/src/tests/test_vesting_steps.cairo b/packages/finance/src/tests/test_vesting_steps.cairo new file mode 100644 index 000000000..f08192b7d --- /dev/null +++ b/packages/finance/src/tests/test_vesting_steps.cairo @@ -0,0 +1,243 @@ +use openzeppelin_access::ownable::interface::{IOwnableDispatcher, IOwnableDispatcherTrait}; +use openzeppelin_finance::tests::common::{VestingStrategy, TestData, setup, set_transfer_to_fail}; +use openzeppelin_finance::tests::mocks::vesting_mocks::StepsVestingMock; +use openzeppelin_finance::vesting::VestingComponent::InternalImpl; +use openzeppelin_finance::vesting::VestingComponent; +use openzeppelin_finance::vesting::interface::IVestingDispatcherTrait; +use openzeppelin_test_common::vesting::VestingSpyHelpers; +use openzeppelin_testing::constants::{OWNER, OTHER}; +use openzeppelin_token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait}; +use snforge_std::{spy_events, start_cheat_caller_address, start_cheat_block_timestamp_global}; + +// +// Setup +// + +type ComponentState = VestingComponent::ComponentState; + +fn COMPONENT_STATE() -> ComponentState { + VestingComponent::component_state_for_testing() +} + +const TOTAL_STEPS: u64 = 10; + +fn TEST_DATA() -> TestData { + TestData { + strategy: VestingStrategy::Steps(TOTAL_STEPS), + total_allocation: 200, + beneficiary: OWNER(), + start: 30, + duration: 100, + cliff_duration: 0 + } +} + +// +// Tests +// + +#[test] +fn test_state_after_init() { + let data = TEST_DATA(); + let (vesting, _) = setup(data); + + assert_eq!(vesting.start(), data.start); + assert_eq!(vesting.duration(), data.duration); + assert_eq!(vesting.cliff(), data.start + data.cliff_duration); + assert_eq!(vesting.end(), data.start + data.duration); + let beneficiary = IOwnableDispatcher { contract_address: vesting.contract_address }.owner(); + assert_eq!(beneficiary, data.beneficiary); +} + +#[test] +#[should_panic(expected: ('Vesting: Invalid cliff duration',))] +fn test_init_invalid_cliff_value() { + let mut component_state = COMPONENT_STATE(); + let mut data = TEST_DATA(); + data.cliff_duration = data.duration + 1; + + component_state.initializer(data.start, data.duration, data.cliff_duration); +} + +#[test] +fn test_vesting_schedule_no_cliff() { + let data = TEST_DATA(); + let (vesting, token) = setup(data); + let tokens_per_step = data.total_allocation / TOTAL_STEPS.into(); + let step_duration = data.duration / TOTAL_STEPS; + + let mut time_passed = 0; + while time_passed <= data.duration { + let steps_passed = time_passed / step_duration; + let expected_vested_amount = tokens_per_step * steps_passed.into(); + let actual_vested_amount = vesting.vested_amount(token, data.start + time_passed); + assert_eq!(actual_vested_amount, expected_vested_amount); + + time_passed += 1; + }; + + let end_timestamp = data.start + data.duration; + assert_eq!(vesting.vested_amount(token, end_timestamp), data.total_allocation); +} + +#[test] +fn test_vesting_schedule_with_cliff() { + let mut data = TEST_DATA(); + data.cliff_duration = 30; + let (vesting, token) = setup(data); + let tokens_per_step = data.total_allocation / TOTAL_STEPS.into(); + let step_duration = data.duration / TOTAL_STEPS; + + let mut time_passed = 0; + while time_passed < data.cliff_duration { + let actual_vested_amount = vesting.vested_amount(token, data.start + time_passed); + assert_eq!(actual_vested_amount, 0); + + time_passed += 1; + }; + + while time_passed <= data.duration { + let steps_passed = time_passed / step_duration; + let expected_vested_amount = tokens_per_step * steps_passed.into(); + let actual_vested_amount = vesting.vested_amount(token, data.start + time_passed); + assert_eq!(actual_vested_amount, expected_vested_amount); + + time_passed += 1; + }; + + let end_timestamp = data.start + data.duration; + assert_eq!(vesting.vested_amount(token, end_timestamp), data.total_allocation); +} + +#[test] +fn test_release_single_call_within_duration() { + let data = TEST_DATA(); + let (vesting, token) = setup(data); + + let tokens_per_step = data.total_allocation / TOTAL_STEPS.into(); + let time_passed = 42; // 4 full steps passed + let expected_release_amount = 4 * tokens_per_step; + start_cheat_block_timestamp_global(data.start + time_passed); + let mut spy = spy_events(); + + assert_eq!(vesting.released(token), 0); + assert_eq!(vesting.releasable(token), expected_release_amount); + + let actual_release_amount = vesting.release(token); + assert_eq!(actual_release_amount, expected_release_amount); + + assert_eq!(vesting.released(token), expected_release_amount); + assert_eq!(vesting.releasable(token), 0); + + spy.assert_only_event_amount_released(vesting.contract_address, token, expected_release_amount); +} + +#[test] +fn test_release_single_call_after_end() { + let data = TEST_DATA(); + let (vesting, token) = setup(data); + + let time_passed = data.duration + 1; + start_cheat_block_timestamp_global(data.start + time_passed); + let mut spy = spy_events(); + + assert_eq!(vesting.released(token), 0); + assert_eq!(vesting.releasable(token), data.total_allocation); + + let actual_release_amount = vesting.release(token); + assert_eq!(actual_release_amount, data.total_allocation); + + assert_eq!(vesting.released(token), data.total_allocation); + assert_eq!(vesting.releasable(token), 0); + + spy.assert_only_event_amount_released(vesting.contract_address, token, data.total_allocation); +} + +#[test] +fn test_release_multiple_calls() { + let mut data = TEST_DATA(); + data.cliff_duration = 30; + let (vesting, token) = setup(data); + + // 1. Before cliff ended + start_cheat_block_timestamp_global(vesting.cliff() - 1); + assert_eq!(vesting.released(token), 0); + assert_eq!(vesting.releasable(token), 0); + + vesting.release(token); + + assert_eq!(vesting.released(token), 0); + assert_eq!(vesting.releasable(token), 0); + + // 2. When the cliff ended + start_cheat_block_timestamp_global(vesting.cliff()); + assert_eq!(vesting.released(token), 0); + assert_eq!(vesting.releasable(token), 60); + + vesting.release(token); + + assert_eq!(vesting.released(token), 60); + assert_eq!(vesting.releasable(token), 0); + + // 3. When 44/100 seconds passed + start_cheat_block_timestamp_global(data.start + 44); + assert_eq!(vesting.released(token), 60); + assert_eq!(vesting.releasable(token), 20); + + vesting.release(token); + + assert_eq!(vesting.released(token), 80); + assert_eq!(vesting.releasable(token), 0); + + // 4. After the vesting ended + start_cheat_block_timestamp_global(data.start + data.duration + 1); + assert_eq!(vesting.released(token), 80); + assert_eq!(vesting.releasable(token), 120); + + vesting.release(token); + + assert_eq!(vesting.released(token), data.total_allocation); + assert_eq!(vesting.releasable(token), 0); +} + +#[test] +fn test_release_after_ownership_transferred() { + let data = TEST_DATA(); + let (vesting, token) = setup(data); + let ownable_vesting = IOwnableDispatcher { contract_address: vesting.contract_address }; + let token_dispatcher = IERC20Dispatcher { contract_address: token }; + + // 1. Release to initial owner + let time_passed = 40; + let release_amount_1 = 80; + start_cheat_block_timestamp_global(data.start + time_passed); + vesting.release(token); + assert_eq!(vesting.released(token), release_amount_1); + assert_eq!(token_dispatcher.balance_of(data.beneficiary), release_amount_1); + + // 2. Transfer ownership + let new_owner = OTHER(); + start_cheat_caller_address(vesting.contract_address, data.beneficiary); + ownable_vesting.transfer_ownership(new_owner); + + // 3. Release to new owner + let release_amount_2 = data.total_allocation - release_amount_1; + start_cheat_block_timestamp_global(data.start + data.duration); + vesting.release(token); + assert_eq!(vesting.released(token), data.total_allocation); + assert_eq!(token_dispatcher.balance_of(data.beneficiary), release_amount_1); + assert_eq!(token_dispatcher.balance_of(new_owner), release_amount_2); +} + +#[test] +#[should_panic(expected: 'Vesting: Token transfer failed')] +fn test_panics_when_transfer_fails() { + let data = TEST_DATA(); + let (vesting, token) = setup(data); + + let time_passed = 42; + start_cheat_block_timestamp_global(data.start + time_passed); + set_transfer_to_fail(token, true); + + vesting.release(token); +} diff --git a/packages/finance/src/vesting.cairo b/packages/finance/src/vesting.cairo new file mode 100644 index 000000000..1f05a8efe --- /dev/null +++ b/packages/finance/src/vesting.cairo @@ -0,0 +1,4 @@ +pub mod interface; +pub mod vesting; + +pub use vesting::{VestingComponent, LinearVestingSchedule}; diff --git a/packages/finance/src/vesting/interface.cairo b/packages/finance/src/vesting/interface.cairo new file mode 100644 index 000000000..2bba85c8b --- /dev/null +++ b/packages/finance/src/vesting/interface.cairo @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts for Cairo v0.15.1 (finance/vesting/interface.cairo) + +use starknet::ContractAddress; + +#[starknet::interface] +pub trait IVesting { + /// Returns the timestamp marking the beginning of the vesting period. + fn start(self: @TState) -> u64; + + /// Returns the timestamp marking the end of the cliff period. + fn cliff(self: @TState) -> u64; + + /// Returns the total duration of the vesting period. + fn duration(self: @TState) -> u64; + + /// Returns the timestamp marking the end of the vesting period. + fn end(self: @TState) -> u64; + + /// Returns the already released amount for a given `token`. + fn released(self: @TState, token: ContractAddress) -> u256; + + /// Returns the amount of a given `token` that can be released at the time of the call. + fn releasable(self: @TState, token: ContractAddress) -> u256; + + /// Returns the total vested amount of a specified `token` at a given `timestamp`. + fn vested_amount(self: @TState, token: ContractAddress, timestamp: u64) -> u256; + + /// Releases the amount of a given `token` that has already vested. + fn release(ref self: TState, token: ContractAddress) -> u256; +} diff --git a/packages/finance/src/vesting/vesting.cairo b/packages/finance/src/vesting/vesting.cairo new file mode 100644 index 000000000..23a1a5d1e --- /dev/null +++ b/packages/finance/src/vesting/vesting.cairo @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts for Cairo v0.15.1 (finance/vesting/vesting.cairo) + +/// # Vesting Component +/// +/// A component for the controlled release of ERC-20 tokens to a designated beneficiary according +/// to a predefined vesting schedule. The implementing contract is required to implement `Ownable` +/// component, so that the owner of the contract is the vesting beneficiary. It also means that +/// ownership rights to the contract and to the vesting allocation can be assigned and transferred. +/// +/// Vesting schedule is specified through the `VestingScheduleTrait` implementation. +/// This trait can be used to implement any custom vesting schedules. +/// +/// Any assets transferred to this contract will follow the vesting schedule as if they were locked +/// from the beginning. Consequently, if the vesting has already started, any amount of tokens sent +/// to this contract may be immediately releasable. +/// +/// By setting the duration to 0, one can configure this contract to behave like an asset timelock +/// that holds tokens for a beneficiary until a specified time. +/// +/// NOTE: +/// - A separate contract with a Vesting component must be deployed for each beneficiary. +/// - Can be used to vest multiple tokens to a single beneficiary, provided that the core vesting +/// parameters (start, duration, and cliff_duration) are identical. +/// - When using this contract with any token whose balance is adjusted automatically +/// (i.e. a rebase token), make sure to account for the supply/balance adjustment in the +/// vesting schedule to ensure the vested amount is as intended. + +use starknet::ContractAddress; + +#[starknet::component] +pub mod VestingComponent { + use openzeppelin_access::ownable::OwnableComponent::OwnableImpl; + use openzeppelin_access::ownable::OwnableComponent; + use openzeppelin_finance::vesting::interface; + use openzeppelin_token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait}; + use starknet::ContractAddress; + use starknet::storage::{Map, StorageMapReadAccess, StorageMapWriteAccess}; + use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess}; + + #[storage] + pub struct Storage { + pub Vesting_start: u64, + pub Vesting_duration: u64, + pub Vesting_cliff: u64, + pub Vesting_released: Map + } + + #[event] + #[derive(Drop, PartialEq, starknet::Event)] + pub enum Event { + AmountReleased: AmountReleased + } + + /// Emitted when vested tokens are released to the beneficiary. + #[derive(Drop, PartialEq, starknet::Event)] + pub struct AmountReleased { + #[key] + pub token: ContractAddress, + pub amount: u256, + } + + pub mod Errors { + pub const INVALID_CLIFF_DURATION: felt252 = 'Vesting: Invalid cliff duration'; + pub const TOKEN_TRANSFER_FAILED: felt252 = 'Vesting: Token transfer failed'; + } + + /// A trait that defines the logic for calculating the vested amount based on a given timestamp. + pub trait VestingScheduleTrait { + /// Calculates and returns the vested amount at a given `timestamp` based on the core + /// vesting parameters. + fn calculate_vested_amount( + self: @ComponentState, + token: ContractAddress, + total_allocation: u256, + timestamp: u64, + start: u64, + duration: u64, + cliff: u64, + ) -> u256; + } + + #[embeddable_as(VestingImpl)] + impl Vesting< + TContractState, + +HasComponent, + impl Ownable: OwnableComponent::HasComponent, + +VestingScheduleTrait + > of interface::IVesting> { + /// Returns the timestamp marking the beginning of the vesting period. + fn start(self: @ComponentState) -> u64 { + self.Vesting_start.read() + } + + /// Returns the timestamp marking the end of the cliff period. + fn cliff(self: @ComponentState) -> u64 { + self.Vesting_cliff.read() + } + + /// Returns the total duration of the vesting period. + fn duration(self: @ComponentState) -> u64 { + self.Vesting_duration.read() + } + + /// Returns the timestamp marking the end of the vesting period. + fn end(self: @ComponentState) -> u64 { + self.start() + self.duration() + } + + /// Returns the already released amount for a given `token`. + fn released(self: @ComponentState, token: ContractAddress) -> u256 { + self.Vesting_released.read(token) + } + + /// Returns the amount of a given `token` that can be released at the time of the call. + fn releasable(self: @ComponentState, token: ContractAddress) -> u256 { + let now = starknet::get_block_timestamp(); + let vested_amount = self.resolve_vested_amount(token, now); + let released_amount = self.released(token); + if vested_amount > released_amount { + vested_amount - released_amount + } else { + 0 + } + } + + /// Returns the total vested amount of a specified `token` at a given `timestamp`. + fn vested_amount( + self: @ComponentState, token: ContractAddress, timestamp: u64 + ) -> u256 { + self.resolve_vested_amount(token, timestamp) + } + + /// Releases the amount of a given `token` that has already vested. + /// + /// Emits an `AmountReleased` event. + fn release(ref self: ComponentState, token: ContractAddress) -> u256 { + let amount = self.releasable(token); + self.Vesting_released.write(token, self.Vesting_released.read(token) + amount); + self.emit(AmountReleased { token, amount }); + + let beneficiary = get_dep_component!(@self, Ownable).owner(); + let token_dispatcher = IERC20Dispatcher { contract_address: token }; + assert(token_dispatcher.transfer(beneficiary, amount), Errors::TOKEN_TRANSFER_FAILED); + + amount + } + } + + #[generate_trait] + pub impl InternalImpl< + TContractState, + +HasComponent, + impl VestingSchedule: VestingScheduleTrait + > of InternalTrait { + /// Initializes the component by setting the vesting start, duration and cliff. + /// To prevent reinitialization, this should only be used inside of a contract's + /// constructor. + fn initializer( + ref self: ComponentState, start: u64, duration: u64, cliff_duration: u64 + ) { + self.Vesting_start.write(start); + self.Vesting_duration.write(duration); + + assert(cliff_duration <= duration, Errors::INVALID_CLIFF_DURATION); + self.Vesting_cliff.write(start + cliff_duration); + } + + /// Returns the vested amount that's calculated using the `VestingScheduleTrait` + /// implementation. + fn resolve_vested_amount( + self: @ComponentState, token: ContractAddress, timestamp: u64 + ) -> u256 { + let released_amount = self.Vesting_released.read(token); + let token_dispatcher = IERC20Dispatcher { contract_address: token }; + let self_balance = token_dispatcher.balance_of(starknet::get_contract_address()); + let total_allocation = self_balance + released_amount; + let vested_amount = VestingSchedule::calculate_vested_amount( + self, + token, + total_allocation, + timestamp, + self.Vesting_start.read(), + self.Vesting_duration.read(), + self.Vesting_cliff.read() + ); + vested_amount + } + } +} + +/// Defines the logic for calculating the vested amount, incorporating a cliff period. +/// It returns 0 before the cliff ends. After the cliff period, the vested amount returned +/// is directly proportional to the time passed since the start of the vesting schedule. +pub impl LinearVestingSchedule< + TContractState +> of VestingComponent::VestingScheduleTrait { + fn calculate_vested_amount( + self: @VestingComponent::ComponentState, + token: ContractAddress, + total_allocation: u256, + timestamp: u64, + start: u64, + duration: u64, + cliff: u64, + ) -> u256 { + if timestamp < cliff { + 0 + } else if timestamp >= start + duration { + total_allocation + } else { + (total_allocation * (timestamp - start).into()) / duration.into() + } + } +} diff --git a/packages/presets/Scarb.toml b/packages/presets/Scarb.toml index 3ce864608..8f4ccc232 100644 --- a/packages/presets/Scarb.toml +++ b/packages/presets/Scarb.toml @@ -1,4 +1,3 @@ - [package] name = "openzeppelin_presets" readme = "README.md" @@ -25,6 +24,7 @@ fmt.workspace = true starknet.workspace = true openzeppelin_access = { path = "../access" } openzeppelin_account = { path = "../account" } +openzeppelin_finance = { path = "../finance" } openzeppelin_introspection = { path = "../introspection" } openzeppelin_token = { path = "../token" } openzeppelin_upgrades = { path = "../upgrades" } diff --git a/packages/presets/src/lib.cairo b/packages/presets/src/lib.cairo index 55240efd2..5dd344f9d 100644 --- a/packages/presets/src/lib.cairo +++ b/packages/presets/src/lib.cairo @@ -7,6 +7,7 @@ pub mod interfaces; mod tests; pub mod universal_deployer; +pub mod vesting; pub use account::AccountUpgradeable; pub use erc1155::ERC1155Upgradeable; @@ -14,3 +15,4 @@ pub use erc20::ERC20Upgradeable; pub use erc721::ERC721Upgradeable; pub use eth_account::EthAccountUpgradeable; pub use universal_deployer::UniversalDeployer; +pub use vesting::VestingWallet; diff --git a/packages/presets/src/tests.cairo b/packages/presets/src/tests.cairo index fa4c3e3c2..68fc25020 100644 --- a/packages/presets/src/tests.cairo +++ b/packages/presets/src/tests.cairo @@ -12,3 +12,5 @@ mod test_erc721; mod test_eth_account; #[cfg(test)] mod test_universal_deployer; +#[cfg(test)] +mod test_vesting; diff --git a/packages/presets/src/tests/test_vesting.cairo b/packages/presets/src/tests/test_vesting.cairo new file mode 100644 index 000000000..d3e17d7b6 --- /dev/null +++ b/packages/presets/src/tests/test_vesting.cairo @@ -0,0 +1,227 @@ +use openzeppelin_access::ownable::interface::{IOwnableDispatcher, IOwnableDispatcherTrait}; +use openzeppelin_finance::vesting::VestingComponent::InternalImpl; +use openzeppelin_finance::vesting::VestingComponent; +use openzeppelin_finance::vesting::interface::{IVestingDispatcher, IVestingDispatcherTrait}; +use openzeppelin_presets::vesting::VestingWallet; +use openzeppelin_test_common::erc20::deploy_erc20; +use openzeppelin_test_common::vesting::VestingSpyHelpers; +use openzeppelin_testing as utils; +use openzeppelin_testing::constants::{OWNER, OTHER}; +use openzeppelin_token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait}; +use openzeppelin_utils::serde::SerializedAppend; +use snforge_std::{spy_events, start_cheat_caller_address, start_cheat_block_timestamp_global}; +use starknet::ContractAddress; + +// +// Setup +// + +#[derive(Copy, Drop)] +struct TestData { + total_allocation: u256, + beneficiary: ContractAddress, + start: u64, + duration: u64, + cliff_duration: u64 +} + +fn TEST_DATA() -> TestData { + TestData { + total_allocation: 200, beneficiary: OWNER(), start: 30, duration: 100, cliff_duration: 0 + } +} + +fn setup(data: TestData) -> (IVestingDispatcher, ContractAddress) { + let mut calldata = array![]; + calldata.append_serde(data.beneficiary); + calldata.append_serde(data.start); + calldata.append_serde(data.duration); + calldata.append_serde(data.cliff_duration); + let contract_address = utils::declare_and_deploy("VestingWallet", calldata); + let token = deploy_erc20(contract_address, data.total_allocation); + let vesting = IVestingDispatcher { contract_address }; + (vesting, token.contract_address) +} + +// +// Tests +// + +#[test] +fn test_state_after_init() { + let data = TEST_DATA(); + let (vesting, _) = setup(data); + + assert_eq!(vesting.start(), data.start); + assert_eq!(vesting.duration(), data.duration); + assert_eq!(vesting.cliff(), data.start + data.cliff_duration); + assert_eq!(vesting.end(), data.start + data.duration); + let beneficiary = IOwnableDispatcher { contract_address: vesting.contract_address }.owner(); + assert_eq!(beneficiary, data.beneficiary); +} + +#[test] +fn test_vesting_schedule_no_cliff() { + let data = TEST_DATA(); + let (vesting, token) = setup(data); + let tokens_per_sec = data.total_allocation / data.duration.into(); + + let mut time_passed = 0; + while time_passed <= data.duration { + let expected_vested_amount = tokens_per_sec * time_passed.into(); + let actual_vested_amount = vesting.vested_amount(token, data.start + time_passed); + assert_eq!(actual_vested_amount, expected_vested_amount); + + time_passed += 1; + }; + + let end_timestamp = data.start + data.duration; + assert_eq!(vesting.vested_amount(token, end_timestamp), data.total_allocation); +} + +#[test] +fn test_vesting_schedule_with_cliff() { + let mut data = TEST_DATA(); + data.cliff_duration = 30; + let (vesting, token) = setup(data); + let tokens_per_sec = data.total_allocation / data.duration.into(); + + let mut time_passed = 0; + while time_passed < data.cliff_duration { + let actual_vested_amount = vesting.vested_amount(token, data.start + time_passed); + assert_eq!(actual_vested_amount, 0); + + time_passed += 1; + }; + + while time_passed <= data.duration { + let expected_vested_amount = tokens_per_sec * time_passed.into(); + let actual_vested_amount = vesting.vested_amount(token, data.start + time_passed); + assert_eq!(actual_vested_amount, expected_vested_amount); + + time_passed += 1; + }; + + let end_timestamp = data.start + data.duration; + assert_eq!(vesting.vested_amount(token, end_timestamp), data.total_allocation); +} + +#[test] +fn test_release_single_call_within_duration() { + let data = TEST_DATA(); + let (vesting, token) = setup(data); + + let time_passed = 40; + let expected_release_amount = time_passed.into() + * (data.total_allocation / data.duration.into()); + start_cheat_block_timestamp_global(data.start + time_passed); + let mut spy = spy_events(); + + assert_eq!(vesting.released(token), 0); + assert_eq!(vesting.releasable(token), expected_release_amount); + + let actual_release_amount = vesting.release(token); + assert_eq!(actual_release_amount, expected_release_amount); + + assert_eq!(vesting.released(token), expected_release_amount); + assert_eq!(vesting.releasable(token), 0); + + spy.assert_only_event_amount_released(vesting.contract_address, token, expected_release_amount); +} + +#[test] +fn test_release_single_call_after_end() { + let data = TEST_DATA(); + let (vesting, token) = setup(data); + + let time_passed = data.duration + 1; + start_cheat_block_timestamp_global(data.start + time_passed); + let mut spy = spy_events(); + + assert_eq!(vesting.released(token), 0); + assert_eq!(vesting.releasable(token), data.total_allocation); + + let actual_release_amount = vesting.release(token); + assert_eq!(actual_release_amount, data.total_allocation); + + assert_eq!(vesting.released(token), data.total_allocation); + assert_eq!(vesting.releasable(token), 0); + + spy.assert_only_event_amount_released(vesting.contract_address, token, data.total_allocation); +} + +#[test] +fn test_release_multiple_calls() { + let mut data = TEST_DATA(); + data.cliff_duration = 30; + let (vesting, token) = setup(data); + + // 1. Before cliff ended + start_cheat_block_timestamp_global(vesting.cliff() - 1); + assert_eq!(vesting.released(token), 0); + assert_eq!(vesting.releasable(token), 0); + + vesting.release(token); + + assert_eq!(vesting.released(token), 0); + assert_eq!(vesting.releasable(token), 0); + + // 2. When the cliff ended + start_cheat_block_timestamp_global(vesting.cliff()); + assert_eq!(vesting.released(token), 0); + assert_eq!(vesting.releasable(token), 60); + + vesting.release(token); + + assert_eq!(vesting.released(token), 60); + assert_eq!(vesting.releasable(token), 0); + + // 3. When 40/100 seconds passed + start_cheat_block_timestamp_global(data.start + 40); + assert_eq!(vesting.released(token), 60); + assert_eq!(vesting.releasable(token), 20); + + vesting.release(token); + + assert_eq!(vesting.released(token), 80); + assert_eq!(vesting.releasable(token), 0); + + // 4. After the vesting ended + start_cheat_block_timestamp_global(data.start + data.duration + 1); + assert_eq!(vesting.released(token), 80); + assert_eq!(vesting.releasable(token), 120); + + vesting.release(token); + + assert_eq!(vesting.released(token), data.total_allocation); + assert_eq!(vesting.releasable(token), 0); +} + +#[test] +fn test_release_after_ownership_transferred() { + let data = TEST_DATA(); + let (vesting, token) = setup(data); + let ownable_vesting = IOwnableDispatcher { contract_address: vesting.contract_address }; + let token_dispatcher = IERC20Dispatcher { contract_address: token }; + + // 1. Release to initial owner + let time_passed = 40; + let release_amount_1 = 80; + start_cheat_block_timestamp_global(data.start + time_passed); + vesting.release(token); + assert_eq!(vesting.released(token), release_amount_1); + assert_eq!(token_dispatcher.balance_of(data.beneficiary), release_amount_1); + + // 2. Transfer ownership + let new_owner = OTHER(); + start_cheat_caller_address(vesting.contract_address, data.beneficiary); + ownable_vesting.transfer_ownership(new_owner); + + // 3. Release to new owner + let release_amount_2 = data.total_allocation - release_amount_1; + start_cheat_block_timestamp_global(data.start + data.duration); + vesting.release(token); + assert_eq!(vesting.released(token), data.total_allocation); + assert_eq!(token_dispatcher.balance_of(data.beneficiary), release_amount_1); + assert_eq!(token_dispatcher.balance_of(new_owner), release_amount_2); +} diff --git a/packages/presets/src/vesting.cairo b/packages/presets/src/vesting.cairo new file mode 100644 index 000000000..2ef8b6891 --- /dev/null +++ b/packages/presets/src/vesting.cairo @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts for Cairo v0.15.1 (presets/vesting.cairo) + +#[starknet::contract] +pub mod VestingWallet { + use openzeppelin_access::ownable::OwnableComponent; + use openzeppelin_finance::vesting::{VestingComponent, LinearVestingSchedule}; + use starknet::ContractAddress; + + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent); + component!(path: VestingComponent, storage: vesting, event: VestingEvent); + + // Ownable Mixin + #[abi(embed_v0)] + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl; + impl OwnableInternalImpl = OwnableComponent::InternalImpl; + + // Vesting + #[abi(embed_v0)] + impl VestingImpl = VestingComponent::VestingImpl; + impl VestingInternalImpl = VestingComponent::InternalImpl; + + #[storage] + struct Storage { + #[substorage(v0)] + ownable: OwnableComponent::Storage, + #[substorage(v0)] + vesting: VestingComponent::Storage + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + #[flat] + OwnableEvent: OwnableComponent::Event, + #[flat] + VestingEvent: VestingComponent::Event + } + + #[constructor] + fn constructor( + ref self: ContractState, beneficiary: ContractAddress, start: u64, duration: u64, cliff: u64 + ) { + self.ownable.initializer(beneficiary); + self.vesting.initializer(start, duration, cliff); + } +} diff --git a/packages/test_common/Scarb.toml b/packages/test_common/Scarb.toml index 6175a86be..1314cb3ea 100644 --- a/packages/test_common/Scarb.toml +++ b/packages/test_common/Scarb.toml @@ -22,6 +22,7 @@ snforge_std.workspace = true openzeppelin_upgrades = { path = "../upgrades" } openzeppelin_access = { path = "../access" } openzeppelin_account = { path = "../account" } +openzeppelin_finance = { path = "../finance" } openzeppelin_token = { path = "../token" } openzeppelin_testing = { path = "../testing" } openzeppelin_utils = { path = "../utils" } diff --git a/packages/test_common/src/lib.cairo b/packages/test_common/src/lib.cairo index cc19458d5..8013cedc4 100644 --- a/packages/test_common/src/lib.cairo +++ b/packages/test_common/src/lib.cairo @@ -5,3 +5,4 @@ pub mod erc721; pub mod eth_account; pub mod ownable; pub mod upgrades; +pub mod vesting; diff --git a/packages/test_common/src/vesting.cairo b/packages/test_common/src/vesting.cairo new file mode 100644 index 000000000..d6039c107 --- /dev/null +++ b/packages/test_common/src/vesting.cairo @@ -0,0 +1,15 @@ +use openzeppelin_finance::vesting::VestingComponent::AmountReleased; +use openzeppelin_finance::vesting::VestingComponent; +use openzeppelin_testing::events::EventSpyExt; +use snforge_std::EventSpy; +use starknet::ContractAddress; + +#[generate_trait] +pub impl VestingSpyHelpersImpl of VestingSpyHelpers { + fn assert_only_event_amount_released( + ref self: EventSpy, contract: ContractAddress, token: ContractAddress, amount: u256 + ) { + let expected = VestingComponent::Event::AmountReleased(AmountReleased { token, amount }); + self.assert_only_event(contract, expected); + } +} diff --git a/src/lib.cairo b/src/lib.cairo index d0a2993ff..1ffc12322 100644 --- a/src/lib.cairo +++ b/src/lib.cairo @@ -1,5 +1,6 @@ pub use openzeppelin_access as access; pub use openzeppelin_account as account; +pub use openzeppelin_finance as finance; pub use openzeppelin_governance as governance; pub use openzeppelin_introspection as introspection; pub use openzeppelin_merkle_tree as merkle_tree;