diff --git a/crates/core/src/scenarios/README.md b/crates/core/src/scenarios/README.md index 4e7311a1c..b5989a839 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 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 new file mode 100644 index 000000000..27e14795d --- /dev/null +++ b/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/README.md @@ -0,0 +1,36 @@ +# 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. + +# 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 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 + +`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/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 new file mode 100644 index 000000000..b4d718de3 --- /dev/null +++ b/crates/core/src/scenarios/protocols/pyth/core-compatible/v2/overrides.yaml @@ -0,0 +1,92 @@ +protocol: Pyth +version: v2 +account_type: PriceUpdateV2 +idl_file_path: idl.json + +tags: + - oracle + - price-feed + - defi + +keep_alive: + - template: pyth-core-compatible-price-publication + address: { type: owned, owner: rec2HHDDnjLfj4kE7VyEtFA1HPGQLK33259532cRyHp, account: PriceUpdateV2 } + 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 + 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 + 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. 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 + - 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. Keep-alive writes the current slot every slot, so a value set here holds only with keepAlive false + 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 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. + 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. 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. + + THE PRICE STAYS PUBLISHED BY ITSELF. From the slot this override plays until the network is + 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. 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 + 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 + 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 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..97d725dc8 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: owned, owner: rec5EKMGg6MxZYaMdyBfgwp4d5rB9T1VQH5pJv5LtFJ, account: PriceUpdateV2 } + values: { price_message.publish_time: $unix_s, posted_slot: $slot } + constants: price_feed: label: Price Feed @@ -194,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 @@ -212,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 @@ -224,11 +229,17 @@ 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. + 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) @@ -255,31 +266,28 @@ 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 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. With keepAlive: true, 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: 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 diff --git a/crates/core/src/scenarios/registry.rs b/crates/core/src/scenarios/registry.rs index 890545d17..16bf0cb43 100644 --- a/crates/core/src/scenarios/registry.rs +++ b/crates/core/src/scenarios/registry.rs @@ -10,6 +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 = @@ -236,6 +243,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) { @@ -727,16 +748,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 (19) = 81 + // + Whirlpool (6) + SPL Token (2) + Pump (2) + PumpSwap (3) + Phoenix Eternal (19) = 82 assert_eq!( registry.count(), - 81, - "Registry should load 81 templates total" + 82, + "Registry should load 82 templates total" ); assert!(registry.contains("pyth-price-feed-v2")); + assert!(registry.contains("pyth-core-compatible-price-feed-v2")); assert!(registry.contains("jupiter-token-ledger-override")); @@ -1278,7 +1300,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"); @@ -1387,8 +1409,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()); @@ -1460,6 +1482,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-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())); @@ -1682,6 +1705,74 @@ templates: [] ); } + #[test] + fn test_pyth_core_compatible_price_feed_takes_a_caller_selected_account() { + let registry = TemplateRegistry::new(); + let template = registry.get("pyth-core-compatible-price-feed-v2").unwrap(); + + assert_eq!( + template.idl().address, + "rec2HHDDnjLfj4kE7VyEtFA1HPGQLK33259532cRyHp" + ); + assert_eq!( + template.address, + AccountAddress::Pubkey(String::new()), + "the price account must be chosen by the caller" + ); + assert!( + template.constants.is_empty(), + "the template must not embed a feed catalog" + ); + assert!( + template.llm_context.as_deref().is_some_and( + |context| context.contains("rec2HHDDnjLfj4kE7VyEtFA1HPGQLK33259532cRyHp") + ), + "the template must say which receiver owns its accounts" + ); + } + + #[test] + fn pyth_price_feeds_keep_their_price_published() { + let registry = TemplateRegistry::new(); + 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(); + 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::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(); + 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/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..fcd52a22c --- /dev/null +++ b/crates/core/src/tests/pyth/mod.rs @@ -0,0 +1,282 @@ +//! 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_clock::Clock; +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_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"; + +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 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-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] = [ + ("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(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()) + .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 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(CORE_COMPATIBLE_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, 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()); + + 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 { + // 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], + "{path}" + ); + } +} + +/// 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; + 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 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(); + svm.set_account(&stale, live.clone()).unwrap(); + }); + let price_override = |address: &Pubkey| { + 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), + )])) + }; + 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 read = |address: &Pubkey| { + locker.with_svm_reader(|svm| { + ( + svm.get_account(address).unwrap().unwrap().data, + svm.inner.get_sysvar::(), + ) + }) + }; + 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}"); + }; + + 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}"), true); + } + 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", true); + } +} diff --git a/crates/mcp/src/surfpool/mod.rs b/crates/mcp/src/surfpool/mod.rs index d7efda601..b8f4c3237 100644 --- a/crates/mcp/src/surfpool/mod.rs +++ b/crates/mcp/src/surfpool/mod.rs @@ -213,7 +213,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, }