diff --git a/target_chains/aptos/contracts/sources/pyth.move b/target_chains/aptos/contracts/sources/pyth.move index a831643061..66a3c129c8 100644 --- a/target_chains/aptos/contracts/sources/pyth.move +++ b/target_chains/aptos/contracts/sources/pyth.move @@ -30,7 +30,6 @@ module pyth::pyth { const PYTHNET_ACCUMULATOR_UPDATE_MAGIC: u64 = 1347305813; const ACCUMULATOR_UPDATE_WORMHOLE_VERIFICATION_MAGIC: u64 = 1096111958; - // ----------------------------------------------------------------------------- // Initialisation functions @@ -520,6 +519,127 @@ module pyth::pyth { }; state::get_base_update_fee() * total_updates } + + + /// Parse Benchmark Prices Method + + /// Parses and validates price feeds within the specified publish time range. + fun parse_and_validate_price_feeds( + price_infos: &vector, + requested_price_ids: vector>, + min_publish_time: u64, + max_publish_time: u64 + ): vector { + // Initialize vectors to store parsed price feeds and price IDs + let valid_price_feeds = vector::empty(); + let valid_price_ids = vector::empty>(); + + // Iterate through the price information to filter and validate based on publish times + let i = 0; + while (i < vector::length(price_infos)) { + let price_info = vector::borrow(price_infos, i); + let price_feed = price_info::get_price_feed(price_info); + let price: price::Price = price_feed::get_price(price_feed); + let timestamp = price::get_timestamp(&price); + + // Check if the price feed is within the specified publish time range + if (timestamp >= min_publish_time && timestamp <= max_publish_time) { + let price_id: &price_identifier::PriceIdentifier = price_feed::get_price_identifier(price_feed); + let price_id_bytes = price_identifier::get_bytes(price_id); + vector::push_back(&mut valid_price_ids, price_id_bytes); + vector::push_back(&mut valid_price_feeds, *price_feed); + }; + i = i + 1; + }; + + // Ensure all requested price IDs have corresponding valid updates + let k = 0; + while (k < vector::length(&requested_price_ids)) { + let requested_price_id = vector::borrow(&requested_price_ids, k); + let is_found = false; + + // Check if the requested price ID is in the valid price IDs + let j = 0; + while (j < vector::length(&valid_price_ids)) { + let valid_price_id = vector::borrow(&valid_price_ids, j); + if (requested_price_id == valid_price_id) { + is_found = true; + break + }; + j = j + 1; + }; + + // Abort if any requested price ID does not have a valid update + if (!is_found) { + abort error::unknown_price_feed() // Replace with a more suitable error if needed + }; + k = k + 1; + }; + + return valid_price_feeds + } + + /// Parses a single VAA and returns a vector of price feeds within the specified publish time range. + fun parse_price_feed_updates_from_vaa( + vaa: vector, + requested_price_ids: vector>, + min_publish_time: u64, + max_publish_time: u64 + ): vector { + let cur = cursor::init(vaa); + let header: u64 = deserialize::deserialize_u32(&mut cur); + if (header == PYTHNET_ACCUMULATOR_UPDATE_MAGIC) { + let price_infos = parse_and_verify_accumulator_message(&mut cur); + cursor::rest(cur); + return parse_and_validate_price_feeds(&price_infos, requested_price_ids, min_publish_time, max_publish_time) + } else { + let vaa = vaa::parse_and_verify(vaa); + verify_data_source(&vaa); + let price_infos = batch_price_attestation::destroy(batch_price_attestation::deserialize(vaa::destroy(vaa))); + cursor::rest(cur); + return parse_and_validate_price_feeds(&price_infos, requested_price_ids, min_publish_time, max_publish_time) + } + } + + /// Public function to parse multiple price feed updates from VAA data. + public fun parse_price_feed_updates( + update_data: vector>, + requested_price_ids: vector>, + min_publish_time: u64, + max_publish_time: u64, + fee: Coin + ): vector { + // Validate and deposit the update fee + let update_fee = get_update_fee(&update_data); + assert!(update_fee <= coin::value(&fee), error::insufficient_fee()); + coin::deposit(@pyth, fee); + let pyth_balance = coin::balance(@pyth); + assert!(pyth_balance >= update_fee, error::insufficient_fee()); + + // Initialize a vector to store all valid price feeds + let all_valid_price_feeds = vector::empty(); + + // Iterate through the update_data vector + let i = 0; + while (i < vector::length(&update_data)) { + // Parse a single VAA and get its valid price feeds + let single_vaa = vector::borrow(&update_data, i); + let valid_price_feeds = parse_price_feed_updates_from_vaa(*single_vaa, requested_price_ids, min_publish_time, max_publish_time); + + // Add each valid price feed to the all_valid_price_feeds vector + let j = 0; + while (j < vector::length(&valid_price_feeds)) { + let price_feed = vector::borrow(&valid_price_feeds, j); + vector::push_back(&mut all_valid_price_feeds, *price_feed); + j = j + 1; + }; + + i = i + 1; + }; + + all_valid_price_feeds + } + } // ----------------------------------------------------------------------------- @@ -1430,4 +1550,172 @@ module pyth::pyth_test { cleanup_test(burn_capability, mint_capability); } -} + + // Test case for successful parsing of price feed updates + #[test(aptos_framework = @aptos_framework)] + fun test_parse_price_feed_updates_success(aptos_framework: &signer) { + let update_fee = 50; + let initial_balance = 100; + let (burn_capability, mint_capability, coins) = setup_test(aptos_framework, 500, 1, + x"5d1f252d5de865279b00c84bce362774c2804294ed53299bc4a0389a5defef92", + vector[/* Add valid DataSource objects here */], + update_fee, + initial_balance); + + let update_data = vector[ + /* Add actual VAA bytes here for testing */ + ]; + let price_ids = vector[ + x"c6c75c89f14810ec1c54c03ab8f1864a4c4032791f05747f560faec380a695d1", + x"3b9551a68d01d954d6387aff4df1529027ffb2fee413082e509feb29cc4904fe", + x"33832fad6e36eb05a8972fe5f219b27b5b2bb2230a79ce79beb4c5c5e7ecc76d", + x"21a28b4c6619968bd8c20e95b0aaed7df2187fd310275347e0376a2cd7427db8", + ]; + let min_publish_time: u64 = 1663074345; + let max_publish_time: u64 = 1663680750; + + let test_price_feeds: vector = pyth::parse_price_feed_updates(update_data, price_ids, min_publish_time, max_publish_time, coins); + + assert!(vector::length(&test_price_feeds) > 0, 1); + // Add further assertions based on expected results + + cleanup_test(burn_capability, mint_capability); + } + + // Test case for insufficient fee during parsing of price feed updates + #[test(aptos_framework = @aptos_framework)] + #[expected_failure(abort_code = 65542, location = pyth::pyth)] + fun test_parse_price_feed_updates_insufficient_fee(aptos_framework: &signer) { + let update_fee = 50; + let initial_balance = 20; + let (burn_capability, mint_capability, coins) = setup_test(aptos_framework, 500, 1, + x"5d1f252d5de865279b00c84bce362774c2804294ed53299bc4a0389a5defef92", + vector[/* Add valid DataSource objects here */], + update_fee, + initial_balance); + + let update_data = vector[ + /* Add actual VAA bytes here for testing */ + ]; + let price_ids = vector[ + x"c6c75c89f14810ec1c54c03ab8f1864a4c4032791f05747f560faec380a695d1", + x"3b9551a68d01d954d6387aff4df1529027ffb2fee413082e509feb29cc4904fe", + x"33832fad6e36eb05a8972fe5f219b27b5b2bb2230a79ce79beb4c5c5e7ecc76d", + x"21a28b4c6619968bd8c20e95b0aaed7df2187fd310275347e0376a2cd7427db8", + ]; + let min_publish_time: u64 = 1663074345; + let max_publish_time: u64 = 1663680750; + + pyth::parse_price_feed_updates(update_data, price_ids, min_publish_time, max_publish_time, coins); + + cleanup_test(burn_capability, mint_capability); + } + + // Test case for corrupt VAA during parsing of price feed updates + #[test(aptos_framework = @aptos_framework)] + #[expected_failure(abort_code = 6, location = wormhole::vaa)] + fun test_parse_price_feed_updates_corrupt_vaa(aptos_framework: &signer) { + let (burn_capability, mint_capability, coins) = setup_test(aptos_framework, 500, 1, x"5d1f252d5de865279b00c84bce362774c2804294ed53299bc4a0389a5defef92", vector[], 50, 100); + + let corrupt_vaa = x"90F8bf6A479f320ead074411a4B0e7944Ea8c9C1"; + let price_ids = vector[ + x"c6c75c89f14810ec1c54c03ab8f1864a4c4032791f05747f560faec380a695d1", + x"3b9551a68d01d954d6387aff4df1529027ffb2fee413082e509feb29cc4904fe", + x"33832fad6e36eb05a8972fe5f219b27b5b2bb2230a79ce79beb4c5c5e7ecc76d", + x"21a28b4c6619968bd8c20e95b0aaed7df2187fd310275347e0376a2cd7427db8", + ]; + let min_publish_time: u64 = 1663074345; + let max_publish_time: u64 = 1663680750; + + pyth::parse_price_feed_updates(vector[corrupt_vaa], price_ids, min_publish_time, max_publish_time, coins); + + cleanup_test(burn_capability, mint_capability); + } + + // Test case for invalid data source during parsing of price feed updates + #[test(aptos_framework = @aptos_framework)] + #[expected_failure(abort_code = 65539, location = pyth::pyth)] + fun test_parse_price_feed_updates_invalid_data_source(aptos_framework: &signer) { + let update_data = vector[ + /* Add actual VAA bytes here for testing */ + ]; + let price_ids = vector[ + x"c6c75c89f14810ec1c54c03ab8f1864a4c4032791f05747f560faec380a695d1", + x"3b9551a68d01d954d6387aff4df1529027ffb2fee413082e509feb29cc4904fe", + x"33832fad6e36eb05a8972fe5f219b27b5b2bb2230a79ce79beb4c5c5e7ecc76d", + x"21a28b4c6619968bd8c20e95b0aaed7df2187fd310275347e0376a2cd7427db8", + ]; + let min_publish_time: u64 = 1663074345; + let max_publish_time: u64 = 1663680750; + + let data_sources = vector[ + DataSource { id: 4, address: external_address::from_bytes(x"0000000000000000000000000000000000000000000000000000000000007742") }, + DataSource { id: 5, address: external_address::from_bytes(x"0000000000000000000000000000000000000000000000000000000000007637") } + ]; + let (burn_capability, mint_capability, coins) = setup_test(aptos_framework, 500, 1, x"5d1f252d5de865279b00c84bce362774c2804294ed53299bc4a0389a5defef92", data_sources, 50, 100); + + pyth::parse_price_feed_updates(update_data, price_ids, min_publish_time, max_publish_time, coins); + + cleanup_test(burn_capability, mint_capability); + } + + // Test case for invalid price ID during parsing of price feed updates + #[test(aptos_framework = @aptos_framework)] + #[expected_failure(abort_code = 393224, location = pyth::pyth)] + fun test_parse_price_feed_updates_invalid_price_id(aptos_framework: &signer) { + let update_fee = 50; + let initial_balance = 100; + let (burn_capability, mint_capability, coins) = setup_test(aptos_framework, 500, 1, + x"5d1f252d5de865279b00c84bce362774c2804294ed53299bc4a0389a5defef92", + vector[/* Add valid DataSource objects here */], + update_fee, + initial_balance); + + let update_data = vector[ + /* Add actual VAA bytes here for testing */ + ]; + let price_ids = vector[ + x"c6c75c89f14810ec1c54c03ab8f1864a4c4032791f05747f560faec380a695d2", // invalid price id + x"3b9551a68d01d954d6387aff4df1529027ffb2fee413082e509feb29cc4904fe", + x"33832fad6e36eb05a8972fe5f219b27b5b2bb2230a79ce79beb4c5c5e7ecc76d", + x"21a28b4c6619968bd8c20e95b0aaed7df2187fd310275347e0376a2cd7427db8", + ]; + let min_publish_time: u64 = 1663074345; + let max_publish_time: u64 = 1663680750; + + pyth::parse_price_feed_updates(update_data, price_ids, min_publish_time, max_publish_time, coins); + + cleanup_test(burn_capability, mint_capability); + } + + // Test case for invalid publish times during parsing of price feed updates + #[test(aptos_framework = @aptos_framework)] + #[expected_failure(abort_code = 393224, location = pyth::pyth)] + fun test_parse_price_feed_updates_invalid_publish_times(aptos_framework: &signer) { + let update_fee = 50; + let initial_balance = 100; + let (burn_capability, mint_capability, coins) = setup_test(aptos_framework, 500, 1, + x"5d1f252d5de865279b00c84bce362774c2804294ed53299bc4a0389a5defef92", + vector[/* Add valid DataSource objects here */], + update_fee, + initial_balance); + + let update_data = vector[ + /* Add actual VAA bytes here for testing */ + ]; + let price_ids = vector[ + x"c6c75c89f14810ec1c54c03ab8f1864a4c4032791f05747f560faec380a695d1", + x"3b9551a68d01d954d6387aff4df1529027ffb2fee413082e509feb29cc4904fe", + x"33832fad6e36eb05a8972fe5f219b27b5b2bb2230a79ce79beb4c5c5e7ecc76d", + x"21a28b4c6619968bd8c20e95b0aaed7df2187fd310275347e0376a2cd7427db8", + ]; + // invalid publish times: max_publish_time is less than min_publish_time + let min_publish_time: u64 = 1663680750; + let max_publish_time: u64 = 1663074345; + + pyth::parse_price_feed_updates(update_data, price_ids, min_publish_time, max_publish_time, coins); + + cleanup_test(burn_capability, mint_capability); + } + +} \ No newline at end of file diff --git a/target_chains/ethereum/sdk/solidity/PriceCombine.sol b/target_chains/ethereum/sdk/solidity/PriceCombine.sol new file mode 100644 index 0000000000..f1e90da722 --- /dev/null +++ b/target_chains/ethereum/sdk/solidity/PriceCombine.sol @@ -0,0 +1,167 @@ +// PriceCombine.sol +// SPDX-License-Identifier: Apache 2 +pragma solidity ^0.8.0; + +import "./PythStructs.sol"; + +// Constants for working with Pyth's number representation +int32 constant PD_EXPO = -9; +uint64 constant PD_SCALE = 1_000_000_000; +uint64 constant MAX_PD_V_U64 = (1 << 28) - 1; + +// Solidity Library to easily combine 2 Pyth Prices into 1 ( for example SOL:USD/ETH:USD=SOL/ETH). For Rust implementation, refer Pyth Rust SDK https://github.com/pyth-network/pyth-sdk-rs/blob/main/pyth-sdk/src/price.rs +// Based on the Rust implementation, the functions are defined within the `impl Price` block, and they are public methods of the `Price` struct and are as follows `get_price_in_quote`, `div`, `normalize`, `scale_to_exponent` +// Similarly the functions implemented in solidity are `public` and `pure` types. + +library PriceCombine { + /** + * @notice Computes the price in quote currency. + * @param self The price of the base currency. + * @param quote The price of the quote currency. + * @param resultExpo The exponent for the result. + * @return The combined price in the quote currency. + */ + function getPriceInQuote(PythStructs.Price memory self, PythStructs.Price memory quote, int32 resultExpo) public pure returns (PythStructs.Price memory) { + PythStructs.Price memory result = div(self, quote); + // Return zero price so the error can be gracefully handled by the caller + if (result.price == 0 && result.conf == 0) { + return PythStructs.Price({ + price: 0, + conf: 0, + expo: 0, + publishTime: 0 + }); + } + return scaleToExponent(result, resultExpo); + } + + /** + * @notice Divides the price of the base currency by the quote currency price. + * @param self The price of the base currency. + * @param other The price of the quote currency. + * @return The combined price after division. + */ + function div(PythStructs.Price memory self, PythStructs.Price memory other) public pure returns (PythStructs.Price memory) { + PythStructs.Price memory base = normalize(self); + other = normalize(other); + + // If the price of the quote currency is zero, return zero + if (other.price == 0) { + return PythStructs.Price({ + price: 0, + conf: 0, + expo: 0, + publishTime: 0 + }); + } + + // Convert prices to unsigned integers and get their signs + (uint64 basePrice, int64 baseSign) = toUnsigned(base.price); + (uint64 otherPrice, int64 otherSign) = toUnsigned(other.price); + + // Compute the midprice + uint64 midprice = basePrice * PD_SCALE / otherPrice; + int32 midpriceExpo = base.expo - other.expo + PD_EXPO; + + // Compute the confidence interval + uint64 otherConfidencePct = other.conf * PD_SCALE / otherPrice; + uint128 conf = (base.conf * PD_SCALE / otherPrice) + (otherConfidencePct * midprice) / PD_SCALE; + + // Check for overflow and return the result + if (conf < type(uint64).max) { + return PythStructs.Price( + int64(int64(midprice) * baseSign * otherSign), + uint64(conf), + midpriceExpo, + self.publishTime < other.publishTime ? self.publishTime : other.publishTime + ); + } else { + // Return zero price if there's an overflow + return PythStructs.Price({ + price: 0, + conf: 0, + expo: 0, + publishTime: 0 + }); + } + } + + /** + * @notice Normalizes the price and confidence to be within acceptable range. + * @param self The price structure to normalize. + * @return The normalized price structure. + */ + function normalize(PythStructs.Price memory self) public pure returns (PythStructs.Price memory) { + (uint64 price, int64 sign) = toUnsigned(self.price); + uint64 conf = self.conf; + int32 expo = self.expo; + + // Adjust the price and confidence if they are too large + while (price > MAX_PD_V_U64 || conf > MAX_PD_V_U64) { + price = price / 10; + conf = conf / 10; + expo = expo + 1; + } + + return PythStructs.Price({ + price: int64(price) * sign, + conf: conf, + expo: expo, + publishTime: self.publishTime + }); + } + + /** + * @notice Scales the price to the target exponent. + * @param self The price structure to scale. + * @param targetExpo The target exponent. + * @return The scaled price structure. + */ + function scaleToExponent(PythStructs.Price memory self, int32 targetExpo) public pure returns (PythStructs.Price memory) { + int32 delta = targetExpo - self.expo; + int64 price = self.price; + uint64 conf = self.conf; + + // Adjust the price and confidence based on the exponent difference + if (delta >= 0) { + while (delta > 0 && (price != 0 || conf != 0)) { + price = price / 10; + conf = conf / 10; + delta = delta - 1; + } + return PythStructs.Price({ + price: price, + conf: conf, + expo: targetExpo, + publishTime: self.publishTime + }); + } else { + while (delta < 0) { + price = price * 10; + conf = conf * 10; + delta = delta + 1; + } + return PythStructs.Price({ + price: price, + conf: conf, + expo: targetExpo, + publishTime: self.publishTime + }); + } + } + + /** + * @notice Converts a signed integer to an unsigned integer and a sign bit. + * @param x here is the signed integer. + * @return The unsigned integer and sign bit. + */ + function toUnsigned(int64 x) public pure returns (uint64, int64) { + if (x == type(int64).min) { + return (uint64(type(int64).max) + 1, -1); + } else if (x < 0) { + return (uint64(-x), -1); + } else { + return (uint64(x), 1); + } + } +} diff --git a/target_chains/ethereum/sdk/solidity/PriceCombineTest.sol b/target_chains/ethereum/sdk/solidity/PriceCombineTest.sol new file mode 100644 index 0000000000..d984f5a856 --- /dev/null +++ b/target_chains/ethereum/sdk/solidity/PriceCombineTest.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: Apache 2 +pragma solidity ^0.8.0; + +import "./PythStructs.sol"; +import "./PriceCombine.sol"; + +// This contract is used to test the library functions and tested in Remix IDE. All ABIs are present in the /abis folder +contract PriceCombineTest { + using PriceCombine for PythStructs.Price; + + /** + * @notice Test the getPriceInQuote function + * @param basePrice The price of the base currency (example: SOL/USD) 13913000000 (represents the current SOL/USD $139.13 with 8 decimal places) + * @param baseConf The confidence interval of the base currency + * @param baseExpo The exponent of the base currency (here: -8 -> 8 decimal units) indicates price is scaled by 10^-8 + * @param basePublishTime The publish time of the base currency (UNIX timestamp) + * @param quotePrice The price of the quote currency (example: ETH/USD) 341853000000 (represents the current ETH/USD $3418.53 with 8 decimal places) + * @param quoteConf The confidence interval of the quote currency + * @param quoteExpo The exponent of the quote currency (here: -8 -> 8 decimal units) indicates price is scaled by 10^-8 + * @param quotePublishTime The publish time of the quote currency (UNIX timestamp) + * @param resultExpo The desired exponent for the result (here: -8) + * @return The price, confidence interval, exponent, and publish time of the resulting price + */ + function testGetPriceInQuote(int64 basePrice, uint64 baseConf, int32 baseExpo, uint basePublishTime, + int64 quotePrice, uint64 quoteConf, int32 quoteExpo, uint quotePublishTime, + int32 resultExpo) public pure returns (int64, uint64, int32, uint) { + PythStructs.Price memory base = PythStructs.Price(basePrice, baseConf, baseExpo, basePublishTime); + PythStructs.Price memory quote = PythStructs.Price(quotePrice, quoteConf, quoteExpo, quotePublishTime); + PythStructs.Price memory result = base.getPriceInQuote(quote, resultExpo); + return (result.price, result.conf, result.expo, result.publishTime); + } + + // Add more test functions as needed +} diff --git a/target_chains/ethereum/sdk/solidity/PythStructs.sol b/target_chains/ethereum/sdk/solidity/PythStructs.sol index b3d2ee2c6a..9696af78bf 100644 --- a/target_chains/ethereum/sdk/solidity/PythStructs.sol +++ b/target_chains/ethereum/sdk/solidity/PythStructs.sol @@ -30,4 +30,4 @@ contract PythStructs { // Latest available exponentially-weighted moving average price Price emaPrice; } -} +} \ No newline at end of file diff --git a/target_chains/ethereum/sdk/solidity/abis/PriceCombine.json b/target_chains/ethereum/sdk/solidity/abis/PriceCombine.json new file mode 100644 index 0000000000..49ce577a55 --- /dev/null +++ b/target_chains/ethereum/sdk/solidity/abis/PriceCombine.json @@ -0,0 +1,342 @@ +[ + { + "inputs": [ + { + "components": [ + { + "internalType": "int64", + "name": "price", + "type": "int64" + }, + { + "internalType": "uint64", + "name": "conf", + "type": "uint64" + }, + { + "internalType": "int32", + "name": "expo", + "type": "int32" + }, + { + "internalType": "uint256", + "name": "publishTime", + "type": "uint256" + } + ], + "internalType": "struct PythStructs.Price", + "name": "self", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "int64", + "name": "price", + "type": "int64" + }, + { + "internalType": "uint64", + "name": "conf", + "type": "uint64" + }, + { + "internalType": "int32", + "name": "expo", + "type": "int32" + }, + { + "internalType": "uint256", + "name": "publishTime", + "type": "uint256" + } + ], + "internalType": "struct PythStructs.Price", + "name": "other", + "type": "tuple" + } + ], + "name": "div", + "outputs": [ + { + "components": [ + { + "internalType": "int64", + "name": "price", + "type": "int64" + }, + { + "internalType": "uint64", + "name": "conf", + "type": "uint64" + }, + { + "internalType": "int32", + "name": "expo", + "type": "int32" + }, + { + "internalType": "uint256", + "name": "publishTime", + "type": "uint256" + } + ], + "internalType": "struct PythStructs.Price", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "int64", + "name": "price", + "type": "int64" + }, + { + "internalType": "uint64", + "name": "conf", + "type": "uint64" + }, + { + "internalType": "int32", + "name": "expo", + "type": "int32" + }, + { + "internalType": "uint256", + "name": "publishTime", + "type": "uint256" + } + ], + "internalType": "struct PythStructs.Price", + "name": "self", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "int64", + "name": "price", + "type": "int64" + }, + { + "internalType": "uint64", + "name": "conf", + "type": "uint64" + }, + { + "internalType": "int32", + "name": "expo", + "type": "int32" + }, + { + "internalType": "uint256", + "name": "publishTime", + "type": "uint256" + } + ], + "internalType": "struct PythStructs.Price", + "name": "quote", + "type": "tuple" + }, + { + "internalType": "int32", + "name": "resultExpo", + "type": "int32" + } + ], + "name": "getPriceInQuote", + "outputs": [ + { + "components": [ + { + "internalType": "int64", + "name": "price", + "type": "int64" + }, + { + "internalType": "uint64", + "name": "conf", + "type": "uint64" + }, + { + "internalType": "int32", + "name": "expo", + "type": "int32" + }, + { + "internalType": "uint256", + "name": "publishTime", + "type": "uint256" + } + ], + "internalType": "struct PythStructs.Price", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "int64", + "name": "price", + "type": "int64" + }, + { + "internalType": "uint64", + "name": "conf", + "type": "uint64" + }, + { + "internalType": "int32", + "name": "expo", + "type": "int32" + }, + { + "internalType": "uint256", + "name": "publishTime", + "type": "uint256" + } + ], + "internalType": "struct PythStructs.Price", + "name": "self", + "type": "tuple" + } + ], + "name": "normalize", + "outputs": [ + { + "components": [ + { + "internalType": "int64", + "name": "price", + "type": "int64" + }, + { + "internalType": "uint64", + "name": "conf", + "type": "uint64" + }, + { + "internalType": "int32", + "name": "expo", + "type": "int32" + }, + { + "internalType": "uint256", + "name": "publishTime", + "type": "uint256" + } + ], + "internalType": "struct PythStructs.Price", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "int64", + "name": "price", + "type": "int64" + }, + { + "internalType": "uint64", + "name": "conf", + "type": "uint64" + }, + { + "internalType": "int32", + "name": "expo", + "type": "int32" + }, + { + "internalType": "uint256", + "name": "publishTime", + "type": "uint256" + } + ], + "internalType": "struct PythStructs.Price", + "name": "self", + "type": "tuple" + }, + { + "internalType": "int32", + "name": "targetExpo", + "type": "int32" + } + ], + "name": "scaleToExponent", + "outputs": [ + { + "components": [ + { + "internalType": "int64", + "name": "price", + "type": "int64" + }, + { + "internalType": "uint64", + "name": "conf", + "type": "uint64" + }, + { + "internalType": "int32", + "name": "expo", + "type": "int32" + }, + { + "internalType": "uint256", + "name": "publishTime", + "type": "uint256" + } + ], + "internalType": "struct PythStructs.Price", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "int64", + "name": "x", + "type": "int64" + } + ], + "name": "toUnsigned", + "outputs": [ + { + "internalType": "uint64", + "name": "", + "type": "uint64" + }, + { + "internalType": "int64", + "name": "", + "type": "int64" + } + ], + "stateMutability": "pure", + "type": "function" + } +] \ No newline at end of file diff --git a/target_chains/ethereum/sdk/solidity/abis/PriceCombineTest.json b/target_chains/ethereum/sdk/solidity/abis/PriceCombineTest.json new file mode 100644 index 0000000000..1ed43ff648 --- /dev/null +++ b/target_chains/ethereum/sdk/solidity/abis/PriceCombineTest.json @@ -0,0 +1,76 @@ +[ + { + "inputs": [ + { + "internalType": "int64", + "name": "basePrice", + "type": "int64" + }, + { + "internalType": "uint64", + "name": "baseConf", + "type": "uint64" + }, + { + "internalType": "int32", + "name": "baseExpo", + "type": "int32" + }, + { + "internalType": "uint256", + "name": "basePublishTime", + "type": "uint256" + }, + { + "internalType": "int64", + "name": "quotePrice", + "type": "int64" + }, + { + "internalType": "uint64", + "name": "quoteConf", + "type": "uint64" + }, + { + "internalType": "int32", + "name": "quoteExpo", + "type": "int32" + }, + { + "internalType": "uint256", + "name": "quotePublishTime", + "type": "uint256" + }, + { + "internalType": "int32", + "name": "resultExpo", + "type": "int32" + } + ], + "name": "testGetPriceInQuote", + "outputs": [ + { + "internalType": "int64", + "name": "", + "type": "int64" + }, + { + "internalType": "uint64", + "name": "", + "type": "uint64" + }, + { + "internalType": "int32", + "name": "", + "type": "int32" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "pure", + "type": "function" + } +] \ No newline at end of file diff --git a/target_chains/ethereum/sdk/solidity/abis/PythStructs.json b/target_chains/ethereum/sdk/solidity/abis/PythStructs.json new file mode 100644 index 0000000000..0637a088a0 --- /dev/null +++ b/target_chains/ethereum/sdk/solidity/abis/PythStructs.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/target_chains/solana/Cargo.lock b/target_chains/solana/Cargo.lock index 7def373edd..a7696515ce 100644 --- a/target_chains/solana/Cargo.lock +++ b/target_chains/solana/Cargo.lock @@ -3074,6 +3074,7 @@ dependencies = [ "pyth-solana-receiver-sdk", "pythnet-sdk", "rand 0.8.5", + "serde_with", "serde_wormhole", "solana-program", "solana-sdk", diff --git a/target_chains/solana/programs/pyth-solana-receiver/.cargo/config.toml b/target_chains/solana/programs/pyth-solana-receiver/.cargo/config.toml new file mode 100644 index 0000000000..a14129b9dd --- /dev/null +++ b/target_chains/solana/programs/pyth-solana-receiver/.cargo/config.toml @@ -0,0 +1,2 @@ +[unstable] +namespaced-features = true diff --git a/target_chains/solana/programs/pyth-solana-receiver/Cargo.toml b/target_chains/solana/programs/pyth-solana-receiver/Cargo.toml index 10e8aa8836..87b0e308e1 100644 --- a/target_chains/solana/programs/pyth-solana-receiver/Cargo.toml +++ b/target_chains/solana/programs/pyth-solana-receiver/Cargo.toml @@ -24,11 +24,13 @@ wormhole-core-bridge-solana = {workspace = true} wormhole-raw-vaas = {version = "0.1.3", features = ["ruint", "on-chain"], default-features = false } pyth-solana-receiver-sdk = { path = "../../pyth_solana_receiver_sdk"} rand = "0.8.5" +serde_with = { version = "2.3.3", default-features = false } [dev-dependencies] -solana-sdk = { workspace = true } +solana-sdk = { workspace = true } tokio = "1.14.1" program-simulator = { path = "../../program_simulator" } wormhole-vaas-serde = { workspace = true } serde_wormhole = { workspace = true } common-test-utils = { path = "../../common_test_utils" } + diff --git a/target_chains/solana/programs/pyth-solana-receiver/src/lib.rs b/target_chains/solana/programs/pyth-solana-receiver/src/lib.rs index 868844bedd..6491e9805e 100644 --- a/target_chains/solana/programs/pyth-solana-receiver/src/lib.rs +++ b/target_chains/solana/programs/pyth-solana-receiver/src/lib.rs @@ -254,6 +254,49 @@ pub mod pyth_solana_receiver { pub fn reclaim_rent(_ctx: Context) -> Result<()> { Ok(()) } + + // New function to initialize a PriceUpdateV2 account without verifications (for testing) + pub fn initialize_price_update_v2( + ctx: Context, + price: i64, + conf: u64, + exponent: i32, + feed_id: [u8; 32], + publish_time: u64, + prev_publish_time: u64, + ) -> Result<()> { + let price_update_account = &mut ctx.accounts.price_update_account; + price_update_account.price = price; + price_update_account.conf = conf; + price_update_account.exponent = exponent; + price_update_account.feed_id = feed_id; + price_update_account.publish_time = publish_time; + price_update_account.prev_publish_time = prev_publish_time; + price_update_account.is_initialized = true; + Ok(()) + } + + // New function to update a PriceUpdateV2 account without verifications (for testing) + pub fn update_price_update_v2( + ctx: Context, + price: i64, + conf: u64, + exponent: i32, + feed_id: [u8; 32], + publish_time: u64, + prev_publish_time: u64, + ) -> Result<()> { + let price_update_account = &mut ctx.accounts.price_update_account; + require!(price_update_account.is_initialized, ReceiverError::UninitializedAccount); + + price_update_account.price = price; + price_update_account.conf = conf; + price_update_account.exponent = exponent; + price_update_account.feed_id = feed_id; + price_update_account.publish_time = publish_time; + price_update_account.prev_publish_time = prev_publish_time; + Ok(()) + } } #[derive(Accounts)] @@ -340,6 +383,24 @@ pub struct ReclaimRent<'info> { pub price_update_account: Account<'info, PriceUpdateV2>, } +// New context for initializing PriceUpdateV2 without verifications +#[derive(Accounts)] +pub struct InitializePriceUpdateV2<'info> { + #[account(init, payer = user, space = PriceUpdateV2::LEN)] + pub price_update_account: Account<'info, PriceUpdateV2>, + #[account(mut)] + pub user: Signer<'info>, + pub system_program: Program<'info, System>, +} + +// New context for updating PriceUpdateV2 without verifications +#[derive(Accounts)] +pub struct UpdatePriceUpdateV2<'info> { + #[account(mut)] + pub price_update_account: Account<'info, PriceUpdateV2>, + pub user: Signer<'info>, +} + fn deserialize_guardian_set_checked( account_info: &AccountInfo<'_>, wormhole: &Pubkey, diff --git a/target_chains/solana/programs/pyth-solana-receiver/src/sdk.rs b/target_chains/solana/programs/pyth-solana-receiver/src/sdk.rs index 1d78484ccc..7525fa7d3e 100644 --- a/target_chains/solana/programs/pyth-solana-receiver/src/sdk.rs +++ b/target_chains/solana/programs/pyth-solana-receiver/src/sdk.rs @@ -302,6 +302,68 @@ impl instruction::ReclaimRent { } } +impl instruction::InitializePriceUpdateV2 { + pub fn populate( + payer: &Pubkey, + price: i64, + conf: u64, + exponent: i32, + feed_id: [u8; 32], + publish_time: u64, + prev_publish_time: u64, + ) -> Instruction { + Instruction { + program_id: ID, + accounts: accounts::InitializePriceUpdateV2 { + price_update_account: Pubkey::new_unique(), + user: *payer, + system_program: system_program::ID, + } + .to_account_metas(None), + data: instruction::InitializePriceUpdateV2 { + price, + conf, + exponent, + feed_id, + publish_time, + prev_publish_time, + } + .data(), + } + } +} + +impl instruction::UpdatePriceUpdateV2 { + pub fn populate( + payer: &Pubkey, + price_update_account: Pubkey, + price: i64, + conf: u64, + exponent: i32, + feed_id: [u8; 32], + publish_time: u64, + prev_publish_time: u64, + ) -> Instruction { + Instruction { + program_id: ID, + accounts: accounts::UpdatePriceUpdateV2 { + price_update_account, + user: *payer, + } + .to_account_metas(None), + data: instruction::UpdatePriceUpdateV2 { + price, + conf, + exponent, + feed_id, + publish_time, + prev_publish_time, + } + .data(), + } + } +} + pub fn get_guardian_set_address(wormhole_address: Pubkey, guardian_set_index: u32) -> Pubkey { Pubkey::find_program_address( &[ diff --git a/target_chains/solana/programs/pyth-solana-receiver/tests/test_stub_price_updates.rs b/target_chains/solana/programs/pyth-solana-receiver/tests/test_stub_price_updates.rs new file mode 100644 index 0000000000..00420c06c3 --- /dev/null +++ b/target_chains/solana/programs/pyth-solana-receiver/tests/test_stub_price_updates.rs @@ -0,0 +1,136 @@ +use anchor_lang::prelude::*; +use pyth_solana_receiver::pyth_solana_receiver::{initialize_price_update_v2, update_price_update_v2, PriceUpdateV2}; +use solana_program_test::*; +use solana_sdk::{ + account::Account as SolanaAccount, signature::Keypair, signer::Signer, transaction::Transaction, +}; + +#[tokio::test] +async fn test_initialize_price_update() { + let program_id = Pubkey::new_unique(); + let mut program_test = ProgramTest::new( + "pyth_solana_receiver", + program_id, + processor!(pyth_solana_receiver::pyth_solana_receiver::processor), + ); + + let price_update_keypair = Keypair::new(); + let price_update_account = price_update_keypair.pubkey(); + + program_test.add_account( + price_update_account, + SolanaAccount::new(1_000_000, PriceUpdateV2::LEN, &program_id), + ); + + let (mut banks_client, payer, recent_blockhash) = program_test.start().await; + + let price_update = PriceUpdateV2 { + price: 100, + conf: 10, + exponent: -2, + feed_id: [0u8; 32], + publish_time: 1_624_099_200, + prev_publish_time: 1_624_098_200, + is_initialized: false, + }; + + let init_ix = initialize_price_update_v2( + price_update_account, + price_update.price, + price_update.conf, + price_update.exponent, + price_update.feed_id, + price_update.publish_time, + price_update.prev_publish_time, + ); + + let mut transaction = Transaction::new_with_payer(&[init_ix], Some(&payer.pubkey())); + transaction.sign(&[&payer, &price_update_keypair], recent_blockhash); + banks_client.process_transaction(transaction).await.unwrap(); + + let account = banks_client + .get_account(price_update_account) + .await + .unwrap() + .unwrap(); + let price_update_data = PriceUpdateV2::try_deserialize(&mut account.data.as_slice()).unwrap(); + + assert_eq!(price_update_data, price_update); +} + +#[tokio::test] +async fn test_update_price_update() { + let program_id = Pubkey::new_unique(); + let mut program_test = ProgramTest::new( + "pyth_solana_receiver", + program_id, + processor!(pyth_solana_receiver::pyth_solana_receiver::processor), + ); + + let price_update_keypair = Keypair::new(); + let price_update_account = price_update_keypair.pubkey(); + + program_test.add_account( + price_update_account, + SolanaAccount::new(1_000_000, PriceUpdateV2::LEN, &program_id), + ); + + let (mut banks_client, payer, recent_blockhash) = program_test.start().await; + + let initial_price_update = PriceUpdateV2 { + price: 100, + conf: 10, + exponent: -2, + feed_id: [0u8; 32], + publish_time: 1_624_099_200, + prev_publish_time: 1_624_098_200, + is_initialized: false, + }; + + let init_ix = initialize_price_update_v2( + price_update_account, + initial_price_update.price, + initial_price_update.conf, + initial_price_update.exponent, + initial_price_update.feed_id, + initial_price_update.publish_time, + initial_price_update.prev_publish_time, + ); + + let mut transaction = Transaction::new_with_payer(&[init_ix], Some(&payer.pubkey())); + transaction.sign(&[&payer, &price_update_keypair], recent_blockhash); + banks_client.process_transaction(transaction).await.unwrap(); + + let updated_price_update = PriceUpdateV2 { + price: 200, + conf: 20, + exponent: -3, + feed_id: [1u8; 32], + publish_time: 1_624_199_200, + prev_publish_time: 1_624_198_200, + is_initialized: true, + }; + + let update_ix = update_price_update_v2( + price_update_account, + updated_price_update.price, + updated_price_update.conf, + updated_price_update.exponent, + updated_price_update.feed_id, + updated_price_update.publish_time, + updated_price_update.prev_publish_time, + ); + + let mut transaction = Transaction::new_with_payer(&[update_ix], Some(&payer.pubkey())); + transaction.sign(&[&payer, &price_update_keypair], recent_blockhash); + banks_client.process_transaction(transaction).await.unwrap(); + + let account = banks_client + .get_account(price_update_account) + .await + .unwrap() + .unwrap(); + let price_update_data = PriceUpdateV2::try_deserialize(&mut account.data.as_slice()).unwrap(); + + assert_eq!(price_update_data, updated_price_update); +}