From 2dd8243c87d9574608db023651c89b7840cb67d3 Mon Sep 17 00:00:00 2001 From: TurcFort07 Date: Fri, 27 Oct 2023 11:48:36 +0200 Subject: [PATCH] feat: implement oracle_utils --- src/lib.cairo | 3 + src/oracle/error.cairo | 57 +++++++- src/oracle/interfaces/account.cairo | 77 ++++++++++ src/oracle/oracle.cairo | 17 +-- src/oracle/oracle_utils.cairo | 219 ++++++++++++++++++++++++---- src/utils/arrays.cairo | 57 +++++++- src/utils/bits.cairo | 2 +- src/utils/calc.cairo | 17 +++ 8 files changed, 398 insertions(+), 51 deletions(-) create mode 100644 src/oracle/interfaces/account.cairo diff --git a/src/lib.cairo b/src/lib.cairo index 2e3cdd5d..7f585bde 100644 --- a/src/lib.cairo +++ b/src/lib.cairo @@ -185,6 +185,9 @@ mod oracle { mod oracle_utils; mod oracle; mod price_feed; + mod interfaces { + mod account; + } } // `order` contains order management functions. diff --git a/src/oracle/error.cairo b/src/oracle/error.cairo index 231551e0..02f29f8b 100644 --- a/src/oracle/error.cairo +++ b/src/oracle/error.cairo @@ -37,15 +37,10 @@ mod OracleError { panic(array!['block number not sorted', data_1.into(), data_2.into()]) } - fn ARRAY_OUT_OF_BOUNDS_FELT252(mut data_1: Span, data_2: u128, msg: felt252) { + fn ARRAY_OUT_OF_BOUNDS_FELT252(mut data_1: Span>, data_2: usize, msg: felt252) { let mut data: Array = array!['array out of bounds felt252']; let mut length = data_1.len(); - loop { - if length == 0 { - break; - } - data.append(*data_1.pop_front().expect('array pop_front failed')); - }; + // TODO add data_1 data to error data.append(data_2.into()); data.append(msg); panic(data) @@ -150,4 +145,52 @@ mod OracleError { data.append(block_number.into()); panic(data) } + + fn BLOCK_NUMBER_NOT_WITHIN_RANGE(mut data_1: Span, mut data_2: Span, data_3: u64) { + let mut data: Array = array!['block number not within range']; + let mut length = data_1.len(); + loop { + if length == 0 { + break; + } + let el = *data_1.pop_front().unwrap(); + data.append(el.into()); + }; + let mut length_2 = data_2.len(); + loop { + if length_2 == 0 { + break; + } + let el = *data_2.pop_front().unwrap(); + data.append(el.into()); + }; + data.append(data_3.into()); + panic(data) + } + + fn EMPTY_COMPACTED_PRICE(data_1: usize) { + panic(array!['empty compacted price', data_1.into()]) + } + + fn EMPTY_COMPACTED_TIMESTAMP(data_1: usize) { + panic(array!['empty compacted timestamp', data_1.into()]) + } + + fn INVALID_SIGNATURE(data_1: felt252, data_2: felt252) { + panic(array!['invalid signature', data_1.into(), data_2.into()]) + } + + fn BLOCK_NUMBERS_ARE_SMALLER_THAN_REQUIRED(mut data_1: Span, data_2: u64) { + let mut data: Array = array!['block numbers too small']; + let mut length = data_1.len(); + loop { + if length == 0 { + break; + } + let el = *data_1.pop_front().unwrap(); + data.append(el.into()); + }; + data.append(data_2.into()); + panic(data) + } } diff --git a/src/oracle/interfaces/account.cairo b/src/oracle/interfaces/account.cairo new file mode 100644 index 00000000..05335cf1 --- /dev/null +++ b/src/oracle/interfaces/account.cairo @@ -0,0 +1,77 @@ +#[starknet::interface] +trait IAccount { + fn __validate_declare__(self: @TContractState, class_hash: felt252) -> felt252; + fn __validate_deploy__( + self: @TContractState, + class_hash: felt252, + contract_address_salt: felt252, + owner: felt252, + guardian: felt252 + ) -> felt252; + // External + + /// @notice Changes the owner + /// Must be called by the account and authorised by the owner and a guardian (if guardian is set). + /// @param new_owner New owner address + /// @param signature_r Signature R from the new owner + /// @param signature_S Signature S from the new owner + /// Signature is required to prevent changing to an address which is not in control of the user + /// Signature is the Signed Message of this hash: + /// hash = pedersen(0, (change_owner selector, chainid, contract address, old_owner)) + fn change_owner( + ref self: TContractState, new_owner: felt252, signature_r: felt252, signature_s: felt252 + ); + + /// @notice Changes the guardian + /// Must be called by the account and authorised by the owner and a guardian (if guardian is set). + /// @param new_guardian The address of the new guardian, or 0 to disable the guardian + /// @dev can only be set to 0 if there is no guardian backup set + fn change_guardian(ref self: TContractState, new_guardian: felt252); + + /// @notice Changes the backup guardian + /// Must be called by the account and authorised by the owner and a guardian (if guardian is set). + /// @param new_guardian_backup The address of the new backup guardian, or 0 to disable the backup guardian + fn change_guardian_backup(ref self: TContractState, new_guardian_backup: felt252); + + /// @notice Triggers the escape of the owner when it is lost or compromised. + /// Must be called by the account and authorised by just a guardian. + /// Cannot override an ongoing escape of the guardian. + /// @param new_owner The new account owner if the escape completes + /// @dev This method assumes that there is a guardian, and that `_newOwner` is not 0. + /// This must be guaranteed before calling this method, usually when validating the transaction. + fn trigger_escape_owner(ref self: TContractState, new_owner: felt252); + + /// @notice Triggers the escape of the guardian when it is lost or compromised. + /// Must be called by the account and authorised by the owner alone. + /// Can override an ongoing escape of the owner. + /// @param new_guardian The new account guardian if the escape completes + /// @dev This method assumes that there is a guardian, and that `new_guardian` can only be 0 + /// if there is no guardian backup. + /// This must be guaranteed before calling this method, usually when validating the transaction + fn trigger_escape_guardian(ref self: TContractState, new_guardian: felt252); + + /// @notice Completes the escape and changes the owner after the security period + /// Must be called by the account and authorised by just a guardian + /// @dev This method assumes that there is a guardian, and that the there is an escape for the owner. + /// This must be guaranteed before calling this method, usually when validating the transaction. + fn escape_owner(ref self: TContractState); + + /// @notice Completes the escape and changes the guardian after the security period + /// Must be called by the account and authorised by just the owner + /// @dev This method assumes that there is a guardian, and that the there is an escape for the guardian. + /// This must be guaranteed before calling this method. Usually when validating the transaction. + fn escape_guardian(ref self: TContractState); + + /// @notice Cancels an ongoing escape if any. + /// Must be called by the account and authorised by the owner and a guardian (if guardian is set). + fn cancel_escape(ref self: TContractState); + + // Views + fn get_owner(self: @TContractState) -> felt252; + fn get_guardian(self: @TContractState) -> felt252; + fn get_guardian_backup(self: @TContractState) -> felt252; + fn get_name(self: @TContractState) -> felt252; + fn get_guardian_escape_attempts(self: @TContractState) -> u32; + fn get_owner_escape_attempts(self: @TContractState) -> u32; + +} \ No newline at end of file diff --git a/src/oracle/oracle.cairo b/src/oracle/oracle.cairo index eeabeb04..533f43f4 100644 --- a/src/oracle/oracle.cairo +++ b/src/oracle/oracle.cairo @@ -165,11 +165,11 @@ struct SetPricesCache { /// Struct used in validate_prices as an inner cache. #[derive(Default, Drop)] struct SetPricesInnerCache { - /// The current price index to retrieve from compactedMinPrices and compactedMaxPrices - /// to construct the minPrices and maxPrices array. - price_index: u128, + /// The current price index to retrieve from compacted_min_prices and compacted_max_prices + /// to construct the min_prices and max_prices array. + price_index: usize, /// The current signature index to retrieve from the signatures array. - signature_index: u128, + signature_index: usize, /// The index of the min price in min_prices for the current signer. min_price_index: u128, /// The index of the max price in max_prices for the current signer. @@ -472,8 +472,8 @@ mod Oracle { validated_price.token, Price { min: validated_price.min, max: validated_price.max } ); + len += 1; }; - len += 1; } /// Validate prices in params. @@ -637,7 +637,7 @@ mod Oracle { compacted_max_span, inner_cache.signature_index ); - if inner_cache.signature_index >= signatures_span.len().into() { + if inner_cache.signature_index >= signatures_span.len() { OracleError::ARRAY_OUT_OF_BOUNDS_FELT252( signatures_span, inner_cache.signature_index, 'signatures' ); @@ -686,10 +686,7 @@ mod Oracle { oracle_utils::validate_signer( self.get_salt(), report_info, - arrays::get_felt252( - signatures_span, - inner_cache.signature_index.try_into().expect('u128 into u32 failed') - ), + *signatures_span.at(inner_cache.signature_index), signers_span.at(j) ); diff --git a/src/oracle/oracle_utils.cairo b/src/oracle/oracle_utils.cairo index 4952b67d..d5d58aaf 100644 --- a/src/oracle/oracle_utils.cairo +++ b/src/oracle/oracle_utils.cairo @@ -5,17 +5,26 @@ use starknet::ContractAddress; use result::ResultTrait; use traits::Default; +use hash::LegacyHash; +use ecdsa::recover_public_key; // Local imports. use satoru::data::data_store::{IDataStoreDispatcher, IDataStoreDispatcherTrait}; use satoru::event::event_emitter::{IEventEmitterDispatcher, IEventEmitterDispatcherTrait}; use satoru::bank::bank::{IBankDispatcher, IBankDispatcherTrait}; use satoru::market::market::{Market}; -use satoru::oracle::oracle::{SetPricesCache, SetPricesInnerCache}; -use satoru::oracle::error::OracleError; +use satoru::oracle::{ + oracle::{SetPricesCache, SetPricesInnerCache}, + error::OracleError, + interfaces::account::{IAccountDispatcher, IAccountDispatcherTrait} +}; use satoru::price::price::{Price}; -use satoru::utils::store_arrays::{ - StoreContractAddressArray, StorePriceArray, StoreU128Array, StoreFelt252Array +use satoru::utils::{ + store_arrays::{ + StoreContractAddressArray, StorePriceArray, StoreU128Array, StoreFelt252Array + }, + arrays::{are_lte_u64, get_uncompacted_value, get_uncompacted_value_u64}, + bits::{BITMASK_8, BITMASK_16, BITMASK_32, BITMASK_64} }; // External imports. @@ -48,7 +57,7 @@ struct SetPricesParams { compacted_min_prices_indexes: Array, compacted_max_prices: Array, compacted_max_prices_indexes: Array, - signatures: Array, + signatures: Array>, price_feed_tokens: Array, } @@ -82,6 +91,36 @@ struct ReportInfo { max_price: u128, } +// compacted prices have a length of 32 bits +const COMPACTED_PRICE_BIT_LENGTH: usize = 32; +fn COMPACTED_PRICE_BITMASK() -> u128 { + BITMASK_32 +} + +// compacted precisions have a length of 8 bits +const COMPACTED_PRECISION_BIT_LENGTH: usize = 8; +fn COMPACTED_PRECISION_BITMASK() -> u128 { + BITMASK_8 +} + +// compacted block numbers have a length of 64 bits +const COMPACTED_BLOCK_NUMBER_BIT_LENGTH: usize = 64; +fn COMPACTED_BLOCK_NUMBER_BITMASK() -> u64 { + BITMASK_64 +} + +// compacted timestamps have a length of 64 bits +const COMPACTED_TIMESTAMP_BIT_LENGTH: usize = 64; +fn COMPACTED_TIMESTAMP_BITMASK() -> u64 { + BITMASK_64 +} + +// compacted price indexes have a length of 8 bits +const COMPACTED_PRICE_INDEX_BIT_LENGTH: usize = 8; +fn COMPACTED_PRICE_INDEX_BITMASK() -> u128 { + BITMASK_8 +} + /// Validates wether a block number is in range. /// # Arguments /// * `min_oracle_block_numbers` - The oracles block number that should be less than block_number. @@ -109,9 +148,15 @@ fn validate_block_number_within_range( fn is_block_number_within_range( min_oracle_block_numbers: Span, max_oracle_block_numbers: Span, block_number: u64 ) -> bool { - let lower_bound = min_oracle_block_numbers.max().expect('array max failed'); - let upper_bound = max_oracle_block_numbers.min().expect('array min failed'); - lower_bound <= block_number && block_number <= upper_bound + if (!are_lte_u64(min_oracle_block_numbers, block_number)) { + return false; + } + + if (!are_lte_u64(max_oracle_block_numbers, block_number)) { + return false; + } + + true } /// Get the uncompacted price at the specified index. @@ -120,9 +165,20 @@ fn is_block_number_within_range( /// * `index` - The index to get the decimal at. /// # Returns /// The price at the specified index. -fn get_uncompacted_price(compacted_prices: Span, index: u128) -> u128 { - // TODO - 10 +fn get_uncompacted_price(compacted_prices: Span, index: usize) -> u128 { + let price = get_uncompacted_value( + compacted_prices, + index, + COMPACTED_PRICE_BIT_LENGTH, + COMPACTED_PRICE_BITMASK(), + 'get_uncompacted_price' + ); + + if (price == 0) { + OracleError::EMPTY_COMPACTED_PRICE(index) + } + + price } /// Get the uncompacted decimal at the specified index. @@ -131,9 +187,16 @@ fn get_uncompacted_price(compacted_prices: Span, index: u128) -> u128 { /// * `index` - The index to get the decimal at. /// # Returns /// The decimal at the specified index. -fn get_uncompacted_decimal(compacted_decimals: Span, index: u128) -> u128 { - // TODO - 0 +fn get_uncompacted_decimal(compacted_decimals: Span, index: usize) -> u128 { + let decimal = get_uncompacted_value( + compacted_decimals, + index, + COMPACTED_PRECISION_BIT_LENGTH, + COMPACTED_PRECISION_BITMASK(), + 'get_uncompacted_decimal' + ); + + decimal } /// Get the uncompacted price index at the specified index. @@ -142,9 +205,16 @@ fn get_uncompacted_decimal(compacted_decimals: Span, index: u128) -> u128 /// * `index` - The index to get the price index at. /// # Returns /// The uncompacted price index at the specified index. -fn get_uncompacted_price_index(compacted_price_indexes: Span, index: u128) -> u128 { - // TODO - 0 +fn get_uncompacted_price_index(compacted_price_indexes: Span, index: usize) -> u128 { + let price_index = get_uncompacted_value( + compacted_price_indexes, + index, + COMPACTED_PRICE_INDEX_BIT_LENGTH, + COMPACTED_PRICE_INDEX_BITMASK(), + 'get_uncompacted_price_index' + ); + + price_index } /// Get the uncompacted oracle block numbers. @@ -156,8 +226,21 @@ fn get_uncompacted_price_index(compacted_price_indexes: Span, index: u128) fn get_uncompacted_oracle_block_numbers( compacted_oracle_block_numbers: Span, length: usize ) -> Array { - // TODO - ArrayTrait::new() + let mut block_numbers = ArrayTrait::new(); + + let mut i = 0; + loop { + if (i == length) { + break; + } + + block_numbers + .append(get_uncompacted_oracle_block_number(compacted_oracle_block_numbers, i)); + + i += 1; + }; + + block_numbers } /// Get the uncompacted oracle block number. @@ -169,8 +252,15 @@ fn get_uncompacted_oracle_block_numbers( fn get_uncompacted_oracle_block_number( compacted_oracle_block_numbers: Span, index: usize ) -> u64 { - // TODO - 0 + let block_number = get_uncompacted_value_u64( + compacted_oracle_block_numbers, + index, + COMPACTED_BLOCK_NUMBER_BIT_LENGTH, + COMPACTED_BLOCK_NUMBER_BITMASK(), + 'get_uncmpctd_oracle_block_numb' + ); + + block_number } /// Get the uncompacted oracle timestamp. @@ -180,8 +270,19 @@ fn get_uncompacted_oracle_block_number( /// # Returns /// The uncompacted oracle timestamp. fn get_uncompacted_oracle_timestamp(compacted_oracle_timestamps: Span, index: usize) -> u64 { - // TODO - 0 + let timestamp = get_uncompacted_value_u64( + compacted_oracle_timestamps, + index, + COMPACTED_TIMESTAMP_BIT_LENGTH, + COMPACTED_TIMESTAMP_BITMASK(), + 'get_uncmpctd_oracle_timestamp' + ); + + if (timestamp == 0) { + OracleError::EMPTY_COMPACTED_TIMESTAMP(index); + } + + timestamp } /// Validate the signer of a price. @@ -200,8 +301,43 @@ fn get_uncompacted_oracle_timestamp(compacted_oracle_timestamps: Span, inde /// * `signature` - The signer's signature. /// * `expected_signer` - The address of the expected signer. fn validate_signer( - salt: felt252, info: ReportInfo, signature: felt252, expected_signer: @ContractAddress -) { // TODO + salt: felt252, info: ReportInfo, signature: Span, expected_signer: @ContractAddress +) { + let signature_r = *signature[0]; + let signature_s = *signature[1]; + let mut digest = LegacyHash::::hash(salt, info.min_oracle_block_number); + digest = LegacyHash::::hash(digest, info.min_oracle_block_number); + digest = LegacyHash::::hash(digest, info.max_oracle_block_number); + digest = LegacyHash::::hash(digest, info.oracle_timestamp); + digest = LegacyHash::::hash(digest, info.block_hash); + digest = LegacyHash::::hash(digest, info.token); + digest = LegacyHash::::hash(digest, info.token_oracle_type); + digest = LegacyHash::::hash(digest, info.precision); + digest = LegacyHash::::hash(digest, info.min_price); + digest = LegacyHash::::hash(digest, info.max_price); + + // We now need to hash message_hash with the size of the array: (change_owner selector, chainid, contract address, old_owner) + // https://github.com/starkware-libs/cairo-lang/blob/b614d1867c64f3fb2cf4a4879348cfcf87c3a5a7/src/starkware/cairo/common/hash_state.py#L6 + digest = LegacyHash::::hash(digest, 10); + + // TODO: What should we have as y_parity (?) + let recovered_public_key = recover_public_key(digest, signature_r, signature_s, true).unwrap(); + + // Get expected public key + let account_dispatcher = IAccountDispatcher { contract_address: *expected_signer }; + let expected_public_key = account_dispatcher.get_owner(); + + if (recovered_public_key != expected_public_key) { + OracleError::INVALID_SIGNATURE(recovered_public_key, expected_public_key); + } +} + +fn revert_oracle_block_number_not_within_range( + min_oracle_block_numbers: Span, max_oracle_block_numbers: Span, block_number: u64 +) { + OracleError::BLOCK_NUMBER_NOT_WITHIN_RANGE( + min_oracle_block_numbers, max_oracle_block_numbers, block_number + ) } /// Check wether `error` is an OracleError. @@ -210,8 +346,15 @@ fn validate_signer( /// # Returns /// Wether it's the right error. fn is_oracle_error(error_selector: felt252) -> bool { - // TODO - true + if (is_oracle_block_number_error(error_selector)) { + return true; + } + + if (is_empty_price_error(error_selector)) { + return true; + } + + return false; } /// Check wether `error` is an EmptyPriceError. @@ -219,9 +362,13 @@ fn is_oracle_error(error_selector: felt252) -> bool { /// * `error` - The error to check. /// # Returns /// Wether it's the right error. +const EMPTY_PRIMARY_PRICE_SELECTOR: felt252 = selector!("EMPTY_PRIMARY_PRICE"); fn is_empty_price_error(error_selector: felt252) -> bool { - // TODO - true + if (error_selector == EMPTY_PRIMARY_PRICE_SELECTOR) { + return true; + } + + return false; } /// Check wether `error` is an OracleBlockNumberError. @@ -229,9 +376,19 @@ fn is_empty_price_error(error_selector: felt252) -> bool { /// * `error` - The error to check. /// # Returns /// Wether it's the right error. +const BLOCK_NUMBERS_ARE_SMALLER_THAN_REQUIRED_SELECTOR: felt252 = + selector!("BLOCK_NUMBERS_ARE_SMALLER_THAN_REQUIRED"); +const BLOCK_NUMBER_NOT_WITHIN_RANGE_SELECTOR: felt252 = selector!("BLOCK_NUMBER_NOT_WITHIN_RANGE"); fn is_oracle_block_number_error(error_selector: felt252) -> bool { - // TODO - true + if (error_selector == BLOCK_NUMBERS_ARE_SMALLER_THAN_REQUIRED_SELECTOR) { + return true; + } + + if (error_selector == BLOCK_NUMBER_NOT_WITHIN_RANGE_SELECTOR) { + return true; + } + + return false; } impl DefaultReportInfo of Default { diff --git a/src/utils/arrays.cairo b/src/utils/arrays.cairo index c8be63bb..08f86fbb 100644 --- a/src/utils/arrays.cairo +++ b/src/utils/arrays.cairo @@ -2,7 +2,8 @@ // IMPORTS // ************************************************************************* // Core lib imports. -use satoru::utils::error_utils; +use satoru::utils::{error_utils, calc}; + /// Gets the value of the element at the specified index in the given array. If the index is out of bounds, returns 0. /// # Arguments /// * `arr` - the array to get the element of. @@ -266,7 +267,7 @@ impl StoreContractAddressSpan of Store> { mut offset: u8, mut value: Span ) -> SyscallResult<()> { - // // Store the length of the array in the first storage slot. + // Store the length of the array in the first storage slot. let len: u8 = value.len().try_into().expect('Storage - Span too large'); Store::::write_at_offset(address_domain, base, offset, len); offset += 1; @@ -291,3 +292,55 @@ impl StoreContractAddressSpan of Store> { 255 * Store::::size() } } + +/// Determines whether all of the elements in the given array are less than or equal to the specified value. +/// # Arguments +/// * `arr` - the array to check the elements of. +/// * `value` - The value to compare the elements to. +/// # Returns +/// true if all of the elements in the array are less than or equal to the specified value, false otherwise. +fn are_lte_u64(mut arr: Span, value: u64) -> bool { + loop { + match arr.pop_front() { + Option::Some(item) => { + if *item > value { + break false; + } + }, + Option::None => { + break true; + }, + }; + } +} + +/// Gets the uncompacted value at the specified index in the given array of compacted values. +/// # Arguments +/// * `compacted_values` - the array of compacted values to get the uncompacted value from. +/// * `index` - the index of the uncompacted value in the array. +/// * `compacted_value_bit_length` - the length of each compacted value, in bits. +/// * `bit_mask` - the bitmask to use to extract the uncompacted value from the compacted value. +/// * `label` - the array of compacted values to get the uncompacted value from. +/// # Returns +/// The uncompacted value at the specified index in the array of compacted values. +fn get_uncompacted_value_u64( + compacted_values: Span, + index: usize, + compacted_value_bit_length: usize, + bit_mask: u64, + label: felt252 +) -> u64 { + let compacted_values_per_slot = 64 / compacted_value_bit_length; + + let slot_index = index / compacted_values_per_slot; + if slot_index >= compacted_values.len() { + panic(array!['CompactedArrayOutOfBounds', index.into(), slot_index.into(), label]); + } + + let slot_bits = *compacted_values.at(slot_index); + let offset = (index - slot_index * compacted_values_per_slot) * compacted_value_bit_length; + + let value = (slot_bits / calc::pow_u64(2, offset)) & bit_mask; + + value +} \ No newline at end of file diff --git a/src/utils/bits.cairo b/src/utils/bits.cairo index c4f68a6b..5ef9d4ee 100644 --- a/src/utils/bits.cairo +++ b/src/utils/bits.cairo @@ -8,4 +8,4 @@ const BITMASK_16: u128 = 32767; const BITMASK_32: u128 = 2147483647; -const BITMASK_64: u128 = 9223372036854775807; +const BITMASK_64: u64 = 9223372036854775807; diff --git a/src/utils/calc.cairo b/src/utils/calc.cairo index 46ba6ec6..06dc6f2e 100644 --- a/src/utils/calc.cairo +++ b/src/utils/calc.cairo @@ -167,3 +167,20 @@ fn min_i128() -> i128 { // Comes from https://doc.rust-lang.org/std/i128/constant.MIN.html i128 { mag: 170_141_183_460_469_231_731_687_303_715_884_105_728, sign: true } } + +/// Raise a number to a power, computes x^n. +/// * `x` - The number to raise. +/// * `n` - The exponent. +/// # Returns +/// * `u64` - The result of x raised to the power of n. +fn pow_u64(x: u64, n: usize) -> u64 { + if n == 0 { + 1 + } else if n == 1 { + x + } else if (n & 1) == 1 { + x * pow_u64(x * x, n / 2) + } else { + pow_u64(x * x, n / 2) + } +} \ No newline at end of file