diff --git a/Cargo.lock b/Cargo.lock index 67edd8af..1dfbc2f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -229,7 +229,7 @@ dependencies = [ "http 1.2.0", "http-body 1.0.1", "http-body-util", - "hyper 1.5.2", + "hyper 1.8.0", "hyper-util", "itoa", "matchit", @@ -1906,7 +1906,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "socket2", + "socket2 0.5.8", "tokio", "tower-service", "tracing", @@ -1915,13 +1915,14 @@ dependencies = [ [[package]] name = "hyper" -version = "1.5.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "256fb8d4bd6413123cc9d91832d78325c48ff41677595be797d90f42969beae0" +checksum = "1744436df46f0bde35af3eda22aeaba453aada65d8f1c171cd8a5f59030bd69f" dependencies = [ + "atomic-waker", "bytes", "futures-channel", - "futures-util", + "futures-core", "h2 0.4.7", "http 1.2.0", "http-body 1.0.1", @@ -1929,6 +1930,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", + "pin-utils", "smallvec", "tokio", "want", @@ -1942,7 +1944,7 @@ checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2" dependencies = [ "futures-util", "http 1.2.0", - "hyper 1.5.2", + "hyper 1.8.0", "hyper-util", "rustls", "rustls-pki-types", @@ -1972,7 +1974,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper 1.5.2", + "hyper 1.8.0", "hyper-util", "native-tls", "tokio", @@ -1982,21 +1984,28 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.10" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" dependencies = [ + "base64 0.22.1", "bytes", "futures-channel", + "futures-core", "futures-util", "http 1.2.0", "http-body 1.0.1", - "hyper 1.5.2", + "hyper 1.8.0", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.1", + "system-configuration 0.6.1", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2319,9 +2328,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.76" +version = "0.3.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717b6b5b077764fb5966237269cb3c64edddde4b14ce42647430a78ced9e7b7" +checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" dependencies = [ "once_cell", "wasm-bindgen", @@ -2769,7 +2778,7 @@ dependencies = [ "near-crypto", "near-jsonrpc-primitives", "near-primitives", - "reqwest 0.12.12", + "reqwest 0.12.24", "serde", "serde_json", "thiserror 2.0.11", @@ -3066,7 +3075,7 @@ dependencies = [ "near-sandbox-utils", "near-token", "rand", - "reqwest 0.12.12", + "reqwest 0.12.24", "serde", "serde_json", "sha2", @@ -3824,7 +3833,7 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls-pemfile 1.0.4", + "rustls-pemfile", "serde", "serde_json", "serde_urlencoded", @@ -3842,46 +3851,42 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.12" +version = "0.12.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43e734407157c3c2034e0258f5e4473ddb361b1e85f95a66690d67264d7cd1da" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", "futures-core", - "futures-util", "h2 0.4.7", "http 1.2.0", "http-body 1.0.1", "http-body-util", - "hyper 1.5.2", + "hyper 1.8.0", "hyper-rustls", "hyper-tls 0.6.0", "hyper-util", - "ipnet", "js-sys", "log", "mime", "native-tls", - "once_cell", "percent-encoding", "pin-project-lite", - "rustls-pemfile 2.2.0", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper 1.0.2", - "system-configuration 0.6.1", "tokio", "tokio-native-tls", "tower 0.5.2", + "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows-registry", ] [[package]] @@ -4057,7 +4062,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -4084,15 +4089,6 @@ dependencies = [ "base64 0.21.7", ] -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" version = "1.10.1" @@ -4497,6 +4493,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + [[package]] name = "solana-account" version = "3.2.0" @@ -5820,7 +5826,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.2", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -5956,6 +5962,7 @@ dependencies = [ "near-sdk-contract-tools", "near-workspaces", "p256", + "reqwest 0.12.24", "rstest", "sha2", "solana-sdk", @@ -6167,7 +6174,7 @@ dependencies = [ "pin-project-lite", "signal-hook-registry", "slab", - "socket2", + "socket2 0.5.8", "tokio-macros", "windows-sys 0.52.0", ] @@ -6613,20 +6620,22 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.99" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a474f6281d1d70c17ae7aa6a613c87fce69a127e2624002df63dcb39d6cf6396" +checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" dependencies = [ "cfg-if 1.0.0", "once_cell", + "rustversion", "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.99" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f89bb38646b4f81674e8f5c3fb81b562be1fd936d84320f3264486418519c79" +checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" dependencies = [ "bumpalo", "log", @@ -6638,9 +6647,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.49" +version = "0.4.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38176d9b44ea84e9184eff0bc34cc167ed044f816accfe5922e54d84cf48eca2" +checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" dependencies = [ "cfg-if 1.0.0", "js-sys", @@ -6651,9 +6660,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.99" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cc6181fd9a7492eef6fef1f33961e3695e4579b9872a6f7c83aee556666d4fe" +checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6661,9 +6670,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.99" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2" +checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" dependencies = [ "proc-macro2", "quote", @@ -6674,15 +6683,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.99" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6" +checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" +dependencies = [ + "unicode-ident", +] [[package]] name = "web-sys" -version = "0.3.76" +version = "0.3.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04dd7223427d52553d3702c004d3b2fe07c148165faa56313cb00211e31c12bc" +checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" dependencies = [ "js-sys", "wasm-bindgen", @@ -6750,34 +6762,45 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-registry" -version = "0.2.0" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" dependencies = [ + "windows-link 0.1.3", "windows-result", "windows-strings", - "windows-targets 0.52.6", ] [[package]] name = "windows-result" -version = "0.2.0" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-targets 0.52.6", + "windows-link 0.1.3", ] [[package]] name = "windows-strings" -version = "0.1.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ - "windows-result", - "windows-targets 0.52.6", + "windows-link 0.1.3", ] [[package]] @@ -6807,6 +6830,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -6831,13 +6863,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -6850,6 +6899,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -6862,6 +6917,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -6874,12 +6935,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -6892,6 +6965,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -6904,6 +6983,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -6916,6 +7001,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -6928,6 +7019,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.5.40" diff --git a/common/src/oracle/pyth.rs b/common/src/oracle/pyth.rs index 41379761..a5314caa 100644 --- a/common/src/oracle/pyth.rs +++ b/common/src/oracle/pyth.rs @@ -26,7 +26,7 @@ use near_sdk::{ pub type OracleResponse = HashMap>; -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[near(serializers = [borsh, json])] pub struct PriceIdentifier( #[serde( @@ -36,6 +36,12 @@ pub struct PriceIdentifier( pub [u8; 32], ); +impl std::fmt::Debug for PriceIdentifier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", hex::encode(self.0)) + } +} + impl Display for PriceIdentifier { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", hex::encode(self.0)) diff --git a/contract/universal-account/examples/02_payload.rs b/contract/universal-account/examples/02_payload.rs index 9cbd5c96..71633688 100644 --- a/contract/universal-account/examples/02_payload.rs +++ b/contract/universal-account/examples/02_payload.rs @@ -15,7 +15,9 @@ pub fn main() { index: U64(0), nonce: U64(1), }, - account_id: "my-universal-account.testnet".parse().unwrap(), + account_id: "default-18843764340.gh-275.templar-in-training.testnet" + .parse() + .unwrap(), payload: vec![Transaction { receiver_id: "alice.testnet".parse().unwrap(), actions: vec![Action::Transfer { diff --git a/service/relayer/Cargo.toml b/service/relayer/Cargo.toml index d0563a7f..bd620f5a 100644 --- a/service/relayer/Cargo.toml +++ b/service/relayer/Cargo.toml @@ -37,6 +37,7 @@ tower.workspace = true tower-http.workspace = true tracing-subscriber = { workspace = true, features = ["env-filter", "json"] } tracing = { workspace = true, features = ["attributes"] } +reqwest = {version = "0.12.24", features = ["json"]} [dev-dependencies] near-workspaces = { workspace = true, features = ["experimental"] } diff --git a/service/relayer/src/app/args.rs b/service/relayer/src/app/args.rs index 1a6da57e..e9104319 100644 --- a/service/relayer/src/app/args.rs +++ b/service/relayer/src/app/args.rs @@ -23,6 +23,8 @@ pub struct Configuration { #[clap(flatten)] pub ua: UniversalAccount, #[clap(flatten)] + pub pyth: Pyth, + #[clap(flatten)] pub cache: Cache, /// Broom batch size. #[arg(long, env = "BROOM_BATCH_SIZE", default_value_t = 16)] @@ -36,6 +38,51 @@ fn duration_from_secs(s: &str) -> Result { Ok(Duration::from_secs(u64::from_str(s)?)) } +#[derive(Args, Debug, Clone)] +pub struct Pyth { + /// Pyth Hermes API URL. See: + #[arg( + long = "pyth-hermes-url", + env = "PYTH_HERMES_URL", + default_value_t = String::from("https://hermes-beta.pyth.network") + )] + pub hermes_url: String, + /// Do not push price updates to Pyth oracle if the last push was less + /// than this long ago, even if requested. + #[arg( + id = "pyth-refresh-secs", + long = "pyth-refresh-secs", + env = "PYTH_REFRESH_SECS", + value_parser = duration_from_secs, + default_value = "3" + )] + pub refresh: Duration, + /// Oracle ID to push price updates to. + #[arg( + id = "pyth-oracle-id", + long = "pyth-oracle-id", + env = "PYTH_ORACLE_ID", + default_value_t = AccountId::from_str("pyth-oracle.testnet").unwrap() + )] + pub oracle_id: AccountId, + /// How much gas (in units of Tgas) to attach to oracle price update calls. + #[arg( + id = "pyth-update-gas", + long = "pyth-update-gas", + env = "PYTH_UPDATE_GAS", + default_value = "300 Tgas" + )] + pub update_gas: near_sdk::Gas, + /// How much NEAR to attach as a deposit to oracle price update calls. + #[arg( + id = "pyth-update-deposit", + long = "pyth-update-deposit", + env = "PYTH_UPDATE_DEPOSIT", + default_value = "0.01 NEAR" + )] + pub update_deposit: NearToken, +} + #[derive(Args, Debug, Clone)] pub struct Cache { /// Refresh the cached gas price after X seconds. @@ -56,15 +103,6 @@ pub struct Cache { default_value = "60" )] pub nonce_refresh: Duration, - /// Refresh the cached protocol configuration after X seconds. - #[arg( - id = "cache-protocol-config-secs", - long = "cache-protocol-config-secs", - env = "CACHE_PROTOCOL_CONFIG_SECS", - value_parser = duration_from_secs, - default_value = "3600" - )] - pub protocol_config_refresh: Duration, } #[derive(Args, Debug, Clone)] diff --git a/service/relayer/src/app/mod.rs b/service/relayer/src/app/mod.rs index 99b866a8..372a407b 100644 --- a/service/relayer/src/app/mod.rs +++ b/service/relayer/src/app/mod.rs @@ -29,9 +29,10 @@ use crate::{ Database, }, near::Near, + pyth::Pyth, }, - error::PreconditionError, - AccountData, AssetTransfer, AssetTransferParseError, ContractData, + error::{FunctionCallRejectionReason, PayloadRejectionReason}, + AccountData, AssetTransfer, ContractData, }; pub mod args; @@ -43,6 +44,7 @@ pub struct App { pub accounts: Arc>, pub relay_near: Near, pub ua_near: Near, + pub pyth: Pyth, pub cache: Arc, pub database: Database, } @@ -74,6 +76,13 @@ impl App { let cache = Cache::new(relay_near.clone(), args.cache.clone(), kill.clone()); + let pyth = Pyth::new( + args.pyth.clone(), + relay_near.clone(), + cache.clone(), + kill.clone(), + ); + tokio::spawn(broom::start( database.clone(), relay_near.clone(), @@ -87,26 +96,27 @@ impl App { accounts: Arc::new(RwLock::new(AccountData::default())), relay_near, ua_near, + pyth, cache: Arc::new(cache), database, } } #[tracing::instrument(skip(self), fields(gas = %gas))] - pub async fn estimate_cost_of_gas(&self, gas: u64) -> Option { + pub async fn estimate_cost_of_gas(&self, gas: near_sdk::Gas) -> Option { const TERA: u128 = near_sdk::Gas::from_tgas(1).as_gas() as u128; let price_per_tgas = self.cache.gas_price().await; let result = price_per_tgas - .checked_mul(u128::from(gas))? - .checked_div(TERA); + .checked_mul(u128::from(gas.as_gas())) + .and_then(|x| x.checked_div(TERA)); tracing::debug!(cost = ?result, "Estimated gas cost"); result } #[allow(clippy::too_many_lines, reason = "procedural")] - #[tracing::instrument(skip(self), name = "load_markets")] + #[tracing::instrument(skip(self))] pub async fn load_markets(&mut self) { tracing::info!("Loading markets from registry and individual sources"); let mut markets = self.args.monitor.market.clone(); @@ -245,58 +255,50 @@ impl App { /// /// - If the receiver is not known. /// - If any of the function call actions are not allowed. - #[tracing::instrument(skip(self, accounts, calls), fields(receiver_id = %receiver_id))] + #[tracing::instrument(skip(self, accounts, contract_data, calls))] pub fn actions_are_allowed<'a>( &self, - receiver_id: &AccountIdRef, accounts: &AccountData, + receiver_id: &AccountIdRef, + contract_data: &ContractData, calls: impl IntoIterator, - ) -> Result, PreconditionError> { + ) -> Result, Vec> { let mut other_interactions = Vec::new(); - - let Some(contract_data) = accounts.allowed_contract_data.get(receiver_id) else { - return Err(PreconditionError::UnknownTransactionReceiverId { - account_id: receiver_id.to_owned(), - }); - }; + let mut errors = vec![]; for (index, call) in calls.into_iter().enumerate() { if !contract_data.allowed_methods.contains(&call.method_name) { - return Err(PreconditionError::UnknownFunctionName { index }); + errors.push(FunctionCallRejectionReason::UnknownFunctionName { + index, + function_name: call.method_name.clone(), + }); } - if let Ok(transfer) = - AssetTransfer::parse(receiver_id.to_owned(), call).map_err(|e| match e { - AssetTransferParseError::UnknownFunctionName => { - PreconditionError::UnknownFunctionName { index } - } - AssetTransferParseError::ArgumentDeserialization => { - PreconditionError::ArgumentDeserializationFailure { index } - } - }) - { + if let Ok(transfer) = AssetTransfer::parse(receiver_id.to_owned(), call) { let market_id = transfer.token_receiver_id(); other_interactions.push(market_id.to_owned()); let Some(market_account_ids) = accounts.market_data.get(market_id) else { - return Err(PreconditionError::UnknownTransferReceiverId { + errors.push(FunctionCallRejectionReason::UnknownTransferReceiverId { account_id: market_id.to_owned(), index, }); + continue; }; let msg = transfer.args.msg(); let Ok(msg) = serde_json::from_str::(msg) else { - return Err(PreconditionError::MsgDeserializationFailure { + errors.push(FunctionCallRejectionReason::MsgDeserializationFailure { index, msg: msg.to_string(), }); + continue; }; #[allow(clippy::unwrap_used, reason = "DepositMsg serialization is infallible")] if transfer.asset() == market_account_ids.borrow_asset { if !matches!(msg, DepositMsg::Supply | DepositMsg::Repay) { - return Err(PreconditionError::InvalidMsgForAsset { + errors.push(FunctionCallRejectionReason::InvalidMsgForAsset { index, expected: "\"Supply\" or \"Repay\"".to_string(), actual: serde_json::to_string(&msg).unwrap(), @@ -304,21 +306,23 @@ impl App { } } else if transfer.asset() == market_account_ids.collateral_asset { if !matches!(msg, DepositMsg::Collateralize) { - return Err(PreconditionError::InvalidMsgForAsset { + errors.push(FunctionCallRejectionReason::InvalidMsgForAsset { index, expected: "\"Collateralize\"".to_string(), actual: serde_json::to_string(&msg).unwrap(), }); } } else { - return Err(PreconditionError::UnknownTransactionReceiverId { - account_id: receiver_id.to_owned(), - }); + // Not a standard-compliant function call } } } - Ok(other_interactions) + if errors.is_empty() { + Ok(other_interactions) + } else { + Err(errors) + } } /// Check and calculate gas for a signed delegate action. @@ -335,20 +339,20 @@ impl App { sender_id = %signed_delegate_action.delegate_action.sender_id, receiver_id = %signed_delegate_action.delegate_action.receiver_id ))] - pub async fn check_and_calculate_gas( + pub async fn sda_check_and_calculate_gas( &self, signed_delegate_action: &SignedDelegateAction, - ) -> Result<(u64, ContractData), PreconditionError> { + ) -> Result<(near_sdk::Gas, ContractData), PayloadRejectionReason> { tracing::debug!("Checking and calculating gas for delegate action"); if !signed_delegate_action.verify() { - return Err(PreconditionError::SignatureVerificationFailure); + return Err(PayloadRejectionReason::SignatureVerificationFailure); } let receiver_id = &signed_delegate_action.delegate_action.receiver_id; let accounts = self.accounts.read().await; let Some(contract_data) = accounts.allowed_contract_data.get(receiver_id).cloned() else { - return Err(PreconditionError::UnknownTransactionReceiverId { + return Err(PayloadRejectionReason::UnknownTransactionReceiverId { account_id: receiver_id.clone(), }); }; @@ -357,21 +361,28 @@ impl App { let len = actions.len(); let calls = actions .into_iter() - .try_fold(Vec::with_capacity(len), |mut v, action| { + .enumerate() + .try_fold(Vec::with_capacity(len), |mut v, (i, action)| { if let Action::FunctionCall(fc) = action { v.push(fc); Ok(v) } else { - Err((v.len(), action)) + Err(i) } }) - .map_err(|(index, action)| PreconditionError::UnsupportedAction { index, action })?; + .map_err(|index| PayloadRejectionReason::UnsupportedAction { index })?; - self.actions_are_allowed(receiver_id, &accounts, calls.iter().map(Borrow::borrow))?; + self.actions_are_allowed( + &accounts, + receiver_id, + &contract_data, + calls.iter().map(Borrow::borrow), + ) + .map_err(PayloadRejectionReason::FunctionCallRejection)?; let gas_total = calls.iter().map(|call| call.gas).sum(); - Ok((gas_total, contract_data)) + Ok((near_sdk::Gas::from_gas(gas_total), contract_data)) } /// # Errors diff --git a/service/relayer/src/cache.rs b/service/relayer/src/cache.rs index f2bc4fea..fde89cb5 100644 --- a/service/relayer/src/cache.rs +++ b/service/relayer/src/cache.rs @@ -79,7 +79,7 @@ impl CacheRecord { } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct Cache { request: mpsc::Sender, } diff --git a/service/relayer/src/client/mod.rs b/service/relayer/src/client/mod.rs index 48a0a738..e0031ecc 100644 --- a/service/relayer/src/client/mod.rs +++ b/service/relayer/src/client/mod.rs @@ -1,2 +1,3 @@ pub mod database; pub mod near; +pub mod pyth; diff --git a/service/relayer/src/client/near.rs b/service/relayer/src/client/near.rs index b11071a4..4fa5c991 100644 --- a/service/relayer/src/client/near.rs +++ b/service/relayer/src/client/near.rs @@ -2,7 +2,7 @@ use std::sync::{atomic::AtomicUsize, Arc}; use near_crypto::{PublicKey, Signer}; use near_jsonrpc_client::{ - errors::JsonRpcError, + errors::{JsonRpcError, JsonRpcServerError}, methods::{ self, block::RpcBlockError, @@ -28,13 +28,16 @@ use near_sdk::{ }; use near_sdk_contract_tools::standard::nep145::{StorageBalance, StorageBalanceBounds}; -use templar_common::market::MarketConfiguration; +use templar_common::{ + market::MarketConfiguration, + oracle::{price_transformer::PriceTransformer, pyth::PriceIdentifier}, +}; use templar_universal_account::{ExecuteArgs, ExecutionParameters, KeyId}; use crate::{cache::Cache, MarketData}; -pub const STORAGE_DEPOSIT_GAS: u64 = Gas::from_tgas(5).as_gas(); -pub const DEPLOY_GAS: u64 = Gas::from_tgas(50).as_gas(); +pub const STORAGE_DEPOSIT_GAS: Gas = Gas::from_tgas(5); +pub const DEPLOY_GAS: Gas = Gas::from_tgas(50); #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(crate = "near_sdk::serde")] @@ -305,7 +308,7 @@ impl Near { "account_id": account_id, })) .unwrap(), - gas: STORAGE_DEPOSIT_GAS, + gas: STORAGE_DEPOSIT_GAS.as_gas(), deposit: amount.as_yoctonear(), }; @@ -338,7 +341,7 @@ impl Near { let action = FunctionCallAction { method_name: "deploy".to_string(), args: serde_json::to_vec(args).unwrap(), - gas: DEPLOY_GAS, + gas: DEPLOY_GAS.as_gas(), deposit: 0, }; @@ -386,6 +389,40 @@ impl Near { .sign(signer) } + #[must_use] + pub async fn construct_pyth_update_transaction( + &self, + cache: &Cache, + pyth_account_id: AccountId, + vaa: Vec, + gas: near_sdk::Gas, + deposit: near_sdk::NearToken, + ) -> SignedTransaction { + let signer = self.next_signer(); + let public_key = signer.public_key(); + + let (nonce, block_hash) = cache + .nonce(self.account_id.clone(), public_key.clone()) + .await; + + let action = FunctionCallAction { + method_name: "update_price_feeds".to_string(), + args: serde_json::to_vec(&json!({ "data": hex::encode(vaa) })).unwrap(), + gas: gas.as_gas(), + deposit: deposit.as_yoctonear(), + }; + + Transaction::V0(TransactionV0 { + signer_id: self.account_id.clone(), + public_key, + nonce, + receiver_id: pyth_account_id, + block_hash, + actions: vec![action.into()], + }) + .sign(signer) + } + /// # Errors /// /// - RPC errors @@ -497,18 +534,61 @@ impl Near { &self, market_id: AccountId, ) -> Result { - let market_configuration = self + let config = self .view::(market_id.clone(), "get_configuration", json!({})) .await?; + let oracle_id = config.price_oracle_configuration.account_id; + + let borrow_asset_price_id = self + .try_resolve_price_identifier( + oracle_id.clone(), + config.price_oracle_configuration.borrow_asset_price_id, + ) + .await?; + let collateral_asset_price_id = self + .try_resolve_price_identifier( + oracle_id.clone(), + config.price_oracle_configuration.collateral_asset_price_id, + ) + .await?; + Ok(MarketData { account_id: market_id.clone(), - oracle_id: market_configuration.price_oracle_configuration.account_id, - borrow_asset: market_configuration.borrow_asset, - collateral_asset: market_configuration.collateral_asset, + oracle_id, + borrow_asset: config.borrow_asset, + borrow_asset_price_id, + collateral_asset: config.collateral_asset, + collateral_asset_price_id, }) } + async fn try_resolve_price_identifier( + &self, + oracle_id: AccountId, + price_identifier: PriceIdentifier, + ) -> Result { + match self + .view::(oracle_id, "get_transformer", json!({})) + .await + { + Ok(transformer) => { + tracing::debug!("Price ID {price_identifier} resolved: LST oracle contract"); + Ok(transformer.price_id) + } + Err(ViewError::Rpc(JsonRpcError::ServerError(JsonRpcServerError::HandlerError( + RpcQueryError::ContractExecutionError { vm_error, .. }, + )))) if vm_error.contains("MethodResolveError(MethodNotFound)") => { + tracing::debug!("Price ID {price_identifier} resolved: not an LST oracle contract"); + Ok(price_identifier) + } + Err(e) => { + tracing::error!("Failed to resolve price ID {price_identifier}: {e:?}"); + Err(e) + } + } + } + /// # Errors /// /// - RPC errors diff --git a/service/relayer/src/client/pyth.rs b/service/relayer/src/client/pyth.rs new file mode 100644 index 00000000..79707466 --- /dev/null +++ b/service/relayer/src/client/pyth.rs @@ -0,0 +1,239 @@ +use std::collections::{HashMap, HashSet}; + +use near_jsonrpc_client::errors::JsonRpcError; +use near_primitives::{ + errors::TxExecutionError, + hash::CryptoHash, + views::{FinalExecutionStatus, TxExecutionStatus}, +}; +use near_sdk::serde::Deserialize; +use templar_common::oracle::pyth::PriceIdentifier; +use tokio::{ + select, + sync::{mpsc, oneshot, watch}, + time::Instant, +}; + +use crate::{app::args, cache::Cache}; + +use super::near::Near; + +#[derive(Debug)] +pub enum PythRequest { + Update { + price_ids: Box<[PriceIdentifier]>, + send: oneshot::Sender, UpdateError>>, + }, +} + +#[tracing::instrument(skip_all, name = "pyth_service")] +async fn start( + mut recv: mpsc::Receiver, + args: args::Pyth, + near: Near, + cache: Cache, + kill: watch::Sender<()>, +) { + let mut pyth = PythClient::new(args, near, cache); + let mut on_kill = kill.subscribe(); + + loop { + select! { + _ = on_kill.changed() => { + tracing::debug!("Received kill notification."); + break; + } + request = recv.recv() => { + let Some(request) = request else { + tracing::debug!("Pyth sender dropped, exiting."); + break; + }; + tracing::debug!("Handling request: {request:?}"); + match request { + PythRequest::Update { price_ids, send } => { + #[allow(clippy::unwrap_used, reason = "Sender should not drop")] + send.send(pyth.update(&price_ids).await).unwrap(); + } + } + } + } + } +} + +#[derive(thiserror::Error, Debug)] +pub enum UpdateError { + #[error(transparent)] + Reqwest(#[from] reqwest::Error), + #[error(transparent)] + JsonRpc(#[from] JsonRpcError), + #[error("Unknown RPC error")] + UnknownRpcError, + #[error("Transaction execution error: {0}")] + TransactionExecution(#[from] TxExecutionError), +} + +#[derive(Debug)] +struct PythClient { + http: reqwest::Client, + last_updated: HashMap, + args: args::Pyth, + near: Near, + cache: Cache, +} + +impl PythClient { + pub fn new(args: args::Pyth, near: Near, cache: Cache) -> Self { + Self { + http: reqwest::Client::new(), + last_updated: HashMap::new(), + args, + near, + cache, + } + } + + pub async fn update( + &mut self, + price_ids: &[PriceIdentifier], + ) -> Result, UpdateError> { + let send_updates_for = IntoIterator::into_iter(price_ids) + .filter(|id| { + self.last_updated + .get(id) + .is_none_or(|i| i.elapsed() > self.args.refresh) + }) + .collect::>(); + + if send_updates_for.is_empty() { + return Ok(None); + } + + let send_updates_for: Vec<_> = send_updates_for.into_iter().copied().collect(); + + tracing::info!(price_ids = ?send_updates_for, "Sending update for Pyth prices"); + + // Start timing from when we request the prices + let now = Instant::now(); + let vaa = self.get_latest_price_updates_vaa(&send_updates_for).await?; + tracing::debug!(vaa = hex::encode(&vaa), "Retrieved VAA"); + let signed_transaction = self + .near + .construct_pyth_update_transaction( + &self.cache, + self.args.oracle_id.clone(), + vaa, + self.args.update_gas, + self.args.update_deposit, + ) + .await; + tracing::debug!(?signed_transaction, "Signed Pyth update transaction."); + + let transaction_hash = signed_transaction.get_hash(); + + let transaction_result = self + .near + .send_transaction(signed_transaction, TxExecutionStatus::Final) + .await?; + tracing::debug!(?transaction_result, "Pyth update transaction sent"); + + if let Some(o) = transaction_result.final_execution_outcome { + match o.into_outcome().status { + FinalExecutionStatus::NotStarted | FinalExecutionStatus::Started => { + // Should never happen because we waited until TxExecutionStatus::Final + tracing::warn!("Unexpected transaction execution status retrieved from RPC"); + Err(UpdateError::UnknownRpcError) + } + FinalExecutionStatus::Failure(error) => { + tracing::error!(?error, "Pyth update transaction failed"); + Err(error.into()) + } + FinalExecutionStatus::SuccessValue(..) => { + tracing::debug!("Pyth update succeeded"); + + self.last_updated + .extend(send_updates_for.into_iter().map(|id| (id, now))); + + Ok(Some(transaction_hash)) + } + } + } else { + tracing::warn!("Unable to retrieve final execution outcome from RPC"); + Err(UpdateError::UnknownRpcError) + } + } + + /// Fetch just the update payload for a set of price IDs. + /// + /// # Errors + /// + /// - [`reqwest::Error`] + /// - Response deserialization. + #[tracing::instrument(skip(self))] + pub async fn get_latest_price_updates_vaa( + &self, + price_ids: &[PriceIdentifier], + ) -> Result, reqwest::Error> { + #[derive(Deserialize)] + #[serde(crate = "near_sdk::serde")] + struct ResponseBody { + binary: Binary, + } + + #[derive(Deserialize)] + #[serde(crate = "near_sdk::serde")] + struct Binary { + data: [Data; 1], + } + + #[derive(Deserialize)] + #[serde(crate = "near_sdk::serde")] + struct Data(#[serde(deserialize_with = "hex::deserialize")] Vec); + + let mut request = self + .http + .get(format!("{}/v2/updates/price/latest", self.args.hermes_url)); + + for id in price_ids { + request = request.query(&[("ids[]", id)]); + } + + let response = request.send().await?.error_for_status()?; + + let body = response.json::().await?; + let [vaa] = body.binary.data; + Ok(vaa.0) + } +} + +#[derive(Debug, Clone)] +pub struct Pyth { + send: mpsc::Sender, +} + +impl Pyth { + pub fn new(args: args::Pyth, near: Near, cache: Cache, kill: watch::Sender<()>) -> Self { + let (send, recv) = mpsc::channel(16); + tokio::spawn(start(recv, args, near, cache, kill)); + + Self { send } + } + + /// # Errors + /// + /// - Network error [`reqwest::Error`] + /// - JSON RPC error + /// - Unexpected/inconsistent RPC behavior + /// - Transaction failure + #[allow(clippy::unwrap_used)] + pub async fn update( + &self, + price_ids: Box<[PriceIdentifier]>, + ) -> Result, UpdateError> { + let (send, recv) = oneshot::channel(); + self.send + .send(PythRequest::Update { price_ids, send }) + .await + .unwrap(); + recv.await.unwrap() + } +} diff --git a/service/relayer/src/error.rs b/service/relayer/src/error.rs index 695fb2fa..22486700 100644 --- a/service/relayer/src/error.rs +++ b/service/relayer/src/error.rs @@ -1,22 +1,29 @@ +use std::fmt::Write; + use near_sdk::AccountId; #[derive(Debug, thiserror::Error)] -pub enum PreconditionError { +pub enum PayloadRejectionReason { #[error("Failed signature verification")] SignatureVerificationFailure, #[error("Unknown transaction receiver account ID {account_id}")] UnknownTransactionReceiverId { account_id: AccountId }, - #[error("Unsupported action at index {index}: {action:?}")] - UnsupportedAction { - index: usize, - action: near_primitives::action::Action, - }, + #[error("Unsupported action at index {index}")] + UnsupportedAction { index: usize }, + #[error("Function call rejection:{}", ._0.iter().fold(String::new(), |mut a, e| { write!(&mut a, "\n\t{e}").unwrap(); a }))] + FunctionCallRejection(Vec), +} + +#[derive(Debug, thiserror::Error)] +pub enum FunctionCallRejectionReason { + #[error("Unknown function name \"{function_name}\" at index {index}")] + UnknownFunctionName { index: usize, function_name: String }, + #[error("Unknown token transfer receiver account ID {account_id} at index {index}")] + UnknownTransferReceiverId { account_id: AccountId, index: usize }, #[error("Argument deserialization failure at index {index}")] ArgumentDeserializationFailure { index: usize }, #[error("Msg deserialization failure at index {index}: {msg}")] MsgDeserializationFailure { index: usize, msg: String }, - #[error("Unknown token transfer receiver account ID {account_id} at index {index}")] - UnknownTransferReceiverId { account_id: AccountId, index: usize }, #[error( "Invalid message for asset at index {index}: expected: {expected}, actual: \"{actual}\"" )] @@ -25,6 +32,19 @@ pub enum PreconditionError { expected: String, actual: String, }, - #[error("Unknown function name at index {index}")] - UnknownFunctionName { index: usize }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compound_rejection_reason() { + let e = PayloadRejectionReason::FunctionCallRejection(vec![ + FunctionCallRejectionReason::ArgumentDeserializationFailure { index: 0 }, + FunctionCallRejectionReason::ArgumentDeserializationFailure { index: 1 }, + ]); + + assert_eq!(e.to_string(), "Function call rejection:\n\tArgument deserialization failure at index 0\n\tArgument deserialization failure at index 1"); + } } diff --git a/service/relayer/src/lib.rs b/service/relayer/src/lib.rs index 2dc51209..3a1550ce 100644 --- a/service/relayer/src/lib.rs +++ b/service/relayer/src/lib.rs @@ -8,7 +8,10 @@ use near_sdk::{ }; use near_sdk_contract_tools::standard::nep145::StorageBalanceBounds; -use templar_common::asset::{AssetClass, BorrowAsset, CollateralAsset, FungibleAsset}; +use templar_common::{ + asset::{AssetClass, BorrowAsset, CollateralAsset, FungibleAsset}, + oracle::pyth::PriceIdentifier, +}; pub mod app; pub mod broom; @@ -34,7 +37,9 @@ pub struct MarketData { pub account_id: AccountId, pub oracle_id: AccountId, pub collateral_asset: FungibleAsset, + pub collateral_asset_price_id: PriceIdentifier, pub borrow_asset: FungibleAsset, + pub borrow_asset_price_id: PriceIdentifier, } pub struct AssetTransfer { diff --git a/service/relayer/src/route/relay/mod.rs b/service/relayer/src/route/relay/mod.rs index 8225529b..fc9eef78 100644 --- a/service/relayer/src/route/relay/mod.rs +++ b/service/relayer/src/route/relay/mod.rs @@ -26,7 +26,10 @@ pub async fn relay( }): Json, ) -> SimpleResponse { tracing::info!("Processing relay request"); - let (gas, contract_data) = match app.check_and_calculate_gas(&signed_delegate_action).await { + let (gas, contract_data) = match app + .sda_check_and_calculate_gas(&signed_delegate_action) + .await + { Ok(x) => { tracing::info!(gas = %x.0, "Gas check passed"); x diff --git a/service/relayer/src/route/universal_account/create.rs b/service/relayer/src/route/universal_account/create.rs index 607cfb0e..1cdc970f 100644 --- a/service/relayer/src/route/universal_account/create.rs +++ b/service/relayer/src/route/universal_account/create.rs @@ -262,13 +262,15 @@ pub async fn create( .await; // NOTE: This only counts gas from function calls, but this is OK, because - // the deploy-from-registy transaction is a function call. - let gas_estimate = signed_transaction - .transaction - .actions() - .iter() - .map(|a| a.get_prepaid_gas()) - .sum(); + // the deploy-from-registry transaction is a function call. + let gas_estimate = near_sdk::Gas::from_gas( + signed_transaction + .transaction + .actions() + .iter() + .map(|a| a.get_prepaid_gas()) + .sum(), + ); let Some(gas_cost_estimate) = app.estimate_cost_of_gas(gas_estimate).await else { return SimpleResponse::Failure { diff --git a/service/relayer/src/route/universal_account/relay.rs b/service/relayer/src/route/universal_account/relay.rs index 167af894..cf785974 100644 --- a/service/relayer/src/route/universal_account/relay.rs +++ b/service/relayer/src/route/universal_account/relay.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::{collections::HashSet, fmt::Write}; use axum::{extract::State, Json}; use near_primitives::{hash::CryptoHash, views::TxExecutionStatus}; @@ -6,6 +6,7 @@ use near_sdk::{ serde::{Deserialize, Serialize}, AccountId, NearToken, }; +use templar_common::oracle::pyth::PriceIdentifier; use templar_universal_account::{ transaction::{Action, Transaction}, ExecuteArgs, @@ -20,6 +21,8 @@ pub struct RelayRequest { pub args: ExecuteArgs>, #[serde(default)] pub storage_deposit: HashSet, + #[serde(default)] + pub update_price_feeds: HashSet, } #[derive(Serialize, Deserialize, Debug, Clone)] @@ -29,20 +32,14 @@ pub struct RelayResponse { } #[allow(clippy::too_many_lines)] -#[tracing::instrument( - name = "relay_universal_account", - skip(app, args), - fields( - account_id = %account_id, - storage_deposit_count = %storage_deposit.len() - ) -)] +#[tracing::instrument(name = "relay_universal_account", skip(app))] pub async fn relay( State(app): State, Json(RelayRequest { account_id, args, storage_deposit, + update_price_feeds, }): Json, ) -> SimpleResponse { tracing::info!("Processing universal account relay"); @@ -87,7 +84,7 @@ pub async fn relay( let accounts = app.accounts.read().await; let mut gas = near_sdk::Gas::from_tgas(app.args.ua.execute_tgas).as_gas(); - let mut eligible_for_storage_deposit = HashSet::with_capacity(payload.len()); + let mut interacted_contract_ids = HashSet::with_capacity(payload.len()); for transaction in payload { let receiver_id = &transaction.receiver_id; if receiver_id == &account_id { @@ -119,12 +116,12 @@ pub async fn relay( tracing::debug!(transaction = ?transaction, "Transaction is reflexive: allowing."); continue; } - if !accounts.allowed_contract_data.contains_key(receiver_id) { + let Some(contract_data) = accounts.allowed_contract_data.get(receiver_id) else { tracing::info!("Unknown receiver {receiver_id}"); return SimpleResponse::Rejected { reason: "Unknown receiver".to_string(), }; - } + }; let calls = match transaction .actions .iter() @@ -137,35 +134,36 @@ pub async fn relay( .collect::, _>>() { Ok(calls) => calls, - Err(e) => { - tracing::info!("Unsupported action type: {e:?}"); + Err(a) => { + tracing::info!("Disallowed action: {a:?}"); return SimpleResponse::Rejected { - reason: "Unsupported action type".to_string(), + reason: "Disallowed action".to_string(), }; } }; let additional_interactions = - match app.actions_are_allowed(receiver_id, &accounts, calls.iter()) { + match app.actions_are_allowed(&accounts, receiver_id, contract_data, calls.iter()) { Ok(a) => a, Err(e) => { - tracing::info!("Disallowed action: {e}"); - return SimpleResponse::Rejected { - reason: "Disallowed action".to_string(), - }; + tracing::info!("Rejecting payload for reason: {e:?}"); + let mut s = e[0].to_string(); + for err in &e[1..] { + let _ = write!(&mut s, "\n{err}"); + } + return SimpleResponse::Rejected { reason: s }; } }; - eligible_for_storage_deposit.insert(receiver_id.to_owned()); - eligible_for_storage_deposit.extend(additional_interactions.into_iter()); + interacted_contract_ids.insert(receiver_id.to_owned()); + interacted_contract_ids.extend(additional_interactions.into_iter()); if let Some(market_data) = accounts.market_data.get(receiver_id) { - eligible_for_storage_deposit.insert(market_data.oracle_id.clone()); - eligible_for_storage_deposit.insert(market_data.borrow_asset.contract_id().to_owned()); - eligible_for_storage_deposit - .insert(market_data.collateral_asset.contract_id().to_owned()); + interacted_contract_ids.insert(market_data.oracle_id.clone()); + interacted_contract_ids.insert(market_data.borrow_asset.contract_id().to_owned()); + interacted_contract_ids.insert(market_data.collateral_asset.contract_id().to_owned()); } gas += calls.iter().map(|f| f.gas).sum::(); } - let storage_deposit = eligible_for_storage_deposit.intersection(&storage_deposit); + let storage_deposit = interacted_contract_ids.intersection(&storage_deposit); // Deposit for storage before sending the user's transaction. for contract_id in storage_deposit { @@ -245,14 +243,38 @@ pub async fn relay( // Resolve synchronously. if let Err(e) = resolve_transaction.await { tracing::error!("Resolve transaction failure: {e}"); + return SimpleResponse::Failure { + error: e.to_string(), + }; } } + // Send any requested price updates + let mut interacted_price_identifiers = HashSet::with_capacity(2); + for contract_id in &interacted_contract_ids { + if let Some(market_data) = accounts.market_data.get(contract_id) { + interacted_price_identifiers.insert(market_data.collateral_asset_price_id); + interacted_price_identifiers.insert(market_data.borrow_asset_price_id); + } + } + + let request_price_updates = interacted_price_identifiers + .intersection(&update_price_feeds) + .copied(); + + if let Err(e) = app.pyth.update(request_price_updates.collect()).await { + tracing::error!(error = ?e, "Failed to update requested Pyth prices"); + return SimpleResponse::Failure { + error: e.to_string(), + }; + } + + // Send the user's transaction let signed_transaction = app .relay_near .construct_ua_execute_transaction(&app.cache, account_id.clone(), args, gas) .await; - let Some(cost_of_gas) = app.estimate_cost_of_gas(gas).await else { + let Some(cost_of_gas) = app.estimate_cost_of_gas(near_sdk::Gas::from_gas(gas)).await else { tracing::error!("Failed to estimate cost of gas"); return SimpleResponse::Failure { error: "Failed to estimate cost of gas".to_string(), diff --git a/service/relayer/tests/relayer.rs b/service/relayer/tests/relayer.rs index fdee5c2d..4f54a6ee 100644 --- a/service/relayer/tests/relayer.rs +++ b/service/relayer/tests/relayer.rs @@ -1,10 +1,10 @@ #![allow(clippy::unwrap_used)] -use std::{collections::HashSet, str::FromStr}; +use std::{collections::HashSet, str::FromStr, time::Duration}; use axum::{extract::State, Json}; use clap::Parser; -use near_jsonrpc_client::methods::tx::TransactionInfo; +use near_jsonrpc_client::{methods::tx::TransactionInfo, JsonRpcClient}; use near_primitives::{ action::{ delegate::{DelegateAction, SignedDelegateAction}, @@ -22,9 +22,11 @@ use p256::elliptic_curve::rand_core::OsRng; use rstest::{fixture, rstest}; use tokio::sync::watch; -use templar_common::registry::DeployMode; +use templar_common::{oracle::pyth::PriceIdentifier, registry::DeployMode}; use templar_relayer::{ - app::{App, Configuration}, + app::{args, App, Configuration}, + cache::Cache, + client::{near::Near, pyth::Pyth}, route::{ relay::RelayRequest as SdaRelayRequest, universal_account::{ @@ -367,6 +369,7 @@ pub async fn universal_account(#[future(awt)] init_test: InitTest) { message: Box::new(message), }, storage_deposit: HashSet::default(), + update_price_feeds: HashSet::default(), }), ) .await; @@ -429,6 +432,7 @@ pub async fn universal_account(#[future(awt)] init_test: InitTest) { message: Box::new(message), }, storage_deposit: HashSet::default(), + update_price_feeds: HashSet::default(), }), ) .await; @@ -450,6 +454,58 @@ pub async fn universal_account(#[future(awt)] init_test: InitTest) { eprintln!("Status: {status:?}"); } +#[rstest] +#[tokio::test] +#[ignore = "Puts tx on testnet. Set ACCOUNT_ID and SECRET_KEY before running."] +pub async fn pyth_updates() { + tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .init(); + + let account_id: AccountId = std::env::var("ACCOUNT_ID").unwrap().parse().unwrap(); + let secret_key: near_crypto::SecretKey = std::env::var("SECRET_KEY").unwrap().parse().unwrap(); + + let pyth_args = args::Pyth { + hermes_url: "https://hermes-beta.pyth.network".to_string(), + refresh: Duration::from_secs(25), + oracle_id: "pyth-oracle.testnet".parse().unwrap(), + update_gas: near_sdk::Gas::from_tgas(300), + update_deposit: NearToken::from_near(1).saturating_div(100), + }; + + let near = Near::new( + JsonRpcClient::connect("https://test.rpc.fastnear.com"), + account_id.clone(), + vec![near_crypto::InMemorySigner::from_secret_key( + account_id, secret_key, + )], + ); + + let cache_args = args::Cache { + gas_price_refresh: Duration::from_secs(600), + nonce_refresh: Duration::from_secs(60), + }; + + let kill = watch::Sender::default(); + + let cache = Cache::new(near.clone(), cache_args, kill.clone()); + + let pyth = Pyth::new(pyth_args.clone(), near.clone(), cache.clone(), kill.clone()); + + let price_id = PriceIdentifier( + hex::decode("f9c0172ba10dfa4d19088d94f5bf61d3b54d5bd7483a322a982e1373ee8ea31b") + .unwrap() + .try_into() + .unwrap(), + ); + + let txid = pyth.update(Box::new([price_id])).await.unwrap(); + + eprintln!("Transaction hash: {txid:?}"); + + kill.send(()).unwrap(); +} + #[rstest] #[tokio::test] pub async fn universal_account_reflexive(#[future(awt)] init_test: InitTest) { @@ -570,6 +626,7 @@ pub async fn universal_account_reflexive(#[future(awt)] init_test: InitTest) { message: Box::new(message), }, storage_deposit: HashSet::default(), + update_price_feeds: HashSet::default(), }), ) .await; @@ -633,6 +690,7 @@ pub async fn universal_account_reflexive(#[future(awt)] init_test: InitTest) { message: Box::new(message), }, storage_deposit: HashSet::default(), + update_price_feeds: HashSet::default(), }), ) .await;