diff --git a/target_chains/near/README.md b/target_chains/near/README.md index 622245a139..0fc8955250 100644 --- a/target_chains/near/README.md +++ b/target_chains/near/README.md @@ -42,6 +42,34 @@ near call --network-id mainnet contract-url.near update_price_feeds '{ "data": " near view --network-id mainnet contract-url.near get_price_unsafe '{ "price_identifier": "e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43" }' ``` +## Pointing the receiver at a new Wormhole contract + +The receiver verifies every price and governance VAA by calling out to a Wormhole core +bridge contract (the `wormhole` `AccountId` stored in contract state). To migrate the receiver +onto a different Wormhole contract — for example a Pyth-owned core bridge whose guardian set is +the Pyth Pro routers rather than the default Wormhole guardians — use the `SetWormhole` +governance action. + +`SetWormhole` is a standard `Target`-module governance action signed by the receiver's current +governance source. Its wire payload is the usual `PTGM` header (magic + module + action +discriminant + target chain) followed by the new Wormhole `AccountId` encoded as its UTF-8 +string representation. It is replay-protected by the same governance sequence number as every +other action, so an action with a sequence at or below the last executed governance VAA is +rejected. + +When the new Wormhole contract requires a fresh receiver code deploy (e.g. its `verify_vaa` ABI +changed), the migration is two independent governance steps, applied after the new Wormhole +contract has been deployed at its own account: + +1. `UpgradeContract { codehash }` → call `update_contract(new_wasm)`. This replaces the + receiver's code and runs `migrate()` (the existing upgrade flow). +2. `SetWormhole { new_wormhole }` → the receiver starts verifying against the new Wormhole + contract on the next `update_price_feeds` / `execute_governance_instruction`. + +If the receiver code does not need to change, step 2 alone is sufficient. The two contracts are +deployed and upgraded independently: bring up the new Wormhole contract first, then issue the +receiver upgrade and/or `SetWormhole`. + ## Further Documentation You can find more in-depth documentation on the [Pyth Website][pyth website] for a more in-depth guide to diff --git a/target_chains/near/receiver/Cargo.lock b/target_chains/near/receiver/Cargo.lock index caf55fb96b..ab549d3d35 100644 --- a/target_chains/near/receiver/Cargo.lock +++ b/target_chains/near/receiver/Cargo.lock @@ -49,18 +49,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "ahash" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" -dependencies = [ - "cfg-if 1.0.0", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.3" @@ -282,21 +270,11 @@ dependencies = [ [[package]] name = "borsh" -version = "0.10.4" +version = "1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115e54d64eb62cdebad391c19efc9dce4981c690c85a33a12199d99bb9546fee" +checksum = "ad8646f98db542e39fc66e68a20b2144f6a732636df7c2354e74645faaa433ce" dependencies = [ - "borsh-derive 0.10.4", - "hashbrown 0.13.2", -] - -[[package]] -name = "borsh" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6362ed55def622cddc70a4746a68554d7b687713770de539e59a739b249f8ed" -dependencies = [ - "borsh-derive 1.5.1", + "borsh-derive 1.5.7", "cfg_aliases", ] @@ -306,21 +284,8 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6441c552f230375d18e3cc377677914d2ca2b0d36e52129fe15450a2dce46775" dependencies = [ - "borsh-derive-internal 0.9.3", - "borsh-schema-derive-internal 0.9.3", - "proc-macro-crate 0.1.5", - "proc-macro2", - "syn 1.0.109", -] - -[[package]] -name = "borsh-derive" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831213f80d9423998dd696e2c5345aba6be7a0bd8cd19e31c5243e13df1cef89" -dependencies = [ - "borsh-derive-internal 0.10.4", - "borsh-schema-derive-internal 0.10.4", + "borsh-derive-internal", + "borsh-schema-derive-internal", "proc-macro-crate 0.1.5", "proc-macro2", "syn 1.0.109", @@ -328,16 +293,15 @@ dependencies = [ [[package]] name = "borsh-derive" -version = "1.5.1" +version = "1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3ef8005764f53cd4dca619f5bf64cafd4664dada50ece25e4d81de54c80cc0b" +checksum = "fdd1d3c0c2f5833f22386f252fe8ed005c7f59fdcddeef025c01b4c3b9fd9ac3" dependencies = [ "once_cell", "proc-macro-crate 3.2.0", "proc-macro2", "quote", "syn 2.0.87", - "syn_derive", ] [[package]] @@ -351,17 +315,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "borsh-derive-internal" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65d6ba50644c98714aa2a70d13d7df3cd75cd2b523a2b452bf010443800976b3" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "borsh-schema-derive-internal" version = "0.9.3" @@ -373,17 +326,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "borsh-schema-derive-internal" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "276691d96f063427be83e6692b86148e488ebba9f48f77788724ca027ba3b6d4" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "brownstone" version = "1.1.0" @@ -1258,7 +1200,7 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" dependencies = [ - "ahash 0.7.8", + "ahash", ] [[package]] @@ -1267,15 +1209,6 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -[[package]] -name = "hashbrown" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" -dependencies = [ - "ahash 0.8.11", -] - [[package]] name = "hashbrown" version = "0.15.1" @@ -1993,7 +1926,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c49593c9e94454a2368a4c0a511bf4bf1413aff4d23f16e1d8f4e64b5215351" dependencies = [ - "borsh 1.5.1", + "borsh 1.5.7", "schemars", "semver", "serde", @@ -2045,7 +1978,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35cbb989542587b47205e608324ddd391f0cee1c22b4b64ae49f458334b95907" dependencies = [ - "borsh 1.5.1", + "borsh 1.5.7", "serde", ] @@ -2093,7 +2026,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "907fdcefa3a42976cd6a8bf626fe2a87eb0d3b3ff144adc67cf32d53c9494b32" dependencies = [ "blake2", - "borsh 1.5.1", + "borsh 1.5.7", "bs58 0.4.0", "curve25519-dalek", "derive_more", @@ -2127,7 +2060,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180edcc7dc2fac41f93570d0c7b759c1b6d492f6ad093d749d644a40b4310a97" dependencies = [ - "borsh 1.5.1", + "borsh 1.5.7", "schemars", "serde", ] @@ -2138,7 +2071,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "161fdc8f73fd9e19a97e05acb10e985ba89bd06e88543cdfd0c8dad0dac266c5" dependencies = [ - "borsh 1.5.1", + "borsh 1.5.7", "lazy_static", "log", "near-chain-configs", @@ -2174,7 +2107,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e41afea5c5e84763586bafc5f5e1b63d90ef4e5454e18406cab8df120178db8d" dependencies = [ - "borsh 1.5.1", + "borsh 1.5.7", "enum-map", "near-account-id", "near-primitives-core", @@ -2194,7 +2127,7 @@ checksum = "165c2dc0fc20d839cfd7948d930ef5e8a4ed2b095abe83e0076ef5d4a5df58ed" dependencies = [ "arbitrary", "base64 0.21.7", - "borsh 1.5.1", + "borsh 1.5.7", "bytes", "bytesize", "cfg-if 1.0.0", @@ -2237,7 +2170,7 @@ checksum = "51fd53f992168589c52022dd220c84a7f2ede92251631a06a3817e4b22af5836" dependencies = [ "arbitrary", "base64 0.21.7", - "borsh 1.5.1", + "borsh 1.5.7", "bs58 0.4.0", "derive_more", "enum-map", @@ -2292,7 +2225,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e296b02c85539c16659e171242d6c6bbea87eec7c9ef860d8dfd3fb3168a18a" dependencies = [ "base64 0.22.1", - "borsh 1.5.1", + "borsh 1.5.7", "bs58 0.5.1", "near-account-id", "near-crypto", @@ -2379,7 +2312,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd3e60aa26a74dc514b1b6408fdd06cefe2eb0ff029020956c1c6517594048fd" dependencies = [ - "borsh 1.5.1", + "borsh 1.5.7", "serde", ] @@ -2390,7 +2323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5a2fecdbec69a1748bd80aa0d73e4de0064d2d8097f429677d3e37a6bde3b2d" dependencies = [ "blst", - "borsh 1.5.1", + "borsh 1.5.7", "bytesize", "ed25519-dalek", "enum-map", @@ -2701,7 +2634,7 @@ version = "4.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c65ee1f9701bf938026630b455d5315f490640234259037edb259798b3bcf85e" dependencies = [ - "borsh 1.5.1", + "borsh 1.5.7", "num-traits", "rand", "serde", @@ -2882,29 +2815,6 @@ dependencies = [ "toml_edit", ] -[[package]] -name = "proc-macro-error" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", -] - [[package]] name = "proc-macro2" version = "1.0.89" @@ -2966,10 +2876,10 @@ dependencies = [ [[package]] name = "pythnet-sdk" -version = "2.3.1" +version = "3.0.0" dependencies = [ "bincode", - "borsh 0.10.4", + "borsh 1.5.7", "bytemuck", "byteorder", "fast-math", @@ -3767,18 +3677,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "syn_derive" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1329189c02ff984e9736652b1631330da25eaa6bc639089ed4915d25446cbe7b" -dependencies = [ - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.87", -] - [[package]] name = "sync_wrapper" version = "1.0.1" diff --git a/target_chains/near/receiver/src/governance.rs b/target_chains/near/receiver/src/governance.rs index f254023561..cb12df847f 100644 --- a/target_chains/near/receiver/src/governance.rs +++ b/target_chains/near/receiver/src/governance.rs @@ -79,6 +79,7 @@ pub enum GovernanceAction { SetFee { base: u64, expo: u64 }, SetValidPeriod { valid_seconds: u64 }, RequestGovernanceDataSourceTransfer { governance_data_source_index: u32 }, + SetWormhole { new_wormhole: AccountId }, } #[derive(BorshDeserialize, BorshSerialize, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -168,6 +169,17 @@ impl GovernanceInstruction { governance_data_source_index, } } + + GovernanceActionId::SetWormhole => { + // The new Wormhole `AccountId` occupies the remainder of the payload, encoded + // as its UTF-8 string representation. + let (_input, bytes) = all_consuming(take(input.len()))(input)?; + let new_wormhole = std::str::from_utf8(bytes) + .map_err(|_| InvalidPayload)? + .parse::() + .map_err(|_| InvalidPayload)?; + GovernanceAction::SetWormhole { new_wormhole } + } }, }) } @@ -223,6 +235,12 @@ impl GovernanceInstruction { buf.extend_from_slice(&u16::from(self.target).to_be_bytes()); buf.extend_from_slice(&governance_data_source_index.to_be_bytes()); } + + GovernanceAction::SetWormhole { new_wormhole } => { + buf.push(GovernanceActionId::SetWormhole as u8); + buf.extend_from_slice(&u16::from(self.target).to_be_bytes()); + buf.extend_from_slice(new_wormhole.as_str().as_bytes()); + } } Ok(buf) @@ -327,6 +345,7 @@ impl Pyth { SetDataSources { data_sources } => self.set_sources(data_sources), SetFee { base, expo } => self.set_update_fee(base, expo)?, SetValidPeriod { valid_seconds } => self.set_valid_period(valid_seconds), + SetWormhole { new_wormhole } => self.set_wormhole(new_wormhole), RequestGovernanceDataSourceTransfer { .. } => Err(InvalidPayload)?, UpgradeContract { codehash } => { // Additionally restrict to only Near for upgrades. This is a safety measure to @@ -535,6 +554,10 @@ impl Pyth { pub fn set_upgrade_hash(&mut self, codehash: [u8; 32]) { self.codehash = codehash; } + + pub fn set_wormhole(&mut self, new_wormhole: AccountId) { + self.wormhole = new_wormhole; + } } #[cfg(test)] @@ -633,6 +656,41 @@ mod tests { assert_eq!(contract.update_fee, NearToken::from_yoctonear(10000)); } + #[test] + fn test_set_wormhole_roundtrip() { + let instruction = GovernanceInstruction { + module: GovernanceModule::Target, + target: Chain::from(WormholeChain::Near), + action: GovernanceAction::SetWormhole { + new_wormhole: "new-wormhole.near".parse().unwrap(), + }, + }; + + assert_eq!( + instruction, + GovernanceInstruction::deserialize(instruction.serialize().unwrap()).unwrap() + ); + } + + #[test] + fn test_set_wormhole_handler() { + let mut context = get_context(); + context.is_view(false); + testing_env!(context.build()); + + let mut contract = Pyth::new( + "pyth.near".parse::().unwrap(), + Source::default(), + Source::default(), + 0.into(), + 32, + ); + + let new_wormhole: AccountId = "new-wormhole.near".parse().unwrap(); + contract.set_wormhole(new_wormhole.clone()); + assert_eq!(contract.wormhole, new_wormhole); + } + #[test] fn test_governance_serialize_matches_deserialize() { // We match on the GovernanceActionId so that when new variants are added the test is @@ -740,6 +798,21 @@ mod tests { GovernanceActionId::RequestGovernanceDataSourceTransfer => { unimplemented!() } + + GovernanceActionId::SetWormhole => { + let instruction = GovernanceInstruction { + module: GovernanceModule::Target, + target: Chain::from(WormholeChain::Near), + action: GovernanceAction::SetWormhole { + new_wormhole: "wormhole.near".parse().unwrap(), + }, + }; + + assert_eq!( + instruction, + GovernanceInstruction::deserialize(instruction.serialize().unwrap()).unwrap() + ); + } } } } diff --git a/target_chains/near/receiver/tests/workspaces.rs b/target_chains/near/receiver/tests/workspaces.rs index df2e3017cc..3e4dad8497 100644 --- a/target_chains/near/receiver/tests/workspaces.rs +++ b/target_chains/near/receiver/tests/workspaces.rs @@ -921,6 +921,195 @@ async fn test_accumulator_updates() { ); } +#[tokio::test] +async fn test_set_wormhole() { + let (worker, contract, _) = initialize_chain().await; + + // Trust the default accumulator data source so price updates are accepted. + let vaa = create_vaa_from_payload( + &GovernanceInstruction { + target: Chain::from(WormholeChain::Any), + module: GovernanceModule::Target, + action: GovernanceAction::SetDataSources { + data_sources: vec![Source { + emitter: DEFAULT_DATA_SOURCE.address.0, + chain: Chain::from(WormholeChain::from(u16::from(DEFAULT_DATA_SOURCE.chain))), + }], + }, + } + .serialize() + .unwrap(), + DEFAULT_GOVERNANCE_SOURCE.address, + DEFAULT_GOVERNANCE_SOURCE.chain, + 1, + ); + let vaa = hex::encode(serde_wormhole::to_vec(&vaa).unwrap()); + assert!(contract + .call("execute_governance_instruction") + .gas(Gas::from_gas(300_000_000_000_000)) + .deposit(NearToken::from_yoctonear(300_000_000_000_000_000_000_000)) + .args_json(json!({ "vaa": vaa })) + .transact_async() + .await + .expect("Failed to submit VAA") + .await + .unwrap() + .failures() + .is_empty()); + + // Deploy a second, independent Wormhole stub and point the receiver at it via SetWormhole. + let new_wormhole = worker + .dev_deploy( + &std::fs::read("wormhole_stub.wasm").expect("Failed to find wormhole_stub.wasm"), + ) + .await + .expect("Failed to deploy second wormhole_stub.wasm"); + let _ = new_wormhole + .call("new") + .args_json(json!({})) + .gas(Gas::from_gas(300_000_000_000_000)) + .transact_async() + .await + .expect("Failed to initialize new Wormhole") + .await + .unwrap(); + + let vaa = create_vaa_from_payload( + &GovernanceInstruction { + target: Chain::from(WormholeChain::Near), + module: GovernanceModule::Target, + action: GovernanceAction::SetWormhole { + new_wormhole: new_wormhole.id().as_str().parse().unwrap(), + }, + } + .serialize() + .unwrap(), + DEFAULT_GOVERNANCE_SOURCE.address, + DEFAULT_GOVERNANCE_SOURCE.chain, + 2, + ); + let vaa = hex::encode(serde_wormhole::to_vec(&vaa).unwrap()); + assert!(contract + .call("execute_governance_instruction") + .gas(Gas::from_gas(300_000_000_000_000)) + .deposit(NearToken::from_yoctonear(300_000_000_000_000_000_000_000)) + .args_json(json!({ "vaa": vaa })) + .transact_async() + .await + .expect("Failed to submit VAA") + .await + .unwrap() + .failures() + .is_empty()); + + // A price update now verifies against the new Wormhole address and lands in storage. + let feed = create_dummy_price_feed_message(100); + let message = hex::encode(create_accumulator_message( + &[&feed], + &[&feed], + false, + false, + None, + )); + assert!(contract + .call("update_price_feeds") + .gas(Gas::from_gas(300_000_000_000_000)) + .deposit(NearToken::from_yoctonear(300_000_000_000_000_000_000_000)) + .args_json(json!({ "data": message })) + .transact_async() + .await + .expect("Failed to submit update") + .await + .unwrap() + .failures() + .is_empty()); + + let mut identifier = [0; 32]; + identifier[0] = 100; + assert_eq!( + Some(Price { + price: 100.into(), + conf: 100.into(), + expo: 100, + publish_time: 100, + }), + serde_json::from_slice::>( + &contract + .view("get_price_unsafe") + .args_json(json!({ "price_identifier": PriceIdentifier(identifier) })) + .await + .unwrap() + .result + ) + .unwrap(), + ); + + // Point the receiver at an address with no Wormhole contract. The cross-contract verify call + // now fails, so subsequent updates are rejected — proving the swap actually takes effect. + let vaa = create_vaa_from_payload( + &GovernanceInstruction { + target: Chain::from(WormholeChain::Near), + module: GovernanceModule::Target, + action: GovernanceAction::SetWormhole { + new_wormhole: "does-not-exist.near".parse().unwrap(), + }, + } + .serialize() + .unwrap(), + DEFAULT_GOVERNANCE_SOURCE.address, + DEFAULT_GOVERNANCE_SOURCE.chain, + 3, + ); + let vaa = hex::encode(serde_wormhole::to_vec(&vaa).unwrap()); + assert!(contract + .call("execute_governance_instruction") + .gas(Gas::from_gas(300_000_000_000_000)) + .deposit(NearToken::from_yoctonear(300_000_000_000_000_000_000_000)) + .args_json(json!({ "vaa": vaa })) + .transact_async() + .await + .expect("Failed to submit VAA") + .await + .unwrap() + .failures() + .is_empty()); + + let feed_2 = create_dummy_price_feed_message(200); + let message = hex::encode(create_accumulator_message( + &[&feed_2], + &[&feed_2], + false, + false, + None, + )); + let _ = contract + .call("update_price_feeds") + .gas(Gas::from_gas(300_000_000_000_000)) + .deposit(NearToken::from_yoctonear(300_000_000_000_000_000_000_000)) + .args_json(json!({ "data": message })) + .transact_async() + .await + .expect("Failed to submit update") + .await + .unwrap(); + + // The new feed must not have landed because verification against the dead address fails. + let mut identifier = [0; 32]; + identifier[0] = 200; + assert_eq!( + None, + serde_json::from_slice::>( + &contract + .view("get_price_unsafe") + .args_json(json!({ "price_identifier": PriceIdentifier(identifier) })) + .await + .unwrap() + .result + ) + .unwrap(), + ); +} + #[tokio::test] async fn test_sdk_compat() { let price = pyth_sdk::Price {