diff --git a/contracts/Scarb.toml b/contracts/Scarb.toml index 5bcc345..f316022 100644 --- a/contracts/Scarb.toml +++ b/contracts/Scarb.toml @@ -22,5 +22,10 @@ casm = true allowed-libfuncs-list.name = "audited" build-external-contracts = ["openzeppelin_presets::erc20::ERC20Upgradeable"] +[profile.dev.cairo] +unstable-add-statements-functions-debug-info = true +unstable-add-statements-code-locations-debug-info = true +inlining-strategy= "avoid" + [scripts] test = "snforge test" diff --git a/contracts/src/components/registry/interface.cairo b/contracts/src/components/registry/interface.cairo index 168bd2f..12712a5 100644 --- a/contracts/src/components/registry/interface.cairo +++ b/contracts/src/components/registry/interface.cairo @@ -1,7 +1,7 @@ use starknet::ContractAddress; use zkramp::utils::hash::HashSerializable; -#[derive(Drop, Copy, Serde, Debug, PartialEq)] +#[derive(Drop, Copy, Serde, Debug, PartialEq, starknet::Store)] pub enum OffchainId { Revolut: felt252 } diff --git a/contracts/src/contracts/ramps/revolut/interface.cairo b/contracts/src/contracts/ramps/revolut/interface.cairo index 3c71e05..6be18af 100644 --- a/contracts/src/contracts/ramps/revolut/interface.cairo +++ b/contracts/src/contracts/ramps/revolut/interface.cairo @@ -6,17 +6,29 @@ pub struct Proof { foo: felt252 } -#[derive(Drop, Copy, Hash, Serde)] +#[derive(Drop, Copy, Hash, Serde, starknet::Store)] pub struct LiquidityKey { pub owner: ContractAddress, pub offchain_id: OffchainId, } +#[derive(Drop, Copy, starknet::Store)] +pub struct LiquidityShareRequest { + pub requestor: ContractAddress, + pub liquidity_key: LiquidityKey, + pub amount: u256, + pub expiration_date: u64, +} + #[starknet::interface] pub trait IZKRampLiquidity { fn add_liquidity(ref self: TState, amount: u256, offchain_id: OffchainId); fn retrieve_liquidity(ref self: TState, liquidity_key: LiquidityKey); fn initiate_liquidity_retrieval(ref self: TState, liquidity_key: LiquidityKey); + fn initiate_liquidity_withdrawal( + ref self: TState, liquidity_key: LiquidityKey, amount: u256, offchain_id: OffchainId + ); + fn withdraw_liquidity(ref self: TState, liquidity_key: LiquidityKey, offchain_id: OffchainId, proof: Proof); } #[starknet::interface] @@ -24,7 +36,10 @@ pub trait ZKRampABI { // IZKRampLiquidity fn add_liquidity(ref self: TState, amount: u256, offchain_id: OffchainId); fn retrieve_liquidity(ref self: TState, liquidity_key: LiquidityKey); - fn initiate_liquidity_retrieval(ref self: TState, liquidity_key: LiquidityKey); + fn initiate_liquidity_withdrawal( + ref self: TState, liquidity_key: LiquidityKey, amount: u256, offchain_id: OffchainId + ); + fn withdraw_liquidity(ref self: TState, liquidity_key: LiquidityKey, offchain_id: OffchainId, proof: Proof); // IRegistry fn is_registered(self: @TState, contract_address: ContractAddress, offchain_id: OffchainId) -> bool; diff --git a/contracts/src/contracts/ramps/revolut/revolut.cairo b/contracts/src/contracts/ramps/revolut/revolut.cairo index ac06579..004f08a 100644 --- a/contracts/src/contracts/ramps/revolut/revolut.cairo +++ b/contracts/src/contracts/ramps/revolut/revolut.cairo @@ -4,11 +4,15 @@ pub mod RevolutRamp { use core::starknet::storage::{StoragePointerReadAccess}; use openzeppelin::access::ownable::OwnableComponent; use starknet::storage::Map; - use starknet::{ContractAddress, get_caller_address}; + use starknet::{ContractAddress, get_caller_address, get_block_timestamp}; use zkramp::components::escrow::escrow::EscrowComponent; use zkramp::components::registry::interface::{OffchainId, IRegistry}; use zkramp::components::registry::registry::RegistryComponent; - use zkramp::contracts::ramps::revolut::interface::{LiquidityKey, IZKRampLiquidity}; + use zkramp::contracts::ramps::revolut::interface::{LiquidityKey, IZKRampLiquidity, LiquidityShareRequest, Proof}; + + // + // Components + // component!(path: OwnableComponent, storage: ownable, event: OwnableEvent); component!(path: RegistryComponent, storage: registry, event: RegistryEvent); @@ -26,6 +30,13 @@ pub mod RevolutRamp { // Escrow impl EscrowImplImpl = EscrowComponent::EscrowImpl; + // + // Constants + // + + const LOCK_DURATION_STEP: u64 = 900; // 15min + const MINIMUM_LOCK_DURATION: u64 = 3600; // 1h + // // Storage // @@ -43,6 +54,10 @@ pub mod RevolutRamp { liquidity: Map::, // liquidity_key -> is_locked locked_liquidity: Map::, + // (liquidity_key, timestamp) -> amount locked until timestamp is reached + locked_liquidity_shares: Map::<(LiquidityKey, u64), u256>, + // offchain_id -> liquidity share request + liquidity_share_request: Map::, } // @@ -52,9 +67,14 @@ pub mod RevolutRamp { pub mod Errors { pub const NOT_REGISTERED: felt252 = 'Caller is not registered'; pub const INVALID_AMOUNT: felt252 = 'Invalid amount'; - pub const WRONG_CALLER_ADDRESS: felt252 = 'Wrong caller address'; - pub const EMPTY_LIQUIDITY_RETRIEVAL: felt252 = 'Empty liquidity retrieval'; - pub const UNLOCKED_LIQUIDITY_RETRIEVAL: felt252 = 'Unlocked liquidity retrieval'; + pub const CALLER_IS_NOT_OWNER: felt252 = 'Caller is not the owner'; + pub const CALLER_IS_OWNER: felt252 = 'Caller is the owner'; + pub const NULL_AMOUNT: felt252 = 'Amount cannot be null'; + pub const UNLOCKED_LIQUIDITY: felt252 = 'Liquidity is unlocked'; + pub const NOT_ENOUGH_LIQUDITY: felt252 = 'Not enough liquidity'; + pub const LOCKED_LIQUIDITY_WITHDRAW: felt252 = 'Liquidity is not available'; + pub const BUSY_OFFCHAIN_ID: felt252 = 'This offchainID is busy'; + pub const LIQUIDITY_SHARE_NOT_AVAILABLE: felt252 = 'Liquidity share not available'; } // @@ -73,6 +93,8 @@ pub mod RevolutRamp { LiquidityAdded: LiquidityAdded, LiquidityLocked: LiquidityLocked, LiquidityRetrieved: LiquidityRetrieved, + LiquidityShareRequested: LiquidityShareRequested, + LiquidityShareWithdrawn: LiquidityShareWithdrawn, } // Emitted when liquidity is added @@ -98,6 +120,27 @@ pub mod RevolutRamp { pub amount: u256, } + // Emitted when a liquidity share is requested + #[derive(Drop, starknet::Event)] + pub struct LiquidityShareRequested { + #[key] + pub liquidity_key: LiquidityKey, + pub amount: u256, + pub requestor: ContractAddress, + pub offchain_id: OffchainId, + pub expiration_date: u64, + } + + // Emitted when a liquidity share is withdrawn + #[derive(Drop, starknet::Event)] + pub struct LiquidityShareWithdrawn { + #[key] + pub liquidity_key: LiquidityKey, + pub amount: u256, + pub withdrawer: ContractAddress, + pub offchain_id: OffchainId, + } + // // Constructor // @@ -142,13 +185,14 @@ pub mod RevolutRamp { self.emit(LiquidityAdded { liquidity_key, amount }); } + /// Makes your liquidity unavailable, in order to retrieve it later. fn initiate_liquidity_retrieval(ref self: ContractState, liquidity_key: LiquidityKey) { let caller = get_caller_address(); // asserts liquidity amount is non null - assert(self.liquidity.read(liquidity_key).is_non_zero(), Errors::EMPTY_LIQUIDITY_RETRIEVAL); + assert(self.liquidity.read(liquidity_key).is_non_zero(), Errors::NULL_AMOUNT); // asserts caller is the liquidity owner - assert(liquidity_key.owner == caller, Errors::WRONG_CALLER_ADDRESS); + assert(liquidity_key.owner == caller, Errors::CALLER_IS_NOT_OWNER); // locks liquidity self.locked_liquidity.write(liquidity_key, true); @@ -157,13 +201,14 @@ pub mod RevolutRamp { self.emit(LiquidityLocked { liquidity_key }); } + /// Retrieve liquidity if locked and owned by the caller. fn retrieve_liquidity(ref self: ContractState, liquidity_key: LiquidityKey) { let caller = get_caller_address(); // asserts caller is the liquidity owner - assert(self.locked_liquidity.read(liquidity_key), Errors::UNLOCKED_LIQUIDITY_RETRIEVAL); + assert(self.locked_liquidity.read(liquidity_key), Errors::UNLOCKED_LIQUIDITY); // asserts caller is the liquidity owner - assert(liquidity_key.owner == caller, Errors::WRONG_CALLER_ADDRESS); + assert(liquidity_key.owner == caller, Errors::CALLER_IS_NOT_OWNER); let token = self.token.read(); let amount = self.liquidity.read(liquidity_key); @@ -174,5 +219,112 @@ pub mod RevolutRamp { // emits Liquidityretrieved event self.emit(LiquidityRetrieved { liquidity_key, amount }); } + + fn initiate_liquidity_withdrawal( + ref self: ContractState, liquidity_key: LiquidityKey, amount: u256, offchain_id: OffchainId + ) { + let caller = get_caller_address(); + let current_timestamp = get_block_timestamp(); + + // assert caller is not the liquidity owner + assert(liquidity_key.owner != caller, Errors::CALLER_IS_OWNER); + // assert liquidity is unlocked + assert(!self.locked_liquidity.read(liquidity_key), Errors::LOCKED_LIQUIDITY_WITHDRAW); + // assert caller registered the offchain ID + assert(self.registry.is_registered(contract_address: caller, :offchain_id), Errors::NOT_REGISTERED); + // assert offchain_id is not busy with another withdrawal + assert( + self.liquidity_share_request.read(offchain_id).expiration_date <= current_timestamp, + Errors::BUSY_OFFCHAIN_ID + ); + + // get actually available liquidity + let available_liquidity_amount = self._get_available_liquidity(:liquidity_key); + + // assert requested amount is valid + assert(amount <= available_liquidity_amount, Errors::NOT_ENOUGH_LIQUDITY); + + // compute liquidity share locking period + let expiration_date = self._get_next_timestamp_key(current_timestamp + MINIMUM_LOCK_DURATION); + + // lock liquidity share amount + let locked_amount = self.locked_liquidity_shares.read((liquidity_key, expiration_date)); + self.locked_liquidity_shares.write((liquidity_key, expiration_date), locked_amount + amount); + + // save share request + self + .liquidity_share_request + .write( + offchain_id, LiquidityShareRequest { requestor: caller, liquidity_key, amount, expiration_date } + ); + + // emit event + self + .emit( + LiquidityShareRequested { liquidity_key, amount, requestor: caller, offchain_id, expiration_date } + ) + } + + fn withdraw_liquidity( + ref self: ContractState, liquidity_key: LiquidityKey, offchain_id: OffchainId, proof: Proof + ) { + let caller = get_caller_address(); + let current_timestamp = get_block_timestamp(); + let mut share_request = self.liquidity_share_request.read(offchain_id); + let token = self.token.read(); + + // assert caller has a valid pending withdrawal + assert( + share_request.expiration_date <= current_timestamp && share_request.requestor == caller, + Errors::LIQUIDITY_SHARE_NOT_AVAILABLE + ); + + // TODO: verify proof + + // update liquidity amount + let amount = self.liquidity.read(liquidity_key); + self.liquidity.write(liquidity_key, amount - share_request.amount); + + // update requested liquidity amount + let amount = self.locked_liquidity_shares.read((liquidity_key, share_request.expiration_date)); + self + .locked_liquidity_shares + .write((liquidity_key, share_request.expiration_date), amount - share_request.amount); + + // invalidate share request + share_request.expiration_date = 0; + self.liquidity_share_request.write(offchain_id, share_request); + + // unlock funds + self.escrow.unlock(from: liquidity_key.owner, to: caller, :token, :amount); + + // emit event + self.emit(LiquidityShareWithdrawn { liquidity_key, amount, withdrawer: caller, offchain_id }) + } + } + + // + // Internals + // + + #[generate_trait] + impl InternalImpl of InternalTrait { + fn _get_available_liquidity(self: @ContractState, liquidity_key: LiquidityKey) -> u256 { + let mut amount = self.liquidity.read(liquidity_key); + let current_timestamp = get_block_timestamp(); + let mut key_timestamp = self._get_next_timestamp_key(current_timestamp + MINIMUM_LOCK_DURATION); + + while key_timestamp > current_timestamp { + amount -= self.locked_liquidity_shares.read((liquidity_key, key_timestamp)); + key_timestamp -= LOCK_DURATION_STEP; + }; + + amount + } + + fn _get_next_timestamp_key(self: @ContractState, after: u64) -> u64 { + // minus 1 in order to return `after` if it's already a valid key timestamp. + after - 1 + LOCK_DURATION_STEP - ((after - 1) % LOCK_DURATION_STEP) + } } } diff --git a/contracts/src/contracts/ramps/revolut/revolut_test.cairo b/contracts/src/contracts/ramps/revolut/revolut_test.cairo index 717c926..efd575f 100644 --- a/contracts/src/contracts/ramps/revolut/revolut_test.cairo +++ b/contracts/src/contracts/ramps/revolut/revolut_test.cairo @@ -19,7 +19,7 @@ fn deploy_revolut_ramp() -> ZKRampABIDispatcher { } #[test] -#[should_panic(expected: 'Unlocked liquidity retrieval')] +#[should_panic(expected: 'Liquidity is unlocked')] fn test_retrieve_uninitialized_liquidity_should_panic() { let test_address: ContractAddress = test_address();