diff --git a/.github/workflows/verify_cairo_programs.yml b/.github/workflows/verify_cairo_programs.yml index 47d17c6a..81a3a802 100644 --- a/.github/workflows/verify_cairo_programs.yml +++ b/.github/workflows/verify_cairo_programs.yml @@ -4,6 +4,7 @@ on: pull_request: branches: - main + workflow_dispatch: jobs: compile_and_verify: @@ -14,7 +15,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - + - name: Configure upstream repository run: | git remote add upstream https://github.com/NethermindEth/StarknetByExample @@ -23,6 +24,9 @@ jobs: - name: Install scarb uses: software-mansion/setup-scarb@v1 + - name: Install snforge + uses: foundry-rs/setup-snfoundry@v3 + - name: Run build script run: | chmod +x scripts/cairo_programs_verifier.sh diff --git a/.tool-versions b/.tool-versions index 441b479d..c62bafce 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1,2 @@ -scarb 2.6.4 \ No newline at end of file +scarb 2.6.4 +starknet-foundry 0.24.0 diff --git a/Scarb.lock b/Scarb.lock index 81ae93eb..31034f8c 100644 --- a/Scarb.lock +++ b/Scarb.lock @@ -101,6 +101,11 @@ version = "0.1.0" name = "simple_vault" version = "0.1.0" +[[package]] +name = "snforge_std" +version = "0.24.0" +source = "git+https://github.com/foundry-rs/starknet-foundry.git?tag=v0.24.0#95e9fb09cb91b3c05295915179ee1b55bf923653" + [[package]] name = "staking" version = "0.1.0" @@ -132,6 +137,15 @@ version = "0.1.0" name = "testing_how_to" version = "0.1.0" +[[package]] +name = "timelock" +version = "0.1.0" +dependencies = [ + "components", + "openzeppelin", + "snforge_std", +] + [[package]] name = "upgradeable_contract" version = "0.1.0" diff --git a/Scarb.toml b/Scarb.toml index e5c678ae..4a232645 100644 --- a/Scarb.toml +++ b/Scarb.toml @@ -13,10 +13,11 @@ test = "$(git rev-parse --show-toplevel)/scripts/test_resolver.sh" [workspace.dependencies] starknet = ">=2.6.3" -# snforge_std = { git = "https://github.com/foundry-rs/starknet-foundry.git", tag = "v0.11.0" } openzeppelin = { git = "https://github.com/OpenZeppelin/cairo-contracts.git", tag="v0.11.0" } +components = { path = "listings/applications/components" } # [workspace.dev-dependencies] +snforge_std = { git = "https://github.com/foundry-rs/starknet-foundry.git", tag = "v0.24.0" } # openzeppelin = { git = "https://github.com/OpenZeppelin/cairo-contracts.git", tag="v0.11.0" } [workspace.package] diff --git a/listings/applications/components/src/ownable.cairo b/listings/applications/components/src/ownable.cairo index 85a70aef..c73180e3 100644 --- a/listings/applications/components/src/ownable.cairo +++ b/listings/applications/components/src/ownable.cairo @@ -7,7 +7,7 @@ pub trait IOwnable { fn renounce_ownership(ref self: TContractState); } -mod Errors { +pub mod Errors { pub const UNAUTHORIZED: felt252 = 'Not owner'; pub const ZERO_ADDRESS_OWNER: felt252 = 'Owner cannot be zero'; pub const ZERO_ADDRESS_CALLER: felt252 = 'Caller cannot be zero'; @@ -43,7 +43,7 @@ pub mod ownable_component { } #[embeddable_as(Ownable)] - impl OwnableImpl< + pub impl OwnableImpl< TContractState, +HasComponent > of super::IOwnable> { fn owner(self: @ComponentState) -> ContractAddress { diff --git a/listings/applications/timelock/.gitignore b/listings/applications/timelock/.gitignore new file mode 100644 index 00000000..eb5a316c --- /dev/null +++ b/listings/applications/timelock/.gitignore @@ -0,0 +1 @@ +target diff --git a/listings/applications/timelock/Scarb.toml b/listings/applications/timelock/Scarb.toml new file mode 100644 index 00000000..a3eaafbf --- /dev/null +++ b/listings/applications/timelock/Scarb.toml @@ -0,0 +1,19 @@ +[package] +name = "timelock" +version.workspace = true +edition = "2023_11" + +[dependencies] +starknet.workspace = true +# Starknet Foundry: +snforge_std.workspace = true +# OpenZeppelin: +openzeppelin.workspace = true +# StarknetByExample Components +components.workspace = true + +[scripts] +test.workspace = true + +[[target.starknet-contract]] +casm = true diff --git a/listings/applications/timelock/src/erc721.cairo b/listings/applications/timelock/src/erc721.cairo new file mode 100644 index 00000000..be7a023c --- /dev/null +++ b/listings/applications/timelock/src/erc721.cairo @@ -0,0 +1,44 @@ +#[starknet::contract] +pub mod ERC721 { + use starknet::ContractAddress; + use openzeppelin::introspection::src5::SRC5Component; + use openzeppelin::token::erc721::ERC721Component; + + component!(path: SRC5Component, storage: src5, event: SRC5Event); + component!(path: ERC721Component, storage: erc721, event: ERC721Event); + + // ERC20Mixin + #[abi(embed_v0)] + impl ERC721MixinImpl = ERC721Component::ERC721MixinImpl; + impl ERC721InternalImpl = ERC721Component::InternalImpl; + + #[storage] + struct Storage { + #[substorage(v0)] + src5: SRC5Component::Storage, + #[substorage(v0)] + erc721: ERC721Component::Storage, + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + #[flat] + SRC5Event: SRC5Component::Event, + #[flat] + ERC721Event: ERC721Component::Event, + } + + #[constructor] + fn constructor( + ref self: ContractState, + name: ByteArray, + symbol: ByteArray, + base_uri: ByteArray, + recipient: ContractAddress, + token_id: u256 + ) { + self.erc721.initializer(name, symbol, base_uri); + self.erc721._mint(recipient, token_id); + } +} diff --git a/listings/applications/timelock/src/lib.cairo b/listings/applications/timelock/src/lib.cairo new file mode 100644 index 00000000..8c08585f --- /dev/null +++ b/listings/applications/timelock/src/lib.cairo @@ -0,0 +1,8 @@ +#[cfg(test)] +mod tests { + #[feature("safe_dispatcher")] + mod timelock; + mod utils; +} +mod erc721; +mod timelock; diff --git a/listings/applications/timelock/src/tests/timelock.cairo b/listings/applications/timelock/src/tests/timelock.cairo new file mode 100644 index 00000000..f3e6c871 --- /dev/null +++ b/listings/applications/timelock/src/tests/timelock.cairo @@ -0,0 +1,228 @@ +use core::panic_with_felt252; +use starknet::get_block_timestamp; +use starknet::account::Call; +use core::poseidon::{PoseidonTrait, poseidon_hash_span}; +use core::hash::{HashStateTrait, HashStateExTrait}; +use snforge_std::{ + cheat_caller_address, cheat_block_timestamp, CheatSpan, spy_events, SpyOn, EventSpy, + EventAssertions +}; +use openzeppelin::token::erc721::interface::IERC721DispatcherTrait; +use openzeppelin::token::erc721::erc721::ERC721Component; +use components::ownable; +use timelock::timelock::{TimeLock, ITimeLockDispatcherTrait, ITimeLockSafeDispatcherTrait}; +use timelock::tests::utils::{TimeLockTestTrait, TOKEN_ID, OTHER}; + +#[test] +fn test_get_tx_id_success() { + let timelock_test = TimeLockTestTrait::setup(); + let timestamp = timelock_test.get_timestamp(); + let tx_id = timelock_test.timelock.get_tx_id(timelock_test.get_call(), timestamp); + let Call { to, selector, calldata } = timelock_test.get_call(); + let hash = PoseidonTrait::new() + .update(to.into()) + .update(selector.into()) + .update(poseidon_hash_span(calldata)) + .update(timestamp.into()) + .finalize(); + assert_eq!(tx_id, hash); +} + +#[test] +fn test_queue_only_owner() { + let timelock_test = TimeLockTestTrait::setup(); + cheat_caller_address(timelock_test.timelock_address, OTHER(), CheatSpan::TargetCalls(1)); + match timelock_test + .timelock_safe + .queue(timelock_test.get_call(), timelock_test.get_timestamp()) { + Result::Ok(_) => panic_with_felt252('FAIL'), + Result::Err(panic_data) => { assert_eq!(*panic_data.at(0), ownable::Errors::UNAUTHORIZED); } + } +} + +#[test] +fn test_queue_already_queued() { + let timelock_test = TimeLockTestTrait::setup(); + let timestamp = timelock_test.get_timestamp(); + timelock_test.timelock.queue(timelock_test.get_call(), timestamp); + match timelock_test.timelock_safe.queue(timelock_test.get_call(), timestamp) { + Result::Ok(_) => panic_with_felt252('FAIL'), + Result::Err(panic_data) => { + assert_eq!(*panic_data.at(0), TimeLock::Errors::ALREADY_QUEUED); + } + } +} + +#[test] +fn test_queue_timestamp_not_in_range() { + let timelock_test = TimeLockTestTrait::setup(); + match timelock_test.timelock_safe.queue(timelock_test.get_call(), 0) { + Result::Ok(_) => panic_with_felt252('FAIL'), + Result::Err(panic_data) => { + assert_eq!(*panic_data.at(0), TimeLock::Errors::TIMESTAMP_NOT_IN_RANGE); + } + } + match timelock_test + .timelock_safe + .queue(timelock_test.get_call(), timelock_test.get_timestamp() + TimeLock::MAX_DELAY) { + Result::Ok(_) => panic_with_felt252('FAIL'), + Result::Err(panic_data) => { + assert_eq!(*panic_data.at(0), TimeLock::Errors::TIMESTAMP_NOT_IN_RANGE); + } + } +} + +#[test] +fn test_queue_success() { + let timelock_test = TimeLockTestTrait::setup(); + let mut spy = spy_events(SpyOn::One(timelock_test.timelock_address)); + let timestamp = timelock_test.get_timestamp(); + let tx_id = timelock_test.timelock.queue(timelock_test.get_call(), timestamp); + spy + .assert_emitted( + @array![ + ( + timelock_test.timelock_address, + TimeLock::Event::Queue( + TimeLock::Queue { tx_id, call: timelock_test.get_call(), timestamp } + ) + ) + ] + ); + assert_eq!(tx_id, timelock_test.timelock.get_tx_id(timelock_test.get_call(), timestamp)); +} + +#[test] +fn test_execute_only_owner() { + let timelock_test = TimeLockTestTrait::setup(); + cheat_caller_address(timelock_test.timelock_address, OTHER(), CheatSpan::TargetCalls(1)); + match timelock_test + .timelock_safe + .execute(timelock_test.get_call(), timelock_test.get_timestamp()) { + Result::Ok(_) => panic_with_felt252('FAIL'), + Result::Err(panic_data) => { assert_eq!(*panic_data.at(0), ownable::Errors::UNAUTHORIZED); } + } +} + +#[test] +fn test_execute_not_queued() { + let timelock_test = TimeLockTestTrait::setup(); + match timelock_test + .timelock_safe + .execute(timelock_test.get_call(), timelock_test.get_timestamp()) { + Result::Ok(_) => panic_with_felt252('FAIL'), + Result::Err(panic_data) => { assert_eq!(*panic_data.at(0), TimeLock::Errors::NOT_QUEUED); } + } +} + +#[test] +fn test_execute_timestamp_not_passed() { + let timelock_test = TimeLockTestTrait::setup(); + let timestamp = timelock_test.get_timestamp(); + timelock_test.timelock.queue(timelock_test.get_call(), timestamp); + match timelock_test.timelock_safe.execute(timelock_test.get_call(), timestamp) { + Result::Ok(_) => panic_with_felt252('FAIL'), + Result::Err(panic_data) => { + assert_eq!(*panic_data.at(0), TimeLock::Errors::TIMESTAMP_NOT_PASSED); + } + } +} + +#[test] +fn test_execute_timestamp_expired() { + let timelock_test = TimeLockTestTrait::setup(); + let timestamp = timelock_test.get_timestamp(); + timelock_test.timelock.queue(timelock_test.get_call(), timestamp); + cheat_block_timestamp( + timelock_test.timelock_address, + timestamp + TimeLock::GRACE_PERIOD + 1, + CheatSpan::TargetCalls(1) + ); + match timelock_test.timelock_safe.execute(timelock_test.get_call(), timestamp) { + Result::Ok(_) => panic_with_felt252('FAIL'), + Result::Err(panic_data) => { + assert_eq!(*panic_data.at(0), TimeLock::Errors::TIMESTAMP_EXPIRED); + } + } +} + +#[test] +fn test_execute_success() { + let timelock_test = TimeLockTestTrait::setup(); + let timestamp = timelock_test.get_timestamp(); + let tx_id = timelock_test.timelock.get_tx_id(timelock_test.get_call(), timestamp); + timelock_test.timelock.queue(timelock_test.get_call(), timestamp); + timelock_test.erc721.approve(timelock_test.timelock_address, TOKEN_ID); + cheat_block_timestamp(timelock_test.timelock_address, timestamp + 1, CheatSpan::TargetCalls(1)); + let mut spy = spy_events(SpyOn::One(timelock_test.timelock_address)); + timelock_test.timelock.execute(timelock_test.get_call(), timestamp); + spy + .assert_emitted( + @array![ + ( + timelock_test.timelock_address, + TimeLock::Event::Execute( + TimeLock::Execute { tx_id, call: timelock_test.get_call(), timestamp } + ) + ) + ] + ); +} + +#[test] +fn test_execute_failed() { + let timelock_test = TimeLockTestTrait::setup(); + let timestamp = timelock_test.get_timestamp(); + timelock_test.timelock.queue(timelock_test.get_call(), timestamp); + cheat_block_timestamp(timelock_test.timelock_address, timestamp + 1, CheatSpan::TargetCalls(1)); + match timelock_test.timelock_safe.execute(timelock_test.get_call(), timestamp) { + Result::Ok(_) => panic_with_felt252('FAIL'), + Result::Err(panic_data) => { + assert_eq!(*panic_data.at(0), ERC721Component::Errors::UNAUTHORIZED); + } + } +} + +#[test] +fn test_cancel_only_owner() { + let timelock_test = TimeLockTestTrait::setup(); + let tx_id = timelock_test + .timelock + .get_tx_id(timelock_test.get_call(), timelock_test.get_timestamp()); + cheat_caller_address(timelock_test.timelock_address, OTHER(), CheatSpan::TargetCalls(1)); + match timelock_test.timelock_safe.cancel(tx_id) { + Result::Ok(_) => panic_with_felt252('FAIL'), + Result::Err(panic_data) => { assert_eq!(*panic_data.at(0), ownable::Errors::UNAUTHORIZED); } + } +} + +#[test] +fn test_cancel_not_queued() { + let timelock_test = TimeLockTestTrait::setup(); + let tx_id = timelock_test + .timelock + .get_tx_id(timelock_test.get_call(), timelock_test.get_timestamp()); + match timelock_test.timelock_safe.cancel(tx_id) { + Result::Ok(_) => panic_with_felt252('FAIL'), + Result::Err(panic_data) => { assert_eq!(*panic_data.at(0), TimeLock::Errors::NOT_QUEUED); } + } +} + +#[test] +fn test_cancel_success() { + let timelock_test = TimeLockTestTrait::setup(); + let tx_id = timelock_test + .timelock + .queue(timelock_test.get_call(), timelock_test.get_timestamp()); + let mut spy = spy_events(SpyOn::One(timelock_test.timelock_address)); + timelock_test.timelock.cancel(tx_id); + spy + .assert_emitted( + @array![ + ( + timelock_test.timelock_address, + TimeLock::Event::Cancel(TimeLock::Cancel { tx_id }) + ) + ] + ); +} diff --git a/listings/applications/timelock/src/tests/utils.cairo b/listings/applications/timelock/src/tests/utils.cairo new file mode 100644 index 00000000..c8de72a5 --- /dev/null +++ b/listings/applications/timelock/src/tests/utils.cairo @@ -0,0 +1,68 @@ +use starknet::{ContractAddress, contract_address_const, get_block_timestamp}; +use starknet::account::Call; +use snforge_std::{declare, ContractClassTrait, test_address}; +use openzeppelin::utils::serde::SerializedAppend; +use openzeppelin::token::erc721::interface::IERC721Dispatcher; +use timelock::timelock::{TimeLock, ITimeLockDispatcher, ITimeLockSafeDispatcher}; + +pub const TOKEN_ID: u256 = 1; + +pub fn NAME() -> ByteArray { + "NAME" +} + +pub fn SYMBOL() -> ByteArray { + "SYMBOL" +} + +pub fn BASE_URI() -> ByteArray { + "https://api.example.com/v1/" +} + +pub fn OTHER() -> ContractAddress { + contract_address_const::<'OTHER'>() +} + +#[derive(Copy, Drop)] +pub struct TimeLockTest { + pub timelock_address: ContractAddress, + pub timelock: ITimeLockDispatcher, + pub timelock_safe: ITimeLockSafeDispatcher, + pub erc721_address: ContractAddress, + pub erc721: IERC721Dispatcher, +} + +#[generate_trait] +pub impl TimeLockTestImpl of TimeLockTestTrait { + fn setup() -> TimeLockTest { + let timelock_contract = declare("TimeLock").unwrap(); + let mut timelock_calldata = array![]; + let (timelock_address, _) = timelock_contract.deploy(@timelock_calldata).unwrap(); + let timelock = ITimeLockDispatcher { contract_address: timelock_address }; + let timelock_safe = ITimeLockSafeDispatcher { contract_address: timelock_address }; + let erc721_contract = declare("ERC721").unwrap(); + let mut erc721_calldata = array![]; + erc721_calldata.append_serde(NAME()); + erc721_calldata.append_serde(SYMBOL()); + erc721_calldata.append_serde(BASE_URI()); + erc721_calldata.append_serde(test_address()); + erc721_calldata.append_serde(TOKEN_ID); + let (erc721_address, _) = erc721_contract.deploy(@erc721_calldata).unwrap(); + let erc721 = IERC721Dispatcher { contract_address: erc721_address }; + TimeLockTest { timelock_address, timelock, timelock_safe, erc721_address, erc721 } + } + fn get_call(self: @TimeLockTest) -> Call { + let mut calldata = array![]; + calldata.append_serde(test_address()); + calldata.append_serde(*self.timelock_address); + calldata.append_serde(TOKEN_ID); + Call { + to: *self.erc721_address, + selector: selector!("transfer_from"), + calldata: calldata.span() + } + } + fn get_timestamp(self: @TimeLockTest) -> u64 { + get_block_timestamp() + TimeLock::MIN_DELAY + } +} diff --git a/listings/applications/timelock/src/timelock.cairo b/listings/applications/timelock/src/timelock.cairo new file mode 100644 index 00000000..29081790 --- /dev/null +++ b/listings/applications/timelock/src/timelock.cairo @@ -0,0 +1,155 @@ +use starknet::ContractAddress; +use starknet::account::Call; + +#[starknet::interface] +pub trait ITimeLock { + fn get_tx_id(self: @TState, call: Call, timestamp: u64) -> felt252; + fn queue(ref self: TState, call: Call, timestamp: u64) -> felt252; + fn execute(ref self: TState, call: Call, timestamp: u64) -> Span; + fn cancel(ref self: TState, tx_id: felt252); +} + +#[starknet::contract] +pub mod TimeLock { + use core::poseidon::{PoseidonTrait, poseidon_hash_span}; + use core::hash::{HashStateTrait, HashStateExTrait}; + use starknet::{ + ContractAddress, get_caller_address, get_block_timestamp, SyscallResultTrait, syscalls + }; + use starknet::account::Call; + use components::ownable::ownable_component; + + component!(path: ownable_component, storage: ownable, event: OwnableEvent); + + // Ownable + #[abi(embed_v0)] + impl OwnableImpl = ownable_component::Ownable; + impl OwnableInternalImpl = ownable_component::OwnableInternalImpl; + + #[storage] + struct Storage { + #[substorage(v0)] + ownable: ownable_component::Storage, + queued: LegacyMap::, + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + #[flat] + OwnableEvent: ownable_component::Event, + Queue: Queue, + Execute: Execute, + Cancel: Cancel + } + + #[derive(Drop, starknet::Event)] + pub struct Queue { + #[key] + pub tx_id: felt252, + pub call: Call, + pub timestamp: u64 + } + + #[derive(Drop, starknet::Event)] + pub struct Execute { + #[key] + pub tx_id: felt252, + pub call: Call, + pub timestamp: u64 + } + + #[derive(Drop, starknet::Event)] + pub struct Cancel { + #[key] + pub tx_id: felt252 + } + + pub const MIN_DELAY: u64 = 10; // seconds + pub const MAX_DELAY: u64 = 1000; // seconds + pub const GRACE_PERIOD: u64 = 1000; // seconds + + pub mod Errors { + pub const ALREADY_QUEUED: felt252 = 'TimeLock: already queued'; + pub const TIMESTAMP_NOT_IN_RANGE: felt252 = 'TimeLock: timestamp range'; + pub const NOT_QUEUED: felt252 = 'TimeLock: not queued'; + pub const TIMESTAMP_NOT_PASSED: felt252 = 'TimeLock: timestamp not passed'; + pub const TIMESTAMP_EXPIRED: felt252 = 'TimeLock: timestamp expired'; + } + + #[constructor] + fn constructor(ref self: ContractState) { + self.ownable._init(get_caller_address()); + } + + #[abi(embed_v0)] + impl TimeLockImpl of super::ITimeLock { + fn get_tx_id(self: @ContractState, call: Call, timestamp: u64) -> felt252 { + PoseidonTrait::new() + .update(call.to.into()) + .update(call.selector.into()) + .update(poseidon_hash_span(call.calldata)) + .update(timestamp.into()) + .finalize() + } + + fn queue(ref self: ContractState, call: Call, timestamp: u64) -> felt252 { + self.ownable._assert_only_owner(); + + let tx_id = self.get_tx_id(self._copy_call(@call), timestamp); + assert(!self.queued.read(tx_id), Errors::ALREADY_QUEUED); + // ---|------------|---------------|------- + // block block + min block + max + let block_timestamp = get_block_timestamp(); + assert( + timestamp >= block_timestamp + + MIN_DELAY && timestamp <= block_timestamp + + MAX_DELAY, + Errors::TIMESTAMP_NOT_IN_RANGE + ); + + self.queued.write(tx_id, true); + self.emit(Queue { tx_id, call: self._copy_call(@call), timestamp }); + + tx_id + } + + fn execute(ref self: ContractState, call: Call, timestamp: u64) -> Span { + self.ownable._assert_only_owner(); + + let tx_id = self.get_tx_id(self._copy_call(@call), timestamp); + assert(self.queued.read(tx_id), Errors::NOT_QUEUED); + // ----|-------------------|------- + // timestamp timestamp + grace period + let block_timestamp = get_block_timestamp(); + assert(block_timestamp >= timestamp, Errors::TIMESTAMP_NOT_PASSED); + assert(block_timestamp <= timestamp + GRACE_PERIOD, Errors::TIMESTAMP_EXPIRED); + + self.queued.write(tx_id, false); + + let result = syscalls::call_contract_syscall(call.to, call.selector, call.calldata) + .unwrap_syscall(); + + self.emit(Execute { tx_id, call: self._copy_call(@call), timestamp }); + + result + } + + fn cancel(ref self: ContractState, tx_id: felt252) { + self.ownable._assert_only_owner(); + + assert(self.queued.read(tx_id), Errors::NOT_QUEUED); + + self.queued.write(tx_id, false); + + self.emit(Cancel { tx_id }); + } + } + + #[generate_trait] + impl InternalImpl of InternalTrait { + fn _copy_call(self: @ContractState, call: @Call) -> Call { + Call { to: *call.to, selector: *call.selector, calldata: *call.calldata } + } + } +} diff --git a/src/SUMMARY.md b/src/SUMMARY.md index f76fdac3..799cb263 100644 --- a/src/SUMMARY.md +++ b/src/SUMMARY.md @@ -56,6 +56,7 @@ Summary - [Defi Vault](./ch01/simple_vault.md) - [ERC20 Token](./ch01/erc20.md) - [Constant Product AMM](./ch01/constant-product-amm.md) +- [TimeLock](./ch01/timelock.md) - [Staking](./ch01/staking.md) diff --git a/src/ch01/timelock.md b/src/ch01/timelock.md new file mode 100644 index 00000000..aaf73d08 --- /dev/null +++ b/src/ch01/timelock.md @@ -0,0 +1,7 @@ +# TimeLock + +This is the Cairo adaptation of the [Solidity by example TimeLock](https://solidity-by-example.org/app/time-lock/). + +```rust +{{#include ../../listings/applications/timelock/src/timelock.cairo}} +```