diff --git a/Scarb.lock b/Scarb.lock index 96d58087..c84915d1 100644 --- a/Scarb.lock +++ b/Scarb.lock @@ -131,9 +131,16 @@ dependencies = [ "openzeppelin", ] +[[package]] +name = "simple_storage" +version = "0.1.0" + [[package]] name = "simple_vault" version = "0.1.0" +dependencies = [ + "erc20", +] [[package]] name = "snforge_std" diff --git a/Scarb.toml b/Scarb.toml index 8df8fcb2..02bd2661 100644 --- a/Scarb.toml +++ b/Scarb.toml @@ -12,6 +12,7 @@ test = "$(git rev-parse --show-toplevel)/scripts/test_resolver.sh" [workspace.tool.snforge] [workspace.dependencies] +snforge = { git = "https://github.com/foundry-rs/starknet-foundry" } starknet = ">=2.6.4" openzeppelin = { git = "https://github.com/OpenZeppelin/cairo-contracts.git", tag="v0.14.0" } components = { path = "listings/applications/components" } diff --git a/listings/applications/erc20/src/erc20_streaming.cairo b/listings/applications/erc20/src/erc20_streaming.cairo new file mode 100644 index 00000000..32f6c55c --- /dev/null +++ b/listings/applications/erc20/src/erc20_streaming.cairo @@ -0,0 +1,137 @@ +#[starknet::contract] + +pub mod erc20_streaming { + + // Import necessary modules and traits + use core::num::traits::Zero; + use starknet::get_caller_address; + use starknet::ContractAddress; + use starknet::LegacyMap; + + #[storage] + struct Storage { + streams: LegacyMap, + next_stream_id: u64, + + } + + #[derive(Copy, Drop, Debug, PartialEq)] + struct Stream { + from: ContractAddress, + start_time: u64, + end_time: u64, + total_amount: felt252, + released_amount: felt252, + to: ContractAddress, + erc20_token: ContractAddress, + } + + #[event] + #[derive(Copy, Drop, Debug, PartialEq, starknet::Event)] + pub enum Event { + StreamCreated: StreamCreated, + TokensReleased: TokensReleased, + } + + #[derive(Copy, Drop, Debug, PartialEq, starknet::Event)] + pub struct StreamCreated { + pub from: ContractAddress, + pub to: ContractAddress, + pub total_amount: felt252, + pub start_time: u64, + pub end_time: u64, + } + + #[derive(Copy, Drop, Debug, PartialEq, starknet::Event)] + pub struct TokensReleased { + pub to: ContractAddress, + pub amount: felt252, + } + + mod Errors { + pub const STREAM_AMOUNT_ZERO: felt252 = 'Stream amount cannot be zero'; + pub const STREAM_ALREADY_EXISTS: felt252 = 'Stream already exists'; + pub const END_TIME_INVALID: felt252 = 'End time must be greater than start time'; + pub const START_TIME_INVALID: felt252 = 'Start time must be greater than or equal to the current block timestamp';''; + pub const STREAM_UNAUTHORIZED: felt252 = 'Caller is not the recipient of the stream'; + } + + #[constructor] + fn constructor(ref self: ContractState, erc20_token: ContractAddress) { + self.erc20_token.write(erc20_token); + self.next_stream_id.write(1); + } + + #[abi(embed_v0)] + impl IStreamImpl of super::IStream { + fn create_stream( + ref self: ContractState, + to: ContractAddress, + total_amount: felt252, + start_time: u64, + end_time: u64 + ) { + assert(total_amount != felt252::zero(), Errors::STREAM_AMOUNT_ZERO); + let caller = get_caller_address(); + let start_time = get_block_timestamp(); // Use block timestamp for start time + assert(end_time > start_time, Errors::END_TIME_INVALID); // Assert end_time > start_time + assert(start_time >= get_block_timestamp(), Errors::START_TIME_INVALID); + + let stream_key = self.next_stream_id; + self.next_stream_id.write(stream_key + 1); + + // Call the ERC20 contract to transfer tokens + let erc20 = self.erc20_token.read(); + erc20.call("transfer_from", (caller, self.contract_address(), total_amount)); + + let stream = Stream { + from: caller; + to, + start_time, + end_time, + total_amount, + released_amount: felt252::zero(), + }; + self.streams.write(stream_id, stream); + + self.emit(StreamCreated { stream_id, from: caller, to, total_amount, start_time, end_time }); + } + self.next_stream_id.write(stream_id + 1); // Increment the stream ID counter + } + + fn release_tokens(ref self: ContractState, stream_id: u64) { + let caller = get_caller_address(); + let stream = self.streams.read(stream_id); + assert(caller == stream.to, Errors::STREAM_UNAUTHORIZED); + + let releasable_amount = self.releasable_amount(stream); + assert( + releasable_amount <= (stream.total_amount - stream.released_amount), + "Releasable amount exceeds remaining tokens" + ); + + self.streams.write( + stream_id, + Stream { + released_amount: stream.released_amount + releasable_amount, + ..stream + } + ); + + // Call the ERC20 contract to transfer tokens + let erc20 = self.erc20_token.read(); + erc20.call("transfer", (stream.to, releasable_amount)); + + self.emit(TokensReleased { to, amount: releasable_amount }); + } + + fn releasable_amount(&self, stream: Stream) -> felt252 { + let current_time = starknet::get_block_timestamp(); + let time_elapsed = current_time - stream.start_time; + let vesting_duration = stream.end_time - stream.start_time; + + let vested_amount = stream.total_amount * min(time_elapsed, vesting_duration) / vesting_duration;; + vested_amount - stream.released_amount; + } + } + diff --git a/listings/applications/erc20/tests/test_erc20_streaming.cairo b/listings/applications/erc20/tests/test_erc20_streaming.cairo new file mode 100644 index 00000000..41f11cd2 --- /dev/null +++ b/listings/applications/erc20/tests/test_erc20_streaming.cairo @@ -0,0 +1,55 @@ +#[cfg(test)] +mod tests { + use super::*; + use starknet::testing::{start_block, get_block_timestamp}; + use starknet::ContractAddress; + use core::num::traits::Zero; + use erc20_streaming::{Stream, Storage, Errors}; + + #[test] + fn test_create_stream() { + let erc20_token = ContractAddress::new(1); // Mock ERC20 token address + let mut contract = erc20_streaming::ContractState::new(erc20_token); + + let to = ContractAddress::new(2); + let total_amount = felt252::from(1000); + let start_time = get_block_timestamp(); + let end_time = start_time + 3600; // 1 hour later + contract.create_stream(to, total_amount, start_time, end_time); + + // Assert + let stream = contract.streams.read(1); // Stream ID should start from 1 + assert_eq!(stream.from, get_caller_address()); + assert_eq!(stream.to, to); + assert_eq!(stream.total_amount, total_amount); + assert_eq!(stream.released_amount, felt252::zero()); + assert_eq!(stream.start_time, start_time); + assert_eq!(stream.end_time, end_time); + + // Check if the next stream ID is incremented + assert_eq!(contract.next_stream_id.read(), 2); + } + + #[test] + fn test_release_tokens() { + let erc20_token = ContractAddress::new(1); + let mut contract = erc20_streaming::ContractState::new(erc20_token); + + // Create a stream + let to = ContractAddress::new(2); + let total_amount = felt252::from(1000); + let start_time = get_block_timestamp(); + let end_time = start_time + 3600; + contract.create_stream(to, total_amount, start_time, end_time); + + start_block(start_time + 1800); // 30 minutes later + + + contract.release_tokens(1); // Release tokens for stream ID 1 + + // Assert + let stream = contract.streams.read(1); + let expected_released_amount = felt252::from(500); // Half of the total amount should be released + assert_eq!(stream.released_amount, expected_released_amount); + } +} diff --git a/src/applications/advanced_factory.md b/src/applications/advanced_factory.md index 5d2a27fd..bc197d9b 100644 --- a/src/applications/advanced_factory.md +++ b/src/applications/advanced_factory.md @@ -9,5 +9,5 @@ Key Features - the factory only updates it's `Campaign` class hash and emits an event to notify any listeners, but the `Campaign` creators are in the end responsible for actually upgrading their contracts. ```rust -{{#include ../../listings/applications/advanced_factory/src/contract.cairo:contract}} +{{#include ../../listings/applications/crowdfunding/src/campaign.cairo:contract}} ``` diff --git a/src/applications/erc20.md b/src/applications/erc20.md index af0085ac..b46ccae0 100644 --- a/src/applications/erc20.md +++ b/src/applications/erc20.md @@ -4,17 +4,17 @@ Contracts that follow the [ERC20 Standard](https://eips.ethereum.org/EIPS/eip-20 To create an ERC20 contract, it must implement the following interface: -```rust +rust {{#include ../../listings/applications/erc20/src/token.cairo:interface}} -``` + In Starknet, function names should be written in _snake_case_. This is not the case in Solidity, where function names are written in _camelCase_. The Starknet ERC20 interface is therefore slightly different from the Solidity ERC20 interface. Here's an implementation of the ERC20 interface in Cairo: -```rust +rust {{#include ../../listings/applications/erc20/src/token.cairo:erc20}} -``` + There's several other implementations, such as the [Open Zeppelin](https://docs.openzeppelin.com/contracts-cairo/0.7.0/erc20) or the [Cairo By Example](https://cairo-by-example.com/examples/erc20/) ones. diff --git a/src/applications/token_streaming.md b/src/applications/token_streaming.md new file mode 100644 index 00000000..a1c82e9b --- /dev/null +++ b/src/applications/token_streaming.md @@ -0,0 +1,11 @@ +# Token Streaming + +Token streaming is a mechanism where tokens are distributed gradually over a specified vesting period. This ensures that the recipient of the tokens cannot access the entire token balance upfront but will receive portions of it gradually: +- **Setting up Token Streams**: Create a stream that specifies the recipient, total tokens, start time, and vesting duration. +- **Vesting Period Specification**: The contract calculates the vested amount over time based on the start and end time of the vesting period. +- **Token Release**: Only the vested amount can be released at any point, ensuring the tokens are gradually unlocked. + +```cairo +{{#include ..listings/applications/erc20/src/erc20_streaming.cairo}} + +``` diff --git a/starknet-foundry b/starknet-foundry new file mode 160000 index 00000000..0e214183 --- /dev/null +++ b/starknet-foundry @@ -0,0 +1 @@ +Subproject commit 0e214183672b37e6958ae7d32c209f18aeaa6fda