Skip to content

Commit

Permalink
add time locked transactions example (#201)
Browse files Browse the repository at this point in the history
* add time locked transactions example

* install snforge in gh-action

* fix: refactor timelock example
  • Loading branch information
saimeunt authored Jun 5, 2024
1 parent 3b9d93e commit e287afa
Show file tree
Hide file tree
Showing 14 changed files with 556 additions and 5 deletions.
6 changes: 5 additions & 1 deletion .github/workflows/verify_cairo_programs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
pull_request:
branches:
- main
workflow_dispatch:

jobs:
compile_and_verify:
Expand All @@ -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
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion .tool-versions
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
scarb 2.6.4
scarb 2.6.4
starknet-foundry 0.24.0
14 changes: 14 additions & 0 deletions Scarb.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion Scarb.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
4 changes: 2 additions & 2 deletions listings/applications/components/src/ownable.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ pub trait IOwnable<TContractState> {
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';
Expand Down Expand Up @@ -43,7 +43,7 @@ pub mod ownable_component {
}

#[embeddable_as(Ownable)]
impl OwnableImpl<
pub impl OwnableImpl<
TContractState, +HasComponent<TContractState>
> of super::IOwnable<ComponentState<TContractState>> {
fn owner(self: @ComponentState<TContractState>) -> ContractAddress {
Expand Down
1 change: 1 addition & 0 deletions listings/applications/timelock/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
target
19 changes: 19 additions & 0 deletions listings/applications/timelock/Scarb.toml
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions listings/applications/timelock/src/erc721.cairo
Original file line number Diff line number Diff line change
@@ -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<ContractState>;
impl ERC721InternalImpl = ERC721Component::InternalImpl<ContractState>;

#[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);
}
}
8 changes: 8 additions & 0 deletions listings/applications/timelock/src/lib.cairo
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#[cfg(test)]
mod tests {
#[feature("safe_dispatcher")]
mod timelock;
mod utils;
}
mod erc721;
mod timelock;
228 changes: 228 additions & 0 deletions listings/applications/timelock/src/tests/timelock.cairo
Original file line number Diff line number Diff line change
@@ -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 })
)
]
);
}
Loading

0 comments on commit e287afa

Please sign in to comment.