#37276 [SC-Medium] Redstone's price feed is used incorrectly.
Submitted on Dec 1st 2024 at 11:03:07 UTC by @jasonxiale for IOP | Fluid Protocol
Report ID: #37276
Report Type: Smart Contract
Report severity: Medium
Target: https://github.com/Hydrogen-Labs/fluid-protocol/tree/main/contracts/oracle-contract/src/main.sw
Impacts:
Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield
Description
Brief/Intro
In current implementation for oracle-contract.get_price
, while function is_pyth_price_stale_or_outside_confidence
return true in main.sw#L88, Redstone price feed will be used.
However there is a flaw when using Redstone price feed that might lead stale price being used.
Vulnerability Details
According to oracle-contract.get_price
, when redstone is used, redstone.read_timestamp
will be used as the price 's publish timestamp.
And redstone.read_timestamp
will be checked against with current_time
in main.sw#L107
The issue is that redstone.read_timestamp
uses Unix timestamp, but Fule's timestamp
function returns TAI64 format timestamp
Quoting from timestamp's implementation
63 /// Get the TAI64 timestamp of a block at a given `block_height`.
64 ///
65 /// # Additional Information
66 ///
67 /// The TAI64 timestamp begins at 2^62 seconds before 1970, and ends at 2^62 seconds after 1970,
68 /// with a TAI second defined as the duration of 9192631770 periods of the radiation corresponding
69 /// to the transition between the two hyperfine levels of the ground state of the cesium atom.
70 ///
71 /// # Arguments
72 ///
73 /// * `block_height`: [u32] - The height of the block to get the timestamp of.
74 ///
75 /// # Returns
76 ///
77 /// * [u64] - The TAI64 timestamp of the block at `block_height`.
78 ///
79 /// # Examples
80 ///
81 /// ```sway
82 /// use std::block::timestamp_of_block;
83 ///
84 /// fn foo() {
85 /// let timestamp_of_block_100 = timestamp_of_block(100u32);
86 /// log(timestamp_of_block_100);
87 /// }
88 /// ```
89 pub fn timestamp_of_block(block_height: u32) -> u64 {
90 asm(timestamp, height: block_height) {
91 time timestamp height;
92 timestamp: u64
93 }
94 }
From above code, we can see that timestamp()
returns [The TAI64 timestamp of the current block.] (https://github.com/FuelLabs/sway/blob/6d9065b8d762a39eb475562426a2d4ed17d92d00/sway-lib-std/src/block.sw#L47)
According to redstone_adapter.read_timestamp, the function uses
storage.timestamp
as return value.And
storage.timestamp
is written by redstone_adapter.sw#L139 inredstone_adapter.overwrite_prices
function.redstone_adapter.overwrite_prices
is called byredstone_adapter.write_prices
in redstone_adapter.sw#L96Now we'll check how
timestamp
is returned in redstone_adapter.sw#L95In redstone_adapter.process_payload, a
config
var is created withconfig.block_timestamp
set toget_unix_timestamp()
get_unix_timestamp() is defined as:
pub fn get_unix_timestamp() -> u64 {
timestamp() - TAI64_UNIX_ADJUSTMENT
}
From here, we know that config.block_timestamp
is UNIX time.
Back to
redstone_adapter.process_payload
, in redstone_adapter.sw#L150,process_input
is called, andprocess_input
is defined in processor.process_input.
28 pub fn process_input(bytes: Bytes, config: Config) -> (Vec<u256>, u64) {
29 config.check_parameters();
30
31 let payload = Payload::from_bytes(bytes);
32 let timestamp = config.validate_timestamps(payload); <<<--- Here we only care timestamp
33
34 let matrix = get_payload_result_matrix(payload, config);
35 let results = get_feed_values(matrix, config);
36
37 config.validate_signer_count(results);
38 (results.aggregated(), timestamp)
39 }
In processor.sw#L32,
timestamp
is returned byconfig.validate_timestamps
function.config.validate_timestamps
is defined in config_validation.sw#L27-L43
27 fn validate_timestamps(self, payload: Payload) -> u64 {
28 let first_timestamp = payload.data_packages.get(0).unwrap().timestamp;
29 validate_timestamp(first_timestamp, self.block_timestamp * 1000);
30
...
41
42 first_timestamp
43 }
In config_validation.sw#L28-L29, the payload's timestamp and self.block_timestamp(which is UNIX timestamp) are passed to
validate_timestamp
function.According to validate_timestamp's implemention, we can get see that
payload's timestamp
in step9 should be UNIX timestamp format.
7 pub fn validate_timestamp(timestamp: u64, block_timestamp: u64) {
8 if (block_timestamp > timestamp) {
9 require(
10 block_timestamp - timestamp <= MAX_DATA_TIMESTAMP_DELAY_SECONDS * 1000,
11 RedStoneError::TimestampOutOfRange((false, block_timestamp, timestamp)),
12 );
13 }
14
15 if (timestamp > block_timestamp) {
16 require(
17 timestamp - block_timestamp <= MAX_DATA_TIMESTAMP_AHEAD_SECONDS * 1000,
18 RedStoneError::TimestampOutOfRange((true, block_timestamp, timestamp)),
19 );
20 }
21 }
Impact Details
As I described above, when is_pyth_price_stale_or_outside_confidence
returns true, Redstone can't work as expected.
References
Add any relevant links to documentation or code
Proof of Concept
Proof of Concept
The following POC is used to demonstrate that when is_pyth_price_stale_or_outside_confidence
returns true, Redstone's oracle will be used.
Please put the following patch in contracts/oracle-contract/tests/authorization.rs
and run
cargo test test_get_price_pyth_from_redstone -- --nocapture
...
running 1 test
xxx_xxx redstone RedstoneCore { contract_id: Bech32ContractId { hrp: "fuel", hash: 6d4aa3cbdfd84bc2702b388838ecd680a0a63a7ae7701c28e5ac0e72ca33b36f }, account: WalletUnlocked { wallet: Wallet { address: Bech32Address { hrp: "fuel", hash: bdaad6a89e073e177895b3e5a9ccd15806749eda134a6438dae32fc5b6601f3f } }, private_key: 0000000000000000000000000000000000000000000000000000000000000003 }, log_decoder: LogDecoder { log_formatters: {}, decoder_config: DecoderConfig { max_depth: 45, max_tokens: 10000 } }, encoder_config: EncoderConfig { max_depth: 45, max_tokens: 10000 } }
thread 'test_get_price_pyth_from_redstone' panicked at /opt/work/fluid-protocol/test-utils/src/interfaces/oracle.rs:54:14:
called `Result::unwrap()` on an `Err` value: Transaction(Reverted { reason: "ContractNotFound", revert_id: 0, receipts: [Call { id: 0000000000000000000000000000000000000000000000000000000000000000, to: 265bda0cb99a723686a3db36b184e8b7282a6cc838420fec68fb74e082127ed5, amount: 0, asset_id: 0000000000000000000000000000000000000000000000000000000000000000, gas: 10968, param1: 10480, param2: 10497, pc: 12408, is: 12408 }, Call { id: 265bda0cb99a723686a3db36b184e8b7282a6cc838420fec68fb74e082127ed5, to: 140a4d3150c6488998e120dbe24bafe9175a11be7a8e9c222ef93f0e59b0b7ad, amount: 0, asset_id: 0000000000000000000000000000000000000000000000000000000000000000, gas: 6998, param1: 67107264, param2: 67106240, pc: 62657, is: 62657 }, ReturnData { id: 140a4d3150c6488998e120dbe24bafe9175a11be7a8e9c222ef93f0e59b0b7ad, ptr: 67103680, len: 28, digest: e5be52b1788a3651027e1eacefe4fe7047f12dcb153688b2ed85aa5e292f6ec6, pc: 64693, is: 62657, data: Some(00000000000000000000000900...) }, Panic { id: 265bda0cb99a723686a3db36b184e8b7282a6cc838420fec68fb74e082127ed5, reason: PanicInstruction { reason: ContractNotFound, instruction: CALL { target_struct: 0x10, fwd_coins: 0x0, asset_id_addr: 0x11, fwd_gas: 0x12 } (bytes: 2d 40 04 52) }, pc: 46752, is: 12408, contract_id: None }, ScriptResult { result: Panic, gas_used: 10654 }] })
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
test test_get_price_pyth_from_redstone ... FAILED
failures:
failures:
test_get_price_pyth_from_redstone
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 2 filtered out; finished in 1.88s
error: test failed, to rerun pass `--test authorization`
Last updated
Was this helpful?