diff --git a/Cargo.lock b/Cargo.lock index 76bb4e2ca..201f7bb88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -972,6 +972,37 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "pyth-pro" +version = "1.0.1" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-multi-test", + "cw-storage-plus", + "hex", + "schemars 0.8.22", + "serde", + "sha3", + "thiserror 1.0.69", +] + +[[package]] +name = "pyth-vaa" +version = "1.0.1" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-multi-test", + "cw-storage-plus", + "hex", + "k256", + "schemars 0.8.22", + "serde", + "sha3", + "thiserror 1.0.69", +] + [[package]] name = "quote" version = "1.0.45" diff --git a/Cargo.toml b/Cargo.toml index 653b00070..18e27a391 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,8 @@ resolver = "2" members = [ "contracts/wormhole", "contracts/pyth", + "contracts/pyth-vaa", + "contracts/pyth-pro", ] [workspace.package] diff --git a/_run/init.sh b/_run/init.sh index bcc674afc..746975226 100755 --- a/_run/init.sh +++ b/_run/init.sh @@ -22,8 +22,8 @@ if [[ -z "$CONTRACTS_DIR" ]]; then exit 1 fi -WORMHOLE_WASM="${CONTRACTS_DIR}/artifacts/wormhole.wasm" -PYTH_WASM="${CONTRACTS_DIR}/artifacts/pyth.wasm" +PYTH_VAA_WASM="${CONTRACTS_DIR}/artifacts/pyth_vaa.wasm" +PYTH_PRO_WASM="${CONTRACTS_DIR}/artifacts/pyth_pro.wasm" HERMES_MNEMONIC="wire museum tragic inmate final lady illegal father whisper margin sea cool soul half moon nut tissue strategy ladder come glory opera device elbow" @@ -38,39 +38,27 @@ ACCOUNTS=($KEYS) # Pyth configuration AKT_PRICE_FEED_ID="0x4ea5bb4d2f5900cc2e97ba534240950740b4d3b89fe712a94a7304fd2fd92702" -PYTH_EMITTER_CHAIN="26" # Pythnet -PYTH_EMITTER_ADDRESS="e101faedac5851e32b9b23b5f9411a8c2bac4aae3ed4dd7b811dd1a72ea4aa71" - - -# Wormhole Mainnet Guardian Set 5 (19 guardians) -# Source: https://github.com/wormhole-foundation/wormhole/blob/main/guardianset/mainnetv2/v5.prototxt -# TODO load them from URL above -GUARDIAN_ADDRESSES=( - "5893B5A76c3f739645648885bDCcC06cd70a3Cd3" - "fF6CB952589BDE862c25Ef4392132fb9D4A42157" - "114De8460193bdf3A2fCf81f86a09765F4762fD1" - "107A0086b32d7A0977926A205131d8731D39cbEB" - "8C82B2fd82FaeD2711d59AF0F2499D16e726f6b2" - "11b39756C042441BE6D8650b69b54EbE715E2343" - "938f104AEb5581293216ce97d771e0CB721221B1" - "15e7cAF07C4e3DC8e7C469f92C8Cd88FB8005a20" - "74a3bf913953D695260D88BC1aA25A4eeE363ef0" - "000aC0076727b35FBea2dAc28fEE5cCB0fEA768e" - "AF45Ced136b9D9e24903464AE889F5C8a723FC14" - "f93124b7c738843CBB89E864c862c38cddCccF95" - "D2CC37A4dc036a8D232b48f62cDD4731412f4890" - "DA798F6896A3331F64b48c12D1D57Fd9cbe70811" - "D1F64e26238811de5553C40f64af41eE1B6057Cc" - "43ac8f567A31e7850Da532B361988Bfe0d3ae11b" - "178e21ad2E77AE06711549CFBB1f9c7a9d8096e8" - "5E1487F35515d02A92753504a8D75471b9f49EdB" - "6FbEBc898F403E4773E95feB15E80C9A99c8348d" + +# Pyth Core upgraded router verifier configuration. +PYTH_ROUTER_SET_INDEX="0" +PYTH_ROUTER_EXPECTED_EMITTER_CHAIN="26" +PYTH_ROUTER_EXPECTED_EMITTER_ADDRESS="507974686e6574507974686e6574507974686e6574507974686e657450797468" +PYTH_ROUTER_ADDRESSES=( + "41534bb176e461a3fb30479400f210549ecce638" + "6502987b62f21cab7eb5ccd8f0173084b60d5b41" + "44a3e8f6a382412cf6bb90a3f8106e68977476c9" + "d9d7d4529577864352c9a6539a48238fcd447052" + "1663a5a822336ece48559b1dfb1e93a017a7dac3" ) log() { echo "[$(date -u '+%Y-%m-%d %H:%M:%S UTC')] $*" } +hex_to_base64() { + echo -n "$1" | xxd -r -p | base64 +} + wait_for_block() { local target=${1:-1} log "Waiting for block $target..." @@ -90,16 +78,6 @@ configure_genesis() { cp "${GENESIS_PATH}" "${GENESIS_PATH}.orig" - # Build guardian addresses JSON array - local guardian_json="[" - for i in "${!GUARDIAN_ADDRESSES[@]}"; do - if [ "$i" -gt 0 ]; then - guardian_json+="," - fi - guardian_json+="\"${GUARDIAN_ADDRESSES[$i]}\"" - done - guardian_json+="]" - # shellcheck disable=SC2002 cat "${GENESIS_PATH}.orig" | \ jq -M '.app_state.gov.voting_params.voting_period = "60s"' \ @@ -108,7 +86,7 @@ configure_genesis() { | jq -M '.app_state.gov.params.max_deposit_period = "60s"' \ | jq -M '.app_state.wasm.params.code_upload_access.permission = "Everybody"' \ | jq -M '.app_state.wasm.params.instantiate_default_permission = "Everybody"' \ - | jq -M --argjson guardians "$guardian_json" --arg feed_id "$AKT_PRICE_FEED_ID" ' + | jq -M ' .app_state.oracle.params.min_price_sources = 1 | .app_state.oracle.params.max_price_staleness_period = "60s" | .app_state.oracle.params.twap_window = "30s" | @@ -162,105 +140,94 @@ deploy_contracts() { admin_addr=$(akash keys show $admin_key -a) # Check if contract files exist - if [ ! -f "$WORMHOLE_WASM" ]; then - log "ERROR: Wormhole contract not found at $WORMHOLE_WASM" + if [ ! -f "$PYTH_VAA_WASM" ]; then + log "ERROR: Pyth VAA contract not found at $PYTH_VAA_WASM" log "Skipping contract deployment. Build contracts first with: cd contracts && make build" write_hermes_config "CONTRACT_NOT_DEPLOYED" return 1 fi - if [ ! -f "$PYTH_WASM" ]; then - log "ERROR: Pyth contract not found at $PYTH_WASM" + if [ ! -f "$PYTH_PRO_WASM" ]; then + log "ERROR: Pyth Pro contract not found at $PYTH_PRO_WASM" log "Skipping contract deployment. Build contracts first with: cd contracts && make build" write_hermes_config "CONTRACT_NOT_DEPLOYED" return 1 fi - # Deploy Wormhole contract - log "Storing Wormhole contract..." - akash tx wasm store "$WORMHOLE_WASM" --from $admin_key -o json - - local wormhole_code_id - wormhole_code_id=$(akash query wasm list-code -o json | jq -r '.code_infos[-1].code_id') - log "Wormhole code ID: $wormhole_code_id" - - # Instantiate Wormhole contract with initial guardian set - # Build guardian set JSON for the new wormhole contract - local guardian_set_json='[' - for i in "${!GUARDIAN_ADDRESSES[@]}"; do + local router_json='[' + for i in "${!PYTH_ROUTER_ADDRESSES[@]}"; do if [ "$i" -gt 0 ]; then - guardian_set_json+=',' + router_json+=',' fi - # Convert hex address to base64 for Binary field - local hex_addr="${GUARDIAN_ADDRESSES[$i]}" - local b64_addr - b64_addr=$(echo -n "$hex_addr" | xxd -r -p | base64) - guardian_set_json+="{\"bytes\":\"$b64_addr\"}" + local router_b64 + router_b64=$(hex_to_base64 "${PYTH_ROUTER_ADDRESSES[$i]}") + router_json+="{\"bytes\":\"$router_b64\"}" done - guardian_set_json+=']' + router_json+=']' + + local pyth_router_emitter_b64 + pyth_router_emitter_b64=$(hex_to_base64 "$PYTH_ROUTER_EXPECTED_EMITTER_ADDRESS") + + # Deploy Pyth VAA verifier contract + log "Storing Pyth VAA contract..." + akash tx wasm store "$PYTH_VAA_WASM" --from $admin_key + + local pyth_vaa_code_id + pyth_vaa_code_id=$(akash query wasm list-code -o json | jq -r '.code_infos[-1].code_id') + log "Pyth VAA code ID: $pyth_vaa_code_id" - local wormhole_init_msg - wormhole_init_msg=$(cat < /tmp/oracle-params.json <, +} + +/// A price update with its Merkle proof (MerklePriceUpdate in pythnet-sdk) +#[derive(Debug)] +pub struct PriceUpdateWithProof { + /// Raw message data (price update payload) + pub message_data: Vec, + /// Merkle proof nodes (20 bytes each) + pub merkle_proof: Vec<[u8; 20]>, +} + +/// Parse PNAU accumulator update format from Hermes v2 API +/// +/// Format (based on pythnet-sdk wire::v1): +/// - Magic: "PNAU" (4 bytes) [offset 0-3] +/// - Major version (1 byte) [offset 4] +/// - Minor version (1 byte) [offset 5] +/// - Trailing length (1 byte) [offset 6] +/// - Trailing data (trailing_len bytes) [offset 7 to 7+trailing_len-1] +/// - Proof discriminant (1 byte): 0=Merkle proof update [offset 7+trailing_len] +/// - VAA length (2 bytes, big-endian) [offset 8+trailing_len] +/// - VAA data (vaa_len bytes) +/// - Number of updates (1 byte) +/// - For each MerklePriceUpdate: +/// - Message size (2 bytes, big-endian) +/// - Message data +/// - Proof count (1 byte) +/// - Proof nodes (20 bytes each) +pub fn parse_accumulator_update(data: &[u8]) -> StdResult { + // Check minimum length for header + if data.len() < 8 { + return Err(StdError::msg("PNAU data too short")); + } + + // Verify magic bytes + if &data[0..4] != PNAU_MAGIC { + return Err(StdError::msg(format!( + "Invalid PNAU magic: expected {:?}, got {:?}", + PNAU_MAGIC, + &data[0..4] + ))); + } + + let major_version = data[4]; + let _minor_version = data[5]; + let trailing_len = data[6] as usize; + + // Validate version + if major_version != 1 { + return Err(StdError::msg(format!( + "Unsupported PNAU major version: {}", + major_version + ))); + } + + // Position after trailing data is where proof discriminant lives + // Format: magic(4) + major(1) + minor(1) + trailing_len(1) + trailing_data(trailing_len) + proof_discriminant(1) + ... + let mut offset = 7 + trailing_len; + + // Read proof discriminant (update type) + if offset >= data.len() { + return Err(StdError::msg("Missing proof discriminant")); + } + let update_type = data[offset]; + offset += 1; + + // Only support Merkle proof updates carrying the router-signed VAA message. + if update_type != UPDATE_TYPE_MERKLE_PROOF { + return Err(StdError::msg(format!( + "Unsupported update type: {}, expected Merkle proof update (0)", + update_type + ))); + } + + // Parse VAA length (u16 big-endian, as PrefixedVec) + if offset + 2 > data.len() { + return Err(StdError::msg("Missing VAA length")); + } + let vaa_len = u16::from_be_bytes([data[offset], data[offset + 1]]) as usize; + offset += 2; + + // Parse VAA data + if offset + vaa_len > data.len() { + return Err(StdError::msg(format!( + "VAA data truncated: need {} bytes, have {}", + vaa_len, + data.len() - offset + ))); + } + let vaa = Binary::from(&data[offset..offset + vaa_len]); + offset += vaa_len; + + // Extract Merkle root from VAA payload + let merkle_root = extract_merkle_root_from_vaa(&vaa)?; + + // Parse number of updates + if offset >= data.len() { + return Err(StdError::msg("Missing update count")); + } + let num_updates = data[offset] as usize; + offset += 1; + + // Parse each price update + let mut price_updates = Vec::with_capacity(num_updates); + for i in 0..num_updates { + let (update, new_offset) = parse_price_update(data, offset) + .map_err(|e| StdError::msg(format!("Failed to parse update {}: {}", i, e)))?; + price_updates.push(update); + offset = new_offset; + } + + Ok(AccumulatorUpdate { + vaa, + merkle_root, + price_updates, + }) +} + +/// Extract the Merkle root from a router-signed VAA-format payload. +fn extract_merkle_root_from_vaa(vaa: &[u8]) -> StdResult<[u8; 20]> { + // VAA structure: + // - Version (1 byte) + // - Router set index, stored in the VAA guardian_set_index field (4 bytes) + // - Signature count (1 byte) + // - Signatures (66 bytes each) + // - Body starts after signatures + + if vaa.len() < 6 { + return Err(StdError::msg("VAA too short")); + } + + let sig_count = vaa[5] as usize; + let body_offset = 6 + (sig_count * 66); + + if body_offset + 51 > vaa.len() { + return Err(StdError::msg("VAA body too short")); + } + + // Body structure: + // - Timestamp (4 bytes) + // - Nonce (4 bytes) + // - Emitter chain (2 bytes) + // - Emitter address (32 bytes) + // - Sequence (8 bytes) + // - Consistency level (1 byte) + // - Payload starts at offset 51 + + let payload_offset = body_offset + 51; + let payload = &vaa[payload_offset..]; + + // Payload for Merkle root: + // - Magic "AUWV" (4 bytes) + // - Update type (1 byte) + // - Slot (8 bytes) + // - Ring size (4 bytes) + // - Root (20 bytes) + + if payload.len() < 37 { + return Err(StdError::msg("Merkle payload too short")); + } + + // Check magic "AUWV" + if &payload[0..4] != b"AUWV" { + return Err(StdError::msg(format!( + "Invalid Merkle root magic: expected AUWV, got {:?}", + String::from_utf8_lossy(&payload[0..4]) + ))); + } + + // Extract root (bytes 17-37) + let mut root = [0u8; 20]; + root.copy_from_slice(&payload[17..37]); + + Ok(root) +} + +/// Parse a single price update with its Merkle proof (MerklePriceUpdate) +/// +/// MerklePriceUpdate format (Pyth wire format): +/// - message: 2-byte length prefix (big-endian) + data +/// - proof: 1-byte count + 20-byte nodes +fn parse_price_update(data: &[u8], mut offset: usize) -> StdResult<(PriceUpdateWithProof, usize)> { + // Message size (2 bytes, big-endian - Pyth wire format) + if offset + 2 > data.len() { + return Err(StdError::msg("Missing message size")); + } + let message_size = u16::from_be_bytes([data[offset], data[offset + 1]]) as usize; + offset += 2; + + // Message data + if offset + message_size > data.len() { + return Err(StdError::msg(format!( + "Message data truncated: need {} bytes, have {}", + message_size, + data.len() - offset + ))); + } + let message_data = data[offset..offset + message_size].to_vec(); + offset += message_size; + + // Merkle proof size (1 byte = number of 20-byte nodes) + if offset >= data.len() { + return Err(StdError::msg("Missing proof size")); + } + let proof_size = data[offset] as usize; + offset += 1; + + // Merkle proof nodes + let mut merkle_proof = Vec::with_capacity(proof_size); + for _ in 0..proof_size { + if offset + 20 > data.len() { + return Err(StdError::msg("Merkle proof truncated")); + } + let mut node = [0u8; 20]; + node.copy_from_slice(&data[offset..offset + 20]); + merkle_proof.push(node); + offset += 20; + } + + Ok(( + PriceUpdateWithProof { + message_data, + merkle_proof, + }, + offset, + )) +} + +/// Verify a Merkle proof for a price update +/// +/// The proof demonstrates that the message is included in the tree +/// whose root was signed by Pyth routers. +pub fn verify_merkle_proof( + message_data: &[u8], + proof: &[[u8; 20]], + expected_root: &[u8; 20], +) -> bool { + // Compute leaf hash: keccak256(MERKLE_LEAF_PREFIX || message_data)[0..20] + let mut hasher = Keccak256::new(); + hasher.update([MERKLE_LEAF_PREFIX]); + hasher.update(message_data); + let leaf_hash = hasher.finalize(); + let mut current: [u8; 20] = [0; 20]; + current.copy_from_slice(&leaf_hash[0..20]); + + // Walk up the tree + for sibling in proof { + let mut hasher = Keccak256::new(); + hasher.update([MERKLE_NODE_PREFIX]); + + // Sort children to ensure consistent ordering + if current < *sibling { + hasher.update(current); + hasher.update(sibling); + } else { + hasher.update(sibling); + hasher.update(current); + } + + let node_hash = hasher.finalize(); + current.copy_from_slice(&node_hash[0..20]); + } + + current == *expected_root +} + +#[cfg(test)] +pub const AKT_UPGRADED_HERMES_PNAU_HEX: &str = + "504e4155010000000124010000000003013e1fb8c03541656c8e9f8a3edc9e939c676a07d6e76a6481babf53b3ffe3eb354cea03c9beacc220c6c1b59039d732cd3403762a8eb8c005829ea93175cb6ce20003d2ed8e50e7d7350255ee6974a845c2f83236c9be4969f6a3063f1c87173fe1dc35e07fe6cea3d9341dc8887a858abacc33883acd2f1c6a9a94cfa3718ea8130901043dbc0d384f891945d9e6c8636826d58ad7f74f4162346052f37bfc5cf5da0ea75f1d1795acf9a8685f96308393947a0a9a22e381585e804506aa7c19d3400945006a4f27ed00000000001a507974686e6574507974686e6574507974686e6574507974686e657450797468000000084e2f1e84004155575600000000084e2f1e84000000000f57eee39f1b76403b1094b3a177ecef270e3226010055004ea5bb4d2f5900cc2e97ba534240950740b4d3b89fe712a94a7304fd2fd9270200000000037ee0d8000000000000dd37fffffff8000000006a4f27ed000000006a4f27ec0000000003811f7d0000000000007eb30ba0cdbf661704cbaafd0b1d24d5212bfb70d6b86de187f9c1fbaf5b8c9816d8a15bbeefe167cfa33e4148f15de06c47c1b06330a086746b9d4e1f4f146081345c6a9c954cae73fa4a7659ac1db4c5fc961f8610d5a455209bc7b3453f23f1cda609fdf32a2df0ac5a8d5d7e87863768ebb2df823831306c409a5c7afad2585d206428db6aa3f03ceeb68cfe62260ba763cb51529ae9360a085e2c9dff0538286a6ec453c0c9b1997be26ef1157fbc5e453a9079a2b0734c15bf28320e803084128b00b50722ad44a541ab71df0ba693d62defeed4e1ebe935b65699c7"; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_merkle_leaf_hash() { + // Test that leaf hashing works correctly + let message = b"test message"; + let mut hasher = Keccak256::new(); + hasher.update([MERKLE_LEAF_PREFIX]); + hasher.update(message); + let hash = hasher.finalize(); + + // Should produce a valid hash + assert_eq!(hash.len(), 32); + } + + #[test] + fn test_pnau_magic_detection() { + let valid_magic = b"PNAU"; + let invalid_magic = b"TEST"; + + assert_eq!(valid_magic, PNAU_MAGIC); + assert_ne!(invalid_magic, PNAU_MAGIC); + } + + #[test] + fn test_parse_accumulator_too_short() { + let data = b"PNAU"; + let result = parse_accumulator_update(data); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("too short")); + } + + #[test] + fn test_parse_accumulator_invalid_magic() { + let data = b"TEST0100"; + let result = parse_accumulator_update(data); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Invalid PNAU magic")); + } + + #[test] + fn parses_live_upgraded_hermes_akt_pnau_fixture() { + let data = hex::decode(AKT_UPGRADED_HERMES_PNAU_HEX).unwrap(); + let update = parse_accumulator_update(&data).unwrap(); + + assert_eq!(update.vaa.len(), 292); + assert_eq!(update.price_updates.len(), 1); + assert_eq!( + hex::encode(update.merkle_root), + "0f57eee39f1b76403b1094b3a177ecef270e3226" + ); + assert!(verify_merkle_proof( + &update.price_updates[0].message_data, + &update.price_updates[0].merkle_proof, + &update.merkle_root + )); + } +} diff --git a/contracts/pyth-pro/src/contract.rs b/contracts/pyth-pro/src/contract.rs new file mode 100644 index 000000000..28a471872 --- /dev/null +++ b/contracts/pyth-pro/src/contract.rs @@ -0,0 +1,555 @@ +use cosmwasm_std::{ + entry_point, to_json_binary, AnyMsg, Binary, CosmosMsg, Deps, DepsMut, Env, MessageInfo, + QueryRequest, Response, StdResult, Uint128, Uint256, WasmQuery, +}; + +use crate::accumulator::{parse_accumulator_update, verify_merkle_proof}; +use crate::error::ContractError; +use crate::msg::{ + ConfigResponse, ExecuteMsg, InstantiateMsg, ParsedVAA, PriceFeedIdResponse, PriceFeedResponse, + PriceResponse, QueryMsg, VaaQueryMsg, +}; +use crate::oracle::{pyth_price_to_decimal, MsgAddPriceEntry}; +use crate::pyth::parse_price_feed_message; +use crate::state::{Config, DataID, PriceFeed, CONFIG, PRICE_FEED}; + +// Expected exponent for AKT/USD price (8 decimals) +const EXPECTED_EXPO: i32 = -8; + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + _env: Env, + _info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + // Validate admin address + let admin = deps.api.addr_validate(&msg.admin)?; + + let pyth_vaa_contract = deps.api.addr_validate(&msg.pyth_vaa_contract)?; + + // Require price feed ID + if msg.price_feed_id.is_empty() { + return Err(ContractError::InvalidPriceData { + reason: "price_feed_id is required".to_string(), + }); + } + + let config = Config { + admin, + pyth_vaa_contract, + update_fee: msg.update_fee, + price_feed_id: msg.price_feed_id.clone(), + default_data_id: DataID::akt_usd(), + }; + CONFIG.save(deps.storage, &config)?; + + // Initialize price feed with default values + let price_feed = PriceFeed::new(); + PRICE_FEED.save(deps.storage, &price_feed)?; + + Ok(Response::new() + .add_attribute("method", "instantiate") + .add_attribute("admin", msg.admin) + .add_attribute("pyth_vaa_contract", msg.pyth_vaa_contract) + .add_attribute("update_fee", msg.update_fee) + .add_attribute("price_feed_id", msg.price_feed_id)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::UpdatePriceFeed { vaa } => execute_update_price_feed(deps, env, info, vaa), + ExecuteMsg::UpdateFee { new_fee } => execute_update_fee(deps, info, new_fee), + ExecuteMsg::TransferAdmin { new_admin } => execute_transfer_admin(deps, info, new_admin), + ExecuteMsg::UpdateConfig { + pyth_vaa_contract, + price_feed_id, + } => execute_update_config(deps, info, pyth_vaa_contract, price_feed_id), + } +} + +/// Validate that a Pyth price is non-negative and convert to Uint128. +/// Rejects negative prices that would otherwise be silently converted +/// to their absolute value by unsigned_abs(). +fn validate_pyth_price(raw_price: i64) -> Result { + if raw_price < 0 { + return Err(ContractError::InvalidPriceData { + reason: "negative price".to_string(), + }); + } + Ok(Uint128::new(raw_price as u128)) +} + +/// Execute price feed update with upgraded Pyth PNAU accumulator data. +pub fn execute_update_price_feed( + deps: DepsMut, + env: Env, + info: MessageInfo, + vaa: Binary, +) -> Result { + let config = CONFIG.load(deps.storage)?; + + // Check if sufficient fee was paid + let sent_amount = info + .funds + .iter() + .find(|coin| coin.denom == "uakt") + .map(|coin| coin.amount) + .unwrap_or_else(Uint256::zero); + + if sent_amount < config.update_fee { + return Err(ContractError::InsufficientFunds { + required: config.update_fee.to_string(), + sent: sent_amount.to_string(), + }); + } + + let data_bytes = vaa.as_slice(); + + let accumulator = + parse_accumulator_update(data_bytes).map_err(|_| ContractError::InvalidPriceData { + reason: "failed to parse accumulator update".to_string(), + })?; + + if accumulator.price_updates.is_empty() { + return Err(ContractError::InvalidPriceData { + reason: "No price updates in accumulator".to_string(), + }); + } + + let verified_vaa: ParsedVAA = deps.querier.query(&QueryRequest::Wasm(WasmQuery::Smart { + contract_addr: config.pyth_vaa_contract.to_string(), + msg: to_json_binary(&VaaQueryMsg::VerifyVAA { + vaa: accumulator.vaa.clone(), + block_time: env.block.time.seconds(), + })?, + }))?; + + let price_update = &accumulator.price_updates[0]; + if !verify_merkle_proof( + &price_update.message_data, + &price_update.merkle_proof, + &accumulator.merkle_root, + ) { + return Err(ContractError::InvalidPriceData { + reason: "Merkle proof verification failed".to_string(), + }); + } + + let pyth_price = parse_price_feed_message(&price_update.message_data).map_err(|_| { + ContractError::InvalidPriceData { + reason: "failed to parse price feed message".to_string(), + } + })?; + + // Step 4: Validate price feed ID matches expected + if pyth_price.id != config.price_feed_id { + return Err(ContractError::InvalidPriceData { + reason: "price feed ID mismatch".to_string(), + }); + } + + // Convert Pyth price types + let price = validate_pyth_price(pyth_price.price)?; + let conf = Uint128::new(pyth_price.conf as u128); + let expo = pyth_price.expo; + let publish_time = pyth_price.publish_time; + + // Validate price data + if price.is_zero() { + return Err(ContractError::ZeroPrice {}); + } + + // Validate exponent + if expo != EXPECTED_EXPO { + return Err(ContractError::InvalidExponent { expo }); + } + + // Load existing price feed to get previous publish time + let mut price_feed = PRICE_FEED.load(deps.storage)?; + + // Reject truly older prices + if publish_time < price_feed.publish_time { + return Err(ContractError::InvalidPriceData { + reason: "price data is older than current data".to_string(), + }); + } + + // Update contract storage for same or newer timestamps. + // Pyth may submit multiple prices within the same timestamp + // but from different slots — all should be accepted. + if publish_time >= price_feed.publish_time { + price_feed.prev_publish_time = price_feed.publish_time; + price_feed.price = price; + price_feed.conf = conf; + price_feed.expo = expo; + price_feed.publish_time = publish_time; + + PRICE_FEED.save(deps.storage, &price_feed)?; + } + + // Convert Pyth price to decimal string for x/oracle module + let price_decimal = pyth_price_to_decimal(pyth_price.price, expo); + + // Create oracle message with proto format + let oracle_msg = MsgAddPriceEntry::new( + env.contract.address.to_string(), + config.default_data_id.denom.clone(), + config.default_data_id.base_denom.clone(), + price_decimal.clone(), + publish_time, + 0, + ); + + // Encode to protobuf for x/oracle module + let oracle_data = oracle_msg.encode_to_protobuf(); + + // Create Any message to submit price to x/oracle module + let oracle_cosmos_msg: CosmosMsg = CosmosMsg::Any(AnyMsg { + type_url: "/akash.oracle.v2.MsgAddPriceEntry".to_string(), + value: oracle_data.clone(), + }); + + Ok(Response::new() + .add_message(oracle_cosmos_msg) + .add_attribute("method", "update_price_feed") + .add_attribute("price", price.to_string()) + .add_attribute("conf", conf.to_string()) + .add_attribute("publish_time", publish_time.to_string()) + .add_attribute("oracle_price", price_decimal.clone()) + .add_attribute("oracle_denom", &config.default_data_id.denom) + .add_attribute("oracle_base_denom", &config.default_data_id.base_denom) + .add_attribute("oracle_data", oracle_data.to_base64()) + .add_attribute("vaa_emitter_chain", verified_vaa.emitter_chain.to_string()) + .add_attribute("updater", info.sender)) +} + +pub fn execute_update_fee( + deps: DepsMut, + info: MessageInfo, + new_fee: Uint256, +) -> Result { + let mut config = CONFIG.load(deps.storage)?; + + // Only admin can update fee + if info.sender != config.admin { + return Err(ContractError::Unauthorized {}); + } + + config.update_fee = new_fee; + CONFIG.save(deps.storage, &config)?; + + Ok(Response::new() + .add_attribute("method", "update_fee") + .add_attribute("new_fee", new_fee.to_string())) +} + +pub fn execute_transfer_admin( + deps: DepsMut, + info: MessageInfo, + new_admin: String, +) -> Result { + let mut config = CONFIG.load(deps.storage)?; + + // Only current admin can transfer admin rights + if info.sender != config.admin { + return Err(ContractError::Unauthorized {}); + } + + let new_admin_addr = deps.api.addr_validate(&new_admin)?; + config.admin = new_admin_addr; + CONFIG.save(deps.storage, &config)?; + + Ok(Response::new() + .add_attribute("method", "transfer_admin") + .add_attribute("new_admin", new_admin)) +} + +pub fn execute_update_config( + deps: DepsMut, + info: MessageInfo, + pyth_vaa_contract: Option, + price_feed_id: Option, +) -> Result { + let mut config = CONFIG.load(deps.storage)?; + + // Only admin can update config + if info.sender != config.admin { + return Err(ContractError::Unauthorized {}); + } + + if let Some(contract) = pyth_vaa_contract { + config.pyth_vaa_contract = deps.api.addr_validate(&contract)?; + } + + if let Some(feed_id) = price_feed_id { + if feed_id.is_empty() { + return Err(ContractError::InvalidPriceData { + reason: "price_feed_id is required".to_string(), + }); + } + config.price_feed_id = feed_id; + } + + CONFIG.save(deps.storage, &config)?; + + Ok(Response::new() + .add_attribute("method", "update_config") + .add_attribute("pyth_vaa_contract", config.pyth_vaa_contract.to_string()) + .add_attribute("price_feed_id", config.price_feed_id)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::GetPrice {} => to_json_binary(&query_price(deps)?), + QueryMsg::GetPriceFeed {} => to_json_binary(&query_price_feed(deps)?), + QueryMsg::GetConfig {} => to_json_binary(&query_config(deps)?), + QueryMsg::GetPriceFeedId {} => to_json_binary(&query_price_feed_id(deps)?), + } +} + +fn query_price(deps: Deps) -> StdResult { + let price_feed = PRICE_FEED.load(deps.storage)?; + + Ok(PriceResponse { + price: price_feed.price, + conf: price_feed.conf, + expo: price_feed.expo, + publish_time: price_feed.publish_time, + }) +} + +fn query_price_feed(deps: Deps) -> StdResult { + let price_feed = PRICE_FEED.load(deps.storage)?; + + Ok(PriceFeedResponse { + symbol: price_feed.symbol, + price: price_feed.price, + conf: price_feed.conf, + expo: price_feed.expo, + publish_time: price_feed.publish_time, + prev_publish_time: price_feed.prev_publish_time, + }) +} + +fn query_config(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage)?; + + Ok(ConfigResponse { + admin: config.admin.to_string(), + pyth_vaa_contract: config.pyth_vaa_contract.to_string(), + update_fee: config.update_fee, + price_feed_id: config.price_feed_id, + default_denom: config.default_data_id.denom, + default_base_denom: config.default_data_id.base_denom, + }) +} + +fn query_price_feed_id(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage)?; + + Ok(PriceFeedIdResponse { + price_feed_id: config.price_feed_id, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use cosmwasm_std::testing::{message_info, mock_env, MockApi, MockQuerier, MockStorage}; + use cosmwasm_std::{coin, from_json, ContractResult, Empty, OwnedDeps, SystemResult}; + + type MockDeps = OwnedDeps; + + fn mock_deps() -> MockDeps { + OwnedDeps { + storage: MockStorage::default(), + api: MockApi::default(), + querier: MockQuerier::default(), + custom_query_type: std::marker::PhantomData, + } + } + + fn setup_config(deps: &mut MockDeps) { + let config = Config { + admin: deps.api.addr_make("admin"), + pyth_vaa_contract: deps.api.addr_make("pythvaa"), + update_fee: Uint256::from(1000u128), + price_feed_id: "0xtest123".to_string(), + default_data_id: DataID::akt_usd(), + }; + CONFIG.save(&mut deps.storage, &config).unwrap(); + } + + fn setup_price_feed(deps: &mut MockDeps) { + PRICE_FEED + .save(&mut deps.storage, &PriceFeed::new()) + .unwrap(); + } + + fn mock_pyth_vaa_query(deps: &mut MockDeps) { + deps.querier.update_wasm(|query| match query { + WasmQuery::Smart { msg, .. } => { + let _: VaaQueryMsg = from_json(msg).unwrap(); + SystemResult::Ok(ContractResult::Ok( + to_json_binary(&ParsedVAA { + version: 1, + guardian_set_index: 0, + timestamp: 0, + nonce: 0, + len_signers: 3, + emitter_chain: 26, + emitter_address: vec![0; 32], + sequence: 0, + consistency_level: 0, + payload: vec![], + hash: vec![0; 32], + }) + .unwrap(), + )) + } + _ => SystemResult::Ok(ContractResult::Err("unsupported wasm query".to_string())), + }); + } + + #[test] + fn test_update_fee() { + let mut deps = mock_deps(); + setup_config(&mut deps); + + let msg = ExecuteMsg::UpdateFee { + new_fee: Uint256::from(2000u128), + }; + let info = message_info(&deps.api.addr_make("admin"), &[]); + let res = execute(deps.as_mut(), mock_env(), info, msg).unwrap(); + assert_eq!(2, res.attributes.len()); + + let config: ConfigResponse = + from_json(query(deps.as_ref(), mock_env(), QueryMsg::GetConfig {}).unwrap()).unwrap(); + assert_eq!(Uint256::from(2000u128), config.update_fee); + } + + #[test] + fn test_query_price_feed_id() { + let mut deps = mock_deps(); + setup_config(&mut deps); + + // Update config with specific price feed id + let mut config = CONFIG.load(&deps.storage).unwrap(); + config.price_feed_id = "0xabc123def456".to_string(); + CONFIG.save(&mut deps.storage, &config).unwrap(); + + let response: PriceFeedIdResponse = + from_json(query(deps.as_ref(), mock_env(), QueryMsg::GetPriceFeedId {}).unwrap()) + .unwrap(); + + assert_eq!("0xabc123def456", response.price_feed_id); + } + + #[test] + fn test_query_config_includes_pyth_vaa_contract() { + let mut deps = mock_deps(); + setup_config(&mut deps); + + let response: ConfigResponse = + from_json(query(deps.as_ref(), mock_env(), QueryMsg::GetConfig {}).unwrap()).unwrap(); + + // Oracle module expects "akt" (not "uakt") for denom + assert_eq!("akt", response.default_denom); + assert_eq!("usd", response.default_base_denom); + assert_eq!( + deps.api.addr_make("pythvaa").to_string(), + response.pyth_vaa_contract + ); + } + + #[test] + fn test_update_price_feed_with_pyth_vaa_verified_pnau() { + let mut deps = mock_deps(); + let config = Config { + admin: deps.api.addr_make("admin"), + pyth_vaa_contract: deps.api.addr_make("pythvaa"), + update_fee: Uint256::from(1000u128), + price_feed_id: "0x4ea5bb4d2f5900cc2e97ba534240950740b4d3b89fe712a94a7304fd2fd92702" + .to_string(), + default_data_id: DataID::akt_usd(), + }; + CONFIG.save(&mut deps.storage, &config).unwrap(); + setup_price_feed(&mut deps); + mock_pyth_vaa_query(&mut deps); + + let update = + Binary::from(hex::decode(crate::accumulator::AKT_UPGRADED_HERMES_PNAU_HEX).unwrap()); + let info = message_info(&deps.api.addr_make("updater"), &[coin(1000, "uakt")]); + let res = execute_update_price_feed(deps.as_mut(), mock_env(), info, update).unwrap(); + + assert!(res + .attributes + .iter() + .any(|attr| attr.key == "method" && attr.value == "update_price_feed")); + + let price_feed = PRICE_FEED.load(&deps.storage).unwrap(); + assert!(!price_feed.price.is_zero()); + assert_eq!(price_feed.expo, -8); + assert!(price_feed.publish_time > 0); + } + + #[test] + fn test_update_price_feed_rejects_non_pnau_input() { + let mut deps = mock_deps(); + let config = Config { + admin: deps.api.addr_make("admin"), + pyth_vaa_contract: deps.api.addr_make("pythvaa"), + update_fee: Uint256::from(1000u128), + price_feed_id: "0x4ea5bb4d2f5900cc2e97ba534240950740b4d3b89fe712a94a7304fd2fd92702" + .to_string(), + default_data_id: DataID::akt_usd(), + }; + CONFIG.save(&mut deps.storage, &config).unwrap(); + setup_price_feed(&mut deps); + + let info = message_info(&deps.api.addr_make("updater"), &[coin(1000, "uakt")]); + let err = + execute_update_price_feed(deps.as_mut(), mock_env(), info, Binary::from(vec![1, 2, 3])) + .unwrap_err(); + + assert!(err + .to_string() + .contains("failed to parse accumulator update")); + } + + #[test] + fn test_negative_price_rejected() { + let result = validate_pyth_price(-500); + assert!(result.is_err(), "negative price should be rejected"); + let err = result.unwrap_err(); + match err { + ContractError::InvalidPriceData { reason } => { + assert_eq!(reason, "negative price"); + } + _ => panic!("expected InvalidPriceData, got {:?}", err), + } + } + + #[test] + fn test_positive_price_accepted() { + let result = validate_pyth_price(123456789); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), Uint128::new(123456789)); + } + + #[test] + fn test_zero_price_accepted_by_converter() { + // validate_pyth_price allows zero; the ZeroPrice check is separate + let result = validate_pyth_price(0); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), Uint128::zero()); + } +} diff --git a/contracts/pyth-pro/src/error.rs b/contracts/pyth-pro/src/error.rs new file mode 100644 index 000000000..5646b6ff5 --- /dev/null +++ b/contracts/pyth-pro/src/error.rs @@ -0,0 +1,23 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Unauthorized")] + Unauthorized {}, + + #[error("Invalid price data: {reason}")] + InvalidPriceData { reason: String }, + + #[error("Insufficient funds: required {required}, sent {sent}")] + InsufficientFunds { required: String, sent: String }, + + #[error("Invalid exponent: expected -8, got {expo}")] + InvalidExponent { expo: i32 }, + + #[error("Price cannot be zero")] + ZeroPrice {}, +} diff --git a/contracts/pyth-pro/src/integration_tests.rs b/contracts/pyth-pro/src/integration_tests.rs new file mode 100644 index 000000000..219e8b633 --- /dev/null +++ b/contracts/pyth-pro/src/integration_tests.rs @@ -0,0 +1,234 @@ +use cosmwasm_std::testing::{message_info, mock_env, MockApi, MockQuerier, MockStorage}; +use cosmwasm_std::{from_json, Addr, Empty, OwnedDeps, Uint128, Uint256}; + +use crate::accumulator::{parse_accumulator_update, PNAU_MAGIC}; +use crate::contract::{execute, query}; +use crate::msg::{ + ConfigResponse, ExecuteMsg, PriceFeedIdResponse, PriceFeedResponse, PriceResponse, QueryMsg, +}; +use crate::oracle::{pyth_price_to_decimal, DataID as OracleDataID, MsgAddPriceEntry}; +use crate::state::{Config, DataID, PriceFeed, CONFIG, PRICE_FEED}; + +type MockDeps = OwnedDeps; + +fn mock_deps() -> MockDeps { + OwnedDeps { + storage: MockStorage::default(), + api: MockApi::default(), + querier: MockQuerier::default(), + custom_query_type: std::marker::PhantomData, + } +} + +fn setup_contract(deps: &mut MockDeps) -> Addr { + let admin = deps.api.addr_make("admin"); + + CONFIG + .save( + &mut deps.storage, + &Config { + admin: admin.clone(), + pyth_vaa_contract: deps.api.addr_make("pythvaa"), + update_fee: Uint256::from(1000u128), + price_feed_id: "0xtest_pyth_price_feed_id".to_string(), + default_data_id: DataID::akt_usd(), + }, + ) + .unwrap(); + + PRICE_FEED + .save(&mut deps.storage, &PriceFeed::new()) + .unwrap(); + + admin +} + +fn simulate_price_update(deps: &mut MockDeps, price: u128, conf: u128, publish_time: i64) { + let mut price_feed = PRICE_FEED.load(&deps.storage).unwrap(); + price_feed.prev_publish_time = price_feed.publish_time; + price_feed.price = Uint128::new(price); + price_feed.conf = Uint128::new(conf); + price_feed.expo = -8; + price_feed.publish_time = publish_time; + PRICE_FEED.save(&mut deps.storage, &price_feed).unwrap(); +} + +#[test] +fn e2e_query_initial_state() { + let mut deps = mock_deps(); + let admin = setup_contract(&mut deps); + let env = mock_env(); + + let config: ConfigResponse = + from_json(query(deps.as_ref(), env.clone(), QueryMsg::GetConfig {}).unwrap()).unwrap(); + assert_eq!(config.admin, admin.to_string()); + assert_eq!( + config.pyth_vaa_contract, + deps.api.addr_make("pythvaa").to_string() + ); + assert_eq!(config.default_denom, "akt"); + assert_eq!(config.default_base_denom, "usd"); + + let price: PriceResponse = + from_json(query(deps.as_ref(), env.clone(), QueryMsg::GetPrice {}).unwrap()).unwrap(); + assert_eq!(price.price, Uint128::zero()); + assert_eq!(price.expo, -8); + + let price_feed: PriceFeedResponse = + from_json(query(deps.as_ref(), env, QueryMsg::GetPriceFeed {}).unwrap()).unwrap(); + assert_eq!(price_feed.symbol, "AKT/USD"); + assert_eq!(price_feed.price, Uint128::zero()); +} + +#[test] +fn e2e_query_after_price_update() { + let mut deps = mock_deps(); + setup_contract(&mut deps); + let env = mock_env(); + let current_time = env.block.time.seconds() as i64; + + simulate_price_update(&mut deps, 52468300, 100000, current_time); + + let price: PriceResponse = + from_json(query(deps.as_ref(), env.clone(), QueryMsg::GetPrice {}).unwrap()).unwrap(); + assert_eq!(price.price, Uint128::new(52468300)); + assert_eq!(price.conf, Uint128::new(100000)); + assert_eq!(price.expo, -8); + assert_eq!(price.publish_time, current_time); + + let price_feed: PriceFeedResponse = + from_json(query(deps.as_ref(), env, QueryMsg::GetPriceFeed {}).unwrap()).unwrap(); + assert_eq!(price_feed.price, Uint128::new(52468300)); + assert_eq!(price_feed.publish_time, current_time); +} + +#[test] +fn e2e_admin_operations_flow() { + let mut deps = mock_deps(); + let admin = setup_contract(&mut deps); + let env = mock_env(); + let admin_info = message_info(&admin, &[]); + + execute( + deps.as_mut(), + env.clone(), + admin_info.clone(), + ExecuteMsg::UpdateFee { + new_fee: Uint256::from(5000u128), + }, + ) + .unwrap(); + + let config: ConfigResponse = + from_json(query(deps.as_ref(), env.clone(), QueryMsg::GetConfig {}).unwrap()).unwrap(); + assert_eq!(config.update_fee, Uint256::from(5000u128)); + + let new_admin = deps.api.addr_make("new_admin"); + execute( + deps.as_mut(), + env.clone(), + admin_info, + ExecuteMsg::TransferAdmin { + new_admin: new_admin.to_string(), + }, + ) + .unwrap(); + + let config: ConfigResponse = + from_json(query(deps.as_ref(), env.clone(), QueryMsg::GetConfig {}).unwrap()).unwrap(); + assert_eq!(config.admin, new_admin.to_string()); + + let err = execute( + deps.as_mut(), + env, + message_info(&admin, &[]), + ExecuteMsg::UpdateFee { + new_fee: Uint256::from(10000u128), + }, + ) + .unwrap_err(); + assert!(err.to_string().contains("Unauthorized")); +} + +#[test] +fn e2e_update_config() { + let mut deps = mock_deps(); + let admin = setup_contract(&mut deps); + let env = mock_env(); + + let new_pyth_vaa = deps.api.addr_make("newpythvaa"); + execute( + deps.as_mut(), + env.clone(), + message_info(&admin, &[]), + ExecuteMsg::UpdateConfig { + pyth_vaa_contract: Some(new_pyth_vaa.to_string()), + price_feed_id: Some("0xnew_price_feed_id".to_string()), + }, + ) + .unwrap(); + + let config: ConfigResponse = + from_json(query(deps.as_ref(), env.clone(), QueryMsg::GetConfig {}).unwrap()).unwrap(); + assert_eq!(config.price_feed_id, "0xnew_price_feed_id"); + assert_eq!(config.pyth_vaa_contract, new_pyth_vaa.to_string()); + + let err = execute( + deps.as_mut(), + env, + message_info(&admin, &[]), + ExecuteMsg::UpdateConfig { + pyth_vaa_contract: None, + price_feed_id: Some(String::new()), + }, + ) + .unwrap_err(); + assert!(err.to_string().contains("price_feed_id is required")); +} + +#[test] +fn e2e_oracle_message_encoding() { + let msg = MsgAddPriceEntry::new( + "akash1abc123def456".to_string(), + "akt".to_string(), + "usd".to_string(), + "524683000000000000".to_string(), + 1234567890, + 123456, + ); + + let binary = msg.encode_to_protobuf(); + assert!(!binary.is_empty()); + assert_eq!(binary[0], 0x0a); +} + +#[test] +fn e2e_price_conversion() { + assert_eq!(pyth_price_to_decimal(52468300, -8), "524683000000000000"); + assert_eq!(pyth_price_to_decimal(123456789, -8), "1234567890000000000"); + assert_eq!(pyth_price_to_decimal(0, -8), "0"); +} + +#[test] +fn e2e_data_id_structure() { + let data_id = OracleDataID::akt_usd(); + assert_eq!(data_id.denom, "akt"); + assert_eq!(data_id.base_denom, "usd"); +} + +#[test] +fn e2e_get_price_feed_id() { + let mut deps = mock_deps(); + setup_contract(&mut deps); + + let feed_id: PriceFeedIdResponse = + from_json(query(deps.as_ref(), mock_env(), QueryMsg::GetPriceFeedId {}).unwrap()).unwrap(); + assert_eq!(feed_id.price_feed_id, "0xtest_pyth_price_feed_id"); +} + +#[test] +fn e2e_accumulator_parsing_rejects_invalid_data() { + assert_eq!(PNAU_MAGIC, b"PNAU"); + assert!(parse_accumulator_update(b"PNA").is_err()); + assert!(parse_accumulator_update(b"TEST0100").is_err()); +} diff --git a/contracts/pyth-pro/src/lib.rs b/contracts/pyth-pro/src/lib.rs new file mode 100644 index 000000000..112d8a0ba --- /dev/null +++ b/contracts/pyth-pro/src/lib.rs @@ -0,0 +1,12 @@ +pub mod accumulator; +pub mod contract; +pub mod error; +pub mod msg; +pub mod oracle; +pub mod pyth; +pub mod state; + +#[cfg(test)] +mod integration_tests; + +pub use crate::error::ContractError; diff --git a/contracts/pyth-pro/src/msg.rs b/contracts/pyth-pro/src/msg.rs new file mode 100644 index 000000000..f1baf893e --- /dev/null +++ b/contracts/pyth-pro/src/msg.rs @@ -0,0 +1,106 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{Binary, Uint128, Uint256}; + +#[cw_serde] +pub struct InstantiateMsg { + /// Address of the contract admin + pub admin: String, + /// Pyth VAA verifier contract address + pub pyth_vaa_contract: String, + /// Initial update fee in uakt (Uint256 for CosmWasm 3.x) + pub update_fee: Uint256, + /// Pyth price feed ID for AKT/USD (required) + pub price_feed_id: String, +} + +#[cw_serde] +pub enum ExecuteMsg { + /// Update the AKT/USD price feed with upgraded Pyth PNAU data. + /// pyth-vaa validates the embedded router-signed VAA before the price is relayed to x/oracle. + UpdatePriceFeed { + /// PNAU update data from the upgraded Pyth Hermes API. + vaa: Binary, + }, + /// Update the update fee (admin only) + UpdateFee { new_fee: Uint256 }, + /// Transfer admin rights (admin only) + TransferAdmin { new_admin: String }, + /// Update contract configuration (admin only) + UpdateConfig { + pyth_vaa_contract: Option, + price_feed_id: Option, + }, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + /// Get the current AKT/USD price + #[returns(PriceResponse)] + GetPrice {}, + + /// Get the current AKT/USD price with metadata + #[returns(PriceFeedResponse)] + GetPriceFeed {}, + + /// Get contract configuration + #[returns(ConfigResponse)] + GetConfig {}, + + /// Get the Pyth price feed ID + #[returns(PriceFeedIdResponse)] + GetPriceFeedId {}, +} + +#[cw_serde] +pub struct PriceResponse { + pub price: Uint128, + pub conf: Uint128, + pub expo: i32, + pub publish_time: i64, +} + +#[cw_serde] +pub struct PriceFeedResponse { + pub symbol: String, + pub price: Uint128, + pub conf: Uint128, + pub expo: i32, + pub publish_time: i64, + pub prev_publish_time: i64, +} + +#[cw_serde] +pub struct ConfigResponse { + pub admin: String, + pub pyth_vaa_contract: String, + pub update_fee: Uint256, + pub price_feed_id: String, + pub default_denom: String, + pub default_base_denom: String, +} + +#[cw_serde] +pub struct PriceFeedIdResponse { + pub price_feed_id: String, +} + +#[cw_serde] +pub enum VaaQueryMsg { + VerifyVAA { vaa: Binary, block_time: u64 }, +} + +#[cw_serde] +pub struct ParsedVAA { + pub version: u8, + pub guardian_set_index: u32, + pub timestamp: u32, + pub nonce: u32, + pub len_signers: u8, + pub emitter_chain: u16, + pub emitter_address: Vec, + pub sequence: u64, + pub consistency_level: u8, + pub payload: Vec, + pub hash: Vec, +} diff --git a/contracts/pyth-pro/src/oracle.rs b/contracts/pyth-pro/src/oracle.rs new file mode 100644 index 000000000..69931a6f4 --- /dev/null +++ b/contracts/pyth-pro/src/oracle.rs @@ -0,0 +1,336 @@ +// oracle.rs - Akash x/oracle module integration + +use cosmwasm_std::Binary; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// DataID uniquely identifies a price pair by asset and base denomination +/// Matches proto: akash.oracle.v2.DataID +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct DataID { + /// Asset denomination (e.g., "akt") + /// Note: Oracle module expects "akt" (not "uakt") + pub denom: String, + /// Base denomination for the price pair (e.g., "usd") + pub base_denom: String, +} + +impl DataID { + pub fn new(denom: String, base_denom: String) -> Self { + Self { denom, base_denom } + } + + /// Default for AKT/USD pair + /// Note: Oracle module expects "akt" (not "uakt") and "usd" as denom/base_denom + pub fn akt_usd() -> Self { + Self { + denom: "akt".to_string(), + base_denom: "usd".to_string(), + } + } +} + +/// MsgAddPriceEntry defines an SDK message to add oracle price entry +/// Matches proto: akash.oracle.v2.MsgAddPriceEntry +/// +/// Proto layout: +/// Field 1: signer (string) +/// Field 2: id (DataID message) +/// Field 3: price (string, cosmos.Dec) +/// Field 4: timestamp (google.protobuf.Timestamp) +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +#[serde(rename = "akash/oracle/v2/MsgAddPriceEntry")] +pub struct MsgAddPriceEntry { + /// Signer is the bech32 address of the account + pub signer: String, + /// ID uniquely identifies the price data + pub id: DataID, + /// Price value in cosmos.Dec string format + pub price: String, + /// Timestamp seconds (for google.protobuf.Timestamp) + pub timestamp_seconds: i64, + /// Timestamp nanoseconds (for google.protobuf.Timestamp) + pub timestamp_nanos: i32, +} + +impl MsgAddPriceEntry { + /// Create a new MsgAddPriceEntry with the new proto format + pub fn new( + signer: String, + denom: String, + base_denom: String, + price: String, + timestamp_seconds: i64, + timestamp_nanos: i32, + ) -> Self { + Self { + signer, + id: DataID::new(denom, base_denom), + price, + timestamp_seconds, + timestamp_nanos, + } + } + + /// Create for AKT/USD price submission + /// Note: Oracle module expects "akt" (not "uakt") and "usd" as denom/base_denom + pub fn akt_usd(signer: String, price: String, timestamp_seconds: i64) -> Self { + Self::new( + signer, + "akt".to_string(), + "usd".to_string(), + price, + timestamp_seconds, + 0, // nanos default to 0 + ) + } + + /// Encode to protobuf binary for the oracle module + pub fn encode_to_protobuf(&self) -> Binary { + self.encode_to_binary() + } + + /// Encode the message to protobuf binary + /// Matches proto field numbers: + /// - Field 1: signer (string) + /// - Field 2: id (DataID message) + /// - Field 3: price (string, cosmos.Dec) + /// - Field 4: timestamp (google.protobuf.Timestamp message) + fn encode_to_binary(&self) -> Binary { + let mut buf = Vec::new(); + + // Field 1: signer (tag = 0x0a = (1 << 3) | 2) + buf.push(0x0a); + encode_varint(&mut buf, self.signer.len() as u64); + buf.extend_from_slice(self.signer.as_bytes()); + + // Field 2: id (tag = 0x12 = (2 << 3) | 2) + let id_bytes = self.encode_data_id(); + buf.push(0x12); + encode_varint(&mut buf, id_bytes.len() as u64); + buf.extend(id_bytes); + + // Field 3: price (tag = 0x1a = (3 << 3) | 2, length-delimited string) + buf.push(0x1a); + encode_varint(&mut buf, self.price.len() as u64); + buf.extend_from_slice(self.price.as_bytes()); + + // Field 4: timestamp (tag = 0x22 = (4 << 3) | 2, length-delimited message) + let timestamp_bytes = self.encode_timestamp(); + if !timestamp_bytes.is_empty() { + buf.push(0x22); + encode_varint(&mut buf, timestamp_bytes.len() as u64); + buf.extend(timestamp_bytes); + } + + Binary::from(buf) + } + + /// Encode DataID submessage + /// Fields: 1=denom, 2=base_denom + fn encode_data_id(&self) -> Vec { + let mut buf = Vec::new(); + + // Field 1: denom + buf.push(0x0a); + encode_varint(&mut buf, self.id.denom.len() as u64); + buf.extend_from_slice(self.id.denom.as_bytes()); + + // Field 2: base_denom + buf.push(0x12); + encode_varint(&mut buf, self.id.base_denom.len() as u64); + buf.extend_from_slice(self.id.base_denom.as_bytes()); + + buf + } + + /// Encode google.protobuf.Timestamp + /// Fields: 1=seconds (int64), 2=nanos (int32) + fn encode_timestamp(&self) -> Vec { + let mut buf = Vec::new(); + + // Field 1: seconds (tag = 0x08 = (1 << 3) | 0 for varint) + if self.timestamp_seconds != 0 { + buf.push(0x08); + encode_varint(&mut buf, self.timestamp_seconds as u64); + } + + // Field 2: nanos (tag = 0x10 = (2 << 3) | 0 for varint) + if self.timestamp_nanos != 0 { + buf.push(0x10); + encode_varint(&mut buf, self.timestamp_nanos as u64); + } + + buf + } +} + +/// Helper to encode unsigned varint +fn encode_varint(buf: &mut Vec, mut value: u64) { + loop { + let mut byte = (value & 0x7F) as u8; + value >>= 7; + if value != 0 { + byte |= 0x80; + } + buf.push(byte); + if value == 0 { + break; + } + } +} + +/// Convert Pyth price data to Cosmos SDK LegacyDec string format. +/// +/// Cosmos SDK LegacyDec uses 18 decimal precision represented as an integer string. +/// For example: +/// - 0.5 becomes "500000000000000000" +/// - 1.0 becomes "1000000000000000000" +/// - 0.524683 becomes "524683000000000000" +/// +/// Pyth provides price as an integer with a negative exponent: +/// - price=52468300, expo=-8 means 0.52468300 +/// +/// To convert: multiply by 10^(18 + expo) to get the 18-decimal representation +pub fn pyth_price_to_decimal(price: i64, expo: i32) -> String { + const COSMOS_DECIMALS: i32 = 18; + + let abs_price = price.unsigned_abs() as u128; + let is_negative = price < 0; + + // Calculate the power adjustment needed + // For expo=-8, we need to multiply by 10^(18-8) = 10^10 + let power_adjustment = COSMOS_DECIMALS + expo; + + let result = if power_adjustment >= 0 { + // Multiply by 10^power_adjustment + let multiplier = 10_u128.pow(power_adjustment as u32); + abs_price * multiplier + } else { + // Divide by 10^|power_adjustment| (should be rare for Pyth data) + let divisor = 10_u128.pow((-power_adjustment) as u32); + abs_price / divisor + }; + + if is_negative { + format!("-{}", result) + } else { + result.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pyth_price_to_decimal() { + // Test positive price with negative exponent + // price=52468300, expo=-8 means 0.52468300 + // In Cosmos LegacyDec (18 decimals): 0.52468300 * 10^18 = 524683000000000000 + assert_eq!(pyth_price_to_decimal(52468300, -8), "524683000000000000"); + + // Test price with more decimals + // price=123456789, expo=-8 means 1.23456789 + // In Cosmos LegacyDec: 1.23456789 * 10^18 = 1234567890000000000 + assert_eq!(pyth_price_to_decimal(123456789, -8), "1234567890000000000"); + + // Test price with fewer decimals + // price=100000000, expo=-8 means 1.00000000 + // In Cosmos LegacyDec: 1.0 * 10^18 = 1000000000000000000 + assert_eq!(pyth_price_to_decimal(100000000, -8), "1000000000000000000"); + + // Test negative price + // In Cosmos LegacyDec: -0.52468300 * 10^18 = -524683000000000000 + assert_eq!(pyth_price_to_decimal(-52468300, -8), "-524683000000000000"); + + // Test zero + assert_eq!(pyth_price_to_decimal(0, -8), "0"); + } + + #[test] + fn test_data_id_creation() { + let data_id = DataID::new("akt".to_string(), "usd".to_string()); + assert_eq!(data_id.denom, "akt"); + assert_eq!(data_id.base_denom, "usd"); + + let akt_usd = DataID::akt_usd(); + assert_eq!(akt_usd.denom, "akt"); + assert_eq!(akt_usd.base_denom, "usd"); + } + + #[test] + fn test_msg_add_price_entry_creation() { + let msg = MsgAddPriceEntry::new( + "akash1abc123".to_string(), + "akt".to_string(), + "usd".to_string(), + "524683000000000000".to_string(), // LegacyDec format + 1234567890, + 0, + ); + + assert_eq!(msg.signer, "akash1abc123"); + assert_eq!(msg.id.denom, "akt"); + assert_eq!(msg.id.base_denom, "usd"); + assert_eq!(msg.price, "524683000000000000"); + assert_eq!(msg.timestamp_seconds, 1234567890); + + // Test protobuf encoding + let binary = msg.encode_to_protobuf(); + assert!(!binary.is_empty()); + } + + #[test] + fn test_msg_add_price_entry_akt_usd() { + let msg = MsgAddPriceEntry::akt_usd( + "akash1test".to_string(), + "1234567890000000000".to_string(), // 1.23456789 in LegacyDec format + 1234567890, + ); + + assert_eq!(msg.signer, "akash1test"); + assert_eq!(msg.id.denom, "akt"); + assert_eq!(msg.id.base_denom, "usd"); + assert_eq!(msg.price, "1234567890000000000"); + } + + #[test] + fn test_encode_to_binary() { + let msg = MsgAddPriceEntry::new( + "akash1test".to_string(), + "akt".to_string(), + "usd".to_string(), + "1230000000000000000".to_string(), // 1.23 in LegacyDec format + 1234567890, + 0, + ); + + let binary = msg.encode_to_binary(); + + // Verify it's not empty + assert!(!binary.is_empty()); + + // Verify it starts with correct field tag for signer (0x0a) + assert_eq!(binary[0], 0x0a); + } + + #[test] + fn test_varint_encoding() { + let mut buf = Vec::new(); + + // Test small value (< 128) + encode_varint(&mut buf, 10); + assert_eq!(buf, vec![10]); + + // Test larger value (requires multiple bytes) + buf.clear(); + encode_varint(&mut buf, 300); + assert_eq!(buf, vec![0xac, 0x02]); // 300 = 0x12c = 0b100101100 + + // Test zero + buf.clear(); + encode_varint(&mut buf, 0); + assert_eq!(buf, vec![0]); + } +} diff --git a/contracts/pyth-pro/src/pyth.rs b/contracts/pyth-pro/src/pyth.rs new file mode 100644 index 000000000..e58216201 --- /dev/null +++ b/contracts/pyth-pro/src/pyth.rs @@ -0,0 +1,172 @@ +use cosmwasm_std::StdError; + +/// Parsed Pyth price data from a PNAU price feed message. +#[derive(Debug, Clone)] +pub struct PythPrice { + /// Price feed ID (32 bytes, hex encoded) + pub id: String, + /// Price value (scaled by 10^expo) + pub price: i64, + /// Confidence interval + pub conf: u64, + /// Price exponent (e.g., -8 means divide by 10^8) + pub expo: i32, + /// Unix timestamp when price was published + pub publish_time: i64, + /// Exponential moving average price + pub ema_price: i64, + /// EMA confidence interval + pub ema_conf: u64, +} + +/// Parse a price feed message from PNAU accumulator update +/// +/// This parses the message_data from a PriceUpdateWithProof that has been +/// Merkle-verified against the root signed by the Pyth router quorum. +/// +/// Message format (from Pyth SDK): +/// - Message type (1 byte): 0 = price feed +/// - Price feed ID (32 bytes) +/// - Price (i64, 8 bytes) +/// - Confidence (u64, 8 bytes) +/// - Exponent (i32, 4 bytes) +/// - Publish time (i64, 8 bytes) +/// - Previous publish time (i64, 8 bytes) +/// - EMA price (i64, 8 bytes) +/// - EMA conf (u64, 8 bytes) +pub fn parse_price_feed_message(data: &[u8]) -> Result { + // Minimum size: 1 + 32 + 8 + 8 + 4 + 8 + 8 + 8 + 8 = 85 bytes + if data.len() < 85 { + return Err(StdError::msg(format!( + "Price feed message too short: {} bytes, need at least 85", + data.len() + ))); + } + + let message_type = data[0]; + if message_type != 0 { + return Err(StdError::msg(format!( + "Invalid message type: {}, expected 0 (price feed)", + message_type + ))); + } + + // Price feed ID (bytes 1-33) - add 0x prefix to match config format + let id = format!("0x{}", hex::encode(&data[1..33])); + + // Price (i64, bytes 33-41) + let price = i64::from_be_bytes([ + data[33], data[34], data[35], data[36], data[37], data[38], data[39], data[40], + ]); + + // Confidence (u64, bytes 41-49) + let conf = u64::from_be_bytes([ + data[41], data[42], data[43], data[44], data[45], data[46], data[47], data[48], + ]); + + // Exponent (i32, bytes 49-53) + let expo = i32::from_be_bytes([data[49], data[50], data[51], data[52]]); + + // Publish time (i64, bytes 53-61) + let publish_time = i64::from_be_bytes([ + data[53], data[54], data[55], data[56], data[57], data[58], data[59], data[60], + ]); + + // Previous publish time (i64, bytes 61-69) - skipped + // let _prev_publish_time = ... + + // EMA price (i64, bytes 69-77) + let ema_price = i64::from_be_bytes([ + data[69], data[70], data[71], data[72], data[73], data[74], data[75], data[76], + ]); + + // EMA conf (u64, bytes 77-85) + let ema_conf = u64::from_be_bytes([ + data[77], data[78], data[79], data[80], data[81], data[82], data[83], data[84], + ]); + + Ok(PythPrice { + id, + price, + conf, + expo, + publish_time, + ema_price, + ema_conf, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_price_feed_message() { + // Create a test price feed message (85 bytes minimum) + let mut message = vec![0u8; 85]; + + // Message type: 0 (price feed) + message[0] = 0; + + // Price feed ID (bytes 1-33, 32 bytes of 0xEF) + message[1..33].fill(0xEF); + + // Price: 234567890 (i64, bytes 33-41) + let price: i64 = 234567890; + message[33..41].copy_from_slice(&price.to_be_bytes()); + + // Conf: 2000 (u64, bytes 41-49) + let conf: u64 = 2000; + message[41..49].copy_from_slice(&conf.to_be_bytes()); + + // Expo: -8 (i32, bytes 49-53) + let expo: i32 = -8; + message[49..53].copy_from_slice(&expo.to_be_bytes()); + + // Publish time: 1704153600 (i64, bytes 53-61) + let publish_time: i64 = 1704153600; + message[53..61].copy_from_slice(&publish_time.to_be_bytes()); + + // Previous publish time (bytes 61-69) - just zeros + + // EMA price: 234000000 (i64, bytes 69-77) + let ema_price: i64 = 234000000; + message[69..77].copy_from_slice(&ema_price.to_be_bytes()); + + // EMA conf: 1500 (u64, bytes 77-85) + let ema_conf: u64 = 1500; + message[77..85].copy_from_slice(&ema_conf.to_be_bytes()); + + let result = parse_price_feed_message(&message).unwrap(); + + assert_eq!(result.id, format!("0x{}", "ef".repeat(32))); + assert_eq!(result.price, 234567890); + assert_eq!(result.conf, 2000); + assert_eq!(result.expo, -8); + assert_eq!(result.publish_time, 1704153600); + assert_eq!(result.ema_price, 234000000); + assert_eq!(result.ema_conf, 1500); + } + + #[test] + fn test_parse_price_feed_message_invalid_type() { + let mut message = vec![0u8; 85]; + message[0] = 1; // Invalid type + + let result = parse_price_feed_message(&message); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Invalid message type")); + } + + #[test] + fn test_parse_price_feed_message_too_short() { + let message = vec![0u8; 50]; // Too short + + let result = parse_price_feed_message(&message); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("too short")); + } +} diff --git a/contracts/pyth-pro/src/state.rs b/contracts/pyth-pro/src/state.rs new file mode 100644 index 000000000..fb8c718f2 --- /dev/null +++ b/contracts/pyth-pro/src/state.rs @@ -0,0 +1,89 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Uint128, Uint256}; +use cw_storage_plus::Item; + +/// DataID uniquely identifies a price pair by asset and base denomination +/// Used in Config to store the default price pair for this contract +#[cw_serde] +pub struct DataID { + /// Asset denomination (e.g., "uakt") + pub denom: String, + /// Base denomination for the price pair (e.g., "usd") + pub base_denom: String, +} + +impl DataID { + pub fn new(denom: String, base_denom: String) -> Self { + Self { denom, base_denom } + } + + /// Default for AKT/USD pair + /// Note: Oracle module expects "akt" (not "uakt") and "usd" as denom/base_denom + pub fn akt_usd() -> Self { + Self { + denom: "akt".to_string(), + base_denom: "usd".to_string(), + } + } +} + +impl Default for DataID { + fn default() -> Self { + Self::akt_usd() + } +} + +#[cw_serde] +pub struct Config { + /// Admin address that can update contract settings + pub admin: Addr, + /// Pyth VAA verifier contract address + pub pyth_vaa_contract: Addr, + /// Fee required to update the price feed (in Uint256 for CosmWasm 3.x) + pub update_fee: Uint256, + /// Pyth price feed ID for AKT/USD + pub price_feed_id: String, + /// Default data ID for price submissions (denom + base_denom) + pub default_data_id: DataID, +} + +#[cw_serde] +pub struct PriceFeed { + /// Symbol for the price feed (always "AKT/USD") + pub symbol: String, + /// Current price with decimals based on expo + pub price: Uint128, + /// Confidence interval + pub conf: Uint128, + /// Price exponent (typically -8 for 8 decimal places) + pub expo: i32, + /// Unix timestamp of current price publication + pub publish_time: i64, + /// Unix timestamp of previous price publication + pub prev_publish_time: i64, +} + +impl PriceFeed { + pub fn new() -> Self { + Self { + symbol: "AKT/USD".to_string(), + price: Uint128::zero(), + conf: Uint128::zero(), + expo: -8, + publish_time: 0, + prev_publish_time: 0, + } + } +} + +impl Default for PriceFeed { + fn default() -> Self { + Self::new() + } +} + +/// Contract configuration storage +pub const CONFIG: Item = Item::new("config"); + +/// AKT/USD price feed storage +pub const PRICE_FEED: Item = Item::new("price_feed"); diff --git a/contracts/pyth-vaa/Cargo.toml b/contracts/pyth-vaa/Cargo.toml new file mode 100644 index 000000000..adbf8f655 --- /dev/null +++ b/contracts/pyth-vaa/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "pyth-vaa" +version = "1.0.1" +authors = ["Artur Troian Result { + let admin = deps.api.addr_validate(&msg.admin)?; + let router_verifier = router::parse_config(msg.router_verifier)?; + + CONFIG.save( + deps.storage, + &Config { + admin, + router_verifier, + }, + )?; + + Ok(Response::new() + .add_attribute("method", "instantiate") + .add_attribute("admin", msg.admin) + .add_attribute("router_verifier", "configured")) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::TransferAdmin { new_admin } => execute_transfer_admin(deps, info, new_admin), + ExecuteMsg::UpdateConfig { router_verifier } => { + execute_update_config(deps, info, router_verifier) + } + } +} + +fn execute_transfer_admin( + deps: DepsMut, + info: MessageInfo, + new_admin: String, +) -> Result { + let mut config = CONFIG.load(deps.storage)?; + if info.sender != config.admin { + return Err(ContractError::Unauthorized {}); + } + + config.admin = deps.api.addr_validate(&new_admin)?; + CONFIG.save(deps.storage, &config)?; + + Ok(Response::new() + .add_attribute("method", "transfer_admin") + .add_attribute("new_admin", new_admin)) +} + +fn execute_update_config( + deps: DepsMut, + info: MessageInfo, + router_verifier: crate::msg::RouterVerifierConfigMsg, +) -> Result { + let mut config = CONFIG.load(deps.storage)?; + if info.sender != config.admin { + return Err(ContractError::Unauthorized {}); + } + + config.router_verifier = router::parse_config(router_verifier)?; + CONFIG.save(deps.storage, &config)?; + + Ok(Response::new() + .add_attribute("method", "update_config") + .add_attribute("router_verifier", "configured")) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::VerifyVAA { vaa, block_time: _ } => to_json_binary(&verify_vaa(deps, vaa)?), + QueryMsg::GetConfig {} => to_json_binary(&query_config(deps)?), + } +} + +fn verify_vaa(deps: Deps, vaa: Binary) -> Result { + let config = CONFIG.load(deps.storage)?; + router::verify_vaa(&config.router_verifier, vaa.as_slice()) +} + +fn query_config(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage)?; + + Ok(ConfigResponse { + admin: config.admin.to_string(), + router_verifier: router::config_to_msg(&config.router_verifier), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use cosmwasm_std::testing::{message_info, mock_dependencies, mock_env}; + use cosmwasm_std::Binary; + + use crate::msg::{RouterAddress, RouterVerifierConfigMsg}; + + fn router_config() -> RouterVerifierConfigMsg { + RouterVerifierConfigMsg { + router_set_index: 0, + routers: [ + "41534bb176e461a3fb30479400f210549ecce638", + "6502987b62f21cab7eb5ccd8f0173084b60d5b41", + "44a3e8f6a382412cf6bb90a3f8106e68977476c9", + "d9d7d4529577864352c9a6539a48238fcd447052", + "1663a5a822336ece48559b1dfb1e93a017a7dac3", + ] + .iter() + .map(|addr| RouterAddress { + bytes: Binary::from(hex::decode(addr).unwrap()), + }) + .collect(), + expected_emitter_chain: 26, + expected_emitter_address: Binary::from( + hex::decode("507974686e6574507974686e6574507974686e6574507974686e657450797468") + .unwrap(), + ), + } + } + + #[test] + fn instantiate_stores_router_config() { + let mut deps = mock_dependencies(); + let msg = InstantiateMsg { + admin: deps.api.addr_make("admin").to_string(), + router_verifier: router_config(), + }; + let sender = deps.api.addr_make("sender"); + + instantiate(deps.as_mut(), mock_env(), message_info(&sender, &[]), msg).unwrap(); + + let response = query_config(deps.as_ref()).unwrap(); + assert_eq!(response.router_verifier.routers.len(), 5); + assert_eq!(response.router_verifier.expected_emitter_chain, 26); + } +} diff --git a/contracts/pyth-vaa/src/error.rs b/contracts/pyth-vaa/src/error.rs new file mode 100644 index 000000000..4ab4afce5 --- /dev/null +++ b/contracts/pyth-vaa/src/error.rs @@ -0,0 +1,50 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Unauthorized")] + Unauthorized {}, + + #[error("InvalidConfig")] + InvalidConfig, + + #[error("InvalidAddressLength")] + InvalidAddressLength, + + #[error("InvalidVAA")] + InvalidVAA, + + #[error("InvalidVersion")] + InvalidVersion, + + #[error("InvalidRouterSetIndex")] + InvalidRouterSetIndex, + + #[error("InvalidEmitter")] + InvalidEmitter, + + #[error("NoQuorum")] + NoQuorum, + + #[error("WrongRouterIndexOrder")] + WrongRouterIndexOrder, + + #[error("InvalidRouterIndex")] + InvalidRouterIndex, + + #[error("TooManySignatures")] + TooManySignatures, + + #[error("CannotDecodeSignature")] + CannotDecodeSignature, + + #[error("CannotRecoverKey")] + CannotRecoverKey, + + #[error("RouterSignatureError")] + RouterSignatureError, +} diff --git a/contracts/pyth-vaa/src/lib.rs b/contracts/pyth-vaa/src/lib.rs new file mode 100644 index 000000000..7f019ae17 --- /dev/null +++ b/contracts/pyth-vaa/src/lib.rs @@ -0,0 +1,8 @@ +pub mod contract; +pub mod error; +pub mod msg; +pub mod router; +pub mod state; +pub mod vaa; + +pub use crate::error::ContractError; diff --git a/contracts/pyth-vaa/src/msg.rs b/contracts/pyth-vaa/src/msg.rs new file mode 100644 index 000000000..b25226f0d --- /dev/null +++ b/contracts/pyth-vaa/src/msg.rs @@ -0,0 +1,47 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::Binary; + +#[cw_serde] +pub struct InstantiateMsg { + pub admin: String, + pub router_verifier: RouterVerifierConfigMsg, +} + +#[cw_serde] +pub struct RouterVerifierConfigMsg { + pub router_set_index: u32, + pub routers: Vec, + pub expected_emitter_chain: u16, + pub expected_emitter_address: Binary, +} + +#[cw_serde] +pub struct RouterAddress { + pub bytes: Binary, +} + +#[cw_serde] +pub enum ExecuteMsg { + TransferAdmin { + new_admin: String, + }, + UpdateConfig { + router_verifier: RouterVerifierConfigMsg, + }, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(crate::vaa::ParsedVAA)] + VerifyVAA { vaa: Binary, block_time: u64 }, + + #[returns(ConfigResponse)] + GetConfig {}, +} + +#[cw_serde] +pub struct ConfigResponse { + pub admin: String, + pub router_verifier: RouterVerifierConfigMsg, +} diff --git a/contracts/pyth-vaa/src/router.rs b/contracts/pyth-vaa/src/router.rs new file mode 100644 index 000000000..a1b525a8a --- /dev/null +++ b/contracts/pyth-vaa/src/router.rs @@ -0,0 +1,414 @@ +use cosmwasm_std::Binary; +use k256::ecdsa::{RecoveryId, Signature, VerifyingKey}; +use sha3::{Digest, Keccak256}; + +use crate::{ + error::ContractError, + msg::{RouterAddress, RouterVerifierConfigMsg}, + state::RouterVerifierConfig, + vaa::ParsedVAA, +}; + +const ROUTER_COUNT: usize = 5; +const ROUTER_QUORUM: usize = 3; + +pub fn parse_config(msg: RouterVerifierConfigMsg) -> Result { + if msg.routers.len() != ROUTER_COUNT { + return Err(ContractError::InvalidConfig); + } + + let mut routers: Vec> = Vec::with_capacity(ROUTER_COUNT); + for router in msg.routers { + let bytes = router.bytes.as_slice(); + if bytes.len() != 20 { + return Err(ContractError::InvalidAddressLength); + } + if routers.iter().any(|existing| existing.as_slice() == bytes) { + return Err(ContractError::InvalidConfig); + } + routers.push(bytes.to_vec()); + } + + if msg.expected_emitter_address.len() != 32 { + return Err(ContractError::InvalidAddressLength); + } + let expected_emitter_address = msg.expected_emitter_address.to_vec(); + + Ok(RouterVerifierConfig { + router_set_index: msg.router_set_index, + routers, + expected_emitter_chain: msg.expected_emitter_chain, + expected_emitter_address, + }) +} + +pub fn config_to_msg(config: &RouterVerifierConfig) -> RouterVerifierConfigMsg { + RouterVerifierConfigMsg { + router_set_index: config.router_set_index, + routers: config + .routers + .iter() + .map(|router| RouterAddress { + bytes: Binary::from(router.clone()), + }) + .collect(), + expected_emitter_chain: config.expected_emitter_chain, + expected_emitter_address: Binary::from(config.expected_emitter_address.clone()), + } +} + +pub fn verify_vaa(config: &RouterVerifierConfig, data: &[u8]) -> Result { + let vaa = ParsedVAA::deserialize(data)?; + + if vaa.version != 1 { + return Err(ContractError::InvalidVersion); + } + if vaa.guardian_set_index != config.router_set_index { + return Err(ContractError::InvalidRouterSetIndex); + } + if vaa.emitter_chain != config.expected_emitter_chain + || vaa.emitter_address != config.expected_emitter_address + { + return Err(ContractError::InvalidEmitter); + } + + verify_router_signatures(config, &vaa, data)?; + Ok(vaa) +} + +fn verify_router_signatures( + config: &RouterVerifierConfig, + vaa: &ParsedVAA, + data: &[u8], +) -> Result<(), ContractError> { + let signer_count = vaa.len_signers as usize; + if signer_count < ROUTER_QUORUM { + return Err(ContractError::NoQuorum); + } + if signer_count > config.routers.len() { + return Err(ContractError::TooManySignatures); + } + + let mut last_index: i16 = -1; + let mut pos = ParsedVAA::HEADER_LEN; + for _ in 0..signer_count { + if pos + ParsedVAA::SIGNATURE_LEN > data.len() { + return Err(ContractError::InvalidVAA); + } + + let router_index = data[pos] as i16; + if router_index <= last_index { + return Err(ContractError::WrongRouterIndexOrder); + } + last_index = router_index; + + let router_index = router_index as usize; + if router_index >= config.routers.len() { + return Err(ContractError::InvalidRouterIndex); + } + + let signature = Signature::try_from( + &data[pos + ParsedVAA::SIG_DATA_POS + ..pos + ParsedVAA::SIG_DATA_POS + ParsedVAA::SIG_DATA_LEN], + ) + .map_err(|_| ContractError::CannotDecodeSignature)?; + let recovery_id = RecoveryId::try_from(data[pos + ParsedVAA::SIG_RECOVERY_POS]) + .map_err(|_| ContractError::CannotDecodeSignature)?; + let verify_key = + VerifyingKey::recover_from_prehash(vaa.hash.as_slice(), &signature, recovery_id) + .map_err(|_| ContractError::CannotRecoverKey)?; + + if router_address(&verify_key) != config.routers[router_index] { + return Err(ContractError::RouterSignatureError); + } + + pos += ParsedVAA::SIGNATURE_LEN; + } + + Ok(()) +} + +fn router_address(key: &VerifyingKey) -> Vec { + let point = key.to_encoded_point(false); + let hash = Keccak256::digest(&point.as_bytes()[1..]); + hash[12..].to_vec() +} + +#[cfg(test)] +mod tests { + use super::*; + use k256::ecdsa::{RecoveryId, Signature, SigningKey, VerifyingKey}; + + const ROUTER_SET_INDEX: u32 = 7; + const EMITTER_CHAIN: u16 = 26; + const EMITTER_ADDRESS: [u8; 32] = [9u8; 32]; + const PRODUCTION_ROUTER_SET_INDEX: u32 = 0; + const PRODUCTION_EMITTER_ADDRESS_HEX: &str = + "507974686e6574507974686e6574507974686e6574507974686e657450797468"; + const AKT_UPGRADED_HERMES_VAA_HEX: &str = + "010000000003013e1fb8c03541656c8e9f8a3edc9e939c676a07d6e76a6481babf53b3ffe3eb354cea03c9beacc220c6c1b59039d732cd3403762a8eb8c005829ea93175cb6ce20003d2ed8e50e7d7350255ee6974a845c2f83236c9be4969f6a3063f1c87173fe1dc35e07fe6cea3d9341dc8887a858abacc33883acd2f1c6a9a94cfa3718ea8130901043dbc0d384f891945d9e6c8636826d58ad7f74f4162346052f37bfc5cf5da0ea75f1d1795acf9a8685f96308393947a0a9a22e381585e804506aa7c19d3400945006a4f27ed00000000001a507974686e6574507974686e6574507974686e6574507974686e657450797468000000084e2f1e84004155575600000000084e2f1e84000000000f57eee39f1b76403b1094b3a177ecef270e3226"; + + #[test] + fn verifies_three_of_five_router_signatures() { + let keys = router_keys(); + let config = setup(&keys); + let payload = b"pyth-root".to_vec(); + let vaa = signed_vaa( + &keys, + &[0, 2, 4], + ROUTER_SET_INDEX, + EMITTER_CHAIN, + EMITTER_ADDRESS, + payload.clone(), + ); + + let parsed = verify_vaa(&config, &vaa).unwrap(); + assert_eq!(parsed.guardian_set_index, ROUTER_SET_INDEX); + assert_eq!(parsed.len_signers, 3); + assert_eq!(parsed.emitter_chain, EMITTER_CHAIN); + assert_eq!(parsed.emitter_address, EMITTER_ADDRESS); + assert_eq!(parsed.payload, payload); + } + + #[test] + fn rejects_two_of_five_router_signatures() { + let keys = router_keys(); + let config = setup(&keys); + let vaa = signed_vaa( + &keys, + &[0, 1], + ROUTER_SET_INDEX, + EMITTER_CHAIN, + EMITTER_ADDRESS, + vec![], + ); + + let err = verify_vaa(&config, &vaa).unwrap_err(); + assert!(err.to_string().contains("NoQuorum")); + } + + #[test] + fn rejects_wrong_router_signature() { + let keys = router_keys(); + let config = setup(&keys); + let outsider = SigningKey::from_bytes((&[42u8; 32]).into()).unwrap(); + let vaa = signed_vaa_with_keys( + &[outsider, keys[1].clone(), keys[2].clone()], + &[0, 1, 2], + ROUTER_SET_INDEX, + EMITTER_CHAIN, + EMITTER_ADDRESS, + vec![], + ); + + let err = verify_vaa(&config, &vaa).unwrap_err(); + assert!(err.to_string().contains("RouterSignatureError")); + } + + #[test] + fn rejects_wrong_emitter() { + let keys = router_keys(); + let config = setup(&keys); + let vaa = signed_vaa( + &keys, + &[0, 1, 2], + ROUTER_SET_INDEX, + 27, + EMITTER_ADDRESS, + vec![], + ); + + let err = verify_vaa(&config, &vaa).unwrap_err(); + assert!(err.to_string().contains("InvalidEmitter")); + } + + #[test] + fn rejects_duplicate_router_addresses() { + let keys = router_keys(); + let mut routers: Vec = keys.iter().map(router_address_msg).collect(); + routers[1] = routers[0].clone(); + + let err = parse_config(RouterVerifierConfigMsg { + router_set_index: ROUTER_SET_INDEX, + routers, + expected_emitter_chain: EMITTER_CHAIN, + expected_emitter_address: Binary::from(EMITTER_ADDRESS), + }) + .unwrap_err(); + + assert!(err.to_string().contains("InvalidConfig")); + } + + #[test] + fn rejects_invalid_router_index() { + let keys = router_keys(); + let config = setup(&keys); + let vaa = signed_vaa_with_keys( + &[keys[0].clone(), keys[1].clone(), keys[4].clone()], + &[0, 1, 5], + ROUTER_SET_INDEX, + EMITTER_CHAIN, + EMITTER_ADDRESS, + vec![], + ); + + let err = verify_vaa(&config, &vaa).unwrap_err(); + assert!(matches!(err, ContractError::InvalidRouterIndex)); + } + + #[test] + fn rejects_too_many_signatures() { + let mut keys = router_keys(); + keys.push(SigningKey::from_bytes((&[6u8; 32]).into()).unwrap()); + let config = setup(&keys[..ROUTER_COUNT]); + let vaa = signed_vaa_with_keys( + &keys, + &[0, 1, 2, 3, 4, 5], + ROUTER_SET_INDEX, + EMITTER_CHAIN, + EMITTER_ADDRESS, + vec![], + ); + + let err = verify_vaa(&config, &vaa).unwrap_err(); + assert!(matches!(err, ContractError::TooManySignatures)); + } + + #[test] + fn verifies_live_upgraded_hermes_akt_vaa_fixture() { + let config = setup_production(); + let vaa = hex::decode(AKT_UPGRADED_HERMES_VAA_HEX).unwrap(); + + let parsed = verify_vaa(&config, &vaa).unwrap(); + assert_eq!(parsed.version, 1); + assert_eq!(parsed.guardian_set_index, PRODUCTION_ROUTER_SET_INDEX); + assert_eq!(parsed.len_signers, 3); + assert_eq!(parsed.emitter_chain, EMITTER_CHAIN); + assert_eq!( + parsed.emitter_address, + hex::decode(PRODUCTION_EMITTER_ADDRESS_HEX).unwrap() + ); + assert_eq!(&parsed.payload[..4], b"AUWV"); + } + + fn setup(keys: &[SigningKey]) -> RouterVerifierConfig { + parse_config(RouterVerifierConfigMsg { + router_set_index: ROUTER_SET_INDEX, + routers: keys.iter().map(router_address_msg).collect(), + expected_emitter_chain: EMITTER_CHAIN, + expected_emitter_address: Binary::from(EMITTER_ADDRESS), + }) + .unwrap() + } + + fn setup_production() -> RouterVerifierConfig { + parse_config(RouterVerifierConfigMsg { + router_set_index: PRODUCTION_ROUTER_SET_INDEX, + routers: [ + "41534bb176e461a3fb30479400f210549ecce638", + "6502987b62f21cab7eb5ccd8f0173084b60d5b41", + "44a3e8f6a382412cf6bb90a3f8106e68977476c9", + "d9d7d4529577864352c9a6539a48238fcd447052", + "1663a5a822336ece48559b1dfb1e93a017a7dac3", + ] + .iter() + .map(|addr| RouterAddress { + bytes: Binary::from(hex::decode(addr).unwrap()), + }) + .collect(), + expected_emitter_chain: EMITTER_CHAIN, + expected_emitter_address: Binary::from( + hex::decode(PRODUCTION_EMITTER_ADDRESS_HEX).unwrap(), + ), + }) + .unwrap() + } + + fn router_keys() -> Vec { + (1u8..=5) + .map(|i| SigningKey::from_bytes((&[i; 32]).into()).unwrap()) + .collect() + } + + fn router_address_msg(key: &SigningKey) -> RouterAddress { + RouterAddress { + bytes: Binary::from(address_from_key(key.verifying_key())), + } + } + + fn address_from_key(key: &VerifyingKey) -> [u8; 20] { + let point = key.to_encoded_point(false); + let hash = Keccak256::digest(&point.as_bytes()[1..]); + hash[12..].try_into().unwrap() + } + + fn signed_vaa( + keys: &[SigningKey], + signer_indexes: &[u8], + router_set_index: u32, + emitter_chain: u16, + emitter_address: [u8; 32], + payload: Vec, + ) -> Vec { + let signing_keys: Vec = signer_indexes + .iter() + .map(|index| keys[*index as usize].clone()) + .collect(); + signed_vaa_with_keys( + &signing_keys, + signer_indexes, + router_set_index, + emitter_chain, + emitter_address, + payload, + ) + } + + fn signed_vaa_with_keys( + signing_keys: &[SigningKey], + signer_indexes: &[u8], + router_set_index: u32, + emitter_chain: u16, + emitter_address: [u8; 32], + payload: Vec, + ) -> Vec { + let body = vaa_body(emitter_chain, emitter_address, payload); + let hash = body_hash(&body); + + let mut vaa = vec![1u8]; + vaa.extend_from_slice(&router_set_index.to_be_bytes()); + vaa.push(signer_indexes.len() as u8); + + for (key, index) in signing_keys.iter().zip(signer_indexes.iter()) { + let (signature, recovery_id) = sign_hash(key, &hash); + vaa.push(*index); + let signature_bytes = signature.to_bytes(); + vaa.extend_from_slice(&signature_bytes); + vaa.push(recovery_id.to_byte()); + } + + vaa.extend_from_slice(&body); + vaa + } + + fn vaa_body(emitter_chain: u16, emitter_address: [u8; 32], payload: Vec) -> Vec { + let mut body = Vec::new(); + body.extend_from_slice(&123u32.to_be_bytes()); + body.extend_from_slice(&456u32.to_be_bytes()); + body.extend_from_slice(&emitter_chain.to_be_bytes()); + body.extend_from_slice(&emitter_address); + body.extend_from_slice(&789u64.to_be_bytes()); + body.push(0); + body.extend_from_slice(&payload); + body + } + + fn body_hash(body: &[u8]) -> [u8; 32] { + let first = Keccak256::digest(body); + Keccak256::digest(first).into() + } + + fn sign_hash(key: &SigningKey, hash: &[u8; 32]) -> (Signature, RecoveryId) { + key.sign_prehash_recoverable(hash).unwrap() + } +} diff --git a/contracts/pyth-vaa/src/state.rs b/contracts/pyth-vaa/src/state.rs new file mode 100644 index 000000000..2a8d386ab --- /dev/null +++ b/contracts/pyth-vaa/src/state.rs @@ -0,0 +1,19 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::Addr; +use cw_storage_plus::Item; + +#[cw_serde] +pub struct Config { + pub admin: Addr, + pub router_verifier: RouterVerifierConfig, +} + +#[cw_serde] +pub struct RouterVerifierConfig { + pub router_set_index: u32, + pub routers: Vec>, + pub expected_emitter_chain: u16, + pub expected_emitter_address: Vec, +} + +pub const CONFIG: Item = Item::new("config"); diff --git a/contracts/pyth-vaa/src/vaa.rs b/contracts/pyth-vaa/src/vaa.rs new file mode 100644 index 000000000..23ae298a8 --- /dev/null +++ b/contracts/pyth-vaa/src/vaa.rs @@ -0,0 +1,102 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{StdError, StdResult}; +use sha3::{Digest, Keccak256}; + +use crate::error::ContractError; + +/// Parsed router-signed VAA payload from upgraded Pyth updates. +#[cw_serde] +pub struct ParsedVAA { + pub version: u8, + pub guardian_set_index: u32, + pub timestamp: u32, + pub nonce: u32, + pub len_signers: u8, + pub emitter_chain: u16, + pub emitter_address: Vec, + pub sequence: u64, + pub consistency_level: u8, + pub payload: Vec, + pub hash: Vec, +} + +impl ParsedVAA { + pub const HEADER_LEN: usize = 6; + pub const SIGNATURE_LEN: usize = 66; + + pub const GUARDIAN_SET_INDEX_POS: usize = 1; + pub const LEN_SIGNER_POS: usize = 5; + + pub const VAA_NONCE_POS: usize = 4; + pub const VAA_EMITTER_CHAIN_POS: usize = 8; + pub const VAA_EMITTER_ADDRESS_POS: usize = 10; + pub const VAA_SEQUENCE_POS: usize = 42; + pub const VAA_CONSISTENCY_LEVEL_POS: usize = 50; + pub const VAA_PAYLOAD_POS: usize = 51; + + pub const SIG_DATA_POS: usize = 1; + pub const SIG_DATA_LEN: usize = 64; + pub const SIG_RECOVERY_POS: usize = Self::SIG_DATA_POS + Self::SIG_DATA_LEN; + + pub fn deserialize(data: &[u8]) -> StdResult { + if data.len() < Self::HEADER_LEN { + return Err(invalid_vaa()); + } + + let version = data[0]; + let guardian_set_index = read_u32(data, Self::GUARDIAN_SET_INDEX_POS)?; + let len_signers = data[Self::LEN_SIGNER_POS] as usize; + let body_offset = Self::HEADER_LEN + Self::SIGNATURE_LEN * len_signers; + + if body_offset >= data.len() || body_offset + Self::VAA_PAYLOAD_POS > data.len() { + return Err(invalid_vaa()); + } + + let body = &data[body_offset..]; + let hash = Keccak256::digest(Keccak256::digest(body)).to_vec(); + + Ok(ParsedVAA { + version, + guardian_set_index, + timestamp: read_u32(data, body_offset)?, + nonce: read_u32(data, body_offset + Self::VAA_NONCE_POS)?, + len_signers: len_signers as u8, + emitter_chain: read_u16(data, body_offset + Self::VAA_EMITTER_CHAIN_POS)?, + emitter_address: read_bytes(data, body_offset + Self::VAA_EMITTER_ADDRESS_POS, 32)? + .to_vec(), + sequence: read_u64(data, body_offset + Self::VAA_SEQUENCE_POS)?, + consistency_level: data[body_offset + Self::VAA_CONSISTENCY_LEVEL_POS], + payload: data[body_offset + Self::VAA_PAYLOAD_POS..].to_vec(), + hash, + }) + } +} + +fn read_u16(data: &[u8], pos: usize) -> StdResult { + let bytes: [u8; 2] = read_bytes(data, pos, 2)? + .try_into() + .map_err(|_| invalid_vaa())?; + Ok(u16::from_be_bytes(bytes)) +} + +fn read_u32(data: &[u8], pos: usize) -> StdResult { + let bytes: [u8; 4] = read_bytes(data, pos, 4)? + .try_into() + .map_err(|_| invalid_vaa())?; + Ok(u32::from_be_bytes(bytes)) +} + +fn read_u64(data: &[u8], pos: usize) -> StdResult { + let bytes: [u8; 8] = read_bytes(data, pos, 8)? + .try_into() + .map_err(|_| invalid_vaa())?; + Ok(u64::from_be_bytes(bytes)) +} + +fn read_bytes(data: &[u8], pos: usize, len: usize) -> StdResult<&[u8]> { + data.get(pos..pos + len).ok_or_else(invalid_vaa) +} + +fn invalid_vaa() -> StdError { + StdError::msg(ContractError::InvalidVAA.to_string()) +} diff --git a/tests/e2e/pyth_contract_test.go b/tests/e2e/pyth_contract_test.go index 7f6a7b0c5..253ca07f7 100644 --- a/tests/e2e/pyth_contract_test.go +++ b/tests/e2e/pyth_contract_test.go @@ -31,11 +31,9 @@ import ( "pkg.akt.dev/node/v2/testutil/network" ) -// priceOracleContractTestSuite tests the Wormhole and Pyth CosmWasm contracts -// deployed on a test network. -// Architecture: Hermes → Pyth (verifies VAA + relays) → x/oracle -// -// Wormhole provides VAA signature verification +// priceOracleContractTestSuite tests the Pyth CosmWasm contracts deployed on a +// test network. Upgraded Hermes submits PNAU data to pyth-pro, which queries +// pyth-vaa for router quorum verification before relaying to x/oracle. type priceOracleContractTestSuite struct { *testutil.NetworkTestSuite @@ -148,37 +146,40 @@ type CoinResponse struct { } // ===================== -// DataSource Type (shared) +// Price Oracle Contract Types // ===================== -// DataSource identifies a valid price feed source (Pyth emitter) -type DataSource struct { - EmitterChain uint16 `json:"emitter_chain"` - EmitterAddress string `json:"emitter_address"` +type RouterAddress struct { + Bytes string `json:"bytes"` } -// ===================== -// Price Oracle Contract Types -// ===================== +type RouterVerifierConfig struct { + RouterSetIndex uint32 `json:"router_set_index"` + Routers []RouterAddress `json:"routers"` + ExpectedEmitterChain uint16 `json:"expected_emitter_chain"` + ExpectedEmitterAddress string `json:"expected_emitter_address"` +} + +type PythVaaInstantiateMsg struct { + Admin string `json:"admin"` + RouterVerifier RouterVerifierConfig `json:"router_verifier"` +} -// InstantiateMsg is the message to instantiate the Pyth contract +// InstantiateMsg is the message to instantiate the pyth-pro contract. type InstantiateMsg struct { - Admin string `json:"admin"` - WormholeContract string `json:"wormhole_contract"` - UpdateFee string `json:"update_fee"` - PriceFeedID string `json:"price_feed_id"` - DataSources []DataSource `json:"data_sources"` + Admin string `json:"admin"` + PythVaaContract string `json:"pyth_vaa_contract"` + UpdateFee string `json:"update_fee"` + PriceFeedID string `json:"price_feed_id"` } -// ExecuteUpdatePriceFeed is the message to update the price feed with VAA +// ExecuteUpdatePriceFeed is the message to update the price feed with PNAU data. type ExecuteUpdatePriceFeed struct { UpdatePriceFeed UpdatePriceFeedData `json:"update_price_feed"` } -// UpdatePriceFeedData contains the VAA for price verification +// UpdatePriceFeedData contains upgraded Pyth PNAU update data. type UpdatePriceFeedData struct { - // VAA data from Pyth Hermes API (base64 encoded Binary) - // Contract will verify VAA via Wormhole, parse Pyth payload, relay to x/oracle VAA string `json:"vaa"` } @@ -188,9 +189,8 @@ type ExecuteUpdateConfig struct { } type UpdateConfigData struct { - WormholeContract *string `json:"wormhole_contract,omitempty"` - PriceFeedID *string `json:"price_feed_id,omitempty"` - DataSources *[]DataSource `json:"data_sources,omitempty"` + PythVaaContract *string `json:"pyth_vaa_contract,omitempty"` + PriceFeedID *string `json:"price_feed_id,omitempty"` } // QueryGetConfig is the query to get contract config @@ -210,13 +210,12 @@ type QueryGetOracleParams struct{} // ConfigResponse is the response from GetConfig query type ConfigResponse struct { - Admin string `json:"admin"` - WormholeContract string `json:"wormhole_contract"` - UpdateFee string `json:"update_fee"` - PriceFeedID string `json:"price_feed_id"` - DefaultDenom string `json:"default_denom"` - DefaultBaseDenom string `json:"default_base_denom"` - DataSources []DataSource `json:"data_sources"` + Admin string `json:"admin"` + PythVaaContract string `json:"pyth_vaa_contract"` + UpdateFee string `json:"update_fee"` + PriceFeedID string `json:"price_feed_id"` + DefaultDenom string `json:"default_denom"` + DefaultBaseDenom string `json:"default_base_denom"` } // PriceResponse is the response from GetPrice query @@ -227,6 +226,29 @@ type PriceResponse struct { PublishTime int64 `json:"publish_time"` } +func testRouterVerifierConfig() RouterVerifierConfig { + return RouterVerifierConfig{ + RouterSetIndex: 0, + Routers: []RouterAddress{ + {Bytes: mustHexToBase64("41534bb176e461a3fb30479400f210549ecce638")}, + {Bytes: mustHexToBase64("6502987b62f21cab7eb5ccd8f0173084b60d5b41")}, + {Bytes: mustHexToBase64("44a3e8f6a382412cf6bb90a3f8106e68977476c9")}, + {Bytes: mustHexToBase64("d9d7d4529577864352c9a6539a48238fcd447052")}, + {Bytes: mustHexToBase64("1663a5a822336ece48559b1dfb1e93a017a7dac3")}, + }, + ExpectedEmitterChain: 26, + ExpectedEmitterAddress: mustHexToBase64("507974686e6574507974686e6574507974686e6574507974686e657450797468"), + } +} + +func mustHexToBase64(value string) string { + decoded, err := hex.DecodeString(value) + if err != nil { + panic(err) + } + return base64.StdEncoding.EncodeToString(decoded) +} + // OracleParamsResponse is the response from GetOracleParams query type OracleParamsResponse struct { MaxPriceDeviationBps uint64 `json:"max_price_deviation_bps"` @@ -248,10 +270,10 @@ func (s *priceOracleContractTestSuite) TestStoreContractViaGovernance() { ctx := context.Background() val := s.Network().Validators[0] - // Load the pyth wasm contract - wasmPath := findWasmPath("pyth", "pyth.wasm") + // Load the pyth-pro wasm contract. + wasmPath := findWasmPath("pyth-pro", "pyth_pro.wasm") if wasmPath == "" { - s.T().Skip("pyth.wasm not found, skipping contract store test") + s.T().Skip("pyth_pro.wasm not found, skipping contract store test") return } @@ -299,8 +321,8 @@ func (s *priceOracleContractTestSuite) TestStoreContractViaGovernance() { sdk.Coins{sdk.NewInt64Coin("uakt", 1000000000)}, val.Address.String(), "", - "Store pyth contract", - "Deploy pyth CosmWasm contract for Pyth price feeds", + "Store pyth-pro contract", + "Deploy pyth-pro CosmWasm contract for Pyth price feeds", false, ) s.Require().NoError(err) @@ -371,52 +393,56 @@ func (s *priceOracleContractTestSuite) TestWormholeContractMessageEncoding() { s.T().Logf("Wormhole QueryAddressHex JSON: %s", string(data)) } -// TestPriceOracleWithVAAMessageEncoding tests that Pyth contract VAA message types serialize correctly +// TestPriceOracleWithVAAMessageEncoding tests that Pyth contract PNAU message types serialize correctly. func (s *priceOracleContractTestSuite) TestPriceOracleWithVAAMessageEncoding() { - // Test InstantiateMsg encoding with Wormhole and data sources - pythEmitterAddr := "e101faedac5851e32b9b23b5f9411a8c2bac4aae3ed4dd7b811dd1a72ea4aa71" + vaaInstantiateMsg := PythVaaInstantiateMsg{ + Admin: "akash1admin123", + RouterVerifier: testRouterVerifierConfig(), + } + + data, err := json.Marshal(vaaInstantiateMsg) + s.Require().NoError(err) + s.T().Logf("Pyth VAA InstantiateMsg JSON: %s", string(data)) + + var decodedVaa PythVaaInstantiateMsg + err = json.Unmarshal(data, &decodedVaa) + s.Require().NoError(err) + s.Require().Len(decodedVaa.RouterVerifier.Routers, 5) + s.Require().Equal(uint16(26), decodedVaa.RouterVerifier.ExpectedEmitterChain) instantiateMsg := InstantiateMsg{ - Admin: "akash1admin123", - WormholeContract: "akash1wormhole456", - UpdateFee: "1000000", - PriceFeedID: "0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d", - DataSources: []DataSource{ - { - EmitterChain: 26, // Pythnet - EmitterAddress: pythEmitterAddr, - }, - }, + Admin: "akash1admin123", + PythVaaContract: "akash1pythvaa456", + UpdateFee: "1000000", + PriceFeedID: "0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d", } - data, err := json.Marshal(instantiateMsg) + data, err = json.Marshal(instantiateMsg) s.Require().NoError(err) - s.T().Logf("Pyth InstantiateMsg JSON: %s", string(data)) + s.T().Logf("Pyth Pro InstantiateMsg JSON: %s", string(data)) var decoded InstantiateMsg err = json.Unmarshal(data, &decoded) s.Require().NoError(err) s.Require().Equal(instantiateMsg.Admin, decoded.Admin) - s.Require().Equal(instantiateMsg.WormholeContract, decoded.WormholeContract) - s.Require().Len(decoded.DataSources, 1) - s.Require().Equal(uint16(26), decoded.DataSources[0].EmitterChain) + s.Require().Equal(instantiateMsg.PythVaaContract, decoded.PythVaaContract) - // Test ExecuteUpdatePriceFeed with VAA encoding + // Test ExecuteUpdatePriceFeed with PNAU encoding executeMsg := ExecuteUpdatePriceFeed{ UpdatePriceFeed: UpdatePriceFeedData{ - VAA: base64.StdEncoding.EncodeToString([]byte("test_vaa_data")), + VAA: base64.StdEncoding.EncodeToString([]byte("test_pnau_data")), }, } data, err = json.Marshal(executeMsg) s.Require().NoError(err) - s.T().Logf("Pyth UpdatePriceFeed with VAA JSON: %s", string(data)) + s.T().Logf("Pyth UpdatePriceFeed with PNAU JSON: %s", string(data)) // Test UpdateConfig encoding - wormholeContract := "akash1newwormhole" + pythVaaContract := "akash1newpythvaa" updateConfigMsg := ExecuteUpdateConfig{ UpdateConfig: UpdateConfigData{ - WormholeContract: &wormholeContract, + PythVaaContract: &pythVaaContract, }, } @@ -457,15 +483,12 @@ func (s *priceOracleContractTestSuite) TestQueryOracleModuleParams() { // TestContractMessageEncoding tests that contract message types serialize correctly func (s *priceOracleContractTestSuite) TestContractMessageEncoding() { - // Test InstantiateMsg encoding (now includes wormhole_contract and data_sources) + // Test pyth-pro InstantiateMsg encoding. instantiateMsg := InstantiateMsg{ - Admin: "akash1test123", - WormholeContract: "akash1wormhole456", - UpdateFee: "1000", - PriceFeedID: "0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d", - DataSources: []DataSource{ - {EmitterChain: 26, EmitterAddress: "e101faedac5851e32b9b23b5f9411a8c2bac4aae3ed4dd7b811dd1a72ea4aa71"}, - }, + Admin: "akash1test123", + PythVaaContract: "akash1pythvaa456", + UpdateFee: "1000", + PriceFeedID: "0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d", } data, err := json.Marshal(instantiateMsg) @@ -475,13 +498,13 @@ func (s *priceOracleContractTestSuite) TestContractMessageEncoding() { err = json.Unmarshal(data, &decoded) s.Require().NoError(err) s.Require().Equal(instantiateMsg.Admin, decoded.Admin) - s.Require().Equal(instantiateMsg.WormholeContract, decoded.WormholeContract) + s.Require().Equal(instantiateMsg.PythVaaContract, decoded.PythVaaContract) s.Require().Equal(instantiateMsg.PriceFeedID, decoded.PriceFeedID) - // Test ExecuteMsg encoding (now uses VAA) + // Test ExecuteMsg encoding with PNAU data. executeMsg := ExecuteUpdatePriceFeed{ UpdatePriceFeed: UpdatePriceFeedData{ - VAA: base64.StdEncoding.EncodeToString([]byte("test_vaa_binary_data")), + VAA: base64.StdEncoding.EncodeToString([]byte("test_pnau_binary_data")), }, } @@ -517,28 +540,25 @@ func (s *priceOracleContractTestSuite) TestContractMessageEncoding() { // TestContractResponseParsing tests parsing of expected contract responses func (s *priceOracleContractTestSuite) TestContractResponseParsing() { - // Test ConfigResponse parsing (now includes wormhole_contract and data_sources) + // Test pyth-pro ConfigResponse parsing. configJSON := `{ "admin": "akash1abc123", - "wormhole_contract": "akash1wormhole456", + "pyth_vaa_contract": "akash1pythvaa456", "update_fee": "1000", "price_feed_id": "0xtest", "default_denom": "uakt", - "default_base_denom": "usd", - "data_sources": [{"emitter_chain": 26, "emitter_address": "e101faedac5851e32b9b23b5f9411a8c2bac4aae3ed4dd7b811dd1a72ea4aa71"}] + "default_base_denom": "usd" }` var config ConfigResponse err := json.Unmarshal([]byte(configJSON), &config) s.Require().NoError(err) s.Require().Equal("akash1abc123", config.Admin) - s.Require().Equal("akash1wormhole456", config.WormholeContract) + s.Require().Equal("akash1pythvaa456", config.PythVaaContract) s.Require().Equal("1000", config.UpdateFee) s.Require().Equal("0xtest", config.PriceFeedID) s.Require().Equal("uakt", config.DefaultDenom) s.Require().Equal("usd", config.DefaultBaseDenom) - s.Require().Len(config.DataSources, 1) - s.Require().Equal(uint16(26), config.DataSources[0].EmitterChain) // Test PriceResponse parsing priceJSON := `{ @@ -611,15 +631,14 @@ func (s *priceOracleContractTestSuite) TestWormholeResponseParsing() { s.Require().Equal("1000", state.Fee.Amount) } -// TestVAAExecuteMessageParsing tests that VAA-based execute messages are properly formatted -func (s *priceOracleContractTestSuite) TestVAAExecuteMessageParsing() { - // Test that VAA binary data is properly base64 encoded in execute message - testVAAData := []byte("P2WH" + "test_vaa_payload_data_with_guardian_signatures") - vaaBase64 := base64.StdEncoding.EncodeToString(testVAAData) +// TestPNAUExecuteMessageParsing tests that PNAU execute messages are properly formatted. +func (s *priceOracleContractTestSuite) TestPNAUExecuteMessageParsing() { + testPNAUData := []byte("PNAU" + "test_router_signed_accumulator_update") + pnauBase64 := base64.StdEncoding.EncodeToString(testPNAUData) executeMsg := ExecuteUpdatePriceFeed{ UpdatePriceFeed: UpdatePriceFeedData{ - VAA: vaaBase64, + VAA: pnauBase64, }, } @@ -636,14 +655,13 @@ func (s *priceOracleContractTestSuite) TestVAAExecuteMessageParsing() { vaaField, ok := updatePriceFeed["vaa"].(string) s.Require().True(ok, "Should have vaa field as string") - s.Require().Equal(vaaBase64, vaaField) + s.Require().Equal(pnauBase64, vaaField) - s.T().Logf("VAA execute message JSON: %s", string(data)) + s.T().Logf("PNAU execute message JSON: %s", string(data)) } // TestAllContractsExist verifies that all contract WASM files are available func (s *priceOracleContractTestSuite) TestAllContractsExist() { - // Note: Pyth contract removed - Pyth now handles VAA verification directly via Wormhole contracts := []struct { name string dir string @@ -651,6 +669,8 @@ func (s *priceOracleContractTestSuite) TestAllContractsExist() { }{ {"wormhole", "wormhole", "wormhole.wasm"}, {"pyth", "pyth", "pyth.wasm"}, + {"pyth-vaa", "pyth-vaa", "pyth_vaa.wasm"}, + {"pyth-pro", "pyth-pro", "pyth_pro.wasm"}, } for _, c := range contracts { @@ -1062,12 +1082,12 @@ func (s *priceOracleContractTestSuite) TestStoreContractCodeViaGovernance() { s.Require().NoError(err) // Step 1: Load contract WASM - wasmPath := findWasmPath("pyth", "pyth.wasm") + wasmPath := findWasmPath("pyth-pro", "pyth_pro.wasm") if wasmPath == "" { - s.T().Skip("pyth.wasm not found, skipping contract deployment test") + s.T().Skip("pyth_pro.wasm not found, skipping contract deployment test") return } - s.T().Logf("Found pyth contract at: %s", wasmPath) + s.T().Logf("Found pyth-pro contract at: %s", wasmPath) wasmBytes, err := LoadAndGzipWasm(wasmPath) s.Require().NoError(err) @@ -1083,8 +1103,8 @@ func (s *priceOracleContractTestSuite) TestStoreContractCodeViaGovernance() { proposalID, err := SubmitStoreCodeProposal( ctx, cl, govAddr, wasmBytes, val.Address, deposit, - "Store pyth contract", - "Deploy pyth CosmWasm contract for testing", + "Store pyth-pro contract", + "Deploy pyth-pro CosmWasm contract for testing", ) s.Require().NoError(err) s.T().Logf("Submitted store code proposal: %d", proposalID) @@ -1128,26 +1148,17 @@ func (s *priceOracleContractTestSuite) TestStoreContractCodeViaGovernance() { codeInfoResp.CodeInfoResponse.DataHash) // Step 7: Instantiate the contract - // The pyth contract requires: - // - wormhole_contract: Address for VAA verification (use placeholder for test) - // - data_sources: Trusted Pyth emitters - // - Queries oracle module params during instantiation via custom Akash querier + // The pyth-pro contract points at pyth-vaa for verification. initMsg := InstantiateMsg{ - Admin: val.Address.String(), - WormholeContract: val.Address.String(), // Use validator address as placeholder wormhole contract - UpdateFee: "1000", - PriceFeedID: "0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d", // AKT/USD price feed ID - DataSources: []DataSource{ - { - EmitterChain: 26, // Pythnet - EmitterAddress: "e101faedac5851e32b9b23b5f9411a8c2bac4aae3ed4dd7b811dd1a72ea4aa71", - }, - }, + Admin: val.Address.String(), + PythVaaContract: val.Address.String(), + UpdateFee: "1000", + PriceFeedID: "0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d", // AKT/USD price feed ID } contractAddr, err := InstantiateContract( ctx, cl, codeID, initMsg, - "pyth-test", + "pyth-pro-test", val.Address.String(), // admin val.Address, ) @@ -1167,5 +1178,6 @@ func (s *priceOracleContractTestSuite) TestStoreContractCodeViaGovernance() { s.Require().Equal(val.Address.String(), config.Admin) s.Require().Equal("1000", config.UpdateFee) + s.Require().Equal(val.Address.String(), config.PythVaaContract) s.T().Log("Contract deployed and configured successfully!") }