From 6743c54bfc0f4943bcae72783c8e03b778c961bc Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Fri, 16 Oct 2026 11:21:58 +0300 Subject: [PATCH 01/12] feat(scenarios): add the Pyth Pro receiver price feed template --- crates/core/src/scenarios/README.md | 2 +- .../scenarios/protocols/pyth/pro/v2/idl.json | 130 ++++++++++++++++ .../protocols/pyth/pro/v2/overrides.yaml | 70 +++++++++ crates/core/src/scenarios/registry.rs | 50 +++++- crates/core/src/tests/mod.rs | 2 + crates/core/src/tests/pyth/mod.rs | 143 ++++++++++++++++++ crates/mcp/src/surfpool/mod.rs | 4 +- 7 files changed, 392 insertions(+), 9 deletions(-) create mode 100644 crates/core/src/scenarios/protocols/pyth/pro/v2/idl.json create mode 100644 crates/core/src/scenarios/protocols/pyth/pro/v2/overrides.yaml create mode 100644 crates/core/src/tests/pyth/mod.rs diff --git a/crates/core/src/scenarios/README.md b/crates/core/src/scenarios/README.md index 65e2d5683..4579e14d4 100644 --- a/crates/core/src/scenarios/README.md +++ b/crates/core/src/scenarios/README.md @@ -13,7 +13,7 @@ For Surfpool to understand how to serialize and deserialize account data, it nee Protocols that are natively supported by Surfpool will have their IDLs included by default. **Currently supported protocols:** -- **Pyth v2** - Price oracle with 4 price feed templates (SOL/USD, BTC/USD, ETH/BTC, ETH/USD) +- **Pyth v2** - Price oracle with a price feed template for the default receiver (`pyth-price-feed-v2`) and one for the Pyth Pro-compatible receiver (`pyth-pro-price-feed-v2`) - **Jupiter v6** - DEX aggregator with TokenLedger manipulation template - **Switchboard On-Demand** - On-demand oracle with QuoteAccount override template - **Kamino** – Lending (v1.23.0), Scope oracle, Farms, Swap/LIMO, Earn vaults and Liquidity, across six programs. See [protocols/kamino/README.md](./protocols/kamino/README.md) diff --git a/crates/core/src/scenarios/protocols/pyth/pro/v2/idl.json b/crates/core/src/scenarios/protocols/pyth/pro/v2/idl.json new file mode 100644 index 000000000..36978697a --- /dev/null +++ b/crates/core/src/scenarios/protocols/pyth/pro/v2/idl.json @@ -0,0 +1,130 @@ +{ + "address": "rec2HHDDnjLfj4kE7VyEtFA1HPGQLK33259532cRyHp", + "metadata": { + "name": "price_feed", + "version": "0.1.0", + "spec": "0.1.0", + "description": "Created with Anchor" + }, + "instructions": [], + "accounts": [ + { + "name": "PriceUpdateV2", + "discriminator": [34, 241, 35, 99, 157, 126, 244, 205] + } + ], + "types": [ + { + "name": "PriceFeedMessage", + "type": { + "kind": "struct", + "fields": [ + { + "name": "feed_id", + "docs": [ + "`FeedId` but avoid the type alias because of compatibility issues with Anchor's `idl-build` feature." + ], + "type": { + "array": ["u8", 32] + } + }, + { + "name": "price", + "type": "i64" + }, + { + "name": "conf", + "type": "u64" + }, + { + "name": "exponent", + "type": "i32" + }, + { + "name": "publish_time", + "docs": ["The timestamp of this price update in seconds"], + "type": "i64" + }, + { + "name": "prev_publish_time", + "docs": [ + "The timestamp of the previous price update. This field is intended to allow users to", + "identify the single unique price update for any moment in time:", + "for any time t, the unique update is the one such that prev_publish_time < t <= publish_time.", + "", + "Note that there may not be such an update while we are migrating to the new message-sending logic,", + "as some price updates on pythnet may not be sent to other chains (because the message-sending", + "logic may not have triggered). We can solve this problem by making the message-sending mandatory", + "(which we can do once publishers have migrated over).", + "", + "Additionally, this field may be equal to publish_time if the message is sent on a slot where", + "where the aggregation was unsuccesful. This problem will go away once all publishers have", + "migrated over to a recent version of pyth-agent." + ], + "type": "i64" + }, + { + "name": "ema_price", + "type": "i64" + }, + { + "name": "ema_conf", + "type": "u64" + } + ] + } + }, + { + "name": "PriceUpdateV2", + "type": { + "kind": "struct", + "fields": [ + { + "name": "write_authority", + "type": "pubkey" + }, + { + "name": "verification_level", + "type": { + "defined": { + "name": "VerificationLevel" + } + } + }, + { + "name": "price_message", + "type": { + "defined": { + "name": "PriceFeedMessage" + } + } + }, + { + "name": "posted_slot", + "type": "u64" + } + ] + } + }, + { + "name": "VerificationLevel", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Partial", + "fields": [ + { + "name": "num_signatures", + "type": "u8" + } + ] + }, + { + "name": "Full" + } + ] + } + } + ] +} diff --git a/crates/core/src/scenarios/protocols/pyth/pro/v2/overrides.yaml b/crates/core/src/scenarios/protocols/pyth/pro/v2/overrides.yaml new file mode 100644 index 000000000..c0c137781 --- /dev/null +++ b/crates/core/src/scenarios/protocols/pyth/pro/v2/overrides.yaml @@ -0,0 +1,70 @@ +protocol: Pyth +version: v2 +account_type: PriceUpdateV2 +idl_file_path: idl.json + +tags: + - oracle + - price-feed + - defi + +templates: + - id: pyth-pro-price-feed-v2 + name: Override Pyth Pro-compatible Price Account + description: Override a price account of the Pyth Pro-compatible receiver, found by address + idl_account_name: PriceUpdateV2 + properties: + - path: price_message.price + label: Price + description: Price as a scaled integer - multiply the USD value by 10^-exponent + - path: price_message.conf + label: Confidence + description: Uncertainty band around the price, in the same scale as price + - path: price_message.exponent + label: Exponent + description: Decimal exponent, normally -8 and -10 on some feeds. Rescales every price field + - path: price_message.publish_time + label: Publish Time + description: Unix seconds of this update, interpreted against the Surfnet clock + - path: price_message.prev_publish_time + label: Previous Publish Time + description: Unix seconds of the preceding update + - path: price_message.ema_price + label: EMA Price + description: Exponential moving average price, in the same scale as price + - path: price_message.ema_conf + label: EMA Confidence + description: Exponential moving average confidence, in the same scale as conf + - path: posted_slot + label: Posted Slot + description: Slot the update was posted at, for consumers that measure freshness in slots + address: + type: pubkey + llm_context: | + FINDING THE PRICE ACCOUNT: + Pyth runs two receivers on Solana with the same feed ids at different addresses, and a + consumer reads only one. This template writes price accounts owned by the Pyth + Pro-compatible receiver rec2HHDDnjLfj4kE7VyEtFA1HPGQLK33259532cRyHp; for the default + receiver rec5EKMGg6MxZYaMdyBfgwp4d5rB9T1VQH5pJv5LtFJ use pyth-price-feed-v2. + 1. Take the price account from the account that reads it: the consumer's protocol templates + say which field names it. A price account the user gives is used as is. + 2. Read it with getAccountInfo, encoding "jsonParsed". Its owner must be + rec2HHDDnjLfj4kE7VyEtFA1HPGQLK33259532cRyHp; price_message.exponent gives the scale. + 3. Put it in the override's account as {"pubkey":""}. + + Set fetchBeforeUse: true to pull the account from upstream first; false only for a later + override that builds on state an earlier override in the same scenario prepared. + + PRICE: price_message.price = usd_price x 10^-exponent, so with exponent -8, $145.00 is + 14500000000. ema_price, conf and ema_conf use the same scale. Move ema_price with price: + consumers use the EMA for some checks and the spot price for others. + + TIME: leave publish_time, prev_publish_time and posted_slot unset. fetchBeforeUse: true + brings the upstream account's current values when the override plays, so the price is as + fresh as upstream's. A time written into the scenario is fixed when the scenario is created + and ages until it plays. To make a price stale on purpose, follow the TIME FIELDS steps of + pyth-price-feed-v2. + + EXAMPLE - "SOL drops to $20" on an account with exponent -8: + price_message.price: 2000000000 + price_message.ema_price: 2000000000 diff --git a/crates/core/src/scenarios/registry.rs b/crates/core/src/scenarios/registry.rs index edef806d0..036ed0d89 100644 --- a/crates/core/src/scenarios/registry.rs +++ b/crates/core/src/scenarios/registry.rs @@ -7,6 +7,9 @@ use surfpool_types::{OverrideInstance, OverrideTemplate, YamlOverrideTemplateCol pub const PYTH_V2_IDL_CONTENT: &str = include_str!("./protocols/pyth/v2/idl.json"); pub const PYTH_V2_OVERRIDES_CONTENT: &str = include_str!("./protocols/pyth/v2/overrides.yaml"); +pub const PYTH_PRO_V2_IDL_CONTENT: &str = include_str!("./protocols/pyth/pro/v2/idl.json"); +pub const PYTH_PRO_V2_OVERRIDES_CONTENT: &str = + include_str!("./protocols/pyth/pro/v2/overrides.yaml"); pub const JUPITER_V6_IDL_CONTENT: &str = include_str!("./protocols/jupiter/v6/idl.json"); pub const JUPITER_V6_OVERRIDES_CONTENT: &str = @@ -122,6 +125,11 @@ impl TemplateRegistry { pub fn load_pyth_overrides(&mut self) { self.load_protocol_overrides(PYTH_V2_IDL_CONTENT, PYTH_V2_OVERRIDES_CONTENT, "pyth"); + self.load_protocol_overrides( + PYTH_PRO_V2_IDL_CONTENT, + PYTH_PRO_V2_OVERRIDES_CONTENT, + "pyth", + ); } pub fn load_jupiter_overrides(&mut self) { @@ -604,16 +612,17 @@ mod tests { fn test_registry_loads_all_protocols() { let registry = TemplateRegistry::new(); - // Pyth (1) + Jupiter (1) + Raydium CLMM (1) + Raydium AMM v4 (4) + Drift (4) + Meteora (2) + // Pyth (2) + Jupiter (1) + Raydium CLMM (1) + Raydium AMM v4 (4) + Drift (4) + Meteora (2) // + Kamino (Lend 17, Scope 3, Farms 5, Swap 2, Vault 5, Liquidity 4 = 36) - // + Whirlpool (6) + SPL Token (2) + Pump (2) + PumpSwap (3) + Phoenix Eternal (8) = 70 + // + Whirlpool (6) + SPL Token (2) + Pump (2) + PumpSwap (3) + Phoenix Eternal (8) = 71 assert_eq!( registry.count(), - 70, - "Registry should load 70 templates total" + 71, + "Registry should load 71 templates total" ); assert!(registry.contains("pyth-price-feed-v2")); + assert!(registry.contains("pyth-pro-price-feed-v2")); assert!(registry.contains("jupiter-token-ledger-override")); @@ -916,7 +925,7 @@ templates: [] let registry = TemplateRegistry::new(); let pyth_templates = registry.by_protocol("Pyth"); - assert_eq!(pyth_templates.len(), 1, "Should have 1 Pyth template"); + assert_eq!(pyth_templates.len(), 2, "Should have 2 Pyth templates"); let jupiter_templates = registry.by_protocol("Jupiter"); assert_eq!(jupiter_templates.len(), 1, "Should have 1 Jupiter template"); @@ -1025,8 +1034,8 @@ templates: [] let oracle_templates = registry.by_tags(&[vec!["oracle".to_string()]].concat()); assert_eq!( oracle_templates.len(), - 4, - "Should find 4 oracle templates (Pyth + 3 Kamino Scope)" + 5, + "Should find 5 oracle templates (2 Pyth + 3 Kamino Scope)" ); let rewards_templates = registry.by_tags(&[vec!["rewards".to_string()]].concat()); @@ -1074,6 +1083,7 @@ templates: [] assert!(ids.contains(&"raydium-amm-custom".to_string())); assert!(ids.contains(&"jupiter-token-ledger-override".to_string())); assert!(ids.contains(&"pyth-price-feed-v2".to_string())); + assert!(ids.contains(&"pyth-pro-price-feed-v2".to_string())); assert!(ids.contains(&"meteora-dlmm-sol-usdc".to_string())); assert!(ids.contains(&"kamino-reserve-state".to_string())); assert!(ids.contains(&"kamino-reserve-config".to_string())); @@ -1296,6 +1306,32 @@ templates: [] ); } + #[test] + fn test_pyth_pro_price_feed_takes_a_caller_selected_account() { + let registry = TemplateRegistry::new(); + let pro = registry.get("pyth-pro-price-feed-v2").unwrap(); + + assert_eq!( + pro.idl().address, + "rec2HHDDnjLfj4kE7VyEtFA1HPGQLK33259532cRyHp" + ); + assert_eq!( + pro.address, + AccountAddress::Pubkey(String::new()), + "the price account must be chosen by the caller" + ); + assert!( + pro.constants.is_empty(), + "the template must not embed a feed catalog" + ); + assert!( + pro.llm_context.as_deref().is_some_and( + |context| context.contains("rec2HHDDnjLfj4kE7VyEtFA1HPGQLK33259532cRyHp") + ), + "the template must say which receiver owns its accounts" + ); + } + #[test] fn test_get_pda_seed_references() { use surfpool_types::AccountAddress; diff --git a/crates/core/src/tests/mod.rs b/crates/core/src/tests/mod.rs index f43607e64..bc35c2748 100644 --- a/crates/core/src/tests/mod.rs +++ b/crates/core/src/tests/mod.rs @@ -7,4 +7,6 @@ pub mod phoenix; pub mod plugin; #[cfg(feature = "integration-tests")] pub mod pump; +#[cfg(feature = "integration-tests")] +pub mod pyth; pub mod simnet_events; diff --git a/crates/core/src/tests/pyth/mod.rs b/crates/core/src/tests/pyth/mod.rs new file mode 100644 index 000000000..fab851e14 --- /dev/null +++ b/crates/core/src/tests/pyth/mod.rs @@ -0,0 +1,143 @@ +//! Pyth integration tests. +//! +//! These fetch a real price feed from mainnet, so they need a network connection and are compiled +//! only behind a feature: +//! +//! ```text +//! cargo test -p surfpool-core --features integration-tests pyth +//! ``` +//! +//! Set `SURFPOOL_TEST_RPC_URL` to use a private endpoint if the public one rate-limits. + +use std::collections::HashMap; + +use solana_commitment_config::CommitmentConfig; +use solana_pubkey::Pubkey; +use surfpool_types::{AccountAddress, OverrideInstance, Scenario}; + +use crate::{ + scenarios::TemplateRegistry, + surfnet::{ + GetAccountResult, locker::SurfnetSvmLocker, remote::SurfnetRemoteClient, svm::SurfnetSvm, + }, +}; + +const RPC_URL_ENV: &str = "SURFPOOL_TEST_RPC_URL"; +const DEFAULT_RPC_URL: &str = "https://api.mainnet-beta.solana.com"; + +const PYTH_PRO_COMPATIBLE_RECEIVER: &str = "rec2HHDDnjLfj4kE7VyEtFA1HPGQLK33259532cRyHp"; +const PRO_SOL_USD_FEED: &str = "7AviUf9nL62mcxNbQGKm4nKDQnPjswo6c5MX4D57HmyE"; +const SOL_USD_FEED_ID: &str = "0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d"; + +async fn fetch(address: &str) -> solana_account::Account { + let client = SurfnetRemoteClient::new( + std::env::var(RPC_URL_ENV).unwrap_or_else(|_| DEFAULT_RPC_URL.to_string()), + ); + match client + .get_multiple_accounts( + &[Pubkey::from_str_const(address)], + CommitmentConfig::confirmed(), + ) + .await + .unwrap_or_else(|e| panic!("failed to fetch {address} from mainnet: {e}")) + .remove(0) + { + GetAccountResult::FoundAccount(_, account, _) + | GetAccountResult::FoundCoupledAccount((_, account), _, _) => account, + GetAccountResult::None(_) => { + panic!("{address} no longer exists on mainnet; the test needs a new address") + } + } +} + +/// Runs the template through the scenario path on the account the caller names, so the IDL comes +/// from the account's owner. +#[tokio::test(flavor = "multi_thread")] +async fn pro_price_feed_touches_only_price_fields() { + let live = fetch(PRO_SOL_USD_FEED).await; + assert_eq!(live.owner.to_string(), PYTH_PRO_COMPATIBLE_RECEIVER); + // A Partial verification level shifts every offset below by one. + assert_eq!(live.data[40], 1, "verification level should be Full"); + let feed_id = hex::decode(SOL_USD_FEED_ID.trim_start_matches("0x")).unwrap(); + assert_eq!(&live.data[41..73], feed_id.as_slice(), "feed_id at 41"); + + let registry = TemplateRegistry::new(); + let template = registry.get("pyth-pro-price-feed-v2").unwrap(); + + // (path, offset, width, value). Each value differs from the live one, so a skipped write fails. + let rows: [(&str, usize, usize, i64); 8] = [ + ("price_message.price", 73, 8, 9_100_000_000_000), + ("price_message.conf", 81, 8, 500_000_000), + ("price_message.exponent", 89, 4, -10), + ("price_message.publish_time", 93, 8, 1_800_000_000), + ("price_message.prev_publish_time", 101, 8, 1_799_999_940), + ("price_message.ema_price", 109, 8, 9_050_000_000_000), + ("price_message.ema_conf", 117, 8, 450_000_000), + ("posted_slot", 125, 8, 500_000_000), + ]; + for path in template.property_paths() { + assert!( + rows.iter().any(|(row, ..)| *row == path), + "{path} has no row" + ); + } + + let target = Pubkey::from_str_const(PRO_SOL_USD_FEED); + let (svm, _events_rx, _geyser_rx) = SurfnetSvm::default(); + let identity = svm + .get_forged_account_data(&target, &live.data, template.idl(), &HashMap::new()) + .unwrap(); + assert_eq!(identity, live.data, "no-op round trip changed bytes"); + + let values: HashMap = rows + .iter() + .map(|(path, _, _, value)| (path.to_string(), serde_json::json!(value))) + .collect(); + + let locker = SurfnetSvmLocker::new(svm); + locker.with_svm_writer(|svm| svm.set_account(&target, live.clone()).unwrap()); + let mut scenario = Scenario::new( + "Pyth Pro-compatible SOL/USD".to_string(), + "Move every price field of the live feed".to_string(), + ); + scenario.add_override( + OverrideInstance::new( + template.id.clone(), + 0, + AccountAddress::Pubkey(PRO_SOL_USD_FEED.to_string()), + ) + .with_values(values), + ); + locker.register_scenario(scenario, Some(100)).unwrap(); + locker + .materialize_overrides_for_slot(&None, 100) + .await + .unwrap(); + + let forged = locker + .with_svm_reader(|svm| svm.get_account(&target)) + .unwrap() + .expect("the feed should exist"); + assert_eq!(forged.owner, live.owner); + assert_eq!(forged.lamports, live.lamports); + assert_eq!(forged.data.len(), live.data.len()); + + let diffs: Vec = (0..live.data.len()) + .filter(|&i| forged.data[i] != live.data[i]) + .collect(); + let written = |i: &usize| { + rows.iter() + .any(|(_, offset, width, _)| (*offset..offset + width).contains(i)) + }; + assert!( + !diffs.is_empty() && diffs.iter().all(written), + "changed {diffs:?}" + ); + for (path, offset, width, value) in rows { + assert_eq!( + &forged.data[offset..offset + width], + &value.to_le_bytes()[..width], + "{path}" + ); + } +} diff --git a/crates/mcp/src/surfpool/mod.rs b/crates/mcp/src/surfpool/mod.rs index 9e23884ce..8653ca44b 100644 --- a/crates/mcp/src/surfpool/mod.rs +++ b/crates/mcp/src/surfpool/mod.rs @@ -198,7 +198,9 @@ pub struct CallSurfnetRpcParams { A list of all the RPC methods available can be found at str:///rpc_endpoints" )] pub method: String, - #[schemars(description = "The parameters to pass to the RPC method")] + #[schemars( + description = "The parameters to pass to the RPC method. Wrap every value at every depth by its type: a string as {\"String\":\"...\"}, a whole number as {\"Number\":5}, a boolean as {\"Bool\":true}, a list as {\"Array\":[...]} and an object as {\"Object\":{\"key\":}}; object keys stay plain. Example for getProgramAccounts with a filter: [{\"String\":\"\"},{\"Object\":{\"encoding\":{\"String\":\"base64\"},\"filters\":{\"Array\":[{\"Object\":{\"memcmp\":{\"Object\":{\"offset\":{\"Number\":0},\"bytes\":{\"String\":\"\"}}}}}]}}}]" + )] pub params: Vec, } From 69b0a9e0551893b3d32a18d5315543ed2c9989ee Mon Sep 17 00:00:00 2001 From: failfmi Date: Wed, 7 Oct 2026 13:43:14 +0300 Subject: [PATCH 02/12] ci: claude code review --- .github/workflows/claude-review.yml | 141 ++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 .github/workflows/claude-review.yml diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml new file mode 100644 index 000000000..844c44d68 --- /dev/null +++ b/.github/workflows/claude-review.yml @@ -0,0 +1,141 @@ +name: Perform a code review when a pull request is created. +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +jobs: + claude: + # Skip drafts, and skip fork PRs: on `pull_request` they don't get the + # ANTHROPIC_API_KEY secret, so the review would only fail. + if: >- + !github.event.pull_request.draft && + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + structured_output: ${{ steps.run_claude.outputs.structured_output }} + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + # Explicitly check out the PR's merge commit. + ref: refs/pull/${{ github.event.pull_request.number }}/merge + persist-credentials: false + + - name: Pre-fetch base and head refs for the PR + env: + PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + # Pass GitHub expressions through env and quote shell expansions. + git fetch --no-tags origin \ + "$PR_BASE_REF" \ + "+refs/pull/$PR_NUMBER/head" + + # If you want Claude to build and run code, install any dependencies that + # need to be downloaded before the "Run Claude" step, and add the commands + # it may run to --allowedTools below (e.g. "Bash(npm test:*)"). Any tool + # not listed there is denied. + + - name: Run Claude + id: run_claude + uses: anthropics/claude-code-action@86d88e619d8e6caf07b5c3944dd14f441c533718 # v1.0.243 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + # Use this job's read-only token instead of the Claude GitHub App, + # which needs `id-token: write` and would get write access. + github_token: ${{ github.token }} + prompt: | + This is PR #${{ github.event.pull_request.number }} for ${{ github.repository }}. + + Review ONLY the changes introduced by the PR, so consider: + git log --oneline ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} + git diff ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} + + Suggest any improvements, potential bugs, or issues. + Be concise and specific in your feedback. + + Return your review in two parts: + - `summary`: a short Markdown overview of the PR and your overall assessment. + - `comments`: one entry per specific issue, anchored to the line it concerns. + `path` is relative to the repository root. `line` is the line number in + the PR head version of the file (${{ github.event.pull_request.head.sha }}), + and must be a line shown in the diff above. The working tree is the merge + commit, so its line numbers can differ; check them with + `git show ${{ github.event.pull_request.head.sha }}:`. + Put issues that don't belong to a single line in `summary` instead. + + Pull request title and body: + ---- + ${{ github.event.pull_request.title }} + ${{ github.event.pull_request.body }} + claude_args: | + --allowedTools "Read,Grep,Glob,Bash(git log:*),Bash(git diff:*),Bash(git show:*)" + --json-schema '{ + "type": "object", + "properties": { + "summary": { + "type": "string", + "description": "Overall review as Markdown, posted as the review body." + }, + "comments": { + "type": "array", + "description": "Inline comments, each anchored to one line of the PR diff.", + "items": { + "type": "object", + "properties": { + "path": { "type": "string", "description": "File path relative to the repository root." }, + "line": { "type": "integer", "minimum": 1, "description": "Line number in the PR head version of the file. Must be within the diff." }, + "body": { "type": "string", "description": "The comment, as Markdown." } + }, + "required": ["path", "line", "body"] + } + } + }, + "required": ["summary", "comments"] + }' + + post_feedback: + runs-on: ubuntu-latest + needs: claude + if: needs.claude.outputs.structured_output != '' + permissions: + issues: write + pull-requests: write + steps: + - name: Report Claude feedback + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + env: + CLAUDE_STRUCTURED_OUTPUT: ${{ needs.claude.outputs.structured_output }} + with: + github-token: ${{ github.token }} + script: | + const { summary = '', comments = [] } = JSON.parse(process.env.CLAUDE_STRUCTURED_OUTPUT); + const pr = context.payload.pull_request; + if (!summary.trim() && comments.length === 0) { + core.info('Claude returned an empty review; nothing to post.'); + return; + } + + try { + await github.rest.pulls.createReview({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + commit_id: pr.head.sha, + event: 'COMMENT', + body: summary.trim() || 'See inline comments.', + comments: comments.map(({ path, line, body }) => ({ path, line, side: 'RIGHT', body })), + }); + } catch (error) { + // GitHub rejects the whole review if any comment points outside the + // diff. Fall back to a single PR comment so no feedback is lost. + core.warning(`Could not post inline review (${error.message}); posting a plain comment instead.`); + const details = comments.map(({ path, line, body }) => `**\`${path}:${line}\`**\n\n${body}`); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: [summary.trim(), ...details].filter(Boolean).join('\n\n---\n\n'), + }); + } From 3dfacb1866c37bb5a8b63f58b9f2a212eb476e02 Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Mon, 19 Oct 2026 17:18:46 +0300 Subject: [PATCH 03/12] fix(scenarios): name the Pyth Pro collection in its load errors --- crates/core/src/scenarios/registry.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/core/src/scenarios/registry.rs b/crates/core/src/scenarios/registry.rs index 036ed0d89..6a1415c78 100644 --- a/crates/core/src/scenarios/registry.rs +++ b/crates/core/src/scenarios/registry.rs @@ -128,7 +128,7 @@ impl TemplateRegistry { self.load_protocol_overrides( PYTH_PRO_V2_IDL_CONTENT, PYTH_PRO_V2_OVERRIDES_CONTENT, - "pyth", + "pyth-pro", ); } From 5891b4e4821cf49556b11cde3ec258918c5286b2 Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Thu, 29 Oct 2026 09:24:39 +0200 Subject: [PATCH 04/12] refactor(scenarios): name the receiver template Pyth Core Compatible and bundle its full published IDL --- crates/core/src/scenarios/README.md | 2 +- .../pyth/core-compatible/v2/README.md | 25 + .../pyth/core-compatible/v2/idl.json | 1002 +++++++++++++++++ .../v2/overrides.yaml | 10 +- .../scenarios/protocols/pyth/pro/v2/idl.json | 130 --- crates/core/src/scenarios/registry.rs | 29 +- crates/core/src/tests/pyth/mod.rs | 18 +- 7 files changed, 1057 insertions(+), 159 deletions(-) create mode 100644 crates/core/src/scenarios/protocols/pyth/core-compatible/v2/README.md create mode 100644 crates/core/src/scenarios/protocols/pyth/core-compatible/v2/idl.json rename crates/core/src/scenarios/protocols/pyth/{pro => core-compatible}/v2/overrides.yaml (91%) delete mode 100644 crates/core/src/scenarios/protocols/pyth/pro/v2/idl.json diff --git a/crates/core/src/scenarios/README.md b/crates/core/src/scenarios/README.md index 4579e14d4..8bec29c43 100644 --- a/crates/core/src/scenarios/README.md +++ b/crates/core/src/scenarios/README.md @@ -13,7 +13,7 @@ For Surfpool to understand how to serialize and deserialize account data, it nee Protocols that are natively supported by Surfpool will have their IDLs included by default. **Currently supported protocols:** -- **Pyth v2** - Price oracle with a price feed template for the default receiver (`pyth-price-feed-v2`) and one for the Pyth Pro-compatible receiver (`pyth-pro-price-feed-v2`) +- **Pyth v2** - Price oracle with a price feed template for the default receiver (`pyth-price-feed-v2`) and one for the Pyth Core Compatible receiver (`pyth-core-compatible-price-feed-v2`) - **Jupiter v6** - DEX aggregator with TokenLedger manipulation template - **Switchboard On-Demand** - On-demand oracle with QuoteAccount override template - **Kamino** – Lending (v1.23.0), Scope oracle, Farms, Swap/LIMO, Earn vaults and Liquidity, across six programs. See [protocols/kamino/README.md](./protocols/kamino/README.md) diff --git a/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/README.md b/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/README.md new file mode 100644 index 000000000..7d4b203b9 --- /dev/null +++ b/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/README.md @@ -0,0 +1,25 @@ +# Pyth Core Compatible + +Pyth runs two price receivers on Solana, both deployments of the `pyth_solana_receiver` program, as +listed on [Pyth's Solana contract addresses](https://docs.pyth.network/price-feeds/contract-addresses/solana): + +| Receiver | Program | Template | +|---|---|---| +| Pyth Core | `rec5EKMGg6MxZYaMdyBfgwp4d5rB9T1VQH5pJv5LtFJ` | `pyth-price-feed-v2` | +| Pyth Core Compatible | `rec2HHDDnjLfj4kE7VyEtFA1HPGQLK33259532cRyHp` | `pyth-core-compatible-price-feed-v2` | + +They hold separate price accounts for the same feeds, and a consumer reads the accounts of one of +them. Take the price account from the consumer's own account that names it. + +# IDL + +`idl.json` is the IDL the program publishes in its Anchor IDL account +`raeUSn8gguFWVFs4iw3YathYM3mdb95W5Bfiu3tqE7F` (`pyth_solana_receiver` 0.2.1, legacy format, sha256 +`9ec4718f66a71fc524ecdd533770ca5a8a3984f8e1fb603f01f5e31ff0ab0caa`), converted with +`anchor idl convert --program-id rec2HHDDnjLfj4kE7VyEtFA1HPGQLK33259532cRyHp` (anchor-cli 0.31.1, +output sha256 `29ab79ef705a3429b3ced2b1222e1b5efdcd4b40fed547177d31c16ebd8473fc`). + +One change is made before the conversion: the account names `priceUpdateV2` and `twapUpdate` are +written `PriceUpdateV2` and `TwapUpdate`, as the program's Rust types are named. Anchor derives an +account's discriminator from its name, and only the capitalized names give the discriminator the +live price accounts carry. diff --git a/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/idl.json b/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/idl.json new file mode 100644 index 000000000..c9b8013ae --- /dev/null +++ b/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/idl.json @@ -0,0 +1,1002 @@ +{ + "address": "rec2HHDDnjLfj4kE7VyEtFA1HPGQLK33259532cRyHp", + "metadata": { + "name": "pyth_solana_receiver", + "version": "0.2.1", + "spec": "0.1.0" + }, + "instructions": [ + { + "name": "initialize", + "discriminator": [ + 175, + 175, + 109, + 31, + 13, + 152, + 155, + 237 + ], + "accounts": [ + { + "name": "payer", + "writable": true, + "signer": true + }, + { + "name": "config", + "writable": true + }, + { + "name": "system_program" + } + ], + "args": [ + { + "name": "initial_config", + "type": { + "defined": { + "name": "Config" + } + } + } + ] + }, + { + "name": "request_governance_authority_transfer", + "discriminator": [ + 92, + 18, + 67, + 156, + 27, + 151, + 183, + 224 + ], + "accounts": [ + { + "name": "payer", + "signer": true + }, + { + "name": "config", + "writable": true + } + ], + "args": [ + { + "name": "target_governance_authority", + "type": "pubkey" + } + ] + }, + { + "name": "cancel_governance_authority_transfer", + "discriminator": [ + 39, + 93, + 70, + 137, + 137, + 90, + 248, + 154 + ], + "accounts": [ + { + "name": "payer", + "signer": true + }, + { + "name": "config", + "writable": true + } + ], + "args": [] + }, + { + "name": "accept_governance_authority_transfer", + "discriminator": [ + 254, + 39, + 222, + 79, + 64, + 217, + 205, + 127 + ], + "accounts": [ + { + "name": "payer", + "signer": true + }, + { + "name": "config", + "writable": true + } + ], + "args": [] + }, + { + "name": "set_data_sources", + "discriminator": [ + 107, + 73, + 15, + 119, + 195, + 116, + 91, + 210 + ], + "accounts": [ + { + "name": "payer", + "signer": true + }, + { + "name": "config", + "writable": true + } + ], + "args": [ + { + "name": "valid_data_sources", + "type": { + "vec": { + "defined": { + "name": "DataSource" + } + } + } + } + ] + }, + { + "name": "set_fee", + "discriminator": [ + 18, + 154, + 24, + 18, + 237, + 214, + 19, + 80 + ], + "accounts": [ + { + "name": "payer", + "signer": true + }, + { + "name": "config", + "writable": true + } + ], + "args": [ + { + "name": "single_update_fee_in_lamports", + "type": "u64" + } + ] + }, + { + "name": "set_wormhole_address", + "discriminator": [ + 154, + 174, + 252, + 157, + 91, + 215, + 179, + 156 + ], + "accounts": [ + { + "name": "payer", + "signer": true + }, + { + "name": "config", + "writable": true + } + ], + "args": [ + { + "name": "wormhole", + "type": "pubkey" + } + ] + }, + { + "name": "set_minimum_signatures", + "discriminator": [ + 5, + 210, + 206, + 124, + 43, + 68, + 104, + 149 + ], + "accounts": [ + { + "name": "payer", + "signer": true + }, + { + "name": "config", + "writable": true + } + ], + "args": [ + { + "name": "minimum_signatures", + "type": "u8" + } + ] + }, + { + "name": "post_update_atomic", + "docs": [ + "Post a price update using a VAA and a MerklePriceUpdate.", + "This function allows you to post a price update in a single transaction.", + "Compared to `post_update`, it only checks whatever signatures are present in the provided VAA and doesn't fail if the number of signatures is lower than the Wormhole quorum of two thirds of the guardians.", + "The number of signatures that were in the VAA is stored in the `VerificationLevel` of the `PriceUpdateV2` account.", + "", + "We recommend using `post_update_atomic` with 5 signatures. This is close to the maximum signatures you can verify in one transaction without exceeding the transaction size limit.", + "", + "# Warning", + "", + "Using partially verified price updates is dangerous, as it lowers the threshold of guardians that need to collude to produce a malicious price update." + ], + "discriminator": [ + 49, + 172, + 84, + 192, + 175, + 180, + 52, + 234 + ], + "accounts": [ + { + "name": "payer", + "writable": true, + "signer": true + }, + { + "name": "guardian_set", + "docs": [ + "Instead we do the same steps in deserialize_guardian_set_checked." + ] + }, + { + "name": "config" + }, + { + "name": "treasury", + "writable": true + }, + { + "name": "price_update_account", + "docs": [ + "The constraint is such that either the price_update_account is uninitialized or the write_authority is the write_authority.", + "Pubkey::default() is the SystemProgram on Solana and it can't sign so it's impossible that price_update_account.write_authority == Pubkey::default() once the account is initialized" + ], + "writable": true, + "signer": true + }, + { + "name": "system_program" + }, + { + "name": "write_authority", + "signer": true + } + ], + "args": [ + { + "name": "params", + "type": { + "defined": { + "name": "PostUpdateAtomicParams" + } + } + } + ] + }, + { + "name": "post_update", + "docs": [ + "Post a price update using an encoded_vaa account and a MerklePriceUpdate calldata.", + "This should be called after the client has already verified the Vaa via the Wormhole contract.", + "Check out target_chains/solana/cli/src/main.rs for an example of how to do this." + ], + "discriminator": [ + 133, + 95, + 207, + 175, + 11, + 79, + 118, + 44 + ], + "accounts": [ + { + "name": "payer", + "writable": true, + "signer": true + }, + { + "name": "encoded_vaa" + }, + { + "name": "config" + }, + { + "name": "treasury", + "writable": true + }, + { + "name": "price_update_account", + "docs": [ + "The constraint is such that either the price_update_account is uninitialized or the write_authority is the write_authority.", + "Pubkey::default() is the SystemProgram on Solana and it can't sign so it's impossible that price_update_account.write_authority == Pubkey::default() once the account is initialized" + ], + "writable": true, + "signer": true + }, + { + "name": "system_program" + }, + { + "name": "write_authority", + "signer": true + } + ], + "args": [ + { + "name": "params", + "type": { + "defined": { + "name": "PostUpdateParams" + } + } + } + ] + }, + { + "name": "post_twap_update", + "docs": [ + "Post a TWAP (time weighted average price) update for a given time window.", + "This should be called after the client has already verified the VAAs via the Wormhole contract.", + "Check out target_chains/solana/cli/src/main.rs for an example of how to do this." + ], + "discriminator": [ + 232, + 176, + 212, + 105, + 69, + 121, + 18, + 30 + ], + "accounts": [ + { + "name": "payer", + "writable": true, + "signer": true + }, + { + "name": "start_encoded_vaa" + }, + { + "name": "end_encoded_vaa" + }, + { + "name": "config" + }, + { + "name": "treasury", + "writable": true + }, + { + "name": "twap_update_account", + "docs": [ + "The constraint is such that either the price_update_account is uninitialized or the write_authority is the write_authority.", + "Pubkey::default() is the SystemProgram on Solana and it can't sign so it's impossible that price_update_account.write_authority == Pubkey::default() once the account is initialized" + ], + "writable": true, + "signer": true + }, + { + "name": "system_program" + }, + { + "name": "write_authority", + "signer": true + } + ], + "args": [ + { + "name": "params", + "type": { + "defined": { + "name": "PostTwapUpdateParams" + } + } + } + ] + }, + { + "name": "reclaim_rent", + "discriminator": [ + 218, + 200, + 19, + 197, + 227, + 89, + 192, + 22 + ], + "accounts": [ + { + "name": "payer", + "writable": true, + "signer": true + }, + { + "name": "price_update_account", + "writable": true + } + ], + "args": [] + }, + { + "name": "reclaim_twap_rent", + "discriminator": [ + 84, + 3, + 32, + 238, + 108, + 217, + 135, + 39 + ], + "accounts": [ + { + "name": "payer", + "writable": true, + "signer": true + }, + { + "name": "twap_update_account", + "writable": true + } + ], + "args": [] + } + ], + "accounts": [ + { + "name": "Config", + "discriminator": [ + 155, + 12, + 170, + 224, + 30, + 250, + 204, + 130 + ] + }, + { + "name": "PriceUpdateV2", + "discriminator": [ + 34, + 241, + 35, + 99, + 157, + 126, + 244, + 205 + ] + }, + { + "name": "TwapUpdate", + "discriminator": [ + 104, + 192, + 188, + 72, + 246, + 166, + 12, + 81 + ] + } + ], + "errors": [ + { + "code": 6000, + "name": "InvalidWormholeMessage", + "msg": "Received an invalid wormhole message" + }, + { + "code": 6001, + "name": "DeserializeMessageFailed", + "msg": "An error occurred when deserializing the message" + }, + { + "code": 6002, + "name": "InvalidPriceUpdate", + "msg": "Received an invalid price update" + }, + { + "code": 6003, + "name": "UnsupportedMessageType", + "msg": "This type of message is not supported currently" + }, + { + "code": 6004, + "name": "InvalidDataSource", + "msg": "The tuple emitter chain, emitter doesn't match one of the valid data sources." + }, + { + "code": 6005, + "name": "InsufficientFunds", + "msg": "Funds are insufficient to pay the receiving fee" + }, + { + "code": 6006, + "name": "FeedIdMismatch", + "msg": "Cannot calculate TWAP, end slot must be greater than start slot" + }, + { + "code": 6007, + "name": "ExponentMismatch", + "msg": "The start and end messages must have the same feed ID" + }, + { + "code": 6008, + "name": "InvalidTwapSlots", + "msg": "The start and end messages must have the same exponent" + }, + { + "code": 6009, + "name": "InvalidTwapStartMessage", + "msg": "Start message is not the first update for its timestamp" + }, + { + "code": 6010, + "name": "InvalidTwapEndMessage", + "msg": "End message is not the first update for its timestamp" + }, + { + "code": 6011, + "name": "TwapCalculationOverflow", + "msg": "Overflow in TWAP calculation" + }, + { + "code": 6012, + "name": "WrongWriteAuthority", + "msg": "This signer can't write to price update account" + }, + { + "code": 6013, + "name": "WrongVaaOwner", + "msg": "The posted VAA account has the wrong owner." + }, + { + "code": 6014, + "name": "DeserializeVaaFailed", + "msg": "An error occurred when deserializing the VAA." + }, + { + "code": 6015, + "name": "InsufficientGuardianSignatures", + "msg": "The number of guardian signatures is below the minimum" + }, + { + "code": 6016, + "name": "InvalidVaaVersion", + "msg": "Invalid VAA version" + }, + { + "code": 6017, + "name": "GuardianSetMismatch", + "msg": "Guardian set version in the VAA doesn't match the guardian set passed" + }, + { + "code": 6018, + "name": "InvalidGuardianOrder", + "msg": "Guardian signature indices must be increasing" + }, + { + "code": 6019, + "name": "InvalidGuardianIndex", + "msg": "Guardian index exceeds the number of guardians in the set" + }, + { + "code": 6020, + "name": "InvalidSignature", + "msg": "A VAA signature is invalid" + }, + { + "code": 6021, + "name": "InvalidGuardianKeyRecovery", + "msg": "The recovered guardian public key doesn't match the guardian set" + }, + { + "code": 6022, + "name": "WrongGuardianSetOwner", + "msg": "The guardian set account is owned by the wrong program" + }, + { + "code": 6023, + "name": "InvalidGuardianSetPda", + "msg": "The Guardian Set account doesn't match the PDA derivation" + }, + { + "code": 6024, + "name": "GuardianSetExpired", + "msg": "The Guardian Set is expired" + }, + { + "code": 6025, + "name": "GovernanceAuthorityMismatch", + "msg": "The signer is not authorized to perform this governance action" + }, + { + "code": 6026, + "name": "TargetGovernanceAuthorityMismatch", + "msg": "The signer is not authorized to accept the governance authority" + }, + { + "code": 6027, + "name": "NonexistentGovernanceAuthorityTransferRequest", + "msg": "The governance authority needs to request a transfer first" + }, + { + "code": 6028, + "name": "ZeroMinimumSignatures", + "msg": "The minimum number of signatures should be at least 1" + } + ], + "types": [ + { + "name": "PriceFeedMessage", + "type": { + "kind": "struct", + "fields": [ + { + "name": "feed_id", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "price", + "type": "i64" + }, + { + "name": "conf", + "type": "u64" + }, + { + "name": "exponent", + "type": "i32" + }, + { + "name": "publish_time", + "type": "i64" + }, + { + "name": "prev_publish_time", + "type": "i64" + }, + { + "name": "ema_price", + "type": "i64" + }, + { + "name": "ema_conf", + "type": "u64" + } + ] + } + }, + { + "name": "TwapPrice", + "docs": [ + "The time weighted average price & conf for a feed over the window [start_time, end_time].", + "This type is used to persist the calculated TWAP in TwapUpdate accounts on Solana." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "feed_id", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "start_time", + "type": "i64" + }, + { + "name": "end_time", + "type": "i64" + }, + { + "name": "price", + "type": "i64" + }, + { + "name": "conf", + "type": "u64" + }, + { + "name": "exponent", + "type": "i32" + }, + { + "name": "down_slots_ratio", + "docs": [ + "Ratio out of 1_000_000, where a value of 1_000_000 represents", + "all slots were missed and 0 represents no slots were missed." + ], + "type": "u32" + } + ] + } + }, + { + "name": "MerklePriceUpdate", + "type": { + "kind": "struct", + "fields": [ + { + "name": "message", + "type": "bytes" + }, + { + "name": "proof", + "type": { + "vec": { + "array": [ + "u8", + 20 + ] + } + } + } + ] + } + }, + { + "name": "DataSource", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chain", + "type": "u16" + }, + { + "name": "emitter", + "type": "pubkey" + } + ] + } + }, + { + "name": "PostUpdateAtomicParams", + "type": { + "kind": "struct", + "fields": [ + { + "name": "vaa", + "type": "bytes" + }, + { + "name": "merkle_price_update", + "type": { + "defined": { + "name": "MerklePriceUpdate" + } + } + }, + { + "name": "treasury_id", + "type": "u8" + } + ] + } + }, + { + "name": "PostUpdateParams", + "type": { + "kind": "struct", + "fields": [ + { + "name": "merkle_price_update", + "type": { + "defined": { + "name": "MerklePriceUpdate" + } + } + }, + { + "name": "treasury_id", + "type": "u8" + } + ] + } + }, + { + "name": "PostTwapUpdateParams", + "type": { + "kind": "struct", + "fields": [ + { + "name": "start_merkle_price_update", + "type": { + "defined": { + "name": "MerklePriceUpdate" + } + } + }, + { + "name": "end_merkle_price_update", + "type": { + "defined": { + "name": "MerklePriceUpdate" + } + } + }, + { + "name": "treasury_id", + "type": "u8" + } + ] + } + }, + { + "name": "VerificationLevel", + "docs": [ + "* This enum represents how many guardian signatures were checked for a Pythnet price update\n * If full, guardian quorum has been attained\n * If partial, at least config.minimum signatures have been verified, but in the case config.minimum_signatures changes in the future we also include the number of signatures that were checked" + ], + "type": { + "kind": "enum", + "variants": [ + { + "name": "Partial", + "fields": [ + { + "name": "num_signatures", + "type": "u8" + } + ] + }, + { + "name": "Full" + } + ] + } + }, + { + "name": "Config", + "type": { + "kind": "struct", + "fields": [ + { + "name": "governance_authority", + "type": "pubkey" + }, + { + "name": "target_governance_authority", + "type": { + "option": "pubkey" + } + }, + { + "name": "wormhole", + "type": "pubkey" + }, + { + "name": "valid_data_sources", + "type": { + "vec": { + "defined": { + "name": "DataSource" + } + } + } + }, + { + "name": "single_update_fee_in_lamports", + "type": "u64" + }, + { + "name": "minimum_signatures", + "type": "u8" + } + ] + } + }, + { + "name": "PriceUpdateV2", + "type": { + "kind": "struct", + "fields": [ + { + "name": "write_authority", + "type": "pubkey" + }, + { + "name": "verification_level", + "type": { + "defined": { + "name": "VerificationLevel" + } + } + }, + { + "name": "price_message", + "type": { + "defined": { + "name": "PriceFeedMessage" + } + } + }, + { + "name": "posted_slot", + "type": "u64" + } + ] + } + }, + { + "name": "TwapUpdate", + "type": { + "kind": "struct", + "fields": [ + { + "name": "write_authority", + "type": "pubkey" + }, + { + "name": "twap", + "type": { + "defined": { + "name": "TwapPrice" + } + } + } + ] + } + } + ] +} \ No newline at end of file diff --git a/crates/core/src/scenarios/protocols/pyth/pro/v2/overrides.yaml b/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/overrides.yaml similarity index 91% rename from crates/core/src/scenarios/protocols/pyth/pro/v2/overrides.yaml rename to crates/core/src/scenarios/protocols/pyth/core-compatible/v2/overrides.yaml index c0c137781..f784499f7 100644 --- a/crates/core/src/scenarios/protocols/pyth/pro/v2/overrides.yaml +++ b/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/overrides.yaml @@ -9,9 +9,9 @@ tags: - defi templates: - - id: pyth-pro-price-feed-v2 - name: Override Pyth Pro-compatible Price Account - description: Override a price account of the Pyth Pro-compatible receiver, found by address + - id: pyth-core-compatible-price-feed-v2 + name: Override Pyth Core Compatible Price Account + description: Override a price account of the Pyth Core Compatible receiver, found by address idl_account_name: PriceUpdateV2 properties: - path: price_message.price @@ -43,8 +43,8 @@ templates: llm_context: | FINDING THE PRICE ACCOUNT: Pyth runs two receivers on Solana with the same feed ids at different addresses, and a - consumer reads only one. This template writes price accounts owned by the Pyth - Pro-compatible receiver rec2HHDDnjLfj4kE7VyEtFA1HPGQLK33259532cRyHp; for the default + consumer reads only one. This template writes price accounts owned by the Pyth Core + Compatible receiver rec2HHDDnjLfj4kE7VyEtFA1HPGQLK33259532cRyHp; for the Pyth Core receiver rec5EKMGg6MxZYaMdyBfgwp4d5rB9T1VQH5pJv5LtFJ use pyth-price-feed-v2. 1. Take the price account from the account that reads it: the consumer's protocol templates say which field names it. A price account the user gives is used as is. diff --git a/crates/core/src/scenarios/protocols/pyth/pro/v2/idl.json b/crates/core/src/scenarios/protocols/pyth/pro/v2/idl.json deleted file mode 100644 index 36978697a..000000000 --- a/crates/core/src/scenarios/protocols/pyth/pro/v2/idl.json +++ /dev/null @@ -1,130 +0,0 @@ -{ - "address": "rec2HHDDnjLfj4kE7VyEtFA1HPGQLK33259532cRyHp", - "metadata": { - "name": "price_feed", - "version": "0.1.0", - "spec": "0.1.0", - "description": "Created with Anchor" - }, - "instructions": [], - "accounts": [ - { - "name": "PriceUpdateV2", - "discriminator": [34, 241, 35, 99, 157, 126, 244, 205] - } - ], - "types": [ - { - "name": "PriceFeedMessage", - "type": { - "kind": "struct", - "fields": [ - { - "name": "feed_id", - "docs": [ - "`FeedId` but avoid the type alias because of compatibility issues with Anchor's `idl-build` feature." - ], - "type": { - "array": ["u8", 32] - } - }, - { - "name": "price", - "type": "i64" - }, - { - "name": "conf", - "type": "u64" - }, - { - "name": "exponent", - "type": "i32" - }, - { - "name": "publish_time", - "docs": ["The timestamp of this price update in seconds"], - "type": "i64" - }, - { - "name": "prev_publish_time", - "docs": [ - "The timestamp of the previous price update. This field is intended to allow users to", - "identify the single unique price update for any moment in time:", - "for any time t, the unique update is the one such that prev_publish_time < t <= publish_time.", - "", - "Note that there may not be such an update while we are migrating to the new message-sending logic,", - "as some price updates on pythnet may not be sent to other chains (because the message-sending", - "logic may not have triggered). We can solve this problem by making the message-sending mandatory", - "(which we can do once publishers have migrated over).", - "", - "Additionally, this field may be equal to publish_time if the message is sent on a slot where", - "where the aggregation was unsuccesful. This problem will go away once all publishers have", - "migrated over to a recent version of pyth-agent." - ], - "type": "i64" - }, - { - "name": "ema_price", - "type": "i64" - }, - { - "name": "ema_conf", - "type": "u64" - } - ] - } - }, - { - "name": "PriceUpdateV2", - "type": { - "kind": "struct", - "fields": [ - { - "name": "write_authority", - "type": "pubkey" - }, - { - "name": "verification_level", - "type": { - "defined": { - "name": "VerificationLevel" - } - } - }, - { - "name": "price_message", - "type": { - "defined": { - "name": "PriceFeedMessage" - } - } - }, - { - "name": "posted_slot", - "type": "u64" - } - ] - } - }, - { - "name": "VerificationLevel", - "type": { - "kind": "enum", - "variants": [ - { - "name": "Partial", - "fields": [ - { - "name": "num_signatures", - "type": "u8" - } - ] - }, - { - "name": "Full" - } - ] - } - } - ] -} diff --git a/crates/core/src/scenarios/registry.rs b/crates/core/src/scenarios/registry.rs index 6a1415c78..04162faf9 100644 --- a/crates/core/src/scenarios/registry.rs +++ b/crates/core/src/scenarios/registry.rs @@ -7,9 +7,10 @@ use surfpool_types::{OverrideInstance, OverrideTemplate, YamlOverrideTemplateCol pub const PYTH_V2_IDL_CONTENT: &str = include_str!("./protocols/pyth/v2/idl.json"); pub const PYTH_V2_OVERRIDES_CONTENT: &str = include_str!("./protocols/pyth/v2/overrides.yaml"); -pub const PYTH_PRO_V2_IDL_CONTENT: &str = include_str!("./protocols/pyth/pro/v2/idl.json"); -pub const PYTH_PRO_V2_OVERRIDES_CONTENT: &str = - include_str!("./protocols/pyth/pro/v2/overrides.yaml"); +pub const PYTH_CORE_COMPATIBLE_V2_IDL_CONTENT: &str = + include_str!("./protocols/pyth/core-compatible/v2/idl.json"); +pub const PYTH_CORE_COMPATIBLE_V2_OVERRIDES_CONTENT: &str = + include_str!("./protocols/pyth/core-compatible/v2/overrides.yaml"); pub const JUPITER_V6_IDL_CONTENT: &str = include_str!("./protocols/jupiter/v6/idl.json"); pub const JUPITER_V6_OVERRIDES_CONTENT: &str = @@ -126,9 +127,9 @@ impl TemplateRegistry { pub fn load_pyth_overrides(&mut self) { self.load_protocol_overrides(PYTH_V2_IDL_CONTENT, PYTH_V2_OVERRIDES_CONTENT, "pyth"); self.load_protocol_overrides( - PYTH_PRO_V2_IDL_CONTENT, - PYTH_PRO_V2_OVERRIDES_CONTENT, - "pyth-pro", + PYTH_CORE_COMPATIBLE_V2_IDL_CONTENT, + PYTH_CORE_COMPATIBLE_V2_OVERRIDES_CONTENT, + "pyth-core-compatible", ); } @@ -622,7 +623,7 @@ mod tests { ); assert!(registry.contains("pyth-price-feed-v2")); - assert!(registry.contains("pyth-pro-price-feed-v2")); + assert!(registry.contains("pyth-core-compatible-price-feed-v2")); assert!(registry.contains("jupiter-token-ledger-override")); @@ -1083,7 +1084,7 @@ templates: [] assert!(ids.contains(&"raydium-amm-custom".to_string())); assert!(ids.contains(&"jupiter-token-ledger-override".to_string())); assert!(ids.contains(&"pyth-price-feed-v2".to_string())); - assert!(ids.contains(&"pyth-pro-price-feed-v2".to_string())); + assert!(ids.contains(&"pyth-core-compatible-price-feed-v2".to_string())); assert!(ids.contains(&"meteora-dlmm-sol-usdc".to_string())); assert!(ids.contains(&"kamino-reserve-state".to_string())); assert!(ids.contains(&"kamino-reserve-config".to_string())); @@ -1307,25 +1308,25 @@ templates: [] } #[test] - fn test_pyth_pro_price_feed_takes_a_caller_selected_account() { + fn test_pyth_core_compatible_price_feed_takes_a_caller_selected_account() { let registry = TemplateRegistry::new(); - let pro = registry.get("pyth-pro-price-feed-v2").unwrap(); + let template = registry.get("pyth-core-compatible-price-feed-v2").unwrap(); assert_eq!( - pro.idl().address, + template.idl().address, "rec2HHDDnjLfj4kE7VyEtFA1HPGQLK33259532cRyHp" ); assert_eq!( - pro.address, + template.address, AccountAddress::Pubkey(String::new()), "the price account must be chosen by the caller" ); assert!( - pro.constants.is_empty(), + template.constants.is_empty(), "the template must not embed a feed catalog" ); assert!( - pro.llm_context.as_deref().is_some_and( + template.llm_context.as_deref().is_some_and( |context| context.contains("rec2HHDDnjLfj4kE7VyEtFA1HPGQLK33259532cRyHp") ), "the template must say which receiver owns its accounts" diff --git a/crates/core/src/tests/pyth/mod.rs b/crates/core/src/tests/pyth/mod.rs index fab851e14..0390d8c6b 100644 --- a/crates/core/src/tests/pyth/mod.rs +++ b/crates/core/src/tests/pyth/mod.rs @@ -25,8 +25,8 @@ use crate::{ const RPC_URL_ENV: &str = "SURFPOOL_TEST_RPC_URL"; const DEFAULT_RPC_URL: &str = "https://api.mainnet-beta.solana.com"; -const PYTH_PRO_COMPATIBLE_RECEIVER: &str = "rec2HHDDnjLfj4kE7VyEtFA1HPGQLK33259532cRyHp"; -const PRO_SOL_USD_FEED: &str = "7AviUf9nL62mcxNbQGKm4nKDQnPjswo6c5MX4D57HmyE"; +const PYTH_CORE_COMPATIBLE_RECEIVER: &str = "rec2HHDDnjLfj4kE7VyEtFA1HPGQLK33259532cRyHp"; +const CORE_COMPATIBLE_SOL_USD_FEED: &str = "7AviUf9nL62mcxNbQGKm4nKDQnPjswo6c5MX4D57HmyE"; const SOL_USD_FEED_ID: &str = "0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d"; async fn fetch(address: &str) -> solana_account::Account { @@ -53,16 +53,16 @@ async fn fetch(address: &str) -> solana_account::Account { /// Runs the template through the scenario path on the account the caller names, so the IDL comes /// from the account's owner. #[tokio::test(flavor = "multi_thread")] -async fn pro_price_feed_touches_only_price_fields() { - let live = fetch(PRO_SOL_USD_FEED).await; - assert_eq!(live.owner.to_string(), PYTH_PRO_COMPATIBLE_RECEIVER); +async fn core_compatible_price_feed_touches_only_price_fields() { + let live = fetch(CORE_COMPATIBLE_SOL_USD_FEED).await; + assert_eq!(live.owner.to_string(), PYTH_CORE_COMPATIBLE_RECEIVER); // A Partial verification level shifts every offset below by one. assert_eq!(live.data[40], 1, "verification level should be Full"); let feed_id = hex::decode(SOL_USD_FEED_ID.trim_start_matches("0x")).unwrap(); assert_eq!(&live.data[41..73], feed_id.as_slice(), "feed_id at 41"); let registry = TemplateRegistry::new(); - let template = registry.get("pyth-pro-price-feed-v2").unwrap(); + let template = registry.get("pyth-core-compatible-price-feed-v2").unwrap(); // (path, offset, width, value). Each value differs from the live one, so a skipped write fails. let rows: [(&str, usize, usize, i64); 8] = [ @@ -82,7 +82,7 @@ async fn pro_price_feed_touches_only_price_fields() { ); } - let target = Pubkey::from_str_const(PRO_SOL_USD_FEED); + let target = Pubkey::from_str_const(CORE_COMPATIBLE_SOL_USD_FEED); let (svm, _events_rx, _geyser_rx) = SurfnetSvm::default(); let identity = svm .get_forged_account_data(&target, &live.data, template.idl(), &HashMap::new()) @@ -97,14 +97,14 @@ async fn pro_price_feed_touches_only_price_fields() { let locker = SurfnetSvmLocker::new(svm); locker.with_svm_writer(|svm| svm.set_account(&target, live.clone()).unwrap()); let mut scenario = Scenario::new( - "Pyth Pro-compatible SOL/USD".to_string(), + "Pyth Core Compatible SOL/USD".to_string(), "Move every price field of the live feed".to_string(), ); scenario.add_override( OverrideInstance::new( template.id.clone(), 0, - AccountAddress::Pubkey(PRO_SOL_USD_FEED.to_string()), + AccountAddress::Pubkey(CORE_COMPATIBLE_SOL_USD_FEED.to_string()), ) .with_values(values), ); From 691c52900aa852d1ffb5c96b8dcde23b241efb8a Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Thu, 29 Oct 2026 09:47:33 +0200 Subject: [PATCH 05/12] fix(scenarios): scale the confidence with the price in the Pyth Core Compatible template guidance --- .../protocols/pyth/core-compatible/v2/overrides.yaml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/overrides.yaml b/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/overrides.yaml index f784499f7..ed69f3e33 100644 --- a/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/overrides.yaml +++ b/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/overrides.yaml @@ -57,7 +57,9 @@ templates: PRICE: price_message.price = usd_price x 10^-exponent, so with exponent -8, $145.00 is 14500000000. ema_price, conf and ema_conf use the same scale. Move ema_price with price: - consumers use the EMA for some checks and the spot price for others. + consumers use the EMA for some checks and the spot price for others. Scale conf and ema_conf + by the same factor as the price: consumers refuse a price whose confidence is too wide a share + of it, so a large drop that keeps the live conf can be refused. TIME: leave publish_time, prev_publish_time and posted_slot unset. fetchBeforeUse: true brings the upstream account's current values when the override plays, so the price is as @@ -65,6 +67,8 @@ templates: and ages until it plays. To make a price stale on purpose, follow the TIME FIELDS steps of pyth-price-feed-v2. - EXAMPLE - "SOL drops to $20" on an account with exponent -8: + EXAMPLE - "SOL drops to $20" on an account with exponent -8, live price p and ema_price q: price_message.price: 2000000000 price_message.ema_price: 2000000000 + price_message.conf: the live conf x 2000000000 / p + price_message.ema_conf: the live ema_conf x 2000000000 / q From 0df95762f3cb50f63926f3fb9fc31d5cfbab2eaf Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Tue, 3 Nov 2026 08:53:27 +0200 Subject: [PATCH 06/12] docs(scenarios): call rec5 the Pyth Core receiver --- crates/core/src/scenarios/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/core/src/scenarios/README.md b/crates/core/src/scenarios/README.md index 8bec29c43..f80a45b11 100644 --- a/crates/core/src/scenarios/README.md +++ b/crates/core/src/scenarios/README.md @@ -13,7 +13,7 @@ For Surfpool to understand how to serialize and deserialize account data, it nee Protocols that are natively supported by Surfpool will have their IDLs included by default. **Currently supported protocols:** -- **Pyth v2** - Price oracle with a price feed template for the default receiver (`pyth-price-feed-v2`) and one for the Pyth Core Compatible receiver (`pyth-core-compatible-price-feed-v2`) +- **Pyth v2** - Price oracle with a price feed template for the Pyth Core receiver (`pyth-price-feed-v2`) and one for the Pyth Core Compatible receiver (`pyth-core-compatible-price-feed-v2`) - **Jupiter v6** - DEX aggregator with TokenLedger manipulation template - **Switchboard On-Demand** - On-demand oracle with QuoteAccount override template - **Kamino** – Lending (v1.23.0), Scope oracle, Farms, Swap/LIMO, Earn vaults and Liquidity, across six programs. See [protocols/kamino/README.md](./protocols/kamino/README.md) From 8886ae14ee27836da1ae8ec024ecc68ac18178a2 Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Mon, 9 Nov 2026 11:10:29 +0200 Subject: [PATCH 07/12] feat(scenarios): keep Pyth price feeds published with keep-alive --- crates/core/src/scenarios/README.md | 2 +- .../pyth/core-compatible/v2/README.md | 9 ++ .../pyth/core-compatible/v2/keep-alive.yaml | 17 +++ .../pyth/core-compatible/v2/overrides.yaml | 16 ++- .../protocols/pyth/v2/keep-alive.yaml | 17 +++ .../protocols/pyth/v2/overrides.yaml | 37 +++--- crates/core/src/scenarios/registry.rs | 46 ++++++++ crates/core/src/tests/pyth/mod.rs | 111 +++++++++++++++++- 8 files changed, 222 insertions(+), 33 deletions(-) create mode 100644 crates/core/src/scenarios/protocols/pyth/core-compatible/v2/keep-alive.yaml create mode 100644 crates/core/src/scenarios/protocols/pyth/v2/keep-alive.yaml diff --git a/crates/core/src/scenarios/README.md b/crates/core/src/scenarios/README.md index 80de68afc..9dab570e4 100644 --- a/crates/core/src/scenarios/README.md +++ b/crates/core/src/scenarios/README.md @@ -13,7 +13,7 @@ For Surfpool to understand how to serialize and deserialize account data, it nee Protocols that are natively supported by Surfpool will have their IDLs included by default. **Currently supported protocols:** -- **Pyth v2** - Price oracle with a price feed template for the Pyth Core receiver (`pyth-price-feed-v2`) and one for the Pyth Core Compatible receiver (`pyth-core-compatible-price-feed-v2`) +- **Pyth v2** - Price oracle with a price feed template for the Pyth Core receiver (`pyth-price-feed-v2`) and one for the Pyth Core Compatible receiver (`pyth-core-compatible-price-feed-v2`); both keep the price they write published - **Jupiter v6** - DEX aggregator with TokenLedger manipulation template - **Switchboard On-Demand** - On-demand oracle with QuoteAccount override template - **Kamino** – Lending (v1.23.0), Scope oracle, Farms, Swap/LIMO, Earn vaults and Liquidity, across six programs. See [protocols/kamino/README.md](./protocols/kamino/README.md) diff --git a/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/README.md b/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/README.md index 7d4b203b9..fb1111d2c 100644 --- a/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/README.md +++ b/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/README.md @@ -11,6 +11,15 @@ listed on [Pyth's Solana contract addresses](https://docs.pyth.network/price-fee They hold separate price accounts for the same feeds, and a consumer reads the accounts of one of them. Take the price account from the consumer's own account that names it. +# Keep-alive + +Nobody publishes Pyth prices on a surfnet, so both templates import a +[keep-alive](../../../../README.md#keep-alive): from the slot an override plays until the network +is reset, Surfpool writes the Clock's unix seconds to `price_message.publish_time` and the slot to +`posted_slot` every slot, time travels included. The price, confidence and EMA fields stay as the +scenario wrote them. A price account no override names is not republished and ages against the +Surfnet clock. + # IDL `idl.json` is the IDL the program publishes in its Anchor IDL account diff --git a/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/keep-alive.yaml b/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/keep-alive.yaml new file mode 100644 index 000000000..854ba956a --- /dev/null +++ b/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/keep-alive.yaml @@ -0,0 +1,17 @@ +protocol: Pyth +version: v2 +account_type: PriceUpdateV2 +idl_file_path: idl.json + +templates: + - id: pyth-core-compatible-price-publication + name: Price publication + description: The Pyth publisher republishing a price + idl_account_name: PriceUpdateV2 + address: + type: pubkey + properties: + - path: price_message.publish_time + description: Unix seconds the price was published at, checked against the Clock + - path: posted_slot + description: Slot the price was posted in, for consumers that measure freshness in slots diff --git a/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/overrides.yaml b/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/overrides.yaml index ed69f3e33..b6a24e88b 100644 --- a/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/overrides.yaml +++ b/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/overrides.yaml @@ -8,6 +8,11 @@ tags: - price-feed - defi +keep_alive: + - template: pyth-core-compatible-price-publication + address: { type: override } + values: { price_message.publish_time: $unix_s, posted_slot: $slot } + templates: - id: pyth-core-compatible-price-feed-v2 name: Override Pyth Core Compatible Price Account @@ -61,11 +66,12 @@ templates: by the same factor as the price: consumers refuse a price whose confidence is too wide a share of it, so a large drop that keeps the live conf can be refused. - TIME: leave publish_time, prev_publish_time and posted_slot unset. fetchBeforeUse: true - brings the upstream account's current values when the override plays, so the price is as - fresh as upstream's. A time written into the scenario is fixed when the scenario is created - and ages until it plays. To make a price stale on purpose, follow the TIME FIELDS steps of - pyth-price-feed-v2. + THE PRICE STAYS PUBLISHED BY ITSELF. From the slot this override plays until the network is + reset, Surfpool republishes this price account every slot, as the Pyth publisher does on + mainnet: publish_time becomes the Surfnet clock's unix seconds and posted_slot the slot, also + after a time travel. Price, conf and the EMA fields keep the values this override wrote. A + value set for publish_time or posted_slot never takes effect, so leave both unset. A price + account no override names is not republished and ages against the Surfnet clock. EXAMPLE - "SOL drops to $20" on an account with exponent -8, live price p and ema_price q: price_message.price: 2000000000 diff --git a/crates/core/src/scenarios/protocols/pyth/v2/keep-alive.yaml b/crates/core/src/scenarios/protocols/pyth/v2/keep-alive.yaml new file mode 100644 index 000000000..b435dfe68 --- /dev/null +++ b/crates/core/src/scenarios/protocols/pyth/v2/keep-alive.yaml @@ -0,0 +1,17 @@ +protocol: Pyth +version: v2 +account_type: PriceUpdateV2 +idl_file_path: idl.json + +templates: + - id: pyth-price-publication + name: Price publication + description: The Pyth publisher republishing a price + idl_account_name: PriceUpdateV2 + address: + type: pubkey + properties: + - path: price_message.publish_time + description: Unix seconds the price was published at, checked against the Clock + - path: posted_slot + description: Slot the price was posted in, for consumers that measure freshness in slots diff --git a/crates/core/src/scenarios/protocols/pyth/v2/overrides.yaml b/crates/core/src/scenarios/protocols/pyth/v2/overrides.yaml index bdc65802d..a8ac0d6fc 100644 --- a/crates/core/src/scenarios/protocols/pyth/v2/overrides.yaml +++ b/crates/core/src/scenarios/protocols/pyth/v2/overrides.yaml @@ -9,6 +9,11 @@ tags: - defi - clock-sensitive +keep_alive: + - template: pyth-price-publication + address: { type: override } + values: { price_message.publish_time: $unix_s, posted_slot: $slot } + constants: price_feed: label: Price Feed @@ -255,31 +260,17 @@ templates: unless the scenario is specifically about an exponent error. If you do change it, restate every scaled field to match. - price_message.ema_price and price_message.ema_conf use the same scales as price and conf. - - posted_slot is a slot number, not a timestamp. Some consumers measure freshness in slots - rather than in seconds. - - TIME FIELDS - READ BEFORE SETTING publish_time: - publish_time and prev_publish_time are unix seconds interpreted against the SURFNET clock, not - the real-world clock. The two can differ by an arbitrary amount, so a timestamp taken from the - real current time will look far in the future inside the simulation and will NOT produce a - stale price. - Do not try to backdate the timestamp either - the Surfnet clock only moves forward. To build a - stale-price scenario, make the price fresh and then advance time past the consumer's limit: - 1. surfnet_pauseClock so the elapsed time is exact rather than dependent on slot production. - It returns an EpochInfo, which carries no timestamp - step 2 is how you read the time. - 2. read the Surfnet clock with getAccountInfo on - SysvarC1ock11111111111111111111111111111111 using the jsonParsed encoding, and take - result.value.data.parsed.info.unixTimestamp, which is in SECONDS. Set publish_time to - that value, which makes the price fresh. - 3. surfnet_timeTravel forward by more than the consumer's staleness limit. Its - absoluteTimestamp is in MILLISECONDS, so multiply the value from step 2 by 1000 before - adding your offset. - 4. send the transaction - the consumer now sees an aged price and should reject it + THE PRICE STAYS PUBLISHED BY ITSELF. From the slot this override plays until the network is + reset, Surfpool republishes this price account every slot, as the Pyth publisher does on + mainnet: publish_time becomes the Surfnet clock's unix seconds and posted_slot the slot, also + after a time travel. Price, conf and the EMA fields keep the values this override wrote. A + value set for publish_time or posted_slot never takes effect, so leave both unset. - UNITS WARNING for step 3: surfnet_timeTravel's absoluteTimestamp is in MILLISECONDS, while - publish_time and the Clock sysvar are in SECONDS. Multiply by 1000 when passing a value read - from the clock, or the call fails with a "past timestamp" error. + STALE PRICE: a price account no override of this template names is not republished and ages + against the Surfnet clock. To test a consumer's staleness check, leave that feed without an + override and surfnet_timeTravel past the consumer's limit. Its absoluteTimestamp is in + MILLISECONDS, while publish_time and the Clock sysvar are in SECONDS. address: type: pda program_id: pythWSnswVUd12oZpeFP8e9CVaEqJg25g1Vtc2biRsT diff --git a/crates/core/src/scenarios/registry.rs b/crates/core/src/scenarios/registry.rs index 830961cb5..009d7d2a5 100644 --- a/crates/core/src/scenarios/registry.rs +++ b/crates/core/src/scenarios/registry.rs @@ -10,10 +10,13 @@ use surfpool_types::{ pub const PYTH_V2_IDL_CONTENT: &str = include_str!("./protocols/pyth/v2/idl.json"); pub const PYTH_V2_OVERRIDES_CONTENT: &str = include_str!("./protocols/pyth/v2/overrides.yaml"); +pub const PYTH_V2_KEEP_ALIVE_CONTENT: &str = include_str!("./protocols/pyth/v2/keep-alive.yaml"); pub const PYTH_CORE_COMPATIBLE_V2_IDL_CONTENT: &str = include_str!("./protocols/pyth/core-compatible/v2/idl.json"); pub const PYTH_CORE_COMPATIBLE_V2_OVERRIDES_CONTENT: &str = include_str!("./protocols/pyth/core-compatible/v2/overrides.yaml"); +pub const PYTH_CORE_COMPATIBLE_V2_KEEP_ALIVE_CONTENT: &str = + include_str!("./protocols/pyth/core-compatible/v2/keep-alive.yaml"); pub const JUPITER_V6_IDL_CONTENT: &str = include_str!("./protocols/jupiter/v6/idl.json"); pub const JUPITER_V6_OVERRIDES_CONTENT: &str = @@ -232,11 +235,20 @@ impl TemplateRegistry { pub fn load_pyth_overrides(&mut self) { self.load_protocol_overrides(PYTH_V2_IDL_CONTENT, PYTH_V2_OVERRIDES_CONTENT, "pyth"); + let idl = serde_json::from_str(PYTH_V2_IDL_CONTENT).expect("pyth idl"); + self.load_keep_alive(Some(idl), PYTH_V2_KEEP_ALIVE_CONTENT, "pyth"); self.load_protocol_overrides( PYTH_CORE_COMPATIBLE_V2_IDL_CONTENT, PYTH_CORE_COMPATIBLE_V2_OVERRIDES_CONTENT, "pyth-core-compatible", ); + let idl = serde_json::from_str(PYTH_CORE_COMPATIBLE_V2_IDL_CONTENT) + .expect("pyth core compatible idl"); + self.load_keep_alive( + Some(idl), + PYTH_CORE_COMPATIBLE_V2_KEEP_ALIVE_CONTENT, + "pyth-core-compatible", + ); } pub fn load_jupiter_overrides(&mut self) { @@ -1687,6 +1699,40 @@ templates: [] ); } + #[test] + fn pyth_price_feeds_keep_their_price_published() { + let registry = TemplateRegistry::new(); + for (id, publication) in [ + ("pyth-price-feed-v2", "pyth-price-publication"), + ( + "pyth-core-compatible-price-feed-v2", + "pyth-core-compatible-price-publication", + ), + ] { + let template = registry.get(id).unwrap(); + let [import] = template.keep_alive.as_slice() else { + panic!("{id} must keep its price published"); + }; + assert_eq!(import.template, publication, "{id}"); + assert_eq!( + import.address, + surfpool_types::KeepAliveAddress::Override, + "{id}" + ); + let mut paths: Vec<&str> = import.values.keys().map(String::as_str).collect(); + paths.sort(); + assert_eq!( + paths, + ["posted_slot", "price_message.publish_time"], + "{id} must leave the scenario's price alone" + ); + assert!( + !registry.contains(publication), + "{publication} is never listed" + ); + } + } + #[test] fn test_get_pda_seed_references() { use surfpool_types::AccountAddress; diff --git a/crates/core/src/tests/pyth/mod.rs b/crates/core/src/tests/pyth/mod.rs index 0390d8c6b..6b2705cfb 100644 --- a/crates/core/src/tests/pyth/mod.rs +++ b/crates/core/src/tests/pyth/mod.rs @@ -11,6 +11,7 @@ use std::collections::HashMap; +use solana_clock::Clock; use solana_commitment_config::CommitmentConfig; use solana_pubkey::Pubkey; use surfpool_types::{AccountAddress, OverrideInstance, Scenario}; @@ -25,6 +26,8 @@ use crate::{ const RPC_URL_ENV: &str = "SURFPOOL_TEST_RPC_URL"; const DEFAULT_RPC_URL: &str = "https://api.mainnet-beta.solana.com"; +const PYTH_CORE_RECEIVER: &str = "rec5EKMGg6MxZYaMdyBfgwp4d5rB9T1VQH5pJv5LtFJ"; +const CORE_SOL_USD_FEED: &str = "7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE"; const PYTH_CORE_COMPATIBLE_RECEIVER: &str = "rec2HHDDnjLfj4kE7VyEtFA1HPGQLK33259532cRyHp"; const CORE_COMPATIBLE_SOL_USD_FEED: &str = "7AviUf9nL62mcxNbQGKm4nKDQnPjswo6c5MX4D57HmyE"; const SOL_USD_FEED_ID: &str = "0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d"; @@ -114,10 +117,14 @@ async fn core_compatible_price_feed_touches_only_price_fields() { .await .unwrap(); - let forged = locker - .with_svm_reader(|svm| svm.get_account(&target)) - .unwrap() - .expect("the feed should exist"); + let (forged, clock) = locker.with_svm_reader(|svm| { + ( + svm.get_account(&target) + .unwrap() + .expect("the feed should exist"), + svm.inner.get_sysvar::(), + ) + }); assert_eq!(forged.owner, live.owner); assert_eq!(forged.lamports, live.lamports); assert_eq!(forged.data.len(), live.data.len()); @@ -134,6 +141,12 @@ async fn core_compatible_price_feed_touches_only_price_fields() { "changed {diffs:?}" ); for (path, offset, width, value) in rows { + // Keep-alive republishes the price after the override plays. + let value = match path { + "price_message.publish_time" => clock.unix_timestamp, + "posted_slot" => clock.slot as i64, + _ => value, + }; assert_eq!( &forged.data[offset..offset + width], &value.to_le_bytes()[..width], @@ -141,3 +154,93 @@ async fn core_compatible_price_feed_touches_only_price_fields() { ); } } + +/// Keep-alive republishes a price account a scenario overrides every slot, as the Pyth publisher +/// does on mainnet, time travels included. Only the publication fields move with the Clock: the +/// price stays the scenario's. +#[tokio::test(flavor = "multi_thread")] +async fn price_feeds_stay_published_through_surfnet_svm() { + const PRICE: i64 = 2_000_000_000; + for (template_id, address, receiver) in [ + ("pyth-price-feed-v2", CORE_SOL_USD_FEED, PYTH_CORE_RECEIVER), + ( + "pyth-core-compatible-price-feed-v2", + CORE_COMPATIBLE_SOL_USD_FEED, + PYTH_CORE_COMPATIBLE_RECEIVER, + ), + ] { + let live = fetch(address).await; + assert_eq!(live.owner.to_string(), receiver); + // A Partial verification level shifts every offset below by one. + assert_eq!( + live.data[40], 1, + "{address} verification level should be Full" + ); + + let target = Pubkey::from_str_const(address); + let (svm, _events_rx, _geyser_rx) = SurfnetSvm::default(); + let locker = SurfnetSvmLocker::new(svm); + locker.with_svm_writer(|svm| svm.set_account(&target, live.clone()).unwrap()); + let mut scenario = Scenario::new("Pyth SOL at $20".to_string(), "Price only".to_string()); + scenario.add_override( + OverrideInstance::new( + template_id.to_string(), + 0, + AccountAddress::Pubkey(address.to_string()), + ) + .with_values(HashMap::from([( + "price_message.price".to_string(), + serde_json::json!(PRICE), + )])), + ); + locker.register_scenario(scenario, None).unwrap(); + + let assert_published = |when: &str| { + let (data, clock) = locker.with_svm_reader(|svm| { + ( + svm.get_account(&target).unwrap().unwrap().data, + svm.inner.get_sysvar::(), + ) + }); + let word = |offset: usize| <[u8; 8]>::try_from(&data[offset..offset + 8]).unwrap(); + assert_eq!( + i64::from_le_bytes(word(93)), + clock.unix_timestamp, + "{template_id} {when}: publish_time" + ); + assert_eq!( + u64::from_le_bytes(word(125)), + clock.slot, + "{template_id} {when}: posted_slot" + ); + assert_eq!(i64::from_le_bytes(word(73)), PRICE, "{template_id} {when}"); + let diffs: Vec = (0..live.data.len()) + .filter(|&i| data[i] != live.data[i]) + .collect(); + assert!( + diffs.iter().all(|i| [73..81, 93..101, 125..133] + .iter() + .any(|written| written.contains(i))), + "{template_id} {when}: changed {diffs:?}" + ); + }; + + for slot in 1..=3 { + locker.confirm_current_block(&None).await.unwrap(); + assert_published(&format!("slot {slot}")); + } + let clock = locker.with_svm_reader(|svm| svm.inner.get_sysvar::()); + locker + .apply_time_travel( + Clock { + slot: clock.slot + 1_000, + unix_timestamp: clock.unix_timestamp + 3_600, + ..clock + }, + &None, + ) + .await + .unwrap(); + assert_published("after a time travel"); + } +} From ebea6b0b133a5a260189218ee4cb65121cd3154c Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Mon, 9 Nov 2026 17:37:00 +0200 Subject: [PATCH 08/12] feat(scenarios): keep every loaded Pyth price of a receiver published with owned keep-alive --- .../pyth/core-compatible/v2/README.md | 12 +- .../pyth/core-compatible/v2/overrides.yaml | 17 ++- .../protocols/pyth/v2/overrides.yaml | 21 ++-- crates/core/src/scenarios/registry.rs | 16 ++- crates/core/src/tests/pyth/mod.rs | 108 ++++++++++++------ 5 files changed, 114 insertions(+), 60 deletions(-) diff --git a/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/README.md b/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/README.md index fb1111d2c..27e14795d 100644 --- a/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/README.md +++ b/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/README.md @@ -14,11 +14,13 @@ them. Take the price account from the consumer's own account that names it. # Keep-alive Nobody publishes Pyth prices on a surfnet, so both templates import a -[keep-alive](../../../../README.md#keep-alive): from the slot an override plays until the network -is reset, Surfpool writes the Clock's unix seconds to `price_message.publish_time` and the slot to -`posted_slot` every slot, time travels included. The price, confidence and EMA fields stay as the -scenario wrote them. A price account no override names is not republished and ages against the -Surfnet clock. +[keep-alive](../../../../README.md#keep-alive): from the slot an override of a template plays until +the network is reset, Surfpool writes the Clock's unix seconds to `price_message.publish_time` and +the slot to `posted_slot` every slot, time travels included. It does so for every price account of +that template's receiver the surfnet has loaded, including feeds no override names and, from the +next slot, feeds a transaction loads later, so a liquidation that reads the borrower's other feeds +finds them fresh. The price, confidence and EMA fields keep their values. To keep one feed stale on +purpose, override it with `keepAlive: false`; it then ages against the Surfnet clock. # IDL diff --git a/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/overrides.yaml b/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/overrides.yaml index b6a24e88b..b82bdf9e1 100644 --- a/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/overrides.yaml +++ b/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/overrides.yaml @@ -10,7 +10,7 @@ tags: keep_alive: - template: pyth-core-compatible-price-publication - address: { type: override } + address: { type: owned, owner: rec2HHDDnjLfj4kE7VyEtFA1HPGQLK33259532cRyHp, account: PriceUpdateV2 } values: { price_message.publish_time: $unix_s, posted_slot: $slot } templates: @@ -67,11 +67,16 @@ templates: of it, so a large drop that keeps the live conf can be refused. THE PRICE STAYS PUBLISHED BY ITSELF. From the slot this override plays until the network is - reset, Surfpool republishes this price account every slot, as the Pyth publisher does on - mainnet: publish_time becomes the Surfnet clock's unix seconds and posted_slot the slot, also - after a time travel. Price, conf and the EMA fields keep the values this override wrote. A - value set for publish_time or posted_slot never takes effect, so leave both unset. A price - account no override names is not republished and ages against the Surfnet clock. + reset, Surfpool republishes every price account of the Pyth Core Compatible receiver the + Surfnet has loaded every slot, as the Pyth publisher does on mainnet: publish_time becomes the + Surfnet clock's unix seconds and posted_slot the slot, also after a time travel. This includes + feeds no override names and, from the next slot, feeds a transaction loads later, so a + liquidation that reads the borrower's other feeds finds them fresh. Price, conf and the EMA + fields keep their values. A value set for publish_time or posted_slot never takes effect, so + leave both unset. + + STALE PRICE: to keep one feed stale on purpose, override it with keepAlive: false. It then + ages against the Surfnet clock. EXAMPLE - "SOL drops to $20" on an account with exponent -8, live price p and ema_price q: price_message.price: 2000000000 diff --git a/crates/core/src/scenarios/protocols/pyth/v2/overrides.yaml b/crates/core/src/scenarios/protocols/pyth/v2/overrides.yaml index a8ac0d6fc..20bd2255e 100644 --- a/crates/core/src/scenarios/protocols/pyth/v2/overrides.yaml +++ b/crates/core/src/scenarios/protocols/pyth/v2/overrides.yaml @@ -11,7 +11,7 @@ tags: keep_alive: - template: pyth-price-publication - address: { type: override } + address: { type: owned, owner: rec5EKMGg6MxZYaMdyBfgwp4d5rB9T1VQH5pJv5LtFJ, account: PriceUpdateV2 } values: { price_message.publish_time: $unix_s, posted_slot: $slot } constants: @@ -262,15 +262,18 @@ templates: - price_message.ema_price and price_message.ema_conf use the same scales as price and conf. THE PRICE STAYS PUBLISHED BY ITSELF. From the slot this override plays until the network is - reset, Surfpool republishes this price account every slot, as the Pyth publisher does on - mainnet: publish_time becomes the Surfnet clock's unix seconds and posted_slot the slot, also - after a time travel. Price, conf and the EMA fields keep the values this override wrote. A - value set for publish_time or posted_slot never takes effect, so leave both unset. + reset, Surfpool republishes every price account of the Pyth Core receiver the Surfnet has + loaded every slot, as the Pyth publisher does on mainnet: publish_time becomes the Surfnet + clock's unix seconds and posted_slot the slot, also after a time travel. This includes feeds + no override names and, from the next slot, feeds a transaction loads later, so a liquidation + that reads the borrower's other feeds finds them fresh. Price, conf and the EMA fields keep + their values. A value set for publish_time or posted_slot never takes effect, so leave both + unset. - STALE PRICE: a price account no override of this template names is not republished and ages - against the Surfnet clock. To test a consumer's staleness check, leave that feed without an - override and surfnet_timeTravel past the consumer's limit. Its absoluteTimestamp is in - MILLISECONDS, while publish_time and the Clock sysvar are in SECONDS. + STALE PRICE: to keep one feed stale on purpose, override it with keepAlive: false. It then + ages against the Surfnet clock. To test a consumer's staleness check, add that override and + surfnet_timeTravel past the consumer's limit. Its absoluteTimestamp is in MILLISECONDS, while + publish_time and the Clock sysvar are in SECONDS. address: type: pda program_id: pythWSnswVUd12oZpeFP8e9CVaEqJg25g1Vtc2biRsT diff --git a/crates/core/src/scenarios/registry.rs b/crates/core/src/scenarios/registry.rs index ec0ff7d3c..16bf0cb43 100644 --- a/crates/core/src/scenarios/registry.rs +++ b/crates/core/src/scenarios/registry.rs @@ -1734,11 +1734,16 @@ templates: [] #[test] fn pyth_price_feeds_keep_their_price_published() { let registry = TemplateRegistry::new(); - for (id, publication) in [ - ("pyth-price-feed-v2", "pyth-price-publication"), + for (id, publication, receiver) in [ + ( + "pyth-price-feed-v2", + "pyth-price-publication", + "rec5EKMGg6MxZYaMdyBfgwp4d5rB9T1VQH5pJv5LtFJ", + ), ( "pyth-core-compatible-price-feed-v2", "pyth-core-compatible-price-publication", + "rec2HHDDnjLfj4kE7VyEtFA1HPGQLK33259532cRyHp", ), ] { let template = registry.get(id).unwrap(); @@ -1748,8 +1753,11 @@ templates: [] assert_eq!(import.template, publication, "{id}"); assert_eq!( import.address, - surfpool_types::KeepAliveAddress::Override, - "{id}" + surfpool_types::KeepAliveAddress::Owned { + owners: vec![receiver.to_string()], + account: "PriceUpdateV2".to_string(), + }, + "{id} must keep every price account of its receiver published" ); let mut paths: Vec<&str> = import.values.keys().map(String::as_str).collect(); paths.sort(); diff --git a/crates/core/src/tests/pyth/mod.rs b/crates/core/src/tests/pyth/mod.rs index 6b2705cfb..fcd52a22c 100644 --- a/crates/core/src/tests/pyth/mod.rs +++ b/crates/core/src/tests/pyth/mod.rs @@ -155,9 +155,11 @@ async fn core_compatible_price_feed_touches_only_price_fields() { } } -/// Keep-alive republishes a price account a scenario overrides every slot, as the Pyth publisher -/// does on mainnet, time travels included. Only the publication fields move with the Clock: the -/// price stays the scenario's. +/// Once a scenario overrides one price account, keep-alive republishes every price account of its +/// receiver every slot, as the Pyth publisher does on mainnet, time travels included: the +/// overridden one, and one loaded after the override played. Only the publication fields move with +/// the Clock: each price stays as it was. An account overridden with keep-alive off is not +/// republished. #[tokio::test(flavor = "multi_thread")] async fn price_feeds_stay_published_through_surfnet_svm() { const PRICE: i64 = 2_000_000_000; @@ -178,11 +180,15 @@ async fn price_feeds_stay_published_through_surfnet_svm() { ); let target = Pubkey::from_str_const(address); + let stale = Pubkey::new_unique(); + let loaded_later = Pubkey::new_unique(); let (svm, _events_rx, _geyser_rx) = SurfnetSvm::default(); let locker = SurfnetSvmLocker::new(svm); - locker.with_svm_writer(|svm| svm.set_account(&target, live.clone()).unwrap()); - let mut scenario = Scenario::new("Pyth SOL at $20".to_string(), "Price only".to_string()); - scenario.add_override( + locker.with_svm_writer(|svm| { + svm.set_account(&target, live.clone()).unwrap(); + svm.set_account(&stale, live.clone()).unwrap(); + }); + let price_override = |address: &Pubkey| { OverrideInstance::new( template_id.to_string(), 0, @@ -191,43 +197,73 @@ async fn price_feeds_stay_published_through_surfnet_svm() { .with_values(HashMap::from([( "price_message.price".to_string(), serde_json::json!(PRICE), - )])), - ); + )])) + }; + let mut scenario = Scenario::new("Pyth SOL at $20".to_string(), "Price only".to_string()); + scenario.add_override(price_override(&target)); + let mut stale_override = price_override(&stale); + stale_override.keep_alive = false; + scenario.add_override(stale_override); locker.register_scenario(scenario, None).unwrap(); - let assert_published = |when: &str| { - let (data, clock) = locker.with_svm_reader(|svm| { + let read = |address: &Pubkey| { + locker.with_svm_reader(|svm| { ( - svm.get_account(&target).unwrap().unwrap().data, + svm.get_account(address).unwrap().unwrap().data, svm.inner.get_sysvar::(), ) - }); - let word = |offset: usize| <[u8; 8]>::try_from(&data[offset..offset + 8]).unwrap(); - assert_eq!( - i64::from_le_bytes(word(93)), - clock.unix_timestamp, - "{template_id} {when}: publish_time" - ); - assert_eq!( - u64::from_le_bytes(word(125)), - clock.slot, - "{template_id} {when}: posted_slot" - ); - assert_eq!(i64::from_le_bytes(word(73)), PRICE, "{template_id} {when}"); - let diffs: Vec = (0..live.data.len()) - .filter(|&i| data[i] != live.data[i]) - .collect(); - assert!( - diffs.iter().all(|i| [73..81, 93..101, 125..133] - .iter() - .any(|written| written.contains(i))), - "{template_id} {when}: changed {diffs:?}" - ); + }) + }; + let word = + |data: &[u8], offset: usize| <[u8; 8]>::try_from(&data[offset..offset + 8]).unwrap(); + let assert_published = |when: &str, with_loaded_later: bool| { + let mut published = vec![(target, "overridden", PRICE)]; + if with_loaded_later { + published.push(( + loaded_later, + "loaded later", + i64::from_le_bytes(word(&live.data, 73)), + )); + } + for (address, name, price) in published { + let (data, clock) = read(&address); + let what = format!("{name} {when}"); + assert_eq!( + i64::from_le_bytes(word(&data, 93)), + clock.unix_timestamp, + "{template_id} {what}: publish_time" + ); + assert_eq!( + u64::from_le_bytes(word(&data, 125)), + clock.slot, + "{template_id} {what}: posted_slot" + ); + assert_eq!( + i64::from_le_bytes(word(&data, 73)), + price, + "{template_id} {what}" + ); + let diffs: Vec = (0..live.data.len()) + .filter(|&i| data[i] != live.data[i]) + .collect(); + assert!( + diffs.iter().all(|i| [73..81, 93..101, 125..133] + .iter() + .any(|written| written.contains(i))), + "{template_id} {what}: changed {diffs:?}" + ); + } + let mut unpublished = live.data.clone(); + unpublished[73..81].copy_from_slice(&PRICE.to_le_bytes()); + assert_eq!(read(&stale).0, unpublished, "{template_id} stale {when}"); }; - for slot in 1..=3 { + locker.confirm_current_block(&None).await.unwrap(); + assert_published("slot 1", false); + locker.with_svm_writer(|svm| svm.set_account(&loaded_later, live.clone()).unwrap()); + for slot in 2..=3 { locker.confirm_current_block(&None).await.unwrap(); - assert_published(&format!("slot {slot}")); + assert_published(&format!("slot {slot}"), true); } let clock = locker.with_svm_reader(|svm| svm.inner.get_sysvar::()); locker @@ -241,6 +277,6 @@ async fn price_feeds_stay_published_through_surfnet_svm() { ) .await .unwrap(); - assert_published("after a time travel"); + assert_published("after a time travel", true); } } From 7c1dbf75756d88ea47f943b302d2dd13a21eb5ed Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Tue, 10 Nov 2026 07:48:13 +0200 Subject: [PATCH 09/12] docs(scenarios): say in each price template that it keeps every loaded price fresh --- .../scenarios/protocols/pyth/core-compatible/v2/overrides.yaml | 2 +- crates/core/src/scenarios/protocols/pyth/v2/overrides.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/overrides.yaml b/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/overrides.yaml index b82bdf9e1..7527becca 100644 --- a/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/overrides.yaml +++ b/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/overrides.yaml @@ -16,7 +16,7 @@ keep_alive: templates: - id: pyth-core-compatible-price-feed-v2 name: Override Pyth Core Compatible Price Account - description: Override a price account of the Pyth Core Compatible receiver, found by address + description: Override a price account of the Pyth Core Compatible receiver, found by address; keeps every loaded one published idl_account_name: PriceUpdateV2 properties: - path: price_message.price diff --git a/crates/core/src/scenarios/protocols/pyth/v2/overrides.yaml b/crates/core/src/scenarios/protocols/pyth/v2/overrides.yaml index 20bd2255e..f33db7384 100644 --- a/crates/core/src/scenarios/protocols/pyth/v2/overrides.yaml +++ b/crates/core/src/scenarios/protocols/pyth/v2/overrides.yaml @@ -199,7 +199,7 @@ constants: templates: - id: pyth-price-feed-v2 name: Override Price Feed - description: Override any Pyth price feed with custom price data + description: Override any Pyth price feed with custom price data; keeps every loaded Pyth Core price account published idl_account_name: PriceUpdateV2 properties: - path: feed_id From 8706557f8e92fd641e97cac842e1783e8fc86741 Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Tue, 10 Nov 2026 10:08:38 +0200 Subject: [PATCH 10/12] docs(scenarios): tell the Pyth Core template's reader which protocols read its receiver --- crates/core/src/scenarios/protocols/pyth/v2/overrides.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/core/src/scenarios/protocols/pyth/v2/overrides.yaml b/crates/core/src/scenarios/protocols/pyth/v2/overrides.yaml index f33db7384..2827c3be9 100644 --- a/crates/core/src/scenarios/protocols/pyth/v2/overrides.yaml +++ b/crates/core/src/scenarios/protocols/pyth/v2/overrides.yaml @@ -234,6 +234,12 @@ templates: CRITICAL: Always set fetchBeforeUse: true for Pyth price feeds! This fetches the current account data from mainnet before applying your price override. + WHICH RECEIVER: this template writes price accounts of the Pyth Core receiver + rec5EKMGg6MxZYaMdyBfgwp4d5rB9T1VQH5pJv5LtFJ, which Save and Kamino read. Project 0 reads the + Pyth Core Compatible receiver instead: for a Project 0 price use + pyth-core-compatible-price-feed-v2 on the bank's config.oracle_keys.0. When a protocol's own + templates name the price account it reads, follow them. + HOW TO USE THIS TEMPLATE: 1. Select the correct price feed from the `feed_id` constant options (e.g., SOL/USD, BTC/USD) 2. Check the feed's `llm_context` field for its decimal precision (most use 8, some use 10) From 7cd2a682b3f36314b409f7c91769f4ec79160af6 Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Tue, 10 Nov 2026 11:00:07 +0200 Subject: [PATCH 11/12] docs(scenarios): explain how to keep one Pyth price stale while the others stay fresh --- .../pyth/core-compatible/v2/overrides.yaml | 11 +++++++++-- .../scenarios/protocols/pyth/v2/overrides.yaml | 16 ++++++++++++---- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/overrides.yaml b/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/overrides.yaml index 7527becca..d2c98accf 100644 --- a/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/overrides.yaml +++ b/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/overrides.yaml @@ -75,8 +75,15 @@ templates: fields keep their values. A value set for publish_time or posted_slot never takes effect, so leave both unset. - STALE PRICE: to keep one feed stale on purpose, override it with keepAlive: false. It then - ages against the Surfnet clock. + STALE PRICE: keep-alive starts only when an override with keepAlive: true writes a price + account, and an override with no values writes nothing. To keep one feed stale while the + others stay fresh, add two overrides of this template, both with fetchBeforeUse: true and + price_message.price set to the account's current price (getAccountInfo, encoding + "jsonParsed"), so no price changes: + - on the stale feed's price account, keepAlive: false. It then ages against the Surfnet + clock and turns stale once the consumer's age limit has passed; + - on any other price account the same consumer reads, keepAlive: true. This starts the + republishing of every loaded price account except the stale one. EXAMPLE - "SOL drops to $20" on an account with exponent -8, live price p and ema_price q: price_message.price: 2000000000 diff --git a/crates/core/src/scenarios/protocols/pyth/v2/overrides.yaml b/crates/core/src/scenarios/protocols/pyth/v2/overrides.yaml index 2827c3be9..1689eb4a8 100644 --- a/crates/core/src/scenarios/protocols/pyth/v2/overrides.yaml +++ b/crates/core/src/scenarios/protocols/pyth/v2/overrides.yaml @@ -276,10 +276,18 @@ templates: their values. A value set for publish_time or posted_slot never takes effect, so leave both unset. - STALE PRICE: to keep one feed stale on purpose, override it with keepAlive: false. It then - ages against the Surfnet clock. To test a consumer's staleness check, add that override and - surfnet_timeTravel past the consumer's limit. Its absoluteTimestamp is in MILLISECONDS, while - publish_time and the Clock sysvar are in SECONDS. + STALE PRICE: keep-alive starts only when an override with keepAlive: true writes a price + account, and an override with no values beyond feed_id writes nothing. To keep one feed + stale while the others stay fresh, add two overrides of this template, both with + fetchBeforeUse: true and price_message.price set to the feed's current price + (getAccountInfo, encoding "jsonParsed"), so no price changes: + - on the stale feed, keepAlive: false. It then ages against the Surfnet clock and turns + stale once the consumer's age limit has passed; + - on any other feed the same consumer reads, keepAlive: true. This starts the + republishing of every loaded price account except the stale one. + To make it stale at once, surfnet_timeTravel past the consumer's limit. Its + absoluteTimestamp is in MILLISECONDS, while publish_time and the Clock sysvar are in + SECONDS. address: type: pda program_id: pythWSnswVUd12oZpeFP8e9CVaEqJg25g1Vtc2biRsT From 8a2d644b6c977008c337be0f18b2ff23123b6501 Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Tue, 10 Nov 2026 12:36:29 +0200 Subject: [PATCH 12/12] docs(scenarios): a Pyth publish_time or posted_slot value holds only with keepAlive false --- .../protocols/pyth/core-compatible/v2/overrides.yaml | 8 ++++---- .../core/src/scenarios/protocols/pyth/v2/overrides.yaml | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/overrides.yaml b/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/overrides.yaml index d2c98accf..b4d718de3 100644 --- a/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/overrides.yaml +++ b/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/overrides.yaml @@ -30,7 +30,7 @@ templates: description: Decimal exponent, normally -8 and -10 on some feeds. Rescales every price field - path: price_message.publish_time label: Publish Time - description: Unix seconds of this update, interpreted against the Surfnet clock + description: Unix seconds of this update, interpreted against the Surfnet clock. Keep-alive writes the Clock time every slot, so a value set here holds only with keepAlive false - path: price_message.prev_publish_time label: Previous Publish Time description: Unix seconds of the preceding update @@ -42,7 +42,7 @@ templates: description: Exponential moving average confidence, in the same scale as conf - path: posted_slot label: Posted Slot - description: Slot the update was posted at, for consumers that measure freshness in slots + description: Slot the update was posted at, for consumers that measure freshness in slots. Keep-alive writes the current slot every slot, so a value set here holds only with keepAlive false address: type: pubkey llm_context: | @@ -72,8 +72,8 @@ templates: Surfnet clock's unix seconds and posted_slot the slot, also after a time travel. This includes feeds no override names and, from the next slot, feeds a transaction loads later, so a liquidation that reads the borrower's other feeds finds them fresh. Price, conf and the EMA - fields keep their values. A value set for publish_time or posted_slot never takes effect, so - leave both unset. + fields keep their values. With keepAlive: true, a value set for publish_time or posted_slot + never takes effect, so leave both unset. STALE PRICE: keep-alive starts only when an override with keepAlive: true writes a price account, and an override with no values writes nothing. To keep one feed stale while the diff --git a/crates/core/src/scenarios/protocols/pyth/v2/overrides.yaml b/crates/core/src/scenarios/protocols/pyth/v2/overrides.yaml index 1689eb4a8..97d725dc8 100644 --- a/crates/core/src/scenarios/protocols/pyth/v2/overrides.yaml +++ b/crates/core/src/scenarios/protocols/pyth/v2/overrides.yaml @@ -217,7 +217,7 @@ templates: description: Decimal exponent, normally -8 and -10 on some feeds. Rescales every price field - path: price_message.publish_time label: Publish Time - description: Unix seconds of this update, interpreted against the Surfnet clock + description: Unix seconds of this update, interpreted against the Surfnet clock. Keep-alive writes the Clock time every slot, so a value set here holds only with keepAlive false - path: price_message.prev_publish_time label: Previous Publish Time description: Unix seconds of the preceding update @@ -229,7 +229,7 @@ templates: description: Exponential moving average confidence, in the same scale as conf - path: posted_slot label: Posted Slot - description: Slot the update was posted at, for consumers that measure freshness in slots + description: Slot the update was posted at, for consumers that measure freshness in slots. Keep-alive writes the current slot every slot, so a value set here holds only with keepAlive false llm_context: | CRITICAL: Always set fetchBeforeUse: true for Pyth price feeds! This fetches the current account data from mainnet before applying your price override. @@ -273,8 +273,8 @@ templates: clock's unix seconds and posted_slot the slot, also after a time travel. This includes feeds no override names and, from the next slot, feeds a transaction loads later, so a liquidation that reads the borrower's other feeds finds them fresh. Price, conf and the EMA fields keep - their values. A value set for publish_time or posted_slot never takes effect, so leave both - unset. + their values. With keepAlive: true, a value set for publish_time or posted_slot never takes + effect, so leave both unset. STALE PRICE: keep-alive starts only when an override with keepAlive: true writes a price account, and an override with no values beyond feed_id writes nothing. To keep one feed